Add correlated calendar invitation capability
This commit is contained in:
@@ -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,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(
|
||||
|
||||
+59
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user