feat(calendar): make external mutations durable
This commit is contained in:
@@ -24,10 +24,88 @@ The first standalone module provides:
|
|||||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
- 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
82
src/govoplan_calendar/backend/capabilities.py
Normal file
82
src/govoplan_calendar/backend/capabilities.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from govoplan_core.core.calendar import (
|
||||||
|
CalendarCapabilityError,
|
||||||
|
CalendarEventRef,
|
||||||
|
CalendarEventRequest,
|
||||||
|
CalendarSchedulingProvider,
|
||||||
|
)
|
||||||
|
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
|
||||||
|
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
|
||||||
|
|
||||||
|
|
||||||
|
class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||||
|
def list_freebusy(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
start_at: datetime,
|
||||||
|
end_at: datetime,
|
||||||
|
calendar_ids: Sequence[str] | None = None,
|
||||||
|
) -> tuple[Mapping[str, object], ...]:
|
||||||
|
try:
|
||||||
|
blocks = list_freebusy(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
start_at=start_at,
|
||||||
|
end_at=end_at,
|
||||||
|
calendar_ids=list(calendar_ids) if calendar_ids is not None else None,
|
||||||
|
)
|
||||||
|
except CalendarError as exc:
|
||||||
|
raise CalendarCapabilityError(str(exc)) from exc
|
||||||
|
return tuple(blocks)
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarEventRequest,
|
||||||
|
) -> CalendarEventRef:
|
||||||
|
try:
|
||||||
|
payload = CalendarEventCreateRequest(
|
||||||
|
calendar_id=request.calendar_id,
|
||||||
|
summary=request.summary,
|
||||||
|
description=request.description,
|
||||||
|
location=request.location,
|
||||||
|
status=request.status,
|
||||||
|
transparency=request.transparency,
|
||||||
|
classification=request.classification,
|
||||||
|
start_at=request.start_at,
|
||||||
|
end_at=request.end_at,
|
||||||
|
timezone=request.timezone,
|
||||||
|
attendees=[dict(item) for item in request.attendees],
|
||||||
|
categories=list(request.categories),
|
||||||
|
related_to=[dict(item) for item in request.related_to],
|
||||||
|
metadata=dict(request.metadata),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CalendarCapabilityError(str(exc)) from exc
|
||||||
|
try:
|
||||||
|
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||||
|
except CalendarError as exc:
|
||||||
|
raise CalendarCapabilityError(str(exc)) from exc
|
||||||
|
caldav_metadata = (event.metadata_ or {}).get("caldav")
|
||||||
|
external_state = "local"
|
||||||
|
outbox_operation_id = None
|
||||||
|
if isinstance(caldav_metadata, dict):
|
||||||
|
external_state = str(caldav_metadata.get("external_state") or "queued")
|
||||||
|
raw_operation_id = caldav_metadata.get("outbox_operation_id")
|
||||||
|
outbox_operation_id = str(raw_operation_id) if raw_operation_id else None
|
||||||
|
return CalendarEventRef(
|
||||||
|
id=event.id,
|
||||||
|
calendar_id=event.calendar_id,
|
||||||
|
uid=event.uid,
|
||||||
|
external_state=external_state,
|
||||||
|
outbox_operation_id=outbox_operation_id,
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add the durable CalDAV desired-state outbox on the development track.
|
||||||
|
|
||||||
|
Revision ID: af1b2c3d4e5f
|
||||||
|
Revises: 9e0f1a2b3c4d
|
||||||
|
Create Date: 2026-07-20 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
|
||||||
|
downgrade as _downgrade,
|
||||||
|
)
|
||||||
|
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
|
||||||
|
upgrade as _upgrade,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
revision = "af1b2c3d4e5f"
|
||||||
|
down_revision = "9e0f1a2b3c4d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_upgrade()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_downgrade()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Add the durable CalDAV desired-state outbox.
|
||||||
|
|
||||||
|
Revision ID: af1b2c3d4e5f
|
||||||
|
Revises: 9e0f1a2b3c4d
|
||||||
|
Create Date: 2026-07-20 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "af1b2c3d4e5f"
|
||||||
|
down_revision = "9e0f1a2b3c4d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"calendar_outbox_operations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("event_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("operation_kind", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("resource_href", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("payload_ics", sa.Text(), nullable=True),
|
||||||
|
sa.Column("payload_fingerprint", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("expected_etag", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("attempt_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("max_attempts", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("available_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("lease_token", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("reconciled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("remote_etag", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["event_id"],
|
||||||
|
["calendar_events.id"],
|
||||||
|
name=op.f("fk_calendar_outbox_operations_event_id_calendar_events"),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["source_id"],
|
||||||
|
["calendar_sync_sources.id"],
|
||||||
|
name=op.f("fk_calendar_outbox_operations_source_id_calendar_sync_sources"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_calendar_outbox_operations_tenant_id_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_outbox_operations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_calendar_outbox_operations_idempotency_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_outbox_due",
|
||||||
|
"calendar_outbox_operations",
|
||||||
|
["status", "available_at", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_outbox_lease",
|
||||||
|
"calendar_outbox_operations",
|
||||||
|
["status", "lease_expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_outbox_resource",
|
||||||
|
"calendar_outbox_operations",
|
||||||
|
["source_id", "resource_href", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_outbox_tenant_status",
|
||||||
|
"calendar_outbox_operations",
|
||||||
|
["tenant_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"available_at",
|
||||||
|
"event_id",
|
||||||
|
"lease_expires_at",
|
||||||
|
"lease_token",
|
||||||
|
"operation_kind",
|
||||||
|
"payload_fingerprint",
|
||||||
|
"source_id",
|
||||||
|
"status",
|
||||||
|
"tenant_id",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_calendar_outbox_operations_{column}"),
|
||||||
|
"calendar_outbox_operations",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("calendar_outbox_operations")
|
||||||
1501
src/govoplan_calendar/backend/outbox.py
Normal file
1501
src/govoplan_calendar/backend/outbox.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
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
|
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
|
||||||
|
|||||||
@@ -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,15 +405,14 @@ 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",
|
||||||
@@ -414,14 +426,18 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
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, [])
|
||||||
|
|||||||
27
tests/test_capabilities.py
Normal file
27
tests/test_capabilities.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
||||||
|
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||||
|
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
|
||||||
|
provider = SqlCalendarSchedulingProvider()
|
||||||
|
|
||||||
|
with self.assertRaises(CalendarCapabilityError):
|
||||||
|
provider.create_event(
|
||||||
|
object(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
request=CalendarEventRequest(
|
||||||
|
summary="x" * 501,
|
||||||
|
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1819
tests/test_outbox.py
Normal file
1819
tests/test_outbox.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user