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

@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import posixpath
import urllib.error
import urllib.parse
import urllib.request
@@ -241,8 +242,23 @@ class CalDAVClient:
return parse_multistatus(payload)
def fetch_object(self, href: str) -> str:
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
return payload.decode("utf-8")
return self.fetch_object_state(href).calendar_data or ""
def fetch_object_state(self, href: str) -> CalDAVObject:
"""Fetch a resource together with its ETag for outbox reconciliation."""
_status, headers, payload = self.request_raw(
"GET",
self.object_url(href),
body=None,
depth=None,
expected={200},
)
return CalDAVObject(
href=href,
etag=response_etag(headers),
calendar_data=payload.decode("utf-8"),
)
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
headers = {"Content-Type": "text/calendar; charset=utf-8"}
@@ -279,7 +295,32 @@ class CalDAVClient:
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str:
return urllib.parse.urljoin(self.collection_url, href)
candidate = urllib.parse.urljoin(self.collection_url, href)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
if (
candidate_parts.username
or candidate_parts.password
or (
candidate_parts.scheme.lower(),
candidate_parts.hostname,
candidate_parts.port,
)
!= (
collection_parts.scheme.lower(),
collection_parts.hostname,
collection_parts.port,
)
):
raise CalDAVError("CalDAV object href must use the configured collection origin")
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise CalDAVError("CalDAV object href must remain inside the configured collection path")
if candidate_parts.query or candidate_parts.fragment:
raise CalDAVError("CalDAV object href must not contain a query or fragment")
return urllib.parse.urlunparse(candidate_parts)
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)

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
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
@@ -151,4 +151,69 @@ class CalendarSyncCredential(Base, TimestampMixin):
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"]
class CalendarOutboxOperation(Base, TimestampMixin):
"""Durable desired state for one external CalDAV resource.
Rows are inserted in the same transaction as the local event mutation. A
dispatcher leases and commits the row before performing network I/O, so a
worker crash can be detected and reconciled without repeating a blind
write.
"""
__tablename__ = "calendar_outbox_operations"
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_calendar_outbox_operations_idempotency_key"),
Index("ix_calendar_outbox_due", "status", "available_at", "created_at"),
Index("ix_calendar_outbox_tenant_status", "tenant_id", "status"),
Index("ix_calendar_outbox_resource", "source_id", "resource_href", "created_at"),
Index("ix_calendar_outbox_lease", "status", "lease_expires_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_id: Mapped[str] = mapped_column(
ForeignKey("calendar_sync_sources.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event_id: Mapped[str | None] = mapped_column(
ForeignKey("calendar_events.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
operation_kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
resource_href: Mapped[str] = mapped_column(String(1000), nullable=False)
payload_ics: Mapped[str | None] = mapped_column(Text)
payload_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
expected_etag: Mapped[str | None] = mapped_column(String(255))
idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, default=8, nullable=False)
available_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
index=True,
)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
lease_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reconciled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_etag: Mapped[str | None] = mapped_column(String(255))
last_error: Mapped[str | None] = mapped_column(Text)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
source: Mapped[CalendarSyncSource] = relationship()
event: Mapped[CalendarEvent | None] = relationship()
__all__ = [
"CalendarCollection",
"CalendarEvent",
"CalendarOutboxOperation",
"CalendarSyncCredential",
"CalendarSyncSource",
"new_uuid",
]

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_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import (
CALENDAR_AVAILABILITY_READ_SCOPE,
CALENDAR_EVENT_WRITE_SCOPE,
CAPABILITY_CALENDAR_OUTBOX,
CAPABILITY_CALENDAR_SCHEDULING,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
@@ -28,11 +43,11 @@ PERMISSIONS = (
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
_permission("calendar:event:write", "Manage calendar events", "Create and edit calendar events."),
_permission(CALENDAR_EVENT_WRITE_SCOPE, "Manage calendar events", "Create and edit calendar events."),
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
_permission("calendar:availability:read", "Read availability", "Read free/busy and availability data for integrations."),
_permission(CALENDAR_AVAILABILITY_READ_SCOPE, "Read availability", "Read free/busy and availability data for integrations."),
)
ROLE_TEMPLATES = (
@@ -44,11 +59,11 @@ ROLE_TEMPLATES = (
"calendar:calendar:read",
"calendar:calendar:write",
"calendar:event:read",
"calendar:event:write",
CALENDAR_EVENT_WRITE_SCOPE,
"calendar:event:delete",
"calendar:event:import",
"calendar:event:export",
"calendar:availability:read",
CALENDAR_AVAILABILITY_READ_SCOPE,
),
),
RoleTemplate(
@@ -59,20 +74,32 @@ ROLE_TEMPLATES = (
"calendar:calendar:read",
"calendar:event:read",
"calendar:event:export",
"calendar:availability:read",
CALENDAR_AVAILABILITY_READ_SCOPE,
),
),
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarOutboxOperation,
CalendarSyncCredential,
CalendarSyncSource,
)
return {
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.status.in_(("pending", "retry", "in_progress")),
)
.count(),
}
@@ -85,16 +112,46 @@ def _calendar_router(context: ModuleContext):
return router
def _calendar_scheduling_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
return SqlCalendarSchedulingProvider()
def _calendar_outbox_provider(context: ModuleContext) -> object:
from govoplan_calendar.backend.outbox import (
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
SqlCalendarOutboxProvider,
)
return SqlCalendarOutboxProvider(
terminal_retention_days=getattr(
context.settings,
"calendar_outbox_terminal_retention_days",
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
)
)
manifest = ModuleManifest(
id="calendar",
name="Calendar",
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
provides_interfaces=(
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
),
permissions=PERMISSIONS,
route_factory=_calendar_router,
role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
},
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
frontend=FrontendModule(
module_id="calendar",
@@ -109,6 +166,7 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
@@ -119,6 +177,7 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",

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.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
@@ -35,6 +36,9 @@ from govoplan_calendar.backend.schemas import (
CalendarFreeBusyRequest,
CalendarFreeBusyResponse,
CalendarIcsImportRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest,
@@ -44,6 +48,14 @@ from govoplan_calendar.backend.schemas import (
CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest,
)
from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_response,
discard_calendar_outbox_operation,
dispatch_calendar_outbox,
list_calendar_outbox_operations,
reconcile_calendar_outbox_operation,
retry_calendar_outbox_operation,
)
from govoplan_calendar.backend.service import (
CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE,
@@ -62,7 +74,6 @@ from govoplan_calendar.backend.service import (
delete_event,
discover_caldav_calendars,
event_response,
get_caldav_source,
get_event,
import_ics_event,
list_freebusy,
@@ -220,13 +231,25 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse:
return CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(operation)
)
@router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:read")
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
calendars = list_calendars(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_admin=principal.has("calendar:calendar:admin"),
)
session.commit()
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
@@ -343,7 +366,12 @@ def api_delete_sync_source(
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse)
@@ -473,7 +501,12 @@ def api_delete_caldav_source(
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
@@ -530,6 +563,117 @@ def api_sync_due_caldav_sources(
)
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
def api_list_caldav_outbox(
operation_status: str | None = Query(default=None, alias="status"),
source_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
operations = list_calendar_outbox_operations(
session,
tenant_id=principal.tenant_id,
status=operation_status,
source_id=source_id,
limit=limit,
)
return CalendarOutboxOperationListResponse(
operations=[_outbox_operation_response(operation) for operation in operations]
)
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
def api_dispatch_caldav_outbox(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
)
@router.post(
"/caldav/outbox/{operation_id}/retry",
response_model=CalendarOutboxOperationResponse,
)
def api_retry_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = retry_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/reconcile",
response_model=CalendarOutboxOperationResponse,
)
def api_reconcile_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = reconcile_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/discard",
response_model=CalendarOutboxOperationResponse,
)
def api_discard_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
"""Abandon local desired state and allow the next sync to accept remote."""
_require_scope(principal, "calendar:calendar:admin")
try:
operation = discard_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.get("/events", response_model=CalendarEventListResponse)
def api_list_events(
calendar_id: str | None = None,
@@ -600,7 +744,7 @@ def api_create_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
@@ -631,7 +775,7 @@ def api_update_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
session.commit()
@@ -648,7 +792,7 @@ def api_delete_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
_require_any_scope(principal, "calendar:event:delete", CALENDAR_EVENT_WRITE_SCOPE)
try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
session.commit()
@@ -713,7 +857,7 @@ def api_freebusy(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:availability:read")
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
busy = list_freebusy(
session,

View File

@@ -11,6 +11,11 @@ from govoplan_core.api.v1.schemas import DeltaDeletedItem
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"]
CalendarBulkMoveExternalAction = Literal[
"detach_keep_remote",
"copy_to_remote",
"remote_move",
]
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
@@ -50,6 +55,7 @@ class CalendarCollectionDeleteRequest(BaseModel):
event_action: CalendarDeleteEventAction = "delete"
target_calendar_id: str | None = None
make_target_default: bool = False
external_action: CalendarBulkMoveExternalAction | None = None
class CalendarCollectionResponse(BaseModel):
@@ -238,6 +244,50 @@ class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
class CalendarOutboxOperationResponse(BaseModel):
id: str
tenant_id: str
source_id: str
event_id: str | None = None
operation_kind: str
resource_href: str
payload_fingerprint: str | None = None
expected_etag: str | None = None
idempotency_key: str
status: str
attempt_count: int
max_attempts: int
available_at: datetime
last_attempt_at: datetime | None = None
lease_expires_at: datetime | None = None
completed_at: datetime | None = None
reconciled_at: datetime | None = None
remote_etag: str | None = None
last_error: str | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarOutboxOperationListResponse(BaseModel):
operations: list[CalendarOutboxOperationResponse] = Field(default_factory=list)
class CalendarOutboxDispatchItemResponse(BaseModel):
id: str
status: str
attempt_count: int
last_error: str | None = None
class CalendarOutboxDispatchResponse(BaseModel):
processed: int = 0
succeeded: int = 0
retrying: int = 0
failed: int = 0
operations: list[CalendarOutboxDispatchItemResponse] = Field(default_factory=list)
class CalendarEventCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import posixpath
import re
import uuid
import os
@@ -9,16 +10,16 @@ import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from typing import Any, Callable, Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from defusedxml import ElementTree as SafeElementTree
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.ical import events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
from govoplan_calendar.backend.runtime import get_registry
from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
@@ -58,12 +59,20 @@ SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
metadata = event.metadata_ or {}
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
return {
f"{prefix}calendar_id": event.calendar_id,
f"{prefix}start_at": response_datetime(event.start_at).isoformat() if event.start_at else None,
f"{prefix}end_at": response_datetime(event.end_at).isoformat() if event.end_at else None,
f"{prefix}summary": event.summary,
f"{prefix}status": event.status,
f"{prefix}external_state": (
caldav.get("external_state") if isinstance(caldav, dict) else "local"
),
f"{prefix}outbox_operation_id": (
caldav.get("outbox_operation_id") if isinstance(caldav, dict) else None
),
}
@@ -324,15 +333,63 @@ def get_default_calendar(session: Session, *, tenant_id: str) -> CalendarCollect
)
def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = False, user_id: str | None = None) -> list[CalendarCollection]:
def calendar_is_visible_to_principal(
calendar: CalendarCollection,
*,
user_id: str | None,
group_ids: Iterable[str] = (),
can_admin: bool = False,
) -> bool:
"""Return whether a principal may discover a calendar collection.
Collection visibility is deliberately enforced before serializing picker
metadata. ``shared`` currently means tenant-visible; explicit per-user
sharing can narrow this rule when Calendar gains a share relation.
"""
if can_admin:
return True
if calendar.visibility in {"tenant", "shared", "public"}:
return True
if calendar.visibility != "private" or user_id is None:
return False
if calendar.created_by_user_id == user_id:
return True
if calendar.owner_type == "user":
return calendar.owner_id == user_id
if calendar.owner_type == "group":
return bool(calendar.owner_id and calendar.owner_id in set(group_ids))
return False
def list_calendars(
session: Session,
*,
tenant_id: str,
ensure_default: bool = False,
user_id: str | None = None,
group_ids: Iterable[str] = (),
can_admin: bool = False,
) -> list[CalendarCollection]:
if ensure_default:
ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id)
return (
calendars = (
session.query(CalendarCollection)
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None))
.order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc())
.all()
)
principal_group_ids = tuple(group_ids)
return [
calendar
for calendar in calendars
if calendar_is_visible_to_principal(
calendar,
user_id=user_id,
group_ids=principal_group_ids,
can_admin=can_admin,
)
]
def create_calendar(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCollectionCreateRequest) -> CalendarCollection:
@@ -387,14 +444,129 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
if payload.target_calendar_id == calendar_id:
raise CalendarError("Target calendar must be different from the deleted calendar")
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
for event in calendar.events:
if event.deleted_at is None:
event.calendar_id = target_calendar.id
source = None
target_source = None
if hasattr(session, "query"):
# Source creation takes the collection row lock before linking a
# source. Lock both collections in stable order so the mode cannot
# change between validation and the local/outbox mutation.
locked_calendars = (
session.query(CalendarCollection)
.filter(
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.id.in_((calendar.id, target_calendar.id)),
CalendarCollection.deleted_at.is_(None),
)
.order_by(CalendarCollection.id.asc())
.populate_existing()
.with_for_update()
.all()
)
locked_calendar_by_id = {item.id: item for item in locked_calendars}
if len(locked_calendar_by_id) != 2:
raise CalendarError("Source or target calendar is no longer available")
calendar = locked_calendar_by_id[calendar.id]
target_calendar = locked_calendar_by_id[target_calendar.id]
source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=calendar.id,
)
target_source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar.id,
)
locked_sources = lock_sync_sources(session, source, target_source)
if source is not None:
source = locked_sources[source.id]
if target_source is not None:
target_source = locked_sources[target_source.id]
if payload.external_action == "remote_move":
raise CalendarError(
"external_action='remote_move' is not supported; no destructive remote move was performed"
)
if source is not None and target_source is not None:
raise CalendarError(
"Events cannot be bulk-moved between synchronized calendars; "
"external_action='remote_move' remains unsupported"
)
active_events = [event for event in calendar.events if event.deleted_at is None]
previous_event_states = (
{
event.id: calendar_event_change_payload(event, prefix="previous_")
for event in active_events
}
if hasattr(session, "query")
else {}
)
if source is not None:
if payload.external_action != "detach_keep_remote":
raise CalendarError(
"Moving events from a synchronized calendar to a local calendar requires "
"external_action='detach_keep_remote'"
)
# Retirement rejects a live delivery lease before any local event
# projection is changed, and cancels all remaining undelivered
# desired state. It never creates a remote DELETE operation.
retire_sync_source(
session,
tenant_id=tenant_id,
source=source,
deleted_at=deleted_at,
)
elif target_source is not None:
if payload.external_action != "copy_to_remote":
raise CalendarError(
"Moving local events to a synchronized calendar requires "
"external_action='copy_to_remote'"
)
if (
target_source.source_kind != "caldav"
or target_source.sync_direction != "two_way"
or not target_source.sync_enabled
):
raise CalendarError(
"external_action='copy_to_remote' requires an active two-way CalDAV target"
)
assert_sync_mutation_allowed(target_source)
elif payload.external_action is not None:
raise CalendarError(
"external_action is only valid when moving events to or from a synchronized calendar"
)
for event in active_events:
if source is not None or target_source is not None:
event.source_kind = "local"
event.source_href = None
event.etag = None
metadata = dict(event.metadata_ or {})
metadata.pop("caldav", None)
event.metadata_ = metadata
event.calendar_id = target_calendar.id
session.flush()
if target_source is not None:
from govoplan_calendar.backend.outbox import enqueue_caldav_put
for event in active_events:
enqueue_caldav_put(session, source=target_source, event_model=event)
if previous_event_states:
for event in active_events:
record_calendar_event_change(
session,
event=event,
operation="updated",
user_id=None,
previous=previous_event_states[event.id],
)
if payload.make_target_default:
clear_default_calendar(session, tenant_id=tenant_id)
target_calendar.is_default = True
elif payload.event_action != "delete":
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
elif payload.external_action is not None:
raise CalendarError("external_action is only valid when event_action='move'")
if calendar.is_default:
calendar.is_default = False
calendar.deleted_at = deleted_at
@@ -515,6 +687,30 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
calendar = (
session.query(CalendarCollection)
.filter(
CalendarCollection.id == calendar.id,
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
existing_calendar_source = (
session.query(CalendarSyncSource.id)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.calendar_id == calendar.id,
CalendarSyncSource.deleted_at.is_(None),
)
.first()
)
if existing_calendar_source is not None:
raise CalendarError(
"A calendar can have only one active synchronization source"
)
source_kind = normalize_source_kind(payload.source_kind)
collection_url = normalize_sync_source_url(source_kind, payload.collection_url)
retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url)
@@ -562,8 +758,123 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source.id,
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
normalized_collection_url = (
normalize_sync_source_url(source.source_kind, payload.collection_url)
if payload.collection_url is not None
else source.collection_url
)
materially_reconfigured = bool(
source.source_kind == "caldav"
and (
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
or normalized_collection_url != source.collection_url
or payload.sync_enabled is False
or payload.sync_direction == "inbound"
)
)
endpoint_or_calendar_changed = bool(
source.source_kind == "caldav"
and (
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
or normalized_collection_url != source.collection_url
)
)
if materially_reconfigured:
from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease,
calendar_outbox_has_unresolved_desired_state,
cancel_calendar_outbox_for_source,
)
if calendar_outbox_has_live_lease(session, source_id=source.id):
raise CalendarError(
"CalDAV source cannot be materially changed while an outbound operation has an active lease"
)
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
session,
source_id=source.id,
)
stops_outbound_delivery = bool(
(payload.sync_enabled is False and source.sync_enabled)
or (
payload.sync_direction == "inbound"
and source.sync_direction == "two_way"
)
)
if stops_outbound_delivery and unresolved_desired_state:
raise CalendarError(
"CalDAV source cannot disable outbound delivery while unresolved local desired "
"changes exist; deliver or explicitly discard them first"
)
if endpoint_or_calendar_changed and unresolved_desired_state:
raise CalendarError(
"CalDAV source endpoint or calendar cannot change while unresolved local desired "
"changes exist; deliver or explicitly discard them first"
)
if endpoint_or_calendar_changed:
sourced_event_exists = (
session.query(CalendarEvent.id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.calendar_id == source.calendar_id,
CalendarEvent.source_kind == "caldav",
CalendarEvent.source_href.is_not(None),
CalendarEvent.deleted_at.is_(None),
)
.first()
is not None
)
if sourced_event_exists:
raise CalendarError(
"CalDAV source endpoint or calendar cannot change while events still reference it; "
"detach or resync those events first"
)
cancel_calendar_outbox_for_source(
session,
source_id=source.id,
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only",
)
if payload.calendar_id is not None:
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
calendar = (
session.query(CalendarCollection)
.filter(
CalendarCollection.id == calendar.id,
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
conflicting_source = (
session.query(CalendarSyncSource.id)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.calendar_id == calendar.id,
CalendarSyncSource.id != source.id,
CalendarSyncSource.deleted_at.is_(None),
)
.first()
)
if conflicting_source is not None:
raise CalendarError(
"A calendar can have only one active synchronization source"
)
source.calendar_id = calendar.id
else:
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
@@ -583,7 +894,7 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
if value is not None:
setattr(source, field_name, value)
if payload.collection_url is not None:
source.collection_url = normalize_sync_source_url(source.source_kind, payload.collection_url)
source.collection_url = normalized_collection_url
if payload.metadata is not None:
source.metadata_ = payload.metadata
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS:
@@ -684,6 +995,31 @@ def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection
def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None:
from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease,
cancel_calendar_outbox_for_source,
)
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source.id,
CalendarSyncSource.tenant_id == tenant_id,
)
.populate_existing()
.with_for_update()
.one()
)
if calendar_outbox_has_live_lease(session, source_id=source.id):
raise CalendarError(
"CalDAV source cannot be retired while an outbound operation has an active lease"
)
cancel_calendar_outbox_for_source(
session,
source_id=source.id,
reason="CalDAV source was retired before queued external changes were delivered",
)
source.deleted_at = deleted_at
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
@@ -933,6 +1269,22 @@ def sync_caldav_source(
force_full: bool = False,
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id)
# Serialize inbound application with local desired-state snapshots and
# outbound delivery for this source. The lock intentionally spans the
# REPORT and its application so an older report cannot land after a newer
# outbound write.
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source.id,
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.source_kind == "caldav",
CalendarSyncSource.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token)
stats = CalendarCalDavSyncStats()
@@ -1452,6 +1804,15 @@ def soft_delete_unseen_source_events(
.all()
)
for event in events:
if effective_source_kind == "caldav" and event.source_href:
from govoplan_calendar.backend.outbox import calendar_outbox_has_active_desired_state
if calendar_outbox_has_active_desired_state(
session,
source_id=source.id,
href=event.source_href,
):
continue
if event.source_href not in seen_hrefs:
previous = calendar_event_change_payload(event, prefix="previous_")
event.deleted_at = deleted_at
@@ -1714,6 +2075,14 @@ def apply_caldav_report(
seen_hrefs: set[str] = set()
for item in report.objects:
href = normalize_caldav_href(source.collection_url, item.href)
from govoplan_calendar.backend.outbox import calendar_outbox_has_active_desired_state
if calendar_outbox_has_active_desired_state(session, source_id=source.id, href=href):
# Do not let an older remote representation erase a committed
# local desired state while its outbound operation is pending.
seen_hrefs.add(href)
stats.unchanged += 1
continue
if item.deleted:
stats.deleted += soft_delete_caldav_href(session, source=source, href=href)
continue
@@ -1899,7 +2268,33 @@ def soft_delete_caldav_href(session: Session, *, source: CalendarSyncSource, hre
def normalize_caldav_href(collection_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(collection_url), href)
collection = ensure_collection_url(collection_url)
candidate = urllib.parse.urljoin(collection, href)
collection_parts = urllib.parse.urlparse(collection)
candidate_parts = urllib.parse.urlparse(candidate)
if (
candidate_parts.username
or candidate_parts.password
or (
candidate_parts.scheme.lower(),
candidate_parts.hostname,
candidate_parts.port,
)
!= (
collection_parts.scheme.lower(),
collection_parts.hostname,
collection_parts.port,
)
):
raise CalendarError("CalDAV resource href must use the configured collection origin")
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise CalendarError("CalDAV resource href must remain inside the configured collection path")
if candidate_parts.query or candidate_parts.fragment:
raise CalendarError("CalDAV resource href must not contain a query or fragment")
return urllib.parse.urlunparse(candidate_parts)
def mark_calendar_caldav(calendar: CalendarCollection, source: CalendarSyncSource) -> None:
@@ -2044,8 +2439,25 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
if not calendar_id:
raise CalendarError("calendar_id is required because no default calendar exists")
get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=calendar_id,
for_update=True,
)
assert_sync_mutation_allowed(source)
supplied_sync_fields = bool(
payload.source_href is not None
or payload.etag is not None
or (
"source_kind" in payload.model_fields_set
and payload.source_kind != "local"
)
)
if source is not None and supplied_sync_fields:
raise CalendarError(
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
)
event = CalendarEvent(
tenant_id=tenant_id,
calendar_id=calendar_id,
@@ -2084,24 +2496,63 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
session.add(event)
session.flush()
if should_push_event_to_caldav(source=source, event=event):
push_caldav_event_resource(session, source=source, event=event, create=True)
from govoplan_calendar.backend.outbox import enqueue_caldav_put
enqueue_caldav_put(session, source=source, event_model=event)
record_calendar_event_change(session, event=event, operation="created", user_id=user_id)
return event
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
event_locator = (
session.query(CalendarEvent.calendar_id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.id == event_id,
CalendarEvent.deleted_at.is_(None),
)
.first()
)
if event_locator is None:
raise CalendarError("Calendar event not found")
original_calendar_id = event_locator[0]
target_calendar_id = payload.calendar_id or original_calendar_id
if payload.calendar_id is not None:
get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id)
original_source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=original_calendar_id,
)
target_source = (
original_source
if target_calendar_id == original_calendar_id
else active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar_id,
)
)
locked_sources = lock_sync_sources(session, original_source, target_source)
if original_source is not None:
original_source = locked_sources[original_source.id]
if target_source is not None:
target_source = locked_sources[target_source.id]
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_")
original_calendar_id = event.calendar_id
original_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set
if (original_source is not None or target_source is not None) and supplied_sync_fields:
raise CalendarError(
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
)
if payload.calendar_id is not None:
get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
assert_sync_mutation_allowed(target_source)
if payload.calendar_id != event.calendar_id and original_source and original_caldav:
assert_sync_mutation_allowed(original_source)
push_caldav_delete_for_event(session, source=original_source, event=event)
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
enqueue_caldav_delete(session, source=original_source, event_model=event)
event.source_kind = "local"
event.source_href = None
event.etag = None
@@ -2149,32 +2600,52 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
validate_event_time(event)
event.raw_ics = None
session.flush()
target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
if should_push_event_to_caldav(source=target_source, event=event):
push_caldav_event_resource(
session,
source=target_source,
event=event,
create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id,
)
from govoplan_calendar.backend.outbox import enqueue_caldav_put
enqueue_caldav_put(session, source=target_source, event_model=event)
record_calendar_event_change(session, event=event, operation="updated", user_id=user_id, previous=previous)
return event
def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None:
event_locator = (
session.query(CalendarEvent.calendar_id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.id == event_id,
CalendarEvent.deleted_at.is_(None),
)
.first()
)
if event_locator is None:
raise CalendarError("Calendar event not found")
source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=event_locator[0],
for_update=True,
)
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_")
source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
assert_sync_mutation_allowed(source)
if should_push_event_to_caldav(source=source, event=event):
push_caldav_delete_for_event(session, source=source, event=event)
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
enqueue_caldav_delete(session, source=source, event_model=event)
event.deleted_at = utcnow()
session.flush()
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous)
def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None:
return (
def active_sync_source_for_calendar(
session: Session,
*,
tenant_id: str,
calendar_id: str,
for_update: bool = False,
) -> CalendarSyncSource | None:
query = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
@@ -2182,8 +2653,30 @@ def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calenda
CalendarSyncSource.deleted_at.is_(None),
)
.order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc())
.first()
)
if for_update:
query = query.populate_existing().with_for_update()
return query.first()
def lock_sync_sources(
session: Session,
*sources: CalendarSyncSource | None,
) -> dict[str, CalendarSyncSource]:
"""Lock source rows in stable order before producing desired snapshots."""
source_ids = sorted({source.id for source in sources if source is not None})
if not source_ids:
return {}
locked_sources = (
session.query(CalendarSyncSource)
.filter(CalendarSyncSource.id.in_(source_ids))
.order_by(CalendarSyncSource.id.asc())
.populate_existing()
.with_for_update()
.all()
)
return {source.id: source for source in locked_sources}
def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None:
@@ -2203,6 +2696,12 @@ def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calen
def assert_sync_mutation_allowed(source: CalendarSyncSource | None) -> None:
if source is None:
return
if source.deleted_at is not None:
raise CalendarError("This calendar synchronization source is no longer active")
if not source.sync_enabled:
raise CalendarError(
"This calendar synchronization source is disabled and cannot accept local changes"
)
if source.source_kind != "caldav" or source.sync_direction != "two_way":
raise CalendarError(f"This {sync_source_label(source.source_kind)} calendar is inbound-only and cannot be changed locally")
@@ -2212,63 +2711,14 @@ def assert_caldav_mutation_allowed(source: CalendarSyncSource | None) -> None:
def should_push_event_to_caldav(*, source: CalendarSyncSource | None, event: CalendarEvent) -> bool:
return bool(source and source.source_kind == "caldav" and source.sync_direction == "two_way" and event.deleted_at is None)
def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None:
href = event.source_href or generated_caldav_href(session, source=source, event=event)
full_href = normalize_caldav_href(source.collection_url, href)
resource_events = caldav_resource_events(session, source=source, href=full_href) if event.source_href else [event]
if event not in resource_events:
resource_events.append(event)
resource_events = sorted(resource_events, key=caldav_resource_sort_key)
ics = events_to_ics(resource_events)
client = caldav_client_for_source(session, source)
overwrite = source.conflict_policy == "overwrite"
etag = None if create else resource_etag(resource_events)
try:
result = client.put_object(full_href, ics, etag=etag, create=create, overwrite=overwrite)
except CalDAVPreconditionFailed as exc:
raise CalendarError("CalDAV resource changed remotely; sync the calendar before saving again") from exc
except CalDAVError as exc:
raise CalendarError(f"CalDAV write failed: {exc}") from exc
next_etag = result.etag
for item in resource_events:
apply_caldav_write_metadata(item, source=source, href=full_href, etag=next_etag, raw_ics=ics)
source.last_status = "outbound_ok"
source.last_error = None
schedule_next_caldav_sync(source)
session.flush()
def push_caldav_delete_for_event(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> None:
if not event.source_href:
return
full_href = normalize_caldav_href(source.collection_url, event.source_href)
remaining = [
item
for item in caldav_resource_events(session, source=source, href=full_href)
if item.id != event.id
]
client = caldav_client_for_source(session, source)
overwrite = source.conflict_policy == "overwrite"
try:
if remaining:
remaining = sorted(remaining, key=caldav_resource_sort_key)
ics = events_to_ics(remaining)
result = client.put_object(full_href, ics, etag=event.etag or resource_etag(remaining), create=False, overwrite=overwrite)
for item in remaining:
apply_caldav_write_metadata(item, source=source, href=full_href, etag=result.etag, raw_ics=ics)
else:
client.delete_object(full_href, etag=event.etag, overwrite=overwrite)
except CalDAVPreconditionFailed as exc:
raise CalendarError("CalDAV resource changed remotely; sync the calendar before deleting again") from exc
except CalDAVError as exc:
raise CalendarError(f"CalDAV delete failed: {exc}") from exc
source.last_status = "outbound_ok"
source.last_error = None
schedule_next_caldav_sync(source)
session.flush()
return bool(
source
and source.deleted_at is None
and source.sync_enabled
and source.source_kind == "caldav"
and source.sync_direction == "two_way"
and event.deleted_at is None
)
def caldav_resource_events(session: Session, *, source: CalendarSyncSource, href: str) -> list[CalendarEvent]:
@@ -2312,16 +2762,6 @@ def generated_caldav_href(session: Session, *, source: CalendarSyncSource, event
return normalize_caldav_href(source.collection_url, f"{safe_uid}-{event.id}.ics")
def apply_caldav_write_metadata(event: CalendarEvent, *, source: CalendarSyncSource, href: str, etag: str | None, raw_ics: str) -> None:
event.source_kind = "caldav"
event.source_href = href
event.etag = etag
event.raw_ics = raw_ics
metadata = dict(event.metadata_ or {})
metadata["caldav"] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag}
event.metadata_ = metadata
def import_ics_event(
session: Session,
*,
@@ -2340,6 +2780,32 @@ def import_ics_event(
target_calendar_id = calendar_id or (default_calendar.id if default_calendar else None)
if not target_calendar_id:
raise CalendarError("calendar_id is required because no default calendar exists")
target_calendar = get_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar_id,
)
# Source creation also locks the calendar row. Holding it here makes the
# public import's provenance decision stable until create/update has queued
# its desired state.
(
session.query(CalendarCollection)
.filter(
CalendarCollection.id == target_calendar.id,
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
target_source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar_id,
for_update=True,
)
synchronized_target = target_source is not None
existing = None
if upsert:
existing = (
@@ -2353,16 +2819,27 @@ def import_ics_event(
)
.first()
)
payload = CalendarEventCreateRequest(
calendar_id=target_calendar_id,
source_kind=source_kind,
source_href=source_href,
etag=etag,
metadata={},
payload_values: dict[str, Any] = {
"calendar_id": target_calendar_id,
"metadata": {},
**parsed,
)
}
if not synchronized_target:
payload_values.update(
{
"source_kind": source_kind,
"source_href": source_href,
"etag": etag,
}
)
payload = CalendarEventCreateRequest(**payload_values)
if existing:
update_payload = CalendarEventUpdateRequest(**payload.model_dump(exclude={"uid", "recurrence_id"}))
excluded_fields = {"uid", "recurrence_id"}
if synchronized_target:
excluded_fields.update({"source_kind", "source_href", "etag"})
update_payload = CalendarEventUpdateRequest(
**payload.model_dump(exclude=excluded_fields)
)
event = update_event(session, tenant_id=tenant_id, user_id=user_id, event_id=existing.id, payload=update_payload)
event.raw_ics = raw_ics
return event