feat(calendar): make external mutations durable

This commit is contained in:
2026-07-20 16:58:45 +02:00
parent 371a00aff5
commit a6cd64e3f9
15 changed files with 4726 additions and 152 deletions

View File

@@ -24,10 +24,88 @@ The first standalone module provides:
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS - 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 - 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 - 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 - 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. 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 ## Integration Points
### Scheduling ### Scheduling

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import posixpath
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -241,8 +242,23 @@ class CalDAVClient:
return parse_multistatus(payload) return parse_multistatus(payload)
def fetch_object(self, href: str) -> str: def fetch_object(self, href: str) -> str:
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200}) return self.fetch_object_state(href).calendar_data or ""
return payload.decode("utf-8")
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: 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"} 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) return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str: 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: 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) _status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)

View 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,
)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text 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) 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",
]

View File

@@ -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_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.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.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 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: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: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: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: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: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: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 = ( ROLE_TEMPLATES = (
@@ -44,11 +59,11 @@ ROLE_TEMPLATES = (
"calendar:calendar:read", "calendar:calendar:read",
"calendar:calendar:write", "calendar:calendar:write",
"calendar:event:read", "calendar:event:read",
"calendar:event:write", CALENDAR_EVENT_WRITE_SCOPE,
"calendar:event:delete", "calendar:event:delete",
"calendar:event:import", "calendar:event:import",
"calendar:event:export", "calendar:event:export",
"calendar:availability:read", CALENDAR_AVAILABILITY_READ_SCOPE,
), ),
), ),
RoleTemplate( RoleTemplate(
@@ -59,20 +74,32 @@ ROLE_TEMPLATES = (
"calendar:calendar:read", "calendar:calendar:read",
"calendar:event:read", "calendar:event:read",
"calendar:event:export", "calendar:event:export",
"calendar:availability:read", CALENDAR_AVAILABILITY_READ_SCOPE,
), ),
), ),
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: 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 { return {
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(), "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_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_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_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 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( manifest = ModuleManifest(
id="calendar", id="calendar",
name="Calendar", name="Calendar",
version="0.1.8", version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"), 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, permissions=PERMISSIONS,
route_factory=_calendar_router, route_factory=_calendar_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,), 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),), nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
frontend=FrontendModule( frontend=FrontendModule(
module_id="calendar", module_id="calendar",
@@ -109,6 +166,7 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider( retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection, calendar_models.CalendarCollection,
calendar_models.CalendarEvent, calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential, calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource, calendar_models.CalendarSyncSource,
label="Calendar", label="Calendar",
@@ -119,6 +177,7 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
calendar_models.CalendarCollection, calendar_models.CalendarCollection,
calendar_models.CalendarEvent, calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential, calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource, calendar_models.CalendarSyncSource,
label="Calendar", label="Calendar",

View File

@@ -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()

View File

@@ -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")

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem 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_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.db.models import CalendarEvent
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
@@ -35,6 +36,9 @@ from govoplan_calendar.backend.schemas import (
CalendarFreeBusyRequest, CalendarFreeBusyRequest,
CalendarFreeBusyResponse, CalendarFreeBusyResponse,
CalendarIcsImportRequest, CalendarIcsImportRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse, CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse, CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest, CalendarSyncSourceCreateRequest,
@@ -44,6 +48,14 @@ from govoplan_calendar.backend.schemas import (
CalendarSyncSourceSyncResponse, CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest, 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 ( from govoplan_calendar.backend.service import (
CALENDAR_EVENTS_COLLECTION, CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE, CALENDAR_EVENT_RESOURCE,
@@ -62,7 +74,6 @@ from govoplan_calendar.backend.service import (
delete_event, delete_event,
discover_caldav_calendars, discover_caldav_calendars,
event_response, event_response,
get_caldav_source,
get_event, get_event,
import_ics_event, import_ics_event,
list_freebusy, list_freebusy,
@@ -220,13 +231,25 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source)) 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) @router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars( def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:calendar:read") _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() session.commit()
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars]) 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) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
session.rollback() 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) @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) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
session.rollback() 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) @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) @router.get("/events", response_model=CalendarEventListResponse)
def api_list_events( def api_list_events(
calendar_id: str | None = None, calendar_id: str | None = None,
@@ -600,7 +744,7 @@ def api_create_event(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:event:write") _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try: try:
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit() session.commit()
@@ -631,7 +775,7 @@ def api_update_event(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:event:write") _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try: try:
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload) event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
session.commit() session.commit()
@@ -648,7 +792,7 @@ def api_delete_event(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), 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: try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id) delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
session.commit() session.commit()
@@ -713,7 +857,7 @@ def api_freebusy(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:availability:read") _require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try: try:
busy = list_freebusy( busy = list_freebusy(
session, session,

View File

@@ -11,6 +11,11 @@ from govoplan_core.api.v1.schemas import DeltaDeletedItem
CalendarOwnerType = Literal["tenant", "user", "group", "resource"] CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"] CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"] CalendarDeleteEventAction = Literal["delete", "move"]
CalendarBulkMoveExternalAction = Literal[
"detach_keep_remote",
"copy_to_remote",
"remote_move",
]
CalendarSyncAuthType = Literal["none", "basic", "bearer"] CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"] CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"] CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
@@ -50,6 +55,7 @@ class CalendarCollectionDeleteRequest(BaseModel):
event_action: CalendarDeleteEventAction = "delete" event_action: CalendarDeleteEventAction = "delete"
target_calendar_id: str | None = None target_calendar_id: str | None = None
make_target_default: bool = False make_target_default: bool = False
external_action: CalendarBulkMoveExternalAction | None = None
class CalendarCollectionResponse(BaseModel): class CalendarCollectionResponse(BaseModel):
@@ -238,6 +244,50 @@ class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list) 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): class CalendarEventCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import posixpath
import re import re
import uuid import uuid
import os import os
@@ -9,16 +10,16 @@ import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Callable from typing import Any, Callable, Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from defusedxml import ElementTree as SafeElementTree from defusedxml import ElementTree as SafeElementTree
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session 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.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.runtime import get_registry
from govoplan_calendar.backend.schemas import ( from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest, CalendarCalDavDiscoveryRequest,
@@ -58,12 +59,20 @@ SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]: 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 { return {
f"{prefix}calendar_id": event.calendar_id, 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}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}end_at": response_datetime(event.end_at).isoformat() if event.end_at else None,
f"{prefix}summary": event.summary, f"{prefix}summary": event.summary,
f"{prefix}status": event.status, 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: if ensure_default:
ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id) ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id)
return ( calendars = (
session.query(CalendarCollection) session.query(CalendarCollection)
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None))
.order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc()) .order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc())
.all() .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: 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: if payload.target_calendar_id == calendar_id:
raise CalendarError("Target calendar must be different from the deleted calendar") 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) target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
for event in calendar.events: source = None
if event.deleted_at is None: target_source = None
event.calendar_id = target_calendar.id 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: if payload.make_target_default:
clear_default_calendar(session, tenant_id=tenant_id) clear_default_calendar(session, tenant_id=tenant_id)
target_calendar.is_default = True target_calendar.is_default = True
elif payload.event_action != "delete": elif payload.event_action != "delete":
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}") 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: if calendar.is_default:
calendar.is_default = False calendar.is_default = False
calendar.deleted_at = deleted_at 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: 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 = 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) source_kind = normalize_source_kind(payload.source_kind)
collection_url = normalize_sync_source_url(source_kind, payload.collection_url) 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) 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: 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 = 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: if payload.calendar_id is not None:
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) 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 source.calendar_id = calendar.id
else: else:
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) 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: if value is not None:
setattr(source, field_name, value) setattr(source, field_name, value)
if payload.collection_url is not None: 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: if payload.metadata is not None:
source.metadata_ = payload.metadata source.metadata_ = payload.metadata
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS: 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: 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 source.deleted_at = deleted_at
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) 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, force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id) 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) 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) client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token)
stats = CalendarCalDavSyncStats() stats = CalendarCalDavSyncStats()
@@ -1452,6 +1804,15 @@ def soft_delete_unseen_source_events(
.all() .all()
) )
for event in events: 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: if event.source_href not in seen_hrefs:
previous = calendar_event_change_payload(event, prefix="previous_") previous = calendar_event_change_payload(event, prefix="previous_")
event.deleted_at = deleted_at event.deleted_at = deleted_at
@@ -1714,6 +2075,14 @@ def apply_caldav_report(
seen_hrefs: set[str] = set() seen_hrefs: set[str] = set()
for item in report.objects: for item in report.objects:
href = normalize_caldav_href(source.collection_url, item.href) 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: if item.deleted:
stats.deleted += soft_delete_caldav_href(session, source=source, href=href) stats.deleted += soft_delete_caldav_href(session, source=source, href=href)
continue 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: 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: 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: if not calendar_id:
raise CalendarError("calendar_id is required because no default calendar exists") raise CalendarError("calendar_id is required because no default calendar exists")
get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) 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) 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( event = CalendarEvent(
tenant_id=tenant_id, tenant_id=tenant_id,
calendar_id=calendar_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.add(event)
session.flush() session.flush()
if should_push_event_to_caldav(source=source, event=event): 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) record_calendar_event_change(session, event=event, operation="created", user_id=user_id)
return event return event
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent: 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) event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_") 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) 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: 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) assert_sync_mutation_allowed(target_source)
if payload.calendar_id != event.calendar_id and original_source and original_caldav: if payload.calendar_id != event.calendar_id and original_source and original_caldav:
assert_sync_mutation_allowed(original_source) 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_kind = "local"
event.source_href = None event.source_href = None
event.etag = 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) validate_event_time(event)
event.raw_ics = None event.raw_ics = None
session.flush() 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): if should_push_event_to_caldav(source=target_source, event=event):
push_caldav_event_resource( from govoplan_calendar.backend.outbox import enqueue_caldav_put
session,
source=target_source, enqueue_caldav_put(session, source=target_source, event_model=event)
event=event,
create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id,
)
record_calendar_event_change(session, event=event, operation="updated", user_id=user_id, previous=previous) record_calendar_event_change(session, event=event, operation="updated", user_id=user_id, previous=previous)
return event return event
def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None: 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) event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_") 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) assert_sync_mutation_allowed(source)
if should_push_event_to_caldav(source=source, event=event): 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() event.deleted_at = utcnow()
session.flush() session.flush()
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) 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: def active_sync_source_for_calendar(
return ( session: Session,
*,
tenant_id: str,
calendar_id: str,
for_update: bool = False,
) -> CalendarSyncSource | None:
query = (
session.query(CalendarSyncSource) session.query(CalendarSyncSource)
.filter( .filter(
CalendarSyncSource.tenant_id == tenant_id, 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), CalendarSyncSource.deleted_at.is_(None),
) )
.order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc()) .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: 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: def assert_sync_mutation_allowed(source: CalendarSyncSource | None) -> None:
if source is None: if source is None:
return 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": 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") 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: 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) return bool(
source
and source.deleted_at is None
def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None: and source.sync_enabled
href = event.source_href or generated_caldav_href(session, source=source, event=event) and source.source_kind == "caldav"
full_href = normalize_caldav_href(source.collection_url, href) and source.sync_direction == "two_way"
resource_events = caldav_resource_events(session, source=source, href=full_href) if event.source_href else [event] and event.deleted_at is None
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()
def caldav_resource_events(session: Session, *, source: CalendarSyncSource, href: str) -> list[CalendarEvent]: 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") 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( def import_ics_event(
session: Session, session: Session,
*, *,
@@ -2340,6 +2780,32 @@ def import_ics_event(
target_calendar_id = calendar_id or (default_calendar.id if default_calendar else None) target_calendar_id = calendar_id or (default_calendar.id if default_calendar else None)
if not target_calendar_id: if not target_calendar_id:
raise CalendarError("calendar_id is required because no default calendar exists") 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 existing = None
if upsert: if upsert:
existing = ( existing = (
@@ -2353,16 +2819,27 @@ def import_ics_event(
) )
.first() .first()
) )
payload = CalendarEventCreateRequest( payload_values: dict[str, Any] = {
calendar_id=target_calendar_id, "calendar_id": target_calendar_id,
source_kind=source_kind, "metadata": {},
source_href=source_href,
etag=etag,
metadata={},
**parsed, **parsed,
) }
if not synchronized_target:
payload_values.update(
{
"source_kind": source_kind,
"source_href": source_href,
"etag": etag,
}
)
payload = CalendarEventCreateRequest(**payload_values)
if existing: 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 = update_event(session, tenant_id=tenant_id, user_id=user_id, event_id=existing.id, payload=update_payload)
event.raw_ics = raw_ics event.raw_ics = raw_ics
return event return event

View File

@@ -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_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.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.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX, CALDAV_INTERNAL_CREDENTIAL_PREFIX,
@@ -226,6 +227,18 @@ END:VCALENDAR</C:calendar-data>
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"): with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
CalDAVClient(collection_url="/remote.php/dav") 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): class CalDAVSyncTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -392,36 +405,39 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(len(provider.requests), 1) self.assertEqual(len(provider.requests), 1)
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok") 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 = self.Session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) 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")) 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")) 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() session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"']) fake = FakeCalDAVClient(put_etags=['"etag-1"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake): event = create_event(
event = create_event( session,
session, tenant_id="tenant-1",
tenant_id="tenant-1", user_id=None,
user_id=None, payload=CalendarEventCreateRequest(
payload=CalendarEventCreateRequest( calendar_id=calendar.id,
calendar_id=calendar.id, uid="event-1@example.test",
uid="event-1@example.test", summary="Planning",
summary="Planning", start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc), end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc), ),
), )
) update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated")) self.assertEqual(fake.puts, [])
session.commit() 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.assertTrue(fake.puts[0]["create"])
self.assertEqual(fake.puts[1]["etag"], '"etag-1"') self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
self.assertFalse(fake.puts[1]["create"])
self.assertEqual(event.source_kind, "caldav") 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 "") self.assertIn("SUMMARY:Updated", event.raw_ics or "")
def test_update_event_reports_remote_etag_conflict(self) -> None: def test_update_event_reports_remote_etag_conflict(self) -> None:
@@ -433,10 +449,22 @@ class CalDAVSyncTests(unittest.TestCase):
session.add(event) session.add(event)
session.commit() session.commit()
fake = FakeCalDAVClient(fail_precondition=True) fake = FakeCalDAVClient(
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake): fail_precondition=True,
with self.assertRaisesRegex(CalendarError, "changed remotely"): objects={event.source_href: recurring_resource()},
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated")) )
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: def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
session = self.Session() session = self.Session()
@@ -450,9 +478,9 @@ class CalDAVSyncTests(unittest.TestCase):
session.commit() session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-2"']) 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)
delete_event(session, tenant_id="tenant-1", event_id=override.id)
session.commit() session.commit()
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1) self.assertEqual(len(fake.puts), 1)
self.assertEqual(fake.deletes, []) self.assertEqual(fake.deletes, [])

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse 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: class FakeSession:
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
self.assertEqual(response.start_at.tzinfo, timezone.utc) 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): class CalendarServiceDeleteTests(unittest.TestCase):
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None: def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
session = FakeSession() session = FakeSession()