diff --git a/src/govoplan_calendar/backend/capabilities.py b/src/govoplan_calendar/backend/capabilities.py index 2825b3f..704c087 100644 --- a/src/govoplan_calendar/backend/capabilities.py +++ b/src/govoplan_calendar/backend/capabilities.py @@ -1,16 +1,163 @@ from __future__ import annotations +import hashlib from collections.abc import Mapping, Sequence from datetime import datetime +from sqlalchemy.orm import Session + from govoplan_core.core.calendar import ( CalendarCapabilityError, CalendarEventRef, CalendarEventRequest, + CalendarInvitationAttendeeRequest, + CalendarInvitationProvider, + CalendarInvitationRef, + CalendarInvitationRequest, CalendarSchedulingProvider, ) -from govoplan_calendar.backend.schemas import CalendarEventCreateRequest -from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy +from govoplan_core.security.time import utc_now +from govoplan_calendar.backend.db.models import CalendarEvent +from govoplan_calendar.backend.schemas import ( + CalendarEventCreateRequest, + CalendarEventUpdateRequest, +) +from govoplan_calendar.backend.service import ( + CalendarError, + create_event, + list_freebusy, + update_event, +) + + +_PARTICIPATION_STATUSES = { + "NEEDS-ACTION", + "ACCEPTED", + "DECLINED", + "TENTATIVE", + "DELEGATED", +} + + +def _external_state(event: CalendarEvent) -> tuple[str, str | None]: + caldav_metadata = (event.metadata_ or {}).get("caldav") + if not isinstance(caldav_metadata, dict): + return "local", None + state = str(caldav_metadata.get("external_state") or "queued") + operation_id = caldav_metadata.get("outbox_operation_id") + return state, str(operation_id) if operation_id else None + + +def _attendee_address(item: Mapping[str, object]) -> str: + raw = item.get("email") or item.get("address") or item.get("value") or "" + value = str(raw).strip() + if value.casefold().startswith("mailto:"): + value = value[7:] + return value.casefold() + + +def _attendee_record( + attendee: CalendarInvitationAttendeeRequest, +) -> dict[str, object]: + status = attendee.participation_status.strip().upper() + if status not in _PARTICIPATION_STATUSES: + raise CalendarCapabilityError( + f"Unsupported attendee participation status: {status}" + ) + params: dict[str, list[str]] = { + "ROLE": [attendee.role.strip().upper() or "REQ-PARTICIPANT"], + "PARTSTAT": [status], + "RSVP": ["TRUE" if attendee.rsvp else "FALSE"], + } + if attendee.name: + params["CN"] = [attendee.name] + return { + "value": f"mailto:{attendee.address.strip()}", + "params": params, + "response_at": None, + } + + +def _attendee_status(item: Mapping[str, object]) -> str: + direct = item.get("participation_status") or item.get("response_status") + if direct: + return str(direct).upper() + params = item.get("params") + if isinstance(params, Mapping): + value = params.get("PARTSTAT") + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return str(value[0]).upper() if value else "NEEDS-ACTION" + if value: + return str(value).upper() + return "NEEDS-ACTION" + + +def _merge_attendees( + current: Sequence[Mapping[str, object]], + requested: Sequence[CalendarInvitationAttendeeRequest], +) -> list[dict[str, object]]: + prior = {_attendee_address(item): item for item in current} + merged: list[dict[str, object]] = [] + for attendee in requested: + record = _attendee_record(attendee) + existing = prior.get(attendee.address.strip().casefold()) + if existing is not None and _attendee_status(existing) != "NEEDS-ACTION": + params = dict(record["params"]) + params["PARTSTAT"] = [_attendee_status(existing)] + record["params"] = params + record["response_at"] = existing.get("response_at") + if existing.get("response_evidence") is not None: + record["response_evidence"] = existing["response_evidence"] + merged.append(record) + return merged + + +def _invitation_uid(request: CalendarInvitationRequest) -> str: + identity = ":".join( + ( + request.source_module, + request.source_resource_type, + request.source_resource_id or "", + request.correlation_id, + ) + ) + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:40] + return f"invitation-{digest}@govoplan.local" + + +def _invitation_metadata( + request: CalendarInvitationRequest, + previous: Mapping[str, object] | None = None, +) -> dict[str, object]: + metadata = dict(previous or {}) + metadata.update(dict(request.metadata)) + metadata["calendar_invitation"] = { + "correlation_id": request.correlation_id, + "source_module": request.source_module, + "source_resource_type": request.source_resource_type, + "source_resource_id": request.source_resource_id, + } + return metadata + + +def _invitation_ref(event: CalendarEvent) -> CalendarInvitationRef: + state, operation_id = _external_state(event) + return CalendarInvitationRef( + event_id=event.id, + calendar_id=event.calendar_id, + uid=event.uid, + correlation_id=event.correlation_key or "", + source_module=event.producer_module or "unknown", + source_resource_type=event.producer_resource_type or "unknown", + source_resource_id=event.producer_resource_id, + attendees=tuple(dict(item) for item in event.attendees or []), + external_state=state, + outbox_operation_id=operation_id, + degraded_reasons=( + "Recurring campaign invitations require a separate series workflow.", + "Mail-delivered iCalendar replies require a Mail adapter to call record_response.", + ), + ) class SqlCalendarSchedulingProvider(CalendarSchedulingProvider): @@ -66,13 +213,7 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider): 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 + external_state, outbox_operation_id = _external_state(event) return CalendarEventRef( id=event.id, calendar_id=event.calendar_id, @@ -80,3 +221,213 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider): external_state=external_state, outbox_operation_id=outbox_operation_id, ) + + +class SqlCalendarInvitationProvider(CalendarInvitationProvider): + def upsert_invitation( + self, + session: object, + *, + tenant_id: str, + user_id: str | None, + request: CalendarInvitationRequest, + ) -> CalendarInvitationRef: + db = self._session(session) + self._validate_request(request) + existing = ( + db.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.correlation_key == request.correlation_id, + CalendarEvent.deleted_at.is_(None), + ) + .one_or_none() + ) + attendees = _merge_attendees( + existing.attendees if existing is not None else (), + request.attendees, + ) + metadata = _invitation_metadata( + request, + existing.metadata_ if existing is not None else None, + ) + try: + if existing is None: + event = create_event( + db, + tenant_id=tenant_id, + user_id=user_id, + payload=CalendarEventCreateRequest( + calendar_id=request.calendar_id, + uid=_invitation_uid(request), + summary=request.summary, + description=request.description, + location=request.location, + classification=request.classification, + start_at=request.start_at, + end_at=request.end_at, + timezone=request.timezone, + organizer=( + dict(request.organizer) if request.organizer else None + ), + attendees=attendees, + categories=list(request.categories), + metadata=metadata, + ), + ) + else: + event = update_event( + db, + tenant_id=tenant_id, + user_id=user_id, + event_id=existing.id, + payload=CalendarEventUpdateRequest( + calendar_id=request.calendar_id, + summary=request.summary, + description=request.description, + location=request.location, + classification=request.classification, + start_at=request.start_at, + end_at=request.end_at, + timezone=request.timezone, + organizer=( + dict(request.organizer) if request.organizer else None + ), + attendees=attendees, + categories=list(request.categories), + metadata=metadata, + ), + ) + except (CalendarError, TypeError, ValueError) as exc: + raise CalendarCapabilityError(str(exc)) from exc + event.correlation_key = request.correlation_id + event.producer_module = request.source_module + event.producer_resource_type = request.source_resource_type + event.producer_resource_id = request.source_resource_id + db.flush() + return _invitation_ref(event) + + def get_invitation( + self, + session: object, + *, + tenant_id: str, + correlation_id: str, + ) -> CalendarInvitationRef | None: + event = ( + self._session(session) + .query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.correlation_key == correlation_id, + CalendarEvent.deleted_at.is_(None), + ) + .one_or_none() + ) + return _invitation_ref(event) if event is not None else None + + def record_response( + self, + session: object, + *, + tenant_id: str, + attendee_address: str, + participation_status: str, + correlation_id: str | None = None, + uid: str | None = None, + responded_at: datetime | None = None, + evidence: Mapping[str, object] | None = None, + ) -> CalendarInvitationRef: + if bool(correlation_id) == bool(uid): + raise CalendarCapabilityError( + "Specify exactly one invitation correlation_id or uid." + ) + status = participation_status.strip().upper() + if status not in _PARTICIPATION_STATUSES - {"NEEDS-ACTION"}: + raise CalendarCapabilityError( + f"Unsupported attendee participation status: {status}" + ) + db = self._session(session) + query = db.query(CalendarEvent).filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.deleted_at.is_(None), + ) + query = query.filter( + CalendarEvent.correlation_key == correlation_id + if correlation_id + else CalendarEvent.uid == uid + ) + event = query.one_or_none() + if event is None: + raise CalendarCapabilityError("Calendar invitation was not found.") + + target = attendee_address.strip().casefold() + attendees: list[dict[str, object]] = [] + matched = False + response_time = responded_at or utc_now() + for raw in event.attendees or []: + item = dict(raw) + if _attendee_address(item) == target: + params = dict(item.get("params") or {}) + params["PARTSTAT"] = [status] + item["params"] = params + item["response_at"] = response_time.isoformat() + item["response_evidence"] = dict(evidence or {}) + matched = True + attendees.append(item) + if not matched: + raise CalendarCapabilityError( + "The reply address is not an attendee of this invitation." + ) + metadata = dict(event.metadata_ or {}) + invitation_metadata = dict(metadata.get("calendar_invitation") or {}) + invitation_metadata["last_response_at"] = response_time.isoformat() + metadata["calendar_invitation"] = invitation_metadata + try: + event = update_event( + db, + tenant_id=tenant_id, + user_id=None, + event_id=event.id, + payload=CalendarEventUpdateRequest( + attendees=attendees, + metadata=metadata, + ), + ) + except CalendarError as exc: + raise CalendarCapabilityError(str(exc)) from exc + return _invitation_ref(event) + + @staticmethod + def _session(session: object) -> Session: + if not isinstance(session, Session): + raise CalendarCapabilityError( + "Calendar invitations require a SQLAlchemy Session." + ) + return session + + @staticmethod + def _validate_request(request: CalendarInvitationRequest) -> None: + for label, value, maximum in ( + ("correlation_id", request.correlation_id, 255), + ("source_module", request.source_module, 100), + ("source_resource_type", request.source_resource_type, 100), + ("summary", request.summary, 500), + ): + if not value.strip() or len(value) > maximum: + raise CalendarCapabilityError( + f"Invitation {label} must contain 1 to {maximum} characters." + ) + if request.source_resource_id and len(request.source_resource_id) > 255: + raise CalendarCapabilityError( + "Invitation source_resource_id must not exceed 255 characters." + ) + if not request.attendees: + raise CalendarCapabilityError( + "Calendar invitations require at least one attendee." + ) + addresses = [item.address.strip().casefold() for item in request.attendees] + if any(not item for item in addresses) or len(set(addresses)) != len(addresses): + raise CalendarCapabilityError( + "Invitation attendee addresses must be non-empty and unique." + ) diff --git a/src/govoplan_calendar/backend/db/models.py b/src/govoplan_calendar/backend/db/models.py index 2ad0dcb..7741abe 100644 --- a/src/govoplan_calendar/backend/db/models.py +++ b/src/govoplan_calendar/backend/db/models.py @@ -52,6 +52,11 @@ class CalendarEvent(Base, TimestampMixin): UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"), Index("ix_calendar_events_range", "tenant_id", "calendar_id", "start_at", "end_at"), Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"), + Index( + "ix_calendar_events_tenant_correlation", + "tenant_id", + "correlation_key", + ), Index("ix_calendar_events_tenant_status", "tenant_id", "status"), ) @@ -59,6 +64,18 @@ class CalendarEvent(Base, TimestampMixin): tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True) uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + correlation_key: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + producer_module: Mapped[str | None] = mapped_column( + String(100), nullable=True, index=True + ) + producer_resource_type: Mapped[str | None] = mapped_column( + String(100), nullable=True + ) + producer_resource_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False) summary: Mapped[str] = mapped_column(String(500), nullable=False) diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 8cd27b3..fc53fb8 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -10,6 +10,7 @@ from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPA from govoplan_core.core.calendar import ( CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE, + CAPABILITY_CALENDAR_INVITATIONS, CAPABILITY_CALENDAR_OUTBOX, CAPABILITY_CALENDAR_SCHEDULING, ) @@ -162,6 +163,13 @@ def _calendar_scheduling_provider(context: ModuleContext) -> object: return SqlCalendarSchedulingProvider() +def _calendar_invitation_provider(context: ModuleContext) -> object: + del context + from govoplan_calendar.backend.capabilities import SqlCalendarInvitationProvider + + return SqlCalendarInvitationProvider() + + def _calendar_outbox_provider(context: ModuleContext) -> object: from govoplan_calendar.backend.outbox import ( OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS, @@ -186,6 +194,7 @@ manifest = ModuleManifest( provides_interfaces=( ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"), ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"), + ModuleInterfaceProvider(name="calendar.invitations", version="0.1.0"), ), permissions=PERMISSIONS, route_factory=_calendar_router, @@ -194,6 +203,7 @@ manifest = ModuleManifest( capability_factories={ CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider, CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider, + CAPABILITY_CALENDAR_INVITATIONS: _calendar_invitation_provider, }, nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),), frontend=FrontendModule( diff --git a/src/govoplan_calendar/backend/migrations/versions/c13d4e5f6071_calendar_invitation_correlation.py b/src/govoplan_calendar/backend/migrations/versions/c13d4e5f6071_calendar_invitation_correlation.py new file mode 100644 index 0000000..b8230e6 --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/versions/c13d4e5f6071_calendar_invitation_correlation.py @@ -0,0 +1,59 @@ +"""calendar invitation correlation + +Revision ID: c13d4e5f6071 +Revises: b02c3d4e5f60 +Create Date: 2026-07-31 18:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c13d4e5f6071" +down_revision = "b02c3d4e5f60" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("calendar_events") as batch_op: + batch_op.add_column( + sa.Column("correlation_key", sa.String(length=255), nullable=True) + ) + batch_op.add_column( + sa.Column("producer_module", sa.String(length=100), nullable=True) + ) + batch_op.add_column( + sa.Column( + "producer_resource_type", sa.String(length=100), nullable=True + ) + ) + batch_op.add_column( + sa.Column("producer_resource_id", sa.String(length=255), nullable=True) + ) + batch_op.create_index( + "ix_calendar_events_correlation_key", ("correlation_key",) + ) + batch_op.create_index( + "ix_calendar_events_producer_module", ("producer_module",) + ) + batch_op.create_index( + "ix_calendar_events_producer_resource_id", ("producer_resource_id",) + ) + batch_op.create_index( + "ix_calendar_events_tenant_correlation", + ("tenant_id", "correlation_key"), + ) + + +def downgrade() -> None: + with op.batch_alter_table("calendar_events") as batch_op: + batch_op.drop_index("ix_calendar_events_tenant_correlation") + batch_op.drop_index("ix_calendar_events_producer_resource_id") + batch_op.drop_index("ix_calendar_events_producer_module") + batch_op.drop_index("ix_calendar_events_correlation_key") + batch_op.drop_column("producer_resource_id") + batch_op.drop_column("producer_resource_type") + batch_op.drop_column("producer_module") + batch_op.drop_column("correlation_key") diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 5ba318c..8aee61b 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -3,8 +3,25 @@ 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 +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db import models as access_models # noqa: F401 +from govoplan_calendar.backend.capabilities import ( + SqlCalendarInvitationProvider, + SqlCalendarSchedulingProvider, +) +from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest +from govoplan_calendar.backend.service import create_calendar +from govoplan_core.core.calendar import ( + CalendarCapabilityError, + CalendarEventRequest, + CalendarInvitationAttendeeRequest, + CalendarInvitationRequest, +) +from govoplan_core.db.base import Base +from govoplan_core.tenancy.scope import create_scope_tables +from govoplan_tenancy.backend.db.models import Tenant class CalendarSchedulingCapabilityTests(unittest.TestCase): @@ -23,5 +40,95 @@ class CalendarSchedulingCapabilityTests(unittest.TestCase): ) +class CalendarInvitationCapabilityTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + create_scope_tables(self.engine) + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + self.session = self.Session() + self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + self.calendar = create_calendar( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Invitations"), + ) + self.provider = SqlCalendarInvitationProvider() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def request(self, *, summary: str = "Planning") -> CalendarInvitationRequest: + return CalendarInvitationRequest( + correlation_id="campaign-1:recipient-1", + source_module="campaigns", + source_resource_type="campaign_recipient", + source_resource_id="recipient-1", + calendar_id=self.calendar.id, + summary=summary, + start_at=datetime(2026, 8, 5, 9, tzinfo=timezone.utc), + end_at=datetime(2026, 8, 5, 10, tzinfo=timezone.utc), + attendees=( + CalendarInvitationAttendeeRequest( + address="ada@example.test", + name="Ada", + ), + ), + ) + + def test_upsert_and_response_reconciliation_preserve_correlation(self) -> None: + created = self.provider.upsert_invitation( + self.session, + tenant_id="tenant-1", + user_id=None, + request=self.request(), + ) + response = self.provider.record_response( + self.session, + tenant_id="tenant-1", + correlation_id=created.correlation_id, + attendee_address="ADA@example.test", + participation_status="accepted", + evidence={"transport": "ical-reply"}, + ) + updated = self.provider.upsert_invitation( + self.session, + tenant_id="tenant-1", + user_id=None, + request=self.request(summary="Updated planning"), + ) + loaded = self.provider.get_invitation( + self.session, + tenant_id="tenant-1", + correlation_id=created.correlation_id, + ) + + self.assertEqual(created.event_id, updated.event_id) + self.assertEqual("ACCEPTED", response.attendees[0]["params"]["PARTSTAT"][0]) + self.assertEqual("ACCEPTED", updated.attendees[0]["params"]["PARTSTAT"][0]) + self.assertEqual(updated, loaded) + self.assertFalse(updated.recurrence_supported) + self.assertTrue(updated.degraded_reasons) + + def test_reply_must_match_an_existing_attendee(self) -> None: + created = self.provider.upsert_invitation( + self.session, + tenant_id="tenant-1", + user_id=None, + request=self.request(), + ) + with self.assertRaisesRegex(CalendarCapabilityError, "not an attendee"): + self.provider.record_response( + self.session, + tenant_id="tenant-1", + uid=created.uid, + attendee_address="mallory@example.test", + participation_status="DECLINED", + ) + + if __name__ == "__main__": unittest.main()