Add correlated calendar invitation capability

This commit is contained in:
2026-07-31 22:48:06 +02:00
parent 001ebd3b6d
commit ccdf12162a
5 changed files with 555 additions and 11 deletions
+360 -9
View File
@@ -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."
)
@@ -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)
+10
View File
@@ -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(
@@ -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")
+109 -2
View File
@@ -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()