Add scheduling calendar integration and WebUI
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||
|
||||
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest"]
|
||||
__all__ = ["SchedulingCandidateSlot", "SchedulingNotification", "SchedulingParticipant", "SchedulingRequest"]
|
||||
|
||||
@@ -77,6 +77,10 @@ class SchedulingCandidateSlot(Base, TimestampMixin):
|
||||
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
|
||||
location: Mapped[str | None] = mapped_column(String(500))
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
freebusy_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
freebusy_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
freebusy_conflicts: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
tentative_hold_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
@@ -109,4 +113,31 @@ class SchedulingParticipant(Base, TimestampMixin):
|
||||
request: Mapped[SchedulingRequest] = relationship(back_populates="participants")
|
||||
|
||||
|
||||
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest", "new_uuid"]
|
||||
class SchedulingNotification(Base, TimestampMixin):
|
||||
__tablename__ = "scheduling_notifications"
|
||||
__table_args__ = (
|
||||
Index("ix_scheduling_notifications_request_status", "request_id", "status"),
|
||||
Index("ix_scheduling_notifications_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
request_id: Mapped[str] = mapped_column(ForeignKey("scheduling_requests.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
participant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
channel: Mapped[str] = mapped_column(String(40), default="mail", nullable=False, index=True)
|
||||
recipient: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending", nullable=False, index=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SchedulingCandidateSlot",
|
||||
"SchedulingNotification",
|
||||
"SchedulingParticipant",
|
||||
"SchedulingRequest",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -6,11 +6,13 @@ from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPA
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
@@ -86,7 +88,7 @@ DOCUMENTATION = (
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||
|
||||
return {
|
||||
"scheduling_requests": (
|
||||
@@ -104,6 +106,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
.filter(SchedulingParticipant.tenant_id == tenant_id, SchedulingParticipant.deleted_at.is_(None))
|
||||
.count()
|
||||
),
|
||||
"scheduling_notifications": (
|
||||
session.query(SchedulingNotification)
|
||||
.filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending")
|
||||
.count()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +139,12 @@ manifest = ModuleManifest(
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/scheduling-webui",
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
|
||||
),
|
||||
route_factory=_scheduling_router,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -143,6 +156,7 @@ manifest = ModuleManifest(
|
||||
scheduling_models.SchedulingRequest,
|
||||
scheduling_models.SchedulingCandidateSlot,
|
||||
scheduling_models.SchedulingParticipant,
|
||||
scheduling_models.SchedulingNotification,
|
||||
label="Scheduling",
|
||||
),
|
||||
retirement_notes="Destructive retirement drops scheduling-owned database tables after the installer captures a database snapshot.",
|
||||
@@ -152,6 +166,7 @@ manifest = ModuleManifest(
|
||||
scheduling_models.SchedulingRequest,
|
||||
scheduling_models.SchedulingCandidateSlot,
|
||||
scheduling_models.SchedulingParticipant,
|
||||
scheduling_models.SchedulingNotification,
|
||||
label="Scheduling",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""v0.1.8 scheduling calendar integration and notification outbox
|
||||
|
||||
Revision ID: 4d5e6f7a8b9c
|
||||
Revises: 3c4d5e6f7a8b
|
||||
Create Date: 2026-07-12 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "4d5e6f7a8b9c"
|
||||
down_revision = "3c4d5e6f7a8b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True))
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("tentative_hold_event_id", sa.String(length=36), nullable=True))
|
||||
op.create_index(op.f("ix_scheduling_candidate_slots_freebusy_status"), "scheduling_candidate_slots", ["freebusy_status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_scheduling_candidate_slots_tentative_hold_event_id"),
|
||||
"scheduling_candidate_slots",
|
||||
["tentative_hold_event_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"scheduling_notifications",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("request_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("participant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("event_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("channel", sa.String(length=40), nullable=False),
|
||||
sa.Column("recipient", sa.String(length=500), nullable=True),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["request_id"],
|
||||
["scheduling_requests.id"],
|
||||
name=op.f("fk_scheduling_notifications_request_id_scheduling_requests"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_scheduling_notifications")),
|
||||
)
|
||||
op.create_index(op.f("ix_scheduling_notifications_channel"), "scheduling_notifications", ["channel"], unique=False)
|
||||
op.create_index(op.f("ix_scheduling_notifications_event_kind"), "scheduling_notifications", ["event_kind"], unique=False)
|
||||
op.create_index(op.f("ix_scheduling_notifications_participant_id"), "scheduling_notifications", ["participant_id"], unique=False)
|
||||
op.create_index(op.f("ix_scheduling_notifications_recipient"), "scheduling_notifications", ["recipient"], unique=False)
|
||||
op.create_index(op.f("ix_scheduling_notifications_request_id"), "scheduling_notifications", ["request_id"], unique=False)
|
||||
op.create_index("ix_scheduling_notifications_request_status", "scheduling_notifications", ["request_id", "status"], unique=False)
|
||||
op.create_index(op.f("ix_scheduling_notifications_status"), "scheduling_notifications", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_scheduling_notifications_tenant_id"), "scheduling_notifications", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_scheduling_notifications_tenant_status", "scheduling_notifications", ["tenant_id", "status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("scheduling_notifications")
|
||||
op.drop_index(op.f("ix_scheduling_candidate_slots_tentative_hold_event_id"), table_name="scheduling_candidate_slots")
|
||||
op.drop_index(op.f("ix_scheduling_candidate_slots_freebusy_status"), table_name="scheduling_candidate_slots")
|
||||
op.drop_column("scheduling_candidate_slots", "tentative_hold_event_id")
|
||||
op.drop_column("scheduling_candidate_slots", "freebusy_conflicts")
|
||||
op.drop_column("scheduling_candidate_slots", "freebusy_status")
|
||||
op.drop_column("scheduling_candidate_slots", "freebusy_checked_at")
|
||||
@@ -8,7 +8,11 @@ from govoplan_core.db.session import get_session
|
||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingCalendarActionResponse,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingNotificationCreateRequest,
|
||||
SchedulingNotificationListResponse,
|
||||
SchedulingNotificationResponse,
|
||||
SchedulingRequestCreateRequest,
|
||||
SchedulingRequestListResponse,
|
||||
SchedulingRequestResponse,
|
||||
@@ -20,11 +24,17 @@ from govoplan_scheduling.backend.service import (
|
||||
SchedulingError,
|
||||
cancel_scheduling_request,
|
||||
close_scheduling_request,
|
||||
create_final_calendar_event,
|
||||
create_scheduling_notification_jobs,
|
||||
create_scheduling_request,
|
||||
create_tentative_calendar_holds,
|
||||
decide_scheduling_request,
|
||||
evaluate_calendar_freebusy,
|
||||
get_scheduling_request,
|
||||
list_scheduling_notifications,
|
||||
list_scheduling_requests,
|
||||
open_scheduling_request,
|
||||
scheduling_notification_response,
|
||||
scheduling_request_response,
|
||||
scheduling_request_summary,
|
||||
update_scheduling_request,
|
||||
@@ -147,12 +157,83 @@ def api_decide_scheduling_request(
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
request = decide_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
|
||||
request = decide_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
user_id=principal.account_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
|
||||
def api_evaluate_calendar_freebusy(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
|
||||
def api_create_tentative_calendar_holds(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.account_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
created_event_ids=created_event_ids,
|
||||
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
|
||||
def api_create_final_calendar_event(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
request, event_id, warnings = create_final_calendar_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.account_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
created_event_ids=[event_id] if event_id else [],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
|
||||
def api_cancel_scheduling_request(
|
||||
request_id: str,
|
||||
@@ -183,3 +264,52 @@ def api_scheduling_summary(
|
||||
request=_request_response(request),
|
||||
poll_summary=PollResultSummaryResponse.model_validate(summary),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
|
||||
def api_list_scheduling_notifications(
|
||||
request_id: str | None = None,
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingNotificationListResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
notifications = list_scheduling_notifications(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
status=status_filter,
|
||||
)
|
||||
return SchedulingNotificationListResponse(
|
||||
notifications=[
|
||||
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
||||
for notification in notifications
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/notifications", response_model=SchedulingNotificationListResponse)
|
||||
def api_create_scheduling_notifications(
|
||||
request_id: str,
|
||||
payload: SchedulingNotificationCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingNotificationListResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
notifications = create_scheduling_notification_jobs(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
event_kind=payload.event_kind,
|
||||
channel=payload.channel,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingNotificationListResponse(
|
||||
notifications=[
|
||||
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
||||
for notification in notifications
|
||||
]
|
||||
)
|
||||
|
||||
@@ -96,6 +96,10 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
timezone: str
|
||||
location: str | None = None
|
||||
position: int
|
||||
freebusy_checked_at: datetime | None = None
|
||||
freebusy_status: str | None = None
|
||||
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
tentative_hold_event_id: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -163,3 +167,38 @@ class SchedulingDecisionRequest(BaseModel):
|
||||
class SchedulingSummaryResponse(BaseModel):
|
||||
request: SchedulingRequestResponse
|
||||
poll_summary: PollResultSummaryResponse
|
||||
|
||||
|
||||
class SchedulingCalendarActionResponse(BaseModel):
|
||||
request: SchedulingRequestResponse
|
||||
created_event_ids: list[str] = Field(default_factory=list)
|
||||
updated_slot_ids: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingNotificationResponse(BaseModel):
|
||||
id: str
|
||||
request_id: str
|
||||
participant_id: str | None = None
|
||||
event_kind: str
|
||||
channel: str
|
||||
recipient: str | None = None
|
||||
status: str
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
sent_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingNotificationListResponse(BaseModel):
|
||||
notifications: list[SchedulingNotificationResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingNotificationCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
|
||||
channel: str = Field(default="mail", max_length=40)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -18,7 +18,7 @@ from govoplan_poll.backend.service import (
|
||||
open_poll,
|
||||
poll_result_summary_by_id,
|
||||
)
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingRequestCreateRequest,
|
||||
@@ -36,6 +36,7 @@ WORKFLOW_CLOSED = "availability_closed"
|
||||
WORKFLOW_DECIDED = "slot_decided"
|
||||
WORKFLOW_HANDED_OFF = "handed_off"
|
||||
WORKFLOW_CANCELLED = "cancelled"
|
||||
NOTIFICATION_KINDS = {"invitation", "reminder", "decision", "cancellation"}
|
||||
|
||||
|
||||
def response_datetime(value: datetime | None) -> datetime | None:
|
||||
@@ -46,6 +47,17 @@ def response_datetime(value: datetime | None) -> datetime | None:
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
normalized = response_datetime(value)
|
||||
return normalized.isoformat() if normalized else None
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return utcnow()
|
||||
|
||||
@@ -148,6 +160,238 @@ def _set_calendar_preferences(request: SchedulingRequest, payload) -> None:
|
||||
request.create_calendar_event_on_decision = False
|
||||
|
||||
|
||||
def _calendar_api():
|
||||
try:
|
||||
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
|
||||
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SchedulingError("Calendar module is not installed") from exc
|
||||
return CalendarEventCreateRequest, CalendarError, create_event, list_freebusy
|
||||
|
||||
|
||||
def _require_calendar_enabled(request: SchedulingRequest) -> None:
|
||||
if not request.calendar_integration_enabled:
|
||||
raise SchedulingError("Calendar integration is not enabled for this scheduling request")
|
||||
if not request.calendar_id:
|
||||
raise SchedulingError("Scheduling request has no target calendar")
|
||||
|
||||
|
||||
def _attendees_for_calendar_event(request: SchedulingRequest) -> list[dict[str, Any]]:
|
||||
attendees: list[dict[str, Any]] = []
|
||||
for participant in _active_participants(request):
|
||||
if not participant.email and not participant.display_name:
|
||||
continue
|
||||
attendees.append(
|
||||
{
|
||||
"email": participant.email,
|
||||
"name": participant.display_name,
|
||||
"required": participant.required,
|
||||
"participant_id": participant.id,
|
||||
"participant_type": participant.participant_type,
|
||||
}
|
||||
)
|
||||
return attendees
|
||||
|
||||
|
||||
def _calendar_event_payload(
|
||||
request: SchedulingRequest,
|
||||
slot: SchedulingCandidateSlot,
|
||||
*,
|
||||
status: str,
|
||||
summary_prefix: str | None = None,
|
||||
) -> Any:
|
||||
CalendarEventCreateRequest, _CalendarError, _create_event, _list_freebusy = _calendar_api()
|
||||
summary = f"{summary_prefix}: {request.title}" if summary_prefix else request.title
|
||||
return CalendarEventCreateRequest(
|
||||
calendar_id=request.calendar_id,
|
||||
summary=summary,
|
||||
description=request.description,
|
||||
location=slot.location or request.location,
|
||||
status=status,
|
||||
transparency="OPAQUE",
|
||||
classification="PRIVATE" if status == "TENTATIVE" else "PUBLIC",
|
||||
start_at=slot.start_at,
|
||||
end_at=slot.end_at,
|
||||
timezone=slot.timezone,
|
||||
attendees=_attendees_for_calendar_event(request),
|
||||
categories=["GovOPlaN", "Scheduling"],
|
||||
related_to=[{"reltype": "SIBLING", "uid": request.poll_id, "type": "poll"}] if request.poll_id else [],
|
||||
metadata={
|
||||
"scheduling_request_id": request.id,
|
||||
"scheduling_slot_id": slot.id,
|
||||
"poll_id": request.poll_id,
|
||||
"kind": "tentative_hold" if status == "TENTATIVE" else "scheduled_meeting",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def evaluate_calendar_freebusy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
) -> tuple[SchedulingRequest, list[str]]:
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
_require_calendar_enabled(request)
|
||||
if not request.calendar_freebusy_enabled:
|
||||
raise SchedulingError("Calendar free/busy checks are not enabled for this scheduling request")
|
||||
_CalendarEventCreateRequest, CalendarError, _create_event, list_freebusy = _calendar_api()
|
||||
warnings: list[str] = []
|
||||
for slot in _active_slots(request):
|
||||
try:
|
||||
conflicts = list_freebusy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
start_at=slot.start_at,
|
||||
end_at=slot.end_at,
|
||||
calendar_ids=[request.calendar_id] if request.calendar_id else None,
|
||||
)
|
||||
except CalendarError as exc:
|
||||
slot.freebusy_checked_at = _now()
|
||||
slot.freebusy_status = "error"
|
||||
slot.freebusy_conflicts = [{"error": str(exc)}]
|
||||
warnings.append(str(exc))
|
||||
continue
|
||||
slot.freebusy_checked_at = _now()
|
||||
slot.freebusy_conflicts = _jsonable(conflicts)
|
||||
slot.freebusy_status = "busy" if conflicts else "free"
|
||||
session.flush()
|
||||
return request, warnings
|
||||
|
||||
|
||||
def create_tentative_calendar_holds(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request_id: str,
|
||||
) -> tuple[SchedulingRequest, list[str], list[str]]:
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
_require_calendar_enabled(request)
|
||||
if not request.calendar_hold_enabled:
|
||||
raise SchedulingError("Tentative calendar holds are not enabled for this scheduling request")
|
||||
_CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api()
|
||||
created_event_ids: list[str] = []
|
||||
warnings: list[str] = []
|
||||
for slot in _active_slots(request):
|
||||
if slot.tentative_hold_event_id:
|
||||
continue
|
||||
try:
|
||||
event = create_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
payload=_calendar_event_payload(request, slot, status="TENTATIVE", summary_prefix="Tentative hold"),
|
||||
)
|
||||
except CalendarError as exc:
|
||||
warnings.append(str(exc))
|
||||
continue
|
||||
slot.tentative_hold_event_id = event.id
|
||||
created_event_ids.append(event.id)
|
||||
session.flush()
|
||||
return request, created_event_ids, warnings
|
||||
|
||||
|
||||
def create_final_calendar_event(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request_id: str,
|
||||
) -> tuple[SchedulingRequest, str | None, list[str]]:
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
_require_calendar_enabled(request)
|
||||
if not request.create_calendar_event_on_decision:
|
||||
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request")
|
||||
if request.calendar_event_id:
|
||||
return request, request.calendar_event_id, []
|
||||
if not request.selected_slot_id:
|
||||
raise SchedulingError("Scheduling request has no selected slot")
|
||||
slot = _selected_slot(request, slot_id=request.selected_slot_id)
|
||||
_CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api()
|
||||
try:
|
||||
event = create_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
payload=_calendar_event_payload(request, slot, status="CONFIRMED"),
|
||||
)
|
||||
except CalendarError as exc:
|
||||
request.metadata_ = {
|
||||
**(request.metadata_ or {}),
|
||||
"calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id},
|
||||
}
|
||||
session.flush()
|
||||
return request, None, [str(exc)]
|
||||
request.calendar_event_id = event.id
|
||||
request.handed_off_at = _now()
|
||||
request.status = "handed_off"
|
||||
request.metadata_ = {
|
||||
**(request.metadata_ or {}),
|
||||
"calendar_handoff": {"status": "created", "event_id": event.id, "slot_id": slot.id, "calendar_id": request.calendar_id},
|
||||
}
|
||||
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
|
||||
session.flush()
|
||||
return request, event.id, []
|
||||
|
||||
|
||||
def create_scheduling_notification_jobs(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
event_kind: str,
|
||||
channel: str = "mail",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> list[SchedulingNotification]:
|
||||
if event_kind not in NOTIFICATION_KINDS:
|
||||
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
notifications: list[SchedulingNotification] = []
|
||||
for participant in _active_participants(request):
|
||||
recipient = participant.email or participant.respondent_id
|
||||
status = "pending" if recipient else "skipped"
|
||||
notification = SchedulingNotification(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
event_kind=event_kind,
|
||||
channel=channel,
|
||||
recipient=recipient,
|
||||
status=status,
|
||||
payload={
|
||||
"title": request.title,
|
||||
"request_id": request.id,
|
||||
"poll_id": request.poll_id,
|
||||
"participant_id": participant.id,
|
||||
"selected_slot_id": request.selected_slot_id,
|
||||
"calendar_event_id": request.calendar_event_id,
|
||||
},
|
||||
metadata_=metadata or {},
|
||||
)
|
||||
if status == "skipped":
|
||||
notification.error = "Participant has no deliverable recipient address or id"
|
||||
session.add(notification)
|
||||
notifications.append(notification)
|
||||
session.flush()
|
||||
return notifications
|
||||
|
||||
|
||||
def list_scheduling_notifications(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[SchedulingNotification]:
|
||||
query = session.query(SchedulingNotification).filter(SchedulingNotification.tenant_id == tenant_id)
|
||||
if request_id:
|
||||
query = query.filter(SchedulingNotification.request_id == request_id)
|
||||
if status:
|
||||
query = query.filter(SchedulingNotification.status == status)
|
||||
return query.order_by(SchedulingNotification.created_at.desc(), SchedulingNotification.id.asc()).all()
|
||||
|
||||
|
||||
def refresh_participant_response_state(session: Session, *, request: SchedulingRequest) -> None:
|
||||
if request.poll_id is None:
|
||||
return
|
||||
@@ -290,6 +534,13 @@ def create_scheduling_request(
|
||||
participant.last_invited_at = _now()
|
||||
participant.status = "invited"
|
||||
invitation_tokens[participant.id] = token
|
||||
create_scheduling_notification_jobs(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request.id,
|
||||
event_kind="invitation",
|
||||
metadata={"created_with_request": True},
|
||||
)
|
||||
session.flush()
|
||||
return request, invitation_tokens
|
||||
|
||||
@@ -368,6 +619,7 @@ def decide_scheduling_request(
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
payload: SchedulingDecisionRequest,
|
||||
user_id: str | None = None,
|
||||
) -> SchedulingRequest:
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
if request.poll_id is None:
|
||||
@@ -384,20 +636,25 @@ def decide_scheduling_request(
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
request.selected_slot_id = slot.id
|
||||
request.status = "decided"
|
||||
if payload.handoff_to_calendar or (
|
||||
should_handoff = payload.handoff_to_calendar or (
|
||||
payload.handoff_to_calendar is None and request.create_calendar_event_on_decision
|
||||
):
|
||||
request.handed_off_at = _now()
|
||||
request.status = "handed_off"
|
||||
request.metadata_ = {
|
||||
**(request.metadata_ or {}),
|
||||
"calendar_handoff": {
|
||||
"status": "pending",
|
||||
"reason": "Calendar event creation is an optional integration step.",
|
||||
"slot_id": slot.id,
|
||||
"calendar_id": request.calendar_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
if should_handoff:
|
||||
if request.calendar_integration_enabled and request.create_calendar_event_on_decision:
|
||||
create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
|
||||
else:
|
||||
request.handed_off_at = _now()
|
||||
request.status = "handed_off"
|
||||
request.metadata_ = {
|
||||
**(request.metadata_ or {}),
|
||||
"calendar_handoff": {
|
||||
"status": "pending",
|
||||
"reason": "Calendar event creation is not enabled or Calendar is unavailable.",
|
||||
"slot_id": slot.id,
|
||||
"calendar_id": request.calendar_id,
|
||||
},
|
||||
}
|
||||
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision")
|
||||
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
|
||||
session.flush()
|
||||
return request
|
||||
@@ -415,6 +672,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
|
||||
except PollError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
|
||||
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="cancellation")
|
||||
session.flush()
|
||||
return request
|
||||
|
||||
@@ -456,6 +714,10 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
|
||||
"timezone": slot.timezone,
|
||||
"location": slot.location,
|
||||
"position": slot.position,
|
||||
"freebusy_checked_at": response_datetime(slot.freebusy_checked_at),
|
||||
"freebusy_status": slot.freebusy_status,
|
||||
"freebusy_conflicts": slot.freebusy_conflicts or [],
|
||||
"tentative_hold_event_id": slot.tentative_hold_event_id,
|
||||
"metadata": slot.metadata_ or {},
|
||||
}
|
||||
|
||||
@@ -511,3 +773,21 @@ def scheduling_request_response(request: SchedulingRequest, *, invitation_tokens
|
||||
for participant in _active_participants(request)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def scheduling_notification_response(notification: SchedulingNotification) -> dict[str, Any]:
|
||||
return {
|
||||
"id": notification.id,
|
||||
"request_id": notification.request_id,
|
||||
"participant_id": notification.participant_id,
|
||||
"event_kind": notification.event_kind,
|
||||
"channel": notification.channel,
|
||||
"recipient": notification.recipient,
|
||||
"status": notification.status,
|
||||
"payload": notification.payload or {},
|
||||
"error": notification.error,
|
||||
"sent_at": response_datetime(notification.sent_at),
|
||||
"created_at": response_datetime(notification.created_at),
|
||||
"updated_at": response_datetime(notification.updated_at),
|
||||
"metadata": notification.metadata_ or {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user