feat(calendar): make external mutations durable
This commit is contained in:
@@ -24,10 +24,88 @@ The first standalone module provides:
|
||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
||||
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
||||
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
||||
state and the exact external resource snapshot commit atomically; workers use
|
||||
deterministic hrefs, conditional requests, expiring leases, bounded
|
||||
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||
|
||||
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
||||
|
||||
## Outbound delivery operations
|
||||
|
||||
No event API or cross-module capability performs CalDAV network I/O inside the
|
||||
caller's transaction. A local mutation instead creates a
|
||||
`calendar_outbox_operations` row containing the exact desired ICS resource,
|
||||
target href, expected ETag, conflict-policy snapshot, and idempotency key.
|
||||
|
||||
The registered `govoplan.calendar.dispatch_outbox` worker commits an expiring
|
||||
lease before doing network I/O and commits each outcome independently. If a
|
||||
worker loses the database connection after a successful remote write, the next
|
||||
attempt reads the remote object and compares semantic ICS fingerprints. A
|
||||
matching PUT or an already absent DELETE completes successfully without a blind
|
||||
duplicate write. Pending updates for one href supersede older never-attempted
|
||||
rows; attempted predecessors are reconciled first, and updates queued behind an
|
||||
in-flight write inherit the ETag produced by that write. Delivery and inbound
|
||||
REPORT application take the same source-row lock,
|
||||
and only one operation per source is leased at a time. This deliberately trades
|
||||
per-source throughput for a simple ordering guarantee: a stale inbound report
|
||||
cannot land after a newer outbound result, and queued leases do not expire while
|
||||
waiting behind another network request for the same source.
|
||||
|
||||
Operators can list, dispatch, retry, reconcile, and discard tenant operations
|
||||
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag
|
||||
conflicts and exhausted retries remain the current local desired state and
|
||||
shield that resource from inbound overwrite until explicitly resolved.
|
||||
Discarding is the administrator's accept-remote transition: it is allowed only
|
||||
for the latest generation, atomically cancels its unresolved predecessor chain,
|
||||
marks the event projection as discarded, clears the sync token, and schedules a
|
||||
full inbound reconciliation so an unchanged remote object is not missed.
|
||||
|
||||
Disabling outbound delivery or switching to inbound-only is rejected while
|
||||
unresolved desired state exists; retirement is the explicit exception and
|
||||
cancels unresolved work. Endpoint/calendar changes also require no active event
|
||||
bindings or unresolved delivery. Credential rotation does not discard committed
|
||||
desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
|
||||
or ETags. Deleting a synchronized collection or retiring its source is a local
|
||||
unlink: it never deletes the remote collection or its remaining remote events.
|
||||
The current singular ownership model permits one active sync source per
|
||||
calendar; multi-source fan-in will require per-event source routing. Celery beat
|
||||
triggers recovery every minute, while root-transaction after-commit dispatch
|
||||
provides the normal low-latency path.
|
||||
|
||||
### Collection retirement and bulk event moves
|
||||
|
||||
Collection deletion accepts two explicit, non-destructive external actions for
|
||||
`event_action="move"`:
|
||||
|
||||
- `detach_keep_remote` applies only when the source collection is synchronized
|
||||
and the target is local. It moves the local event projections, clears their
|
||||
sync-owned source kind, href, ETag, and CalDAV projection metadata, retires
|
||||
the source locally, and cancels undelivered outbox state. An active delivery
|
||||
lease blocks the transaction. The action never enqueues a remote DELETE, so
|
||||
the remote collection and remote events remain unchanged.
|
||||
- `copy_to_remote` applies only when the source is local and the target has an
|
||||
active, enabled, two-way CalDAV source. It moves the local events and commits
|
||||
destination PUT desired states in the durable outbox in the same database
|
||||
transaction. Remote failures therefore remain visible and retryable without
|
||||
rolling back or hiding the local move.
|
||||
|
||||
Local-to-local moves retain their existing behavior and do not accept an
|
||||
`external_action`. Implicit or mismatched actions, inbound-only destinations,
|
||||
and synchronized-to-synchronized moves are rejected. `remote_move` is a known
|
||||
but deliberately unsupported action: implementing it requires a separately
|
||||
approved saga for destination reconciliation, conditional source deletion,
|
||||
collision handling, and concurrent-edit policy. The WebUI does not offer that
|
||||
destructive mode.
|
||||
|
||||
Resolved terminal rows (`succeeded`, `superseded`, and `cancelled`) are removed
|
||||
in bounded batches after `CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS` (90 days by
|
||||
default; `0` disables cleanup). `conflict` and `dead` rows are never removed by
|
||||
this cleanup because they still carry unresolved local desired state. Each
|
||||
periodic or after-commit dispatch also performs one bounded cleanup batch for
|
||||
the same tenant scope.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Scheduling
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import posixpath
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -241,8 +242,23 @@ class CalDAVClient:
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def fetch_object(self, href: str) -> str:
|
||||
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
|
||||
return payload.decode("utf-8")
|
||||
return self.fetch_object_state(href).calendar_data or ""
|
||||
|
||||
def fetch_object_state(self, href: str) -> CalDAVObject:
|
||||
"""Fetch a resource together with its ETag for outbox reconciliation."""
|
||||
|
||||
_status, headers, payload = self.request_raw(
|
||||
"GET",
|
||||
self.object_url(href),
|
||||
body=None,
|
||||
depth=None,
|
||||
expected={200},
|
||||
)
|
||||
return CalDAVObject(
|
||||
href=href,
|
||||
etag=response_etag(headers),
|
||||
calendar_data=payload.decode("utf-8"),
|
||||
)
|
||||
|
||||
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
||||
headers = {"Content-Type": "text/calendar; charset=utf-8"}
|
||||
@@ -279,7 +295,32 @@ class CalDAVClient:
|
||||
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||
|
||||
def object_url(self, href: str) -> str:
|
||||
return urllib.parse.urljoin(self.collection_url, href)
|
||||
candidate = urllib.parse.urljoin(self.collection_url, href)
|
||||
collection_parts = urllib.parse.urlparse(self.collection_url)
|
||||
candidate_parts = urllib.parse.urlparse(candidate)
|
||||
if (
|
||||
candidate_parts.username
|
||||
or candidate_parts.password
|
||||
or (
|
||||
candidate_parts.scheme.lower(),
|
||||
candidate_parts.hostname,
|
||||
candidate_parts.port,
|
||||
)
|
||||
!= (
|
||||
collection_parts.scheme.lower(),
|
||||
collection_parts.hostname,
|
||||
collection_parts.port,
|
||||
)
|
||||
):
|
||||
raise CalDAVError("CalDAV object href must use the configured collection origin")
|
||||
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
|
||||
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
|
||||
collection_prefix = collection_path.rstrip("/") + "/"
|
||||
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
|
||||
raise CalDAVError("CalDAV object href must remain inside the configured collection path")
|
||||
if candidate_parts.query or candidate_parts.fragment:
|
||||
raise CalDAVError("CalDAV object href must not contain a query or fragment")
|
||||
return urllib.parse.urlunparse(candidate_parts)
|
||||
|
||||
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
||||
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
||||
|
||||
82
src/govoplan_calendar/backend/capabilities.py
Normal file
82
src/govoplan_calendar/backend/capabilities.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from govoplan_core.core.calendar import (
|
||||
CalendarCapabilityError,
|
||||
CalendarEventRef,
|
||||
CalendarEventRequest,
|
||||
CalendarSchedulingProvider,
|
||||
)
|
||||
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
|
||||
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
|
||||
|
||||
|
||||
class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||
def list_freebusy(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
calendar_ids: Sequence[str] | None = None,
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
try:
|
||||
blocks = list_freebusy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
calendar_ids=list(calendar_ids) if calendar_ids is not None else None,
|
||||
)
|
||||
except CalendarError as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
return tuple(blocks)
|
||||
|
||||
def create_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarEventRequest,
|
||||
) -> CalendarEventRef:
|
||||
try:
|
||||
payload = CalendarEventCreateRequest(
|
||||
calendar_id=request.calendar_id,
|
||||
summary=request.summary,
|
||||
description=request.description,
|
||||
location=request.location,
|
||||
status=request.status,
|
||||
transparency=request.transparency,
|
||||
classification=request.classification,
|
||||
start_at=request.start_at,
|
||||
end_at=request.end_at,
|
||||
timezone=request.timezone,
|
||||
attendees=[dict(item) for item in request.attendees],
|
||||
categories=list(request.categories),
|
||||
related_to=[dict(item) for item in request.related_to],
|
||||
metadata=dict(request.metadata),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
try:
|
||||
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||
except CalendarError as exc:
|
||||
raise CalendarCapabilityError(str(exc)) from exc
|
||||
caldav_metadata = (event.metadata_ or {}).get("caldav")
|
||||
external_state = "local"
|
||||
outbox_operation_id = None
|
||||
if isinstance(caldav_metadata, dict):
|
||||
external_state = str(caldav_metadata.get("external_state") or "queued")
|
||||
raw_operation_id = caldav_metadata.get("outbox_operation_id")
|
||||
outbox_operation_id = str(raw_operation_id) if raw_operation_id else None
|
||||
return CalendarEventRef(
|
||||
id=event.id,
|
||||
calendar_id=event.calendar_id,
|
||||
uid=event.uid,
|
||||
external_state=external_state,
|
||||
outbox_operation_id=outbox_operation_id,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
||||
@@ -151,4 +151,69 @@ class CalendarSyncCredential(Base, TimestampMixin):
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"]
|
||||
class CalendarOutboxOperation(Base, TimestampMixin):
|
||||
"""Durable desired state for one external CalDAV resource.
|
||||
|
||||
Rows are inserted in the same transaction as the local event mutation. A
|
||||
dispatcher leases and commits the row before performing network I/O, so a
|
||||
worker crash can be detected and reconciled without repeating a blind
|
||||
write.
|
||||
"""
|
||||
|
||||
__tablename__ = "calendar_outbox_operations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("idempotency_key", name="uq_calendar_outbox_operations_idempotency_key"),
|
||||
Index("ix_calendar_outbox_due", "status", "available_at", "created_at"),
|
||||
Index("ix_calendar_outbox_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_calendar_outbox_resource", "source_id", "resource_href", "created_at"),
|
||||
Index("ix_calendar_outbox_lease", "status", "lease_expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
source_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("calendar_sync_sources.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("calendar_events.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
operation_kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
resource_href: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
payload_ics: Mapped[str | None] = mapped_column(Text)
|
||||
payload_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
expected_etag: Mapped[str | None] = mapped_column(String(255))
|
||||
idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False, index=True)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, default=8, nullable=False)
|
||||
available_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
lease_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reconciled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
remote_etag: Mapped[str | None] = mapped_column(String(255))
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
source: Mapped[CalendarSyncSource] = relationship()
|
||||
event: Mapped[CalendarEvent | None] = relationship()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CalendarCollection",
|
||||
"CalendarEvent",
|
||||
"CalendarOutboxOperation",
|
||||
"CalendarSyncCredential",
|
||||
"CalendarSyncSource",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -4,8 +4,23 @@ from pathlib import Path
|
||||
|
||||
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.calendar import (
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
CAPABILITY_CALENDAR_OUTBOX,
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
)
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -28,11 +43,11 @@ PERMISSIONS = (
|
||||
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
|
||||
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
|
||||
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
|
||||
_permission("calendar:event:write", "Manage calendar events", "Create and edit calendar events."),
|
||||
_permission(CALENDAR_EVENT_WRITE_SCOPE, "Manage calendar events", "Create and edit calendar events."),
|
||||
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
|
||||
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
|
||||
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
|
||||
_permission("calendar:availability:read", "Read availability", "Read free/busy and availability data for integrations."),
|
||||
_permission(CALENDAR_AVAILABILITY_READ_SCOPE, "Read availability", "Read free/busy and availability data for integrations."),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -44,11 +59,11 @@ ROLE_TEMPLATES = (
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar:write",
|
||||
"calendar:event:read",
|
||||
"calendar:event:write",
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
"calendar:event:delete",
|
||||
"calendar:event:import",
|
||||
"calendar:event:export",
|
||||
"calendar:availability:read",
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
@@ -59,20 +74,32 @@ ROLE_TEMPLATES = (
|
||||
"calendar:calendar:read",
|
||||
"calendar:event:read",
|
||||
"calendar:event:export",
|
||||
"calendar:availability:read",
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||
from govoplan_calendar.backend.db.models import (
|
||||
CalendarCollection,
|
||||
CalendarEvent,
|
||||
CalendarOutboxOperation,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
)
|
||||
|
||||
return {
|
||||
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
||||
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
|
||||
.filter(
|
||||
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||
CalendarOutboxOperation.status.in_(("pending", "retry", "in_progress")),
|
||||
)
|
||||
.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -85,16 +112,46 @@ def _calendar_router(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _calendar_scheduling_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
||||
|
||||
return SqlCalendarSchedulingProvider()
|
||||
|
||||
|
||||
def _calendar_outbox_provider(context: ModuleContext) -> object:
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
||||
SqlCalendarOutboxProvider,
|
||||
)
|
||||
|
||||
return SqlCalendarOutboxProvider(
|
||||
terminal_retention_days=getattr(
|
||||
context.settings,
|
||||
"calendar_outbox_terminal_retention_days",
|
||||
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="calendar",
|
||||
name="Calendar",
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_calendar_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
capability_factories={
|
||||
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
||||
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
|
||||
},
|
||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||
frontend=FrontendModule(
|
||||
module_id="calendar",
|
||||
@@ -109,6 +166,7 @@ manifest = ModuleManifest(
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
calendar_models.CalendarCollection,
|
||||
calendar_models.CalendarEvent,
|
||||
calendar_models.CalendarOutboxOperation,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
label="Calendar",
|
||||
@@ -119,6 +177,7 @@ manifest = ModuleManifest(
|
||||
persistent_table_uninstall_guard(
|
||||
calendar_models.CalendarCollection,
|
||||
calendar_models.CalendarEvent,
|
||||
calendar_models.CalendarOutboxOperation,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
label="Calendar",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add the durable CalDAV desired-state outbox on the development track.
|
||||
|
||||
Revision ID: af1b2c3d4e5f
|
||||
Revises: 9e0f1a2b3c4d
|
||||
Create Date: 2026-07-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
|
||||
downgrade as _downgrade,
|
||||
)
|
||||
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
|
||||
upgrade as _upgrade,
|
||||
)
|
||||
|
||||
|
||||
revision = "af1b2c3d4e5f"
|
||||
down_revision = "9e0f1a2b3c4d"
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_downgrade()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Add the durable CalDAV desired-state outbox.
|
||||
|
||||
Revision ID: af1b2c3d4e5f
|
||||
Revises: 9e0f1a2b3c4d
|
||||
Create Date: 2026-07-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "af1b2c3d4e5f"
|
||||
down_revision = "9e0f1a2b3c4d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"calendar_outbox_operations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("operation_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("resource_href", sa.String(length=1000), nullable=False),
|
||||
sa.Column("payload_ics", sa.Text(), nullable=True),
|
||||
sa.Column("payload_fingerprint", sa.String(length=64), nullable=True),
|
||||
sa.Column("expected_etag", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False),
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("available_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("lease_token", sa.String(length=36), nullable=True),
|
||||
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("reconciled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("remote_etag", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["event_id"],
|
||||
["calendar_events.id"],
|
||||
name=op.f("fk_calendar_outbox_operations_event_id_calendar_events"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_id"],
|
||||
["calendar_sync_sources.id"],
|
||||
name=op.f("fk_calendar_outbox_operations_source_id_calendar_sync_sources"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_calendar_outbox_operations_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_outbox_operations")),
|
||||
sa.UniqueConstraint(
|
||||
"idempotency_key",
|
||||
name="uq_calendar_outbox_operations_idempotency_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_due",
|
||||
"calendar_outbox_operations",
|
||||
["status", "available_at", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_lease",
|
||||
"calendar_outbox_operations",
|
||||
["status", "lease_expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_resource",
|
||||
"calendar_outbox_operations",
|
||||
["source_id", "resource_href", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_tenant_status",
|
||||
"calendar_outbox_operations",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"available_at",
|
||||
"event_id",
|
||||
"lease_expires_at",
|
||||
"lease_token",
|
||||
"operation_kind",
|
||||
"payload_fingerprint",
|
||||
"source_id",
|
||||
"status",
|
||||
"tenant_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_calendar_outbox_operations_{column}"),
|
||||
"calendar_outbox_operations",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("calendar_outbox_operations")
|
||||
1501
src/govoplan_calendar/backend/outbox.py
Normal file
1501
src/govoplan_calendar/backend/outbox.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
|
||||
@@ -35,6 +36,9 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarFreeBusyRequest,
|
||||
CalendarFreeBusyResponse,
|
||||
CalendarIcsImportRequest,
|
||||
CalendarOutboxDispatchResponse,
|
||||
CalendarOutboxOperationListResponse,
|
||||
CalendarOutboxOperationResponse,
|
||||
CalendarSyncDueSyncItemResponse,
|
||||
CalendarSyncDueSyncResponse,
|
||||
CalendarSyncSourceCreateRequest,
|
||||
@@ -44,6 +48,14 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarSyncSourceSyncResponse,
|
||||
CalendarSyncSourceUpdateRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
calendar_outbox_operation_response,
|
||||
discard_calendar_outbox_operation,
|
||||
dispatch_calendar_outbox,
|
||||
list_calendar_outbox_operations,
|
||||
reconcile_calendar_outbox_operation,
|
||||
retry_calendar_outbox_operation,
|
||||
)
|
||||
from govoplan_calendar.backend.service import (
|
||||
CALENDAR_EVENTS_COLLECTION,
|
||||
CALENDAR_EVENT_RESOURCE,
|
||||
@@ -62,7 +74,6 @@ from govoplan_calendar.backend.service import (
|
||||
delete_event,
|
||||
discover_caldav_calendars,
|
||||
event_response,
|
||||
get_caldav_source,
|
||||
get_event,
|
||||
import_ics_event,
|
||||
list_freebusy,
|
||||
@@ -220,13 +231,25 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
|
||||
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
|
||||
|
||||
|
||||
def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse:
|
||||
return CalendarOutboxOperationResponse.model_validate(
|
||||
calendar_outbox_operation_response(operation)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
||||
def api_list_calendars(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:read")
|
||||
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
|
||||
calendars = list_calendars(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
group_ids=principal.group_ids,
|
||||
can_admin=principal.has("calendar:calendar:admin"),
|
||||
)
|
||||
session.commit()
|
||||
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
|
||||
|
||||
@@ -343,7 +366,12 @@ def api_delete_sync_source(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
error_status = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if "not found" in str(exc).lower()
|
||||
else status.HTTP_409_CONFLICT
|
||||
)
|
||||
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse)
|
||||
@@ -473,7 +501,12 @@ def api_delete_caldav_source(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
error_status = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if "not found" in str(exc).lower()
|
||||
else status.HTTP_409_CONFLICT
|
||||
)
|
||||
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
|
||||
@@ -530,6 +563,117 @@ def api_sync_due_caldav_sources(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
|
||||
def api_list_caldav_outbox(
|
||||
operation_status: str | None = Query(default=None, alias="status"),
|
||||
source_id: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
operations = list_calendar_outbox_operations(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
status=operation_status,
|
||||
source_id=source_id,
|
||||
limit=limit,
|
||||
)
|
||||
return CalendarOutboxOperationListResponse(
|
||||
operations=[_outbox_operation_response(operation) for operation in operations]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
|
||||
def api_dispatch_caldav_outbox(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
return CalendarOutboxDispatchResponse.model_validate(
|
||||
dispatch_calendar_outbox(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/caldav/outbox/{operation_id}/retry",
|
||||
response_model=CalendarOutboxOperationResponse,
|
||||
)
|
||||
def api_retry_caldav_outbox_operation(
|
||||
operation_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
operation = retry_calendar_outbox_operation(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(operation)
|
||||
return _outbox_operation_response(operation)
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/caldav/outbox/{operation_id}/reconcile",
|
||||
response_model=CalendarOutboxOperationResponse,
|
||||
)
|
||||
def api_reconcile_caldav_outbox_operation(
|
||||
operation_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
operation = reconcile_calendar_outbox_operation(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(operation)
|
||||
return _outbox_operation_response(operation)
|
||||
except (ValueError, CalDAVError, CalendarError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/caldav/outbox/{operation_id}/discard",
|
||||
response_model=CalendarOutboxOperationResponse,
|
||||
)
|
||||
def api_discard_caldav_outbox_operation(
|
||||
operation_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Abandon local desired state and allow the next sync to accept remote."""
|
||||
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
operation = discard_calendar_outbox_operation(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(operation)
|
||||
return _outbox_operation_response(operation)
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/events", response_model=CalendarEventListResponse)
|
||||
def api_list_events(
|
||||
calendar_id: str | None = None,
|
||||
@@ -600,7 +744,7 @@ def api_create_event(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:write")
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
||||
session.commit()
|
||||
@@ -631,7 +775,7 @@ def api_update_event(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:write")
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
|
||||
session.commit()
|
||||
@@ -648,7 +792,7 @@ def api_delete_event(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
|
||||
_require_any_scope(principal, "calendar:event:delete", CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
|
||||
session.commit()
|
||||
@@ -713,7 +857,7 @@ def api_freebusy(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:availability:read")
|
||||
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||
try:
|
||||
busy = list_freebusy(
|
||||
session,
|
||||
|
||||
@@ -11,6 +11,11 @@ from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
||||
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
||||
CalendarDeleteEventAction = Literal["delete", "move"]
|
||||
CalendarBulkMoveExternalAction = Literal[
|
||||
"detach_keep_remote",
|
||||
"copy_to_remote",
|
||||
"remote_move",
|
||||
]
|
||||
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
|
||||
CalendarSyncDirection = Literal["inbound", "two_way"]
|
||||
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
|
||||
@@ -50,6 +55,7 @@ class CalendarCollectionDeleteRequest(BaseModel):
|
||||
event_action: CalendarDeleteEventAction = "delete"
|
||||
target_calendar_id: str | None = None
|
||||
make_target_default: bool = False
|
||||
external_action: CalendarBulkMoveExternalAction | None = None
|
||||
|
||||
|
||||
class CalendarCollectionResponse(BaseModel):
|
||||
@@ -238,6 +244,50 @@ class CalendarCalDavDueSyncResponse(BaseModel):
|
||||
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarOutboxOperationResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
source_id: str
|
||||
event_id: str | None = None
|
||||
operation_kind: str
|
||||
resource_href: str
|
||||
payload_fingerprint: str | None = None
|
||||
expected_etag: str | None = None
|
||||
idempotency_key: str
|
||||
status: str
|
||||
attempt_count: int
|
||||
max_attempts: int
|
||||
available_at: datetime
|
||||
last_attempt_at: datetime | None = None
|
||||
lease_expires_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
reconciled_at: datetime | None = None
|
||||
remote_etag: str | None = None
|
||||
last_error: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarOutboxOperationListResponse(BaseModel):
|
||||
operations: list[CalendarOutboxOperationResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarOutboxDispatchItemResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
attempt_count: int
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class CalendarOutboxDispatchResponse(BaseModel):
|
||||
processed: int = 0
|
||||
succeeded: int = 0
|
||||
retrying: int = 0
|
||||
failed: int = 0
|
||||
operations: list[CalendarOutboxDispatchItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarEventCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import uuid
|
||||
import os
|
||||
@@ -9,16 +10,16 @@ import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||
from govoplan_calendar.backend.ical import events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents
|
||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
||||
from govoplan_calendar.backend.runtime import get_registry
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarCalDavDiscoveryRequest,
|
||||
@@ -58,12 +59,20 @@ SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||
|
||||
|
||||
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
|
||||
metadata = event.metadata_ or {}
|
||||
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
|
||||
return {
|
||||
f"{prefix}calendar_id": event.calendar_id,
|
||||
f"{prefix}start_at": response_datetime(event.start_at).isoformat() if event.start_at else None,
|
||||
f"{prefix}end_at": response_datetime(event.end_at).isoformat() if event.end_at else None,
|
||||
f"{prefix}summary": event.summary,
|
||||
f"{prefix}status": event.status,
|
||||
f"{prefix}external_state": (
|
||||
caldav.get("external_state") if isinstance(caldav, dict) else "local"
|
||||
),
|
||||
f"{prefix}outbox_operation_id": (
|
||||
caldav.get("outbox_operation_id") if isinstance(caldav, dict) else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -324,15 +333,63 @@ def get_default_calendar(session: Session, *, tenant_id: str) -> CalendarCollect
|
||||
)
|
||||
|
||||
|
||||
def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = False, user_id: str | None = None) -> list[CalendarCollection]:
|
||||
def calendar_is_visible_to_principal(
|
||||
calendar: CalendarCollection,
|
||||
*,
|
||||
user_id: str | None,
|
||||
group_ids: Iterable[str] = (),
|
||||
can_admin: bool = False,
|
||||
) -> bool:
|
||||
"""Return whether a principal may discover a calendar collection.
|
||||
|
||||
Collection visibility is deliberately enforced before serializing picker
|
||||
metadata. ``shared`` currently means tenant-visible; explicit per-user
|
||||
sharing can narrow this rule when Calendar gains a share relation.
|
||||
"""
|
||||
|
||||
if can_admin:
|
||||
return True
|
||||
if calendar.visibility in {"tenant", "shared", "public"}:
|
||||
return True
|
||||
if calendar.visibility != "private" or user_id is None:
|
||||
return False
|
||||
if calendar.created_by_user_id == user_id:
|
||||
return True
|
||||
if calendar.owner_type == "user":
|
||||
return calendar.owner_id == user_id
|
||||
if calendar.owner_type == "group":
|
||||
return bool(calendar.owner_id and calendar.owner_id in set(group_ids))
|
||||
return False
|
||||
|
||||
|
||||
def list_calendars(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
ensure_default: bool = False,
|
||||
user_id: str | None = None,
|
||||
group_ids: Iterable[str] = (),
|
||||
can_admin: bool = False,
|
||||
) -> list[CalendarCollection]:
|
||||
if ensure_default:
|
||||
ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id)
|
||||
return (
|
||||
calendars = (
|
||||
session.query(CalendarCollection)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None))
|
||||
.order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc())
|
||||
.all()
|
||||
)
|
||||
principal_group_ids = tuple(group_ids)
|
||||
return [
|
||||
calendar
|
||||
for calendar in calendars
|
||||
if calendar_is_visible_to_principal(
|
||||
calendar,
|
||||
user_id=user_id,
|
||||
group_ids=principal_group_ids,
|
||||
can_admin=can_admin,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def create_calendar(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCollectionCreateRequest) -> CalendarCollection:
|
||||
@@ -387,14 +444,129 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
||||
if payload.target_calendar_id == calendar_id:
|
||||
raise CalendarError("Target calendar must be different from the deleted calendar")
|
||||
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
|
||||
for event in calendar.events:
|
||||
if event.deleted_at is None:
|
||||
source = None
|
||||
target_source = None
|
||||
if hasattr(session, "query"):
|
||||
# Source creation takes the collection row lock before linking a
|
||||
# source. Lock both collections in stable order so the mode cannot
|
||||
# change between validation and the local/outbox mutation.
|
||||
locked_calendars = (
|
||||
session.query(CalendarCollection)
|
||||
.filter(
|
||||
CalendarCollection.tenant_id == tenant_id,
|
||||
CalendarCollection.id.in_((calendar.id, target_calendar.id)),
|
||||
CalendarCollection.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(CalendarCollection.id.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
locked_calendar_by_id = {item.id: item for item in locked_calendars}
|
||||
if len(locked_calendar_by_id) != 2:
|
||||
raise CalendarError("Source or target calendar is no longer available")
|
||||
calendar = locked_calendar_by_id[calendar.id]
|
||||
target_calendar = locked_calendar_by_id[target_calendar.id]
|
||||
source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar.id,
|
||||
)
|
||||
target_source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=target_calendar.id,
|
||||
)
|
||||
locked_sources = lock_sync_sources(session, source, target_source)
|
||||
if source is not None:
|
||||
source = locked_sources[source.id]
|
||||
if target_source is not None:
|
||||
target_source = locked_sources[target_source.id]
|
||||
|
||||
if payload.external_action == "remote_move":
|
||||
raise CalendarError(
|
||||
"external_action='remote_move' is not supported; no destructive remote move was performed"
|
||||
)
|
||||
if source is not None and target_source is not None:
|
||||
raise CalendarError(
|
||||
"Events cannot be bulk-moved between synchronized calendars; "
|
||||
"external_action='remote_move' remains unsupported"
|
||||
)
|
||||
active_events = [event for event in calendar.events if event.deleted_at is None]
|
||||
previous_event_states = (
|
||||
{
|
||||
event.id: calendar_event_change_payload(event, prefix="previous_")
|
||||
for event in active_events
|
||||
}
|
||||
if hasattr(session, "query")
|
||||
else {}
|
||||
)
|
||||
if source is not None:
|
||||
if payload.external_action != "detach_keep_remote":
|
||||
raise CalendarError(
|
||||
"Moving events from a synchronized calendar to a local calendar requires "
|
||||
"external_action='detach_keep_remote'"
|
||||
)
|
||||
# Retirement rejects a live delivery lease before any local event
|
||||
# projection is changed, and cancels all remaining undelivered
|
||||
# desired state. It never creates a remote DELETE operation.
|
||||
retire_sync_source(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source=source,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
elif target_source is not None:
|
||||
if payload.external_action != "copy_to_remote":
|
||||
raise CalendarError(
|
||||
"Moving local events to a synchronized calendar requires "
|
||||
"external_action='copy_to_remote'"
|
||||
)
|
||||
if (
|
||||
target_source.source_kind != "caldav"
|
||||
or target_source.sync_direction != "two_way"
|
||||
or not target_source.sync_enabled
|
||||
):
|
||||
raise CalendarError(
|
||||
"external_action='copy_to_remote' requires an active two-way CalDAV target"
|
||||
)
|
||||
assert_sync_mutation_allowed(target_source)
|
||||
elif payload.external_action is not None:
|
||||
raise CalendarError(
|
||||
"external_action is only valid when moving events to or from a synchronized calendar"
|
||||
)
|
||||
|
||||
for event in active_events:
|
||||
if source is not None or target_source is not None:
|
||||
event.source_kind = "local"
|
||||
event.source_href = None
|
||||
event.etag = None
|
||||
metadata = dict(event.metadata_ or {})
|
||||
metadata.pop("caldav", None)
|
||||
event.metadata_ = metadata
|
||||
event.calendar_id = target_calendar.id
|
||||
session.flush()
|
||||
if target_source is not None:
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_put
|
||||
|
||||
for event in active_events:
|
||||
enqueue_caldav_put(session, source=target_source, event_model=event)
|
||||
if previous_event_states:
|
||||
for event in active_events:
|
||||
record_calendar_event_change(
|
||||
session,
|
||||
event=event,
|
||||
operation="updated",
|
||||
user_id=None,
|
||||
previous=previous_event_states[event.id],
|
||||
)
|
||||
if payload.make_target_default:
|
||||
clear_default_calendar(session, tenant_id=tenant_id)
|
||||
target_calendar.is_default = True
|
||||
elif payload.event_action != "delete":
|
||||
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
|
||||
elif payload.external_action is not None:
|
||||
raise CalendarError("external_action is only valid when event_action='move'")
|
||||
if calendar.is_default:
|
||||
calendar.is_default = False
|
||||
calendar.deleted_at = deleted_at
|
||||
@@ -515,6 +687,30 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
|
||||
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
calendar = (
|
||||
session.query(CalendarCollection)
|
||||
.filter(
|
||||
CalendarCollection.id == calendar.id,
|
||||
CalendarCollection.tenant_id == tenant_id,
|
||||
CalendarCollection.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
existing_calendar_source = (
|
||||
session.query(CalendarSyncSource.id)
|
||||
.filter(
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.calendar_id == calendar.id,
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_calendar_source is not None:
|
||||
raise CalendarError(
|
||||
"A calendar can have only one active synchronization source"
|
||||
)
|
||||
source_kind = normalize_source_kind(payload.source_kind)
|
||||
collection_url = normalize_sync_source_url(source_kind, payload.collection_url)
|
||||
retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url)
|
||||
@@ -562,8 +758,123 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non
|
||||
|
||||
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource:
|
||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
||||
source = (
|
||||
session.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.id == source.id,
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
normalized_collection_url = (
|
||||
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||
if payload.collection_url is not None
|
||||
else source.collection_url
|
||||
)
|
||||
materially_reconfigured = bool(
|
||||
source.source_kind == "caldav"
|
||||
and (
|
||||
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
|
||||
or normalized_collection_url != source.collection_url
|
||||
or payload.sync_enabled is False
|
||||
or payload.sync_direction == "inbound"
|
||||
)
|
||||
)
|
||||
endpoint_or_calendar_changed = bool(
|
||||
source.source_kind == "caldav"
|
||||
and (
|
||||
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
|
||||
or normalized_collection_url != source.collection_url
|
||||
)
|
||||
)
|
||||
if materially_reconfigured:
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
calendar_outbox_has_live_lease,
|
||||
calendar_outbox_has_unresolved_desired_state,
|
||||
cancel_calendar_outbox_for_source,
|
||||
)
|
||||
|
||||
if calendar_outbox_has_live_lease(session, source_id=source.id):
|
||||
raise CalendarError(
|
||||
"CalDAV source cannot be materially changed while an outbound operation has an active lease"
|
||||
)
|
||||
|
||||
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
|
||||
session,
|
||||
source_id=source.id,
|
||||
)
|
||||
stops_outbound_delivery = bool(
|
||||
(payload.sync_enabled is False and source.sync_enabled)
|
||||
or (
|
||||
payload.sync_direction == "inbound"
|
||||
and source.sync_direction == "two_way"
|
||||
)
|
||||
)
|
||||
if stops_outbound_delivery and unresolved_desired_state:
|
||||
raise CalendarError(
|
||||
"CalDAV source cannot disable outbound delivery while unresolved local desired "
|
||||
"changes exist; deliver or explicitly discard them first"
|
||||
)
|
||||
if endpoint_or_calendar_changed and unresolved_desired_state:
|
||||
raise CalendarError(
|
||||
"CalDAV source endpoint or calendar cannot change while unresolved local desired "
|
||||
"changes exist; deliver or explicitly discard them first"
|
||||
)
|
||||
|
||||
if endpoint_or_calendar_changed:
|
||||
sourced_event_exists = (
|
||||
session.query(CalendarEvent.id)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.calendar_id == source.calendar_id,
|
||||
CalendarEvent.source_kind == "caldav",
|
||||
CalendarEvent.source_href.is_not(None),
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if sourced_event_exists:
|
||||
raise CalendarError(
|
||||
"CalDAV source endpoint or calendar cannot change while events still reference it; "
|
||||
"detach or resync those events first"
|
||||
)
|
||||
|
||||
cancel_calendar_outbox_for_source(
|
||||
session,
|
||||
source_id=source.id,
|
||||
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only",
|
||||
)
|
||||
if payload.calendar_id is not None:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
calendar = (
|
||||
session.query(CalendarCollection)
|
||||
.filter(
|
||||
CalendarCollection.id == calendar.id,
|
||||
CalendarCollection.tenant_id == tenant_id,
|
||||
CalendarCollection.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
conflicting_source = (
|
||||
session.query(CalendarSyncSource.id)
|
||||
.filter(
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.calendar_id == calendar.id,
|
||||
CalendarSyncSource.id != source.id,
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if conflicting_source is not None:
|
||||
raise CalendarError(
|
||||
"A calendar can have only one active synchronization source"
|
||||
)
|
||||
source.calendar_id = calendar.id
|
||||
else:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
|
||||
@@ -583,7 +894,7 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
||||
if value is not None:
|
||||
setattr(source, field_name, value)
|
||||
if payload.collection_url is not None:
|
||||
source.collection_url = normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||
source.collection_url = normalized_collection_url
|
||||
if payload.metadata is not None:
|
||||
source.metadata_ = payload.metadata
|
||||
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS:
|
||||
@@ -684,6 +995,31 @@ def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection
|
||||
|
||||
|
||||
def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None:
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
calendar_outbox_has_live_lease,
|
||||
cancel_calendar_outbox_for_source,
|
||||
)
|
||||
|
||||
source = (
|
||||
session.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.id == source.id,
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
if calendar_outbox_has_live_lease(session, source_id=source.id):
|
||||
raise CalendarError(
|
||||
"CalDAV source cannot be retired while an outbound operation has an active lease"
|
||||
)
|
||||
|
||||
cancel_calendar_outbox_for_source(
|
||||
session,
|
||||
source_id=source.id,
|
||||
reason="CalDAV source was retired before queued external changes were delivered",
|
||||
)
|
||||
source.deleted_at = deleted_at
|
||||
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
||||
|
||||
@@ -933,6 +1269,22 @@ def sync_caldav_source(
|
||||
force_full: bool = False,
|
||||
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||
source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id)
|
||||
# Serialize inbound application with local desired-state snapshots and
|
||||
# outbound delivery for this source. The lock intentionally spans the
|
||||
# REPORT and its application so an older report cannot land after a newer
|
||||
# outbound write.
|
||||
source = (
|
||||
session.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.id == source.id,
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.source_kind == "caldav",
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
|
||||
client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token)
|
||||
stats = CalendarCalDavSyncStats()
|
||||
@@ -1452,6 +1804,15 @@ def soft_delete_unseen_source_events(
|
||||
.all()
|
||||
)
|
||||
for event in events:
|
||||
if effective_source_kind == "caldav" and event.source_href:
|
||||
from govoplan_calendar.backend.outbox import calendar_outbox_has_active_desired_state
|
||||
|
||||
if calendar_outbox_has_active_desired_state(
|
||||
session,
|
||||
source_id=source.id,
|
||||
href=event.source_href,
|
||||
):
|
||||
continue
|
||||
if event.source_href not in seen_hrefs:
|
||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||
event.deleted_at = deleted_at
|
||||
@@ -1714,6 +2075,14 @@ def apply_caldav_report(
|
||||
seen_hrefs: set[str] = set()
|
||||
for item in report.objects:
|
||||
href = normalize_caldav_href(source.collection_url, item.href)
|
||||
from govoplan_calendar.backend.outbox import calendar_outbox_has_active_desired_state
|
||||
|
||||
if calendar_outbox_has_active_desired_state(session, source_id=source.id, href=href):
|
||||
# Do not let an older remote representation erase a committed
|
||||
# local desired state while its outbound operation is pending.
|
||||
seen_hrefs.add(href)
|
||||
stats.unchanged += 1
|
||||
continue
|
||||
if item.deleted:
|
||||
stats.deleted += soft_delete_caldav_href(session, source=source, href=href)
|
||||
continue
|
||||
@@ -1899,7 +2268,33 @@ def soft_delete_caldav_href(session: Session, *, source: CalendarSyncSource, hre
|
||||
|
||||
|
||||
def normalize_caldav_href(collection_url: str, href: str) -> str:
|
||||
return urllib.parse.urljoin(ensure_collection_url(collection_url), href)
|
||||
collection = ensure_collection_url(collection_url)
|
||||
candidate = urllib.parse.urljoin(collection, href)
|
||||
collection_parts = urllib.parse.urlparse(collection)
|
||||
candidate_parts = urllib.parse.urlparse(candidate)
|
||||
if (
|
||||
candidate_parts.username
|
||||
or candidate_parts.password
|
||||
or (
|
||||
candidate_parts.scheme.lower(),
|
||||
candidate_parts.hostname,
|
||||
candidate_parts.port,
|
||||
)
|
||||
!= (
|
||||
collection_parts.scheme.lower(),
|
||||
collection_parts.hostname,
|
||||
collection_parts.port,
|
||||
)
|
||||
):
|
||||
raise CalendarError("CalDAV resource href must use the configured collection origin")
|
||||
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
|
||||
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
|
||||
collection_prefix = collection_path.rstrip("/") + "/"
|
||||
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
|
||||
raise CalendarError("CalDAV resource href must remain inside the configured collection path")
|
||||
if candidate_parts.query or candidate_parts.fragment:
|
||||
raise CalendarError("CalDAV resource href must not contain a query or fragment")
|
||||
return urllib.parse.urlunparse(candidate_parts)
|
||||
|
||||
|
||||
def mark_calendar_caldav(calendar: CalendarCollection, source: CalendarSyncSource) -> None:
|
||||
@@ -2044,8 +2439,25 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
|
||||
if not calendar_id:
|
||||
raise CalendarError("calendar_id is required because no default calendar exists")
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
for_update=True,
|
||||
)
|
||||
assert_sync_mutation_allowed(source)
|
||||
supplied_sync_fields = bool(
|
||||
payload.source_href is not None
|
||||
or payload.etag is not None
|
||||
or (
|
||||
"source_kind" in payload.model_fields_set
|
||||
and payload.source_kind != "local"
|
||||
)
|
||||
)
|
||||
if source is not None and supplied_sync_fields:
|
||||
raise CalendarError(
|
||||
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
|
||||
)
|
||||
event = CalendarEvent(
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
@@ -2084,24 +2496,63 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
|
||||
session.add(event)
|
||||
session.flush()
|
||||
if should_push_event_to_caldav(source=source, event=event):
|
||||
push_caldav_event_resource(session, source=source, event=event, create=True)
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_put
|
||||
|
||||
enqueue_caldav_put(session, source=source, event_model=event)
|
||||
record_calendar_event_change(session, event=event, operation="created", user_id=user_id)
|
||||
return event
|
||||
|
||||
|
||||
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
|
||||
event_locator = (
|
||||
session.query(CalendarEvent.calendar_id)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if event_locator is None:
|
||||
raise CalendarError("Calendar event not found")
|
||||
original_calendar_id = event_locator[0]
|
||||
target_calendar_id = payload.calendar_id or original_calendar_id
|
||||
if payload.calendar_id is not None:
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id)
|
||||
original_source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=original_calendar_id,
|
||||
)
|
||||
target_source = (
|
||||
original_source
|
||||
if target_calendar_id == original_calendar_id
|
||||
else active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=target_calendar_id,
|
||||
)
|
||||
)
|
||||
locked_sources = lock_sync_sources(session, original_source, target_source)
|
||||
if original_source is not None:
|
||||
original_source = locked_sources[original_source.id]
|
||||
if target_source is not None:
|
||||
target_source = locked_sources[target_source.id]
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||
original_calendar_id = event.calendar_id
|
||||
original_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
|
||||
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
|
||||
supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set
|
||||
if (original_source is not None or target_source is not None) and supplied_sync_fields:
|
||||
raise CalendarError(
|
||||
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
|
||||
)
|
||||
if payload.calendar_id is not None:
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
assert_sync_mutation_allowed(target_source)
|
||||
if payload.calendar_id != event.calendar_id and original_source and original_caldav:
|
||||
assert_sync_mutation_allowed(original_source)
|
||||
push_caldav_delete_for_event(session, source=original_source, event=event)
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
|
||||
|
||||
enqueue_caldav_delete(session, source=original_source, event_model=event)
|
||||
event.source_kind = "local"
|
||||
event.source_href = None
|
||||
event.etag = None
|
||||
@@ -2149,32 +2600,52 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
||||
validate_event_time(event)
|
||||
event.raw_ics = None
|
||||
session.flush()
|
||||
target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
|
||||
if should_push_event_to_caldav(source=target_source, event=event):
|
||||
push_caldav_event_resource(
|
||||
session,
|
||||
source=target_source,
|
||||
event=event,
|
||||
create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id,
|
||||
)
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_put
|
||||
|
||||
enqueue_caldav_put(session, source=target_source, event_model=event)
|
||||
record_calendar_event_change(session, event=event, operation="updated", user_id=user_id, previous=previous)
|
||||
return event
|
||||
|
||||
|
||||
def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None:
|
||||
event_locator = (
|
||||
session.query(CalendarEvent.calendar_id)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if event_locator is None:
|
||||
raise CalendarError("Calendar event not found")
|
||||
source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=event_locator[0],
|
||||
for_update=True,
|
||||
)
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||
source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
|
||||
assert_sync_mutation_allowed(source)
|
||||
if should_push_event_to_caldav(source=source, event=event):
|
||||
push_caldav_delete_for_event(session, source=source, event=event)
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
|
||||
|
||||
enqueue_caldav_delete(session, source=source, event_model=event)
|
||||
event.deleted_at = utcnow()
|
||||
session.flush()
|
||||
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous)
|
||||
|
||||
|
||||
def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None:
|
||||
return (
|
||||
def active_sync_source_for_calendar(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
calendar_id: str,
|
||||
for_update: bool = False,
|
||||
) -> CalendarSyncSource | None:
|
||||
query = (
|
||||
session.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
@@ -2182,8 +2653,30 @@ def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calenda
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if for_update:
|
||||
query = query.populate_existing().with_for_update()
|
||||
return query.first()
|
||||
|
||||
|
||||
def lock_sync_sources(
|
||||
session: Session,
|
||||
*sources: CalendarSyncSource | None,
|
||||
) -> dict[str, CalendarSyncSource]:
|
||||
"""Lock source rows in stable order before producing desired snapshots."""
|
||||
|
||||
source_ids = sorted({source.id for source in sources if source is not None})
|
||||
if not source_ids:
|
||||
return {}
|
||||
locked_sources = (
|
||||
session.query(CalendarSyncSource)
|
||||
.filter(CalendarSyncSource.id.in_(source_ids))
|
||||
.order_by(CalendarSyncSource.id.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
return {source.id: source for source in locked_sources}
|
||||
|
||||
|
||||
def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None:
|
||||
@@ -2203,6 +2696,12 @@ def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calen
|
||||
def assert_sync_mutation_allowed(source: CalendarSyncSource | None) -> None:
|
||||
if source is None:
|
||||
return
|
||||
if source.deleted_at is not None:
|
||||
raise CalendarError("This calendar synchronization source is no longer active")
|
||||
if not source.sync_enabled:
|
||||
raise CalendarError(
|
||||
"This calendar synchronization source is disabled and cannot accept local changes"
|
||||
)
|
||||
if source.source_kind != "caldav" or source.sync_direction != "two_way":
|
||||
raise CalendarError(f"This {sync_source_label(source.source_kind)} calendar is inbound-only and cannot be changed locally")
|
||||
|
||||
@@ -2212,63 +2711,14 @@ def assert_caldav_mutation_allowed(source: CalendarSyncSource | None) -> None:
|
||||
|
||||
|
||||
def should_push_event_to_caldav(*, source: CalendarSyncSource | None, event: CalendarEvent) -> bool:
|
||||
return bool(source and source.source_kind == "caldav" and source.sync_direction == "two_way" and event.deleted_at is None)
|
||||
|
||||
|
||||
def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None:
|
||||
href = event.source_href or generated_caldav_href(session, source=source, event=event)
|
||||
full_href = normalize_caldav_href(source.collection_url, href)
|
||||
resource_events = caldav_resource_events(session, source=source, href=full_href) if event.source_href else [event]
|
||||
if event not in resource_events:
|
||||
resource_events.append(event)
|
||||
resource_events = sorted(resource_events, key=caldav_resource_sort_key)
|
||||
ics = events_to_ics(resource_events)
|
||||
client = caldav_client_for_source(session, source)
|
||||
overwrite = source.conflict_policy == "overwrite"
|
||||
etag = None if create else resource_etag(resource_events)
|
||||
try:
|
||||
result = client.put_object(full_href, ics, etag=etag, create=create, overwrite=overwrite)
|
||||
except CalDAVPreconditionFailed as exc:
|
||||
raise CalendarError("CalDAV resource changed remotely; sync the calendar before saving again") from exc
|
||||
except CalDAVError as exc:
|
||||
raise CalendarError(f"CalDAV write failed: {exc}") from exc
|
||||
next_etag = result.etag
|
||||
for item in resource_events:
|
||||
apply_caldav_write_metadata(item, source=source, href=full_href, etag=next_etag, raw_ics=ics)
|
||||
source.last_status = "outbound_ok"
|
||||
source.last_error = None
|
||||
schedule_next_caldav_sync(source)
|
||||
session.flush()
|
||||
|
||||
|
||||
def push_caldav_delete_for_event(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> None:
|
||||
if not event.source_href:
|
||||
return
|
||||
full_href = normalize_caldav_href(source.collection_url, event.source_href)
|
||||
remaining = [
|
||||
item
|
||||
for item in caldav_resource_events(session, source=source, href=full_href)
|
||||
if item.id != event.id
|
||||
]
|
||||
client = caldav_client_for_source(session, source)
|
||||
overwrite = source.conflict_policy == "overwrite"
|
||||
try:
|
||||
if remaining:
|
||||
remaining = sorted(remaining, key=caldav_resource_sort_key)
|
||||
ics = events_to_ics(remaining)
|
||||
result = client.put_object(full_href, ics, etag=event.etag or resource_etag(remaining), create=False, overwrite=overwrite)
|
||||
for item in remaining:
|
||||
apply_caldav_write_metadata(item, source=source, href=full_href, etag=result.etag, raw_ics=ics)
|
||||
else:
|
||||
client.delete_object(full_href, etag=event.etag, overwrite=overwrite)
|
||||
except CalDAVPreconditionFailed as exc:
|
||||
raise CalendarError("CalDAV resource changed remotely; sync the calendar before deleting again") from exc
|
||||
except CalDAVError as exc:
|
||||
raise CalendarError(f"CalDAV delete failed: {exc}") from exc
|
||||
source.last_status = "outbound_ok"
|
||||
source.last_error = None
|
||||
schedule_next_caldav_sync(source)
|
||||
session.flush()
|
||||
return bool(
|
||||
source
|
||||
and source.deleted_at is None
|
||||
and source.sync_enabled
|
||||
and source.source_kind == "caldav"
|
||||
and source.sync_direction == "two_way"
|
||||
and event.deleted_at is None
|
||||
)
|
||||
|
||||
|
||||
def caldav_resource_events(session: Session, *, source: CalendarSyncSource, href: str) -> list[CalendarEvent]:
|
||||
@@ -2312,16 +2762,6 @@ def generated_caldav_href(session: Session, *, source: CalendarSyncSource, event
|
||||
return normalize_caldav_href(source.collection_url, f"{safe_uid}-{event.id}.ics")
|
||||
|
||||
|
||||
def apply_caldav_write_metadata(event: CalendarEvent, *, source: CalendarSyncSource, href: str, etag: str | None, raw_ics: str) -> None:
|
||||
event.source_kind = "caldav"
|
||||
event.source_href = href
|
||||
event.etag = etag
|
||||
event.raw_ics = raw_ics
|
||||
metadata = dict(event.metadata_ or {})
|
||||
metadata["caldav"] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag}
|
||||
event.metadata_ = metadata
|
||||
|
||||
|
||||
def import_ics_event(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -2340,6 +2780,32 @@ def import_ics_event(
|
||||
target_calendar_id = calendar_id or (default_calendar.id if default_calendar else None)
|
||||
if not target_calendar_id:
|
||||
raise CalendarError("calendar_id is required because no default calendar exists")
|
||||
target_calendar = get_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=target_calendar_id,
|
||||
)
|
||||
# Source creation also locks the calendar row. Holding it here makes the
|
||||
# public import's provenance decision stable until create/update has queued
|
||||
# its desired state.
|
||||
(
|
||||
session.query(CalendarCollection)
|
||||
.filter(
|
||||
CalendarCollection.id == target_calendar.id,
|
||||
CalendarCollection.tenant_id == tenant_id,
|
||||
CalendarCollection.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
target_source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=target_calendar_id,
|
||||
for_update=True,
|
||||
)
|
||||
synchronized_target = target_source is not None
|
||||
existing = None
|
||||
if upsert:
|
||||
existing = (
|
||||
@@ -2353,16 +2819,27 @@ def import_ics_event(
|
||||
)
|
||||
.first()
|
||||
)
|
||||
payload = CalendarEventCreateRequest(
|
||||
calendar_id=target_calendar_id,
|
||||
source_kind=source_kind,
|
||||
source_href=source_href,
|
||||
etag=etag,
|
||||
metadata={},
|
||||
payload_values: dict[str, Any] = {
|
||||
"calendar_id": target_calendar_id,
|
||||
"metadata": {},
|
||||
**parsed,
|
||||
}
|
||||
if not synchronized_target:
|
||||
payload_values.update(
|
||||
{
|
||||
"source_kind": source_kind,
|
||||
"source_href": source_href,
|
||||
"etag": etag,
|
||||
}
|
||||
)
|
||||
payload = CalendarEventCreateRequest(**payload_values)
|
||||
if existing:
|
||||
update_payload = CalendarEventUpdateRequest(**payload.model_dump(exclude={"uid", "recurrence_id"}))
|
||||
excluded_fields = {"uid", "recurrence_id"}
|
||||
if synchronized_target:
|
||||
excluded_fields.update({"source_kind", "source_href", "etag"})
|
||||
update_payload = CalendarEventUpdateRequest(
|
||||
**payload.model_dump(exclude=excluded_fields)
|
||||
)
|
||||
event = update_event(session, tenant_id=tenant_id, user_id=user_id, event_id=existing.id, payload=update_payload)
|
||||
event.raw_ics = raw_ics
|
||||
return event
|
||||
|
||||
@@ -9,7 +9,8 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
|
||||
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
|
||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||
from govoplan_calendar.backend.service import (
|
||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||
@@ -226,6 +227,18 @@ END:VCALENDAR</C:calendar-data>
|
||||
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
|
||||
CalDAVClient(collection_url="/remote.php/dav")
|
||||
|
||||
def test_object_url_rejects_off_origin_and_cross_collection_hrefs(self) -> None:
|
||||
client = CalDAVClient(collection_url="https://dav.example.test/cal")
|
||||
|
||||
with self.assertRaisesRegex(CalDAVError, "collection origin"):
|
||||
client.object_url("https://evil.example.test/steal.ics")
|
||||
with self.assertRaisesRegex(CalDAVError, "collection path"):
|
||||
client.object_url("https://dav.example.test/other/steal.ics")
|
||||
self.assertEqual(
|
||||
client.object_url("/cal/event.ics"),
|
||||
"https://dav.example.test/cal/event.ics",
|
||||
)
|
||||
|
||||
|
||||
class CalDAVSyncTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -392,15 +405,14 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
self.assertEqual(len(provider.requests), 1)
|
||||
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
|
||||
|
||||
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
|
||||
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"'])
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-1"'])
|
||||
event = create_event(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
@@ -414,14 +426,18 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
self.assertEqual(fake.puts, [])
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(len(fake.puts), 2)
|
||||
operations = session.query(CalendarOutboxOperation).all()
|
||||
self.assertCountEqual([operation.status for operation in operations], ["superseded", "pending"])
|
||||
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
|
||||
|
||||
self.assertEqual(len(fake.puts), 1)
|
||||
self.assertTrue(fake.puts[0]["create"])
|
||||
self.assertEqual(fake.puts[1]["etag"], '"etag-1"')
|
||||
self.assertFalse(fake.puts[1]["create"])
|
||||
self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
|
||||
self.assertEqual(event.source_kind, "caldav")
|
||||
self.assertEqual(event.etag, '"etag-2"')
|
||||
self.assertEqual(event.etag, '"etag-1"')
|
||||
self.assertIn("SUMMARY:Updated", event.raw_ics or "")
|
||||
|
||||
def test_update_event_reports_remote_etag_conflict(self) -> None:
|
||||
@@ -433,10 +449,22 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
session.add(event)
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(fail_precondition=True)
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
with self.assertRaisesRegex(CalendarError, "changed remotely"):
|
||||
fake = FakeCalDAVClient(
|
||||
fail_precondition=True,
|
||||
objects={event.source_href: recurring_resource()},
|
||||
)
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
session.commit()
|
||||
result = dispatch_calendar_outbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
client_factory=lambda _session, _source: fake,
|
||||
)
|
||||
|
||||
self.assertEqual(result["failed"], 1)
|
||||
operation = session.query(CalendarOutboxOperation).one()
|
||||
self.assertEqual(operation.status, "conflict")
|
||||
self.assertIn("changed remotely", operation.last_error or "")
|
||||
|
||||
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
|
||||
session = self.Session()
|
||||
@@ -450,9 +478,9 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-2"'])
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
delete_event(session, tenant_id="tenant-1", event_id=override.id)
|
||||
session.commit()
|
||||
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
|
||||
|
||||
self.assertEqual(len(fake.puts), 1)
|
||||
self.assertEqual(fake.deletes, [])
|
||||
|
||||
27
tests/test_capabilities.py
Normal file
27
tests/test_capabilities.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
||||
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
|
||||
|
||||
|
||||
class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
|
||||
provider = SqlCalendarSchedulingProvider()
|
||||
|
||||
with self.assertRaises(CalendarCapabilityError):
|
||||
provider.create_event(
|
||||
object(),
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
request=CalendarEventRequest(
|
||||
summary="x" * 501,
|
||||
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1819
tests/test_outbox.py
Normal file
1819
tests/test_outbox.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
|
||||
from govoplan_calendar.backend.service import delete_calendar, event_response
|
||||
from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
|
||||
self.assertEqual(response.start_at.tzinfo, timezone.utc)
|
||||
|
||||
|
||||
class CalendarVisibilityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def calendar(**overrides):
|
||||
values = {
|
||||
"visibility": "private",
|
||||
"owner_type": "user",
|
||||
"owner_id": "owner-1",
|
||||
"created_by_user_id": "creator-1",
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
def test_tenant_shared_and_public_calendars_are_visible_to_readers(self) -> None:
|
||||
for visibility in ("tenant", "shared", "public"):
|
||||
with self.subTest(visibility=visibility):
|
||||
self.assertTrue(
|
||||
calendar_is_visible_to_principal(
|
||||
self.calendar(visibility=visibility),
|
||||
user_id="reader-1",
|
||||
)
|
||||
)
|
||||
|
||||
def test_private_calendar_is_visible_to_user_owner_creator_or_group_member(self) -> None:
|
||||
self.assertTrue(
|
||||
calendar_is_visible_to_principal(
|
||||
self.calendar(),
|
||||
user_id="owner-1",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
calendar_is_visible_to_principal(
|
||||
self.calendar(),
|
||||
user_id="creator-1",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
calendar_is_visible_to_principal(
|
||||
self.calendar(owner_type="group", owner_id="group-1"),
|
||||
user_id="member-1",
|
||||
group_ids=("group-1",),
|
||||
)
|
||||
)
|
||||
|
||||
def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None:
|
||||
calendar = self.calendar()
|
||||
self.assertFalse(
|
||||
calendar_is_visible_to_principal(
|
||||
calendar,
|
||||
user_id="other-1",
|
||||
group_ids=("group-2",),
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
calendar_is_visible_to_principal(
|
||||
calendar,
|
||||
user_id="other-1",
|
||||
can_admin=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CalendarServiceDeleteTests(unittest.TestCase):
|
||||
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
|
||||
session = FakeSession()
|
||||
|
||||
Reference in New Issue
Block a user