intermittent commit

This commit is contained in:
2026-07-14 13:22:12 +02:00
parent c1afce7bdb
commit 2f1b7fb6b8
9 changed files with 364 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.db.base import utcnow
from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput
from govoplan_poll.backend.db.models import PollResponse
@@ -24,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
)
from govoplan_scheduling.backend.runtime import get_registry
class SchedulingError(ValueError):
@@ -348,6 +350,7 @@ def create_scheduling_notification_jobs(
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] = []
base_metadata = metadata or {}
for participant in _active_participants(request):
recipient = participant.email or participant.respondent_id
status = "pending" if recipient else "skipped"
@@ -367,16 +370,178 @@ def create_scheduling_notification_jobs(
"selected_slot_id": request.selected_slot_id,
"calendar_event_id": request.calendar_event_id,
},
metadata_=metadata or {},
metadata_=dict(base_metadata),
)
if status == "skipped":
notification.error = "Participant has no deliverable recipient address or id"
session.add(notification)
notifications.append(notification)
session.flush()
_mirror_scheduling_notifications_to_center(session, request=request, notifications=notifications)
return notifications
def _mirror_scheduling_notifications_to_center(
session: Session,
*,
request: SchedulingRequest,
notifications: list[SchedulingNotification],
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
participants_by_id = {participant.id: participant for participant in _active_participants(request)}
for notification in notifications:
if notification.status == "skipped":
continue
participant = participants_by_id.get(notification.participant_id or "")
try:
response = provider.enqueue_notification(
session,
_notification_dispatch_request(request=request, participant=participant, notification=notification),
enqueue_delivery=True,
)
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
notification.status = "failed"
notification.error = f"Notification center enqueue failed: {exc}"
continue
notification.metadata_ = {
**(notification.metadata_ or {}),
"notification_id": response.get("id"),
"notification_status": response.get("status"),
}
if response.get("status") in {"queued", "sending", "sent"}:
notification.status = str(response["status"])
elif response.get("status") == "skipped":
notification.status = "skipped"
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
session.flush()
def _notification_dispatch_request(
*,
request: SchedulingRequest,
participant: SchedulingParticipant | None,
notification: SchedulingNotification,
) -> NotificationDispatchRequest:
payload = {
**(notification.payload or {}),
"scheduling_notification_id": notification.id,
"participant": {
"id": participant.id,
"display_name": participant.display_name,
"email": participant.email,
"required": participant.required,
"participant_type": participant.participant_type,
}
if participant
else None,
}
return NotificationDispatchRequest(
tenant_id=request.tenant_id,
source_module="scheduling",
source_resource_type="request",
source_resource_id=request.id,
event_kind=notification.event_kind,
channel=notification.channel,
recipient=notification.recipient,
recipient_type="scheduling_participant",
recipient_id=participant.id if participant else notification.participant_id,
recipient_label=participant.display_name if participant else None,
subject=_notification_subject(request, notification.event_kind),
body_text=_notification_body_text(request, participant, notification.event_kind),
action_url=f"/scheduling?request_id={request.id}",
payload=_jsonable(payload),
metadata={
**(notification.metadata_ or {}),
"scheduling_notification_id": notification.id,
"scheduling_request_id": request.id,
},
)
def _notification_subject(request: SchedulingRequest, event_kind: str) -> str:
labels = {
"invitation": "Scheduling invitation",
"reminder": "Scheduling reminder",
"decision": "Scheduling decision",
"cancellation": "Scheduling cancelled",
}
return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}"
def _notification_body_text(
request: SchedulingRequest,
participant: SchedulingParticipant | None,
event_kind: str,
) -> str:
greeting = f"Hello {participant.display_name}," if participant and participant.display_name else "Hello,"
lines = [greeting, "", _notification_body_intro(request, event_kind)]
selected_slot = next((slot for slot in _active_slots(request) if slot.id == request.selected_slot_id), None)
if selected_slot is not None:
lines.append(f"Selected slot: {_slot_label(selected_slot.start_at, selected_slot.end_at, timezone_name=selected_slot.timezone)}")
if request.location:
lines.append(f"Location: {request.location}")
lines.append(f"Scheduling request: {request.title}")
return "\n".join(lines)
def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str:
if event_kind == "invitation":
return f"You are invited to respond to the scheduling poll for {request.title}."
if event_kind == "reminder":
return f"This is a reminder to respond to the scheduling poll for {request.title}."
if event_kind == "decision":
return f"A final slot was selected for {request.title}."
if event_kind == "cancellation":
return f"The scheduling request {request.title} was cancelled."
return f"There is an update for {request.title}."
def _emit_scheduling_center_notification(
session: Session,
*,
request: SchedulingRequest,
event_kind: str,
subject: str,
body_text: str,
participant: SchedulingParticipant | None = None,
priority: int = 0,
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
try:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=request.tenant_id,
source_module="scheduling",
source_resource_type="request",
source_resource_id=request.id,
event_kind=event_kind,
channel="inbox",
recipient_type="user" if request.organizer_user_id else None,
recipient_id=request.organizer_user_id,
subject=subject,
body_text=body_text,
action_url=f"/scheduling?request_id={request.id}",
priority=priority,
payload=_jsonable(
{
"request_id": request.id,
"poll_id": request.poll_id,
"participant_id": participant.id if participant else None,
}
),
metadata={"scheduling_request_id": request.id},
),
enqueue_delivery=False,
)
except Exception:
return
def list_scheduling_notifications(
session: Session,
*,
@@ -414,6 +579,14 @@ def refresh_participant_response_state(session: Session, *, request: SchedulingR
if participant is not None and participant.status != "responded":
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification(
session,
request=request,
participant=participant,
event_kind="scheduling.participant_responded",
subject=f"Scheduling response: {request.title}",
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
)
changed = True
if changed:
session.flush()