Add correlated calendar invitation capability
This commit is contained in:
@@ -1,16 +1,163 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.calendar import (
|
from govoplan_core.core.calendar import (
|
||||||
CalendarCapabilityError,
|
CalendarCapabilityError,
|
||||||
CalendarEventRef,
|
CalendarEventRef,
|
||||||
CalendarEventRequest,
|
CalendarEventRequest,
|
||||||
|
CalendarInvitationAttendeeRequest,
|
||||||
|
CalendarInvitationProvider,
|
||||||
|
CalendarInvitationRef,
|
||||||
|
CalendarInvitationRequest,
|
||||||
CalendarSchedulingProvider,
|
CalendarSchedulingProvider,
|
||||||
)
|
)
|
||||||
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
|
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):
|
class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
||||||
@@ -66,13 +213,7 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
|||||||
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
raise CalendarCapabilityError(str(exc)) from exc
|
raise CalendarCapabilityError(str(exc)) from exc
|
||||||
caldav_metadata = (event.metadata_ or {}).get("caldav")
|
external_state, outbox_operation_id = _external_state(event)
|
||||||
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(
|
return CalendarEventRef(
|
||||||
id=event.id,
|
id=event.id,
|
||||||
calendar_id=event.calendar_id,
|
calendar_id=event.calendar_id,
|
||||||
@@ -80,3 +221,213 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
|
|||||||
external_state=external_state,
|
external_state=external_state,
|
||||||
outbox_operation_id=outbox_operation_id,
|
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"),
|
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_range", "tenant_id", "calendar_id", "start_at", "end_at"),
|
||||||
Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"),
|
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"),
|
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)
|
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)
|
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)
|
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)
|
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
summary: Mapped[str] = mapped_column(String(500), 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 (
|
from govoplan_core.core.calendar import (
|
||||||
CALENDAR_AVAILABILITY_READ_SCOPE,
|
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||||
CALENDAR_EVENT_WRITE_SCOPE,
|
CALENDAR_EVENT_WRITE_SCOPE,
|
||||||
|
CAPABILITY_CALENDAR_INVITATIONS,
|
||||||
CAPABILITY_CALENDAR_OUTBOX,
|
CAPABILITY_CALENDAR_OUTBOX,
|
||||||
CAPABILITY_CALENDAR_SCHEDULING,
|
CAPABILITY_CALENDAR_SCHEDULING,
|
||||||
)
|
)
|
||||||
@@ -162,6 +163,13 @@ def _calendar_scheduling_provider(context: ModuleContext) -> object:
|
|||||||
return SqlCalendarSchedulingProvider()
|
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:
|
def _calendar_outbox_provider(context: ModuleContext) -> object:
|
||||||
from govoplan_calendar.backend.outbox import (
|
from govoplan_calendar.backend.outbox import (
|
||||||
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
||||||
@@ -186,6 +194,7 @@ manifest = ModuleManifest(
|
|||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
|
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
|
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name="calendar.invitations", version="0.1.0"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_calendar_router,
|
route_factory=_calendar_router,
|
||||||
@@ -194,6 +203,7 @@ manifest = ModuleManifest(
|
|||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
||||||
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_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),),
|
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||||
frontend=FrontendModule(
|
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
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
from sqlalchemy import create_engine
|
||||||
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
|
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):
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user