Compare commits
6 Commits
4ddac65367
...
886579942f
| Author | SHA1 | Date | |
|---|---|---|---|
| 886579942f | |||
| 0452100242 | |||
| 74e4508b8f | |||
| bb7cea03df | |||
| 770ecf8786 | |||
| 98b797a95d |
@@ -6,6 +6,8 @@ Create Date: 2026-07-12 00:00:00.000000
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
@@ -55,6 +57,62 @@ _NOTIFICATION_INDEXES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_extension_problems(inspector: Any, slot_columns: set[str]) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
missing_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
|
||||||
|
if missing_columns:
|
||||||
|
problems.append(
|
||||||
|
"scheduling_candidate_slots missing columns: "
|
||||||
|
f"{', '.join(sorted(missing_columns))}"
|
||||||
|
)
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
|
||||||
|
missing_indexes = _CALENDAR_SLOT_INDEXES - indexes
|
||||||
|
if missing_indexes:
|
||||||
|
problems.append(
|
||||||
|
"scheduling_candidate_slots missing indexes: "
|
||||||
|
f"{', '.join(sorted(missing_indexes))}"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _has_cascading_notification_request_foreign_key(inspector: Any) -> bool:
|
||||||
|
return any(
|
||||||
|
tuple(item.get("constrained_columns") or ()) == ("request_id",)
|
||||||
|
and item.get("referred_table") == "scheduling_requests"
|
||||||
|
and tuple(item.get("referred_columns") or ()) == ("id",)
|
||||||
|
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
|
||||||
|
for item in inspector.get_foreign_keys("scheduling_notifications")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_extension_problems(inspector: Any) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
columns = {item["name"] for item in inspector.get_columns("scheduling_notifications")}
|
||||||
|
missing_columns = _NOTIFICATION_COLUMNS - columns
|
||||||
|
if missing_columns:
|
||||||
|
problems.append(
|
||||||
|
"scheduling_notifications missing columns: "
|
||||||
|
f"{', '.join(sorted(missing_columns))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("scheduling_notifications")}
|
||||||
|
missing_indexes = _NOTIFICATION_INDEXES - indexes
|
||||||
|
if missing_indexes:
|
||||||
|
problems.append(
|
||||||
|
"scheduling_notifications missing indexes: "
|
||||||
|
f"{', '.join(sorted(missing_indexes))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
primary_key = tuple(
|
||||||
|
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
|
||||||
|
)
|
||||||
|
if primary_key != ("id",):
|
||||||
|
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
|
||||||
|
if not _has_cascading_notification_request_foreign_key(inspector):
|
||||||
|
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def _adopt_existing_calendar_notifications() -> bool:
|
def _adopt_existing_calendar_notifications() -> bool:
|
||||||
"""Adopt only the complete calendar/notification extension."""
|
"""Adopt only the complete calendar/notification extension."""
|
||||||
|
|
||||||
@@ -71,59 +129,11 @@ def _adopt_existing_calendar_notifications() -> bool:
|
|||||||
if not present_slot_columns and not notification_exists:
|
if not present_slot_columns and not notification_exists:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
problems: list[str] = []
|
problems = _slot_extension_problems(inspector, slot_columns)
|
||||||
missing_slot_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
|
|
||||||
if missing_slot_columns:
|
|
||||||
problems.append(
|
|
||||||
"scheduling_candidate_slots missing columns: "
|
|
||||||
f"{', '.join(sorted(missing_slot_columns))}"
|
|
||||||
)
|
|
||||||
slot_indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
|
|
||||||
missing_slot_indexes = _CALENDAR_SLOT_INDEXES - slot_indexes
|
|
||||||
if missing_slot_indexes:
|
|
||||||
problems.append(
|
|
||||||
"scheduling_candidate_slots missing indexes: "
|
|
||||||
f"{', '.join(sorted(missing_slot_indexes))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not notification_exists:
|
if not notification_exists:
|
||||||
problems.append("missing table: scheduling_notifications")
|
problems.append("missing table: scheduling_notifications")
|
||||||
else:
|
else:
|
||||||
notification_columns = {
|
problems.extend(_notification_extension_problems(inspector))
|
||||||
item["name"] for item in inspector.get_columns("scheduling_notifications")
|
|
||||||
}
|
|
||||||
missing_notification_columns = _NOTIFICATION_COLUMNS - notification_columns
|
|
||||||
if missing_notification_columns:
|
|
||||||
problems.append(
|
|
||||||
"scheduling_notifications missing columns: "
|
|
||||||
f"{', '.join(sorted(missing_notification_columns))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
notification_indexes = {
|
|
||||||
item["name"] for item in inspector.get_indexes("scheduling_notifications")
|
|
||||||
}
|
|
||||||
missing_notification_indexes = _NOTIFICATION_INDEXES - notification_indexes
|
|
||||||
if missing_notification_indexes:
|
|
||||||
problems.append(
|
|
||||||
"scheduling_notifications missing indexes: "
|
|
||||||
f"{', '.join(sorted(missing_notification_indexes))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
primary_key = tuple(
|
|
||||||
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
|
|
||||||
)
|
|
||||||
if primary_key != ("id",):
|
|
||||||
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
|
|
||||||
|
|
||||||
request_foreign_key_exists = any(
|
|
||||||
tuple(item.get("constrained_columns") or ()) == ("request_id",)
|
|
||||||
and item.get("referred_table") == "scheduling_requests"
|
|
||||||
and tuple(item.get("referred_columns") or ()) == ("id",)
|
|
||||||
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
|
|
||||||
for item in inspector.get_foreign_keys("scheduling_notifications")
|
|
||||||
)
|
|
||||||
if not request_foreign_key_exists:
|
|
||||||
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
|
|
||||||
|
|
||||||
if problems:
|
if problems:
|
||||||
detail = "; ".join(problems)
|
detail = "; ".join(problems)
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from sqlalchemy.orm import Session, object_session
|
from sqlalchemy.orm import Session, object_session
|
||||||
|
|
||||||
@@ -787,16 +788,9 @@ def list_visible_scheduling_notifications(
|
|||||||
return visible
|
return visible
|
||||||
|
|
||||||
|
|
||||||
def refresh_participant_response_state(
|
def _participant_response_indexes(
|
||||||
session: Session,
|
participants: list[SchedulingParticipant],
|
||||||
*,
|
) -> tuple[dict[str, SchedulingParticipant], dict[str, SchedulingParticipant]]:
|
||||||
request: SchedulingRequest,
|
|
||||||
actor_ids: tuple[str, ...] = (),
|
|
||||||
reset_missing: bool = False,
|
|
||||||
) -> None:
|
|
||||||
if request.poll_id is None:
|
|
||||||
return
|
|
||||||
participants = _active_participants(request)
|
|
||||||
by_invitation_id = {
|
by_invitation_id = {
|
||||||
participant.poll_invitation_id: participant
|
participant.poll_invitation_id: participant
|
||||||
for participant in participants
|
for participant in participants
|
||||||
@@ -808,6 +802,97 @@ def refresh_participant_response_state(
|
|||||||
for identity in (participant.respondent_id, stored_identity):
|
for identity in (participant.respondent_id, stored_identity):
|
||||||
if isinstance(identity, str) and identity:
|
if isinstance(identity, str) and identity:
|
||||||
by_respondent_id[identity] = participant
|
by_respondent_id[identity] = participant
|
||||||
|
return by_invitation_id, by_respondent_id
|
||||||
|
|
||||||
|
|
||||||
|
def _response_participant(
|
||||||
|
response: PollResponseRef,
|
||||||
|
*,
|
||||||
|
by_invitation_id: dict[str, SchedulingParticipant],
|
||||||
|
by_respondent_id: dict[str, SchedulingParticipant],
|
||||||
|
actor_ids: set[str],
|
||||||
|
actor_participants: list[SchedulingParticipant],
|
||||||
|
) -> SchedulingParticipant | None:
|
||||||
|
participant = by_invitation_id.get(response.invitation_id)
|
||||||
|
if participant is None and response.respondent_id is not None:
|
||||||
|
participant = by_respondent_id.get(response.respondent_id)
|
||||||
|
# Poll deliberately ignores client-supplied invitation identifiers on
|
||||||
|
# authenticated submissions. While reconciling for that same actor, map
|
||||||
|
# the server-authenticated respondent to their unique Scheduling row.
|
||||||
|
if participant is None and response.respondent_id in actor_ids and len(actor_participants) == 1:
|
||||||
|
return actor_participants[0]
|
||||||
|
return participant
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_participant_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
participant: SchedulingParticipant,
|
||||||
|
response: PollResponseRef,
|
||||||
|
) -> bool:
|
||||||
|
changed = False
|
||||||
|
if response.respondent_id:
|
||||||
|
metadata = participant.metadata_ or {}
|
||||||
|
if metadata.get("poll_response_respondent_id") != response.respondent_id:
|
||||||
|
participant.metadata_ = {
|
||||||
|
**metadata,
|
||||||
|
"poll_response_respondent_id": response.respondent_id,
|
||||||
|
}
|
||||||
|
changed = True
|
||||||
|
if participant.status == "responded":
|
||||||
|
return changed
|
||||||
|
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}.",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_missing_participant_responses(
|
||||||
|
participants: list[SchedulingParticipant],
|
||||||
|
*,
|
||||||
|
matched_participant_ids: set[str],
|
||||||
|
unmatched_response_count: int,
|
||||||
|
) -> bool:
|
||||||
|
changed = False
|
||||||
|
for participant in participants:
|
||||||
|
if participant.status != "responded" or participant.id in matched_participant_ids:
|
||||||
|
continue
|
||||||
|
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
|
||||||
|
if not isinstance(stored_identity, str) and unmatched_response_count:
|
||||||
|
# Preserve an older projection while an active response exists
|
||||||
|
# that cannot yet be mapped safely to a participant.
|
||||||
|
continue
|
||||||
|
participant.status = "invited" if participant.poll_invitation_id else "draft"
|
||||||
|
participant.responded_at = None
|
||||||
|
if isinstance(stored_identity, str):
|
||||||
|
participant.metadata_ = {
|
||||||
|
key: value
|
||||||
|
for key, value in (participant.metadata_ or {}).items()
|
||||||
|
if key != "poll_response_respondent_id"
|
||||||
|
}
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_participant_response_state(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
actor_ids: tuple[str, ...] = (),
|
||||||
|
reset_missing: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if request.poll_id is None:
|
||||||
|
return
|
||||||
|
participants = _active_participants(request)
|
||||||
|
by_invitation_id, by_respondent_id = _participant_response_indexes(participants)
|
||||||
try:
|
try:
|
||||||
responses = _poll_provider().list_responses(
|
responses = _poll_provider().list_responses(
|
||||||
session,
|
session,
|
||||||
@@ -826,61 +911,29 @@ def refresh_participant_response_state(
|
|||||||
matched_participant_ids: set[str] = set()
|
matched_participant_ids: set[str] = set()
|
||||||
unmatched_response_count = 0
|
unmatched_response_count = 0
|
||||||
for response in responses:
|
for response in responses:
|
||||||
participant = by_invitation_id.get(response.invitation_id)
|
participant = _response_participant(
|
||||||
if participant is None and response.respondent_id is not None:
|
response,
|
||||||
participant = by_respondent_id.get(response.respondent_id)
|
by_invitation_id=by_invitation_id,
|
||||||
# Poll deliberately ignores client-supplied invitation identifiers on
|
by_respondent_id=by_respondent_id,
|
||||||
# authenticated submissions. While reconciling for that same actor,
|
actor_ids=ids,
|
||||||
# map the server-authenticated respondent id to their unique Scheduling
|
actor_participants=actor_participants,
|
||||||
# participant row (which may have been invited by email only).
|
)
|
||||||
if (
|
|
||||||
participant is None
|
|
||||||
and response.respondent_id in ids
|
|
||||||
and len(actor_participants) == 1
|
|
||||||
):
|
|
||||||
participant = actor_participants[0]
|
|
||||||
if participant is None:
|
if participant is None:
|
||||||
unmatched_response_count += 1
|
unmatched_response_count += 1
|
||||||
continue
|
continue
|
||||||
matched_participant_ids.add(participant.id)
|
matched_participant_ids.add(participant.id)
|
||||||
if response.respondent_id:
|
changed = _apply_participant_response(
|
||||||
metadata = participant.metadata_ or {}
|
session,
|
||||||
if metadata.get("poll_response_respondent_id") != response.respondent_id:
|
request=request,
|
||||||
participant.metadata_ = {
|
participant=participant,
|
||||||
**metadata,
|
response=response,
|
||||||
"poll_response_respondent_id": response.respondent_id,
|
) or changed
|
||||||
}
|
if reset_missing and _reset_missing_participant_responses(
|
||||||
changed = True
|
participants,
|
||||||
if participant.status != "responded":
|
matched_participant_ids=matched_participant_ids,
|
||||||
participant.status = "responded"
|
unmatched_response_count=unmatched_response_count,
|
||||||
participant.responded_at = response_datetime(response.submitted_at)
|
):
|
||||||
_emit_scheduling_center_notification(
|
changed = True
|
||||||
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 reset_missing:
|
|
||||||
for participant in participants:
|
|
||||||
if participant.status != "responded" or participant.id in matched_participant_ids:
|
|
||||||
continue
|
|
||||||
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
|
|
||||||
if not isinstance(stored_identity, str) and unmatched_response_count:
|
|
||||||
# Do not reset an older projection while an active response
|
|
||||||
# exists that cannot yet be mapped safely to a participant.
|
|
||||||
continue
|
|
||||||
participant.status = "invited" if participant.poll_invitation_id else "draft"
|
|
||||||
participant.responded_at = None
|
|
||||||
if isinstance(stored_identity, str):
|
|
||||||
participant.metadata_ = {
|
|
||||||
key: value
|
|
||||||
for key, value in (participant.metadata_ or {}).items()
|
|
||||||
if key != "poll_response_respondent_id"
|
|
||||||
}
|
|
||||||
changed = True
|
|
||||||
if changed:
|
if changed:
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
@@ -1364,6 +1417,121 @@ def update_scheduling_request(
|
|||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CandidateSlotChanges:
|
||||||
|
label: str
|
||||||
|
description: str | None
|
||||||
|
start_at: datetime
|
||||||
|
end_at: datetime
|
||||||
|
timezone: str
|
||||||
|
location: str | None
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
normalized_start: datetime
|
||||||
|
normalized_end: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_slot_changes(
|
||||||
|
slot: SchedulingCandidateSlot,
|
||||||
|
payload: SchedulingCandidateSlotUpdateRequest,
|
||||||
|
) -> _CandidateSlotChanges:
|
||||||
|
supplied = payload.model_fields_set
|
||||||
|
for field in ("label", "start_at", "end_at", "timezone"):
|
||||||
|
if field in supplied and getattr(payload, field) is None:
|
||||||
|
raise SchedulingError(f"{field} cannot be null")
|
||||||
|
|
||||||
|
label = cast(str, payload.label if "label" in supplied else slot.label)
|
||||||
|
description = payload.description if "description" in supplied else slot.description
|
||||||
|
start_at = cast(datetime, payload.start_at if "start_at" in supplied else slot.start_at)
|
||||||
|
end_at = cast(datetime, payload.end_at if "end_at" in supplied else slot.end_at)
|
||||||
|
timezone_name = cast(str, payload.timezone if "timezone" in supplied else slot.timezone)
|
||||||
|
location = payload.location if "location" in supplied else slot.location
|
||||||
|
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
|
||||||
|
normalized_start = cast(datetime, response_datetime(start_at))
|
||||||
|
normalized_end = cast(datetime, response_datetime(end_at))
|
||||||
|
if normalized_end <= normalized_start:
|
||||||
|
raise SchedulingError("end_at must be after start_at")
|
||||||
|
return _CandidateSlotChanges(
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
start_at=start_at,
|
||||||
|
end_at=end_at,
|
||||||
|
timezone=timezone_name,
|
||||||
|
location=location,
|
||||||
|
metadata=metadata,
|
||||||
|
normalized_start=normalized_start,
|
||||||
|
normalized_end=normalized_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_slot_change_flags(
|
||||||
|
slot: SchedulingCandidateSlot,
|
||||||
|
changes: _CandidateSlotChanges,
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
temporal_or_location_changed = (
|
||||||
|
changes.normalized_start != response_datetime(slot.start_at)
|
||||||
|
or changes.normalized_end != response_datetime(slot.end_at)
|
||||||
|
or changes.timezone != slot.timezone
|
||||||
|
or changes.location != slot.location
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
changes.label != slot.label
|
||||||
|
or changes.description != slot.description
|
||||||
|
or temporal_or_location_changed
|
||||||
|
or changes.metadata != (slot.metadata_ or {})
|
||||||
|
)
|
||||||
|
return changed, temporal_or_location_changed
|
||||||
|
|
||||||
|
|
||||||
|
def _update_candidate_poll_option(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
slot: SchedulingCandidateSlot,
|
||||||
|
changes: _CandidateSlotChanges,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
_poll_provider().update_option(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=cast(str, request.poll_id),
|
||||||
|
option_id=cast(str, slot.poll_option_id),
|
||||||
|
command=PollOptionUpdateCommand(
|
||||||
|
label=changes.label,
|
||||||
|
description=changes.description,
|
||||||
|
value={
|
||||||
|
"slot_id": slot.id,
|
||||||
|
"start_at": changes.normalized_start.isoformat(),
|
||||||
|
"end_at": changes.normalized_end.isoformat(),
|
||||||
|
"timezone": changes.timezone,
|
||||||
|
"location": changes.location,
|
||||||
|
},
|
||||||
|
metadata=changes.metadata,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except PollCapabilityError as exc:
|
||||||
|
raise SchedulingError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_candidate_slot_changes(
|
||||||
|
slot: SchedulingCandidateSlot,
|
||||||
|
changes: _CandidateSlotChanges,
|
||||||
|
*,
|
||||||
|
temporal_or_location_changed: bool,
|
||||||
|
) -> None:
|
||||||
|
slot.label = changes.label
|
||||||
|
slot.description = changes.description
|
||||||
|
slot.start_at = changes.start_at
|
||||||
|
slot.end_at = changes.end_at
|
||||||
|
slot.timezone = changes.timezone
|
||||||
|
slot.location = changes.location
|
||||||
|
slot.metadata_ = changes.metadata
|
||||||
|
if temporal_or_location_changed:
|
||||||
|
slot.freebusy_checked_at = None
|
||||||
|
slot.freebusy_status = None
|
||||||
|
slot.freebusy_conflicts = []
|
||||||
|
|
||||||
|
|
||||||
def update_scheduling_candidate_slot(
|
def update_scheduling_candidate_slot(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1388,35 +1556,8 @@ def update_scheduling_candidate_slot(
|
|||||||
if slot.poll_option_id is None:
|
if slot.poll_option_id is None:
|
||||||
raise SchedulingError("Scheduling slot has no backing poll option")
|
raise SchedulingError("Scheduling slot has no backing poll option")
|
||||||
|
|
||||||
supplied = payload.model_fields_set
|
changes = _candidate_slot_changes(slot, payload)
|
||||||
for field in ("label", "start_at", "end_at", "timezone"):
|
changed, temporal_or_location_changed = _candidate_slot_change_flags(slot, changes)
|
||||||
if field in supplied and getattr(payload, field) is None:
|
|
||||||
raise SchedulingError(f"{field} cannot be null")
|
|
||||||
|
|
||||||
label = payload.label if "label" in supplied else slot.label
|
|
||||||
description = payload.description if "description" in supplied else slot.description
|
|
||||||
start_at = payload.start_at if "start_at" in supplied else slot.start_at
|
|
||||||
end_at = payload.end_at if "end_at" in supplied else slot.end_at
|
|
||||||
timezone_name = payload.timezone if "timezone" in supplied else slot.timezone
|
|
||||||
location = payload.location if "location" in supplied else slot.location
|
|
||||||
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
|
|
||||||
normalized_start = response_datetime(start_at)
|
|
||||||
normalized_end = response_datetime(end_at)
|
|
||||||
if normalized_end <= normalized_start:
|
|
||||||
raise SchedulingError("end_at must be after start_at")
|
|
||||||
|
|
||||||
temporal_or_location_changed = (
|
|
||||||
normalized_start != response_datetime(slot.start_at)
|
|
||||||
or normalized_end != response_datetime(slot.end_at)
|
|
||||||
or timezone_name != slot.timezone
|
|
||||||
or location != slot.location
|
|
||||||
)
|
|
||||||
changed = (
|
|
||||||
label != slot.label
|
|
||||||
or description != slot.description
|
|
||||||
or temporal_or_location_changed
|
|
||||||
or metadata != (slot.metadata_ or {})
|
|
||||||
)
|
|
||||||
if not changed:
|
if not changed:
|
||||||
return request
|
return request
|
||||||
if temporal_or_location_changed and slot.tentative_hold_event_id:
|
if temporal_or_location_changed and slot.tentative_hold_event_id:
|
||||||
@@ -1424,27 +1565,13 @@ def update_scheduling_candidate_slot(
|
|||||||
"A slot with a tentative calendar hold cannot change time, timezone, or location"
|
"A slot with a tentative calendar hold cannot change time, timezone, or location"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
_update_candidate_poll_option(
|
||||||
_poll_provider().update_option(
|
session,
|
||||||
session,
|
tenant_id=tenant_id,
|
||||||
tenant_id=tenant_id,
|
request=request,
|
||||||
poll_id=request.poll_id,
|
slot=slot,
|
||||||
option_id=slot.poll_option_id,
|
changes=changes,
|
||||||
command=PollOptionUpdateCommand(
|
)
|
||||||
label=label,
|
|
||||||
description=description,
|
|
||||||
value={
|
|
||||||
"slot_id": slot.id,
|
|
||||||
"start_at": normalized_start.isoformat(),
|
|
||||||
"end_at": normalized_end.isoformat(),
|
|
||||||
"timezone": timezone_name,
|
|
||||||
"location": location,
|
|
||||||
},
|
|
||||||
metadata=metadata,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except PollCapabilityError as exc:
|
|
||||||
raise SchedulingError(str(exc)) from exc
|
|
||||||
|
|
||||||
refresh_participant_response_state(
|
refresh_participant_response_state(
|
||||||
session,
|
session,
|
||||||
@@ -1452,17 +1579,11 @@ def update_scheduling_candidate_slot(
|
|||||||
reset_missing=True,
|
reset_missing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
slot.label = label
|
_apply_candidate_slot_changes(
|
||||||
slot.description = description
|
slot,
|
||||||
slot.start_at = start_at
|
changes,
|
||||||
slot.end_at = end_at
|
temporal_or_location_changed=temporal_or_location_changed,
|
||||||
slot.timezone = timezone_name
|
)
|
||||||
slot.location = location
|
|
||||||
slot.metadata_ = metadata
|
|
||||||
if temporal_or_location_changed:
|
|
||||||
slot.freebusy_checked_at = None
|
|
||||||
slot.freebusy_status = None
|
|
||||||
slot.freebusy_conflicts = []
|
|
||||||
session.flush()
|
session.flush()
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
|||||||
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
|
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
|
||||||
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
|
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
|
||||||
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
|
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
|
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
|
||||||
assert.match(page, /className="scheduling-full-editor"/);
|
assert.match(page, /className="scheduling-full-editor"/);
|
||||||
assert.match(page, /className="scheduling-page-actions"/);
|
assert.match(page, /className="scheduling-page-actions"/);
|
||||||
@@ -36,6 +37,9 @@ assert.match(browseBranch, /<h1>\{I18N\.requests\}<\/h1>/);
|
|||||||
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
|
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
|
||||||
assert.match(page, /title=\{I18N\.myRequests\}/);
|
assert.match(page, /title=\{I18N\.myRequests\}/);
|
||||||
assert.match(page, /title=\{I18N\.invitedRequests\}/);
|
assert.match(page, /title=\{I18N\.invitedRequests\}/);
|
||||||
|
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
|
||||||
|
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
|
||||||
|
assert.doesNotMatch(page, /<button[\s\S]{0,160}scheduling-list-item/);
|
||||||
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
|
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
|
||||||
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
|
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
|
||||||
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
|
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
|
||||||
@@ -52,7 +56,7 @@ assert.match(page, /<DataGridEmptyAction/);
|
|||||||
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
|
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
|
||||||
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
|
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
|
||||||
assert.doesNotMatch(page, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
assert.doesNotMatch(page, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
||||||
assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
|
assert.match(page, /<TableActionGroup[\s\S]*label: i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
|
||||||
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
||||||
assert.match(page, /option_revision: slot\.revision/);
|
assert.match(page, /option_revision: slot\.revision/);
|
||||||
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
|
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ import {
|
|||||||
XCircle
|
XCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
AdminIconButton,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
DataGridEmptyAction,
|
DataGridEmptyAction,
|
||||||
DataGridRowActions,
|
DataGridRowActions,
|
||||||
|
DateTimeField,
|
||||||
|
formatDateTime,
|
||||||
ModuleSubnav,
|
ModuleSubnav,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
@@ -26,6 +32,7 @@ import {
|
|||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type CalendarPickerUiCapability,
|
type CalendarPickerUiCapability,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
|
type FormatDateTimeOptions,
|
||||||
type ModuleSubnavGroup
|
type ModuleSubnavGroup
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -578,9 +585,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
<h1>{I18N.requests}</h1>
|
<h1>{I18N.requests}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="scheduling-page-actions">
|
<div className="scheduling-page-actions">
|
||||||
<Button type="button" className="scheduling-icon-button" onClick={() => void loadRequests()} title={I18N.refresh} aria-label={I18N.refresh}>
|
<AdminIconButton label={I18N.refresh} icon={<RefreshCw aria-hidden="true" size={16} />} onClick={() => void loadRequests()} disabled={loading} />
|
||||||
<RefreshCw aria-hidden="true" size={16} />
|
|
||||||
</Button>
|
|
||||||
{canWrite ? (
|
{canWrite ? (
|
||||||
<Button type="button" variant="primary" onClick={beginCreate}>
|
<Button type="button" variant="primary" onClick={beginCreate}>
|
||||||
<Plus aria-hidden="true" size={16} /> {I18N.add}
|
<Plus aria-hidden="true" size={16} /> {I18N.add}
|
||||||
@@ -809,16 +814,20 @@ function RequestGroup({
|
|||||||
return (
|
return (
|
||||||
<Card title={title}>
|
<Card title={title}>
|
||||||
<div className="scheduling-list-group">
|
<div className="scheduling-list-group">
|
||||||
{requests.length ? requests.map((request) => (
|
{requests.length ? (
|
||||||
<button
|
<SelectionList label={title} className="scheduling-request-list">
|
||||||
key={request.id}
|
{requests.map((request) => (
|
||||||
className={`scheduling-list-item ${selectedId === request.id ? "is-selected" : ""}`}
|
<SelectionListItem
|
||||||
type="button"
|
key={request.id}
|
||||||
onClick={() => onSelect(request.id)}>
|
selected={selectedId === request.id}
|
||||||
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
|
className="scheduling-list-item"
|
||||||
<small className="scheduling-list-status">{requestStatusLabel(request, actor)}</small>
|
onClick={() => onSelect(request.id)}>
|
||||||
</button>
|
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
|
||||||
)) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>}
|
<small className="scheduling-list-status">{requestStatusLabel(request, actor)}</small>
|
||||||
|
</SelectionListItem>
|
||||||
|
))}
|
||||||
|
</SelectionList>
|
||||||
|
) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -883,33 +892,31 @@ function EditableSlots({
|
|||||||
{
|
{
|
||||||
id: "start",
|
id: "start",
|
||||||
header: I18N.start,
|
header: I18N.start,
|
||||||
minWidth: 200,
|
minWidth: 280,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
value: (slot) => slot.start_at,
|
value: (slot) => slot.start_at,
|
||||||
render: (slot, index) => (
|
render: (slot, index) => (
|
||||||
<input
|
<DateTimeField
|
||||||
className="scheduling-grid-input"
|
className="scheduling-grid-date-time"
|
||||||
required
|
required
|
||||||
type="datetime-local"
|
|
||||||
aria-label={I18N.start}
|
aria-label={I18N.start}
|
||||||
value={slot.start_at}
|
value={slot.start_at}
|
||||||
onChange={(event) => onChange(index, "start_at", event.target.value)} />
|
onChange={(value) => onChange(index, "start_at", value)} />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "end",
|
id: "end",
|
||||||
header: I18N.end,
|
header: I18N.end,
|
||||||
minWidth: 200,
|
minWidth: 280,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
value: (slot) => slot.end_at,
|
value: (slot) => slot.end_at,
|
||||||
render: (slot, index) => (
|
render: (slot, index) => (
|
||||||
<input
|
<DateTimeField
|
||||||
className="scheduling-grid-input"
|
className="scheduling-grid-date-time"
|
||||||
required
|
required
|
||||||
type="datetime-local"
|
|
||||||
aria-label={I18N.end}
|
aria-label={I18N.end}
|
||||||
value={slot.end_at}
|
value={slot.end_at}
|
||||||
onChange={(event) => onChange(index, "end_at", event.target.value)} />
|
onChange={(value) => onChange(index, "end_at", value)} />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1059,8 +1066,8 @@ function CandidateSlotsGrid({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at) },
|
{ id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at, SHORT_DATE_TIME_OPTIONS) },
|
||||||
{ id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at) },
|
{ id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at, SHORT_DATE_TIME_OPTIONS) },
|
||||||
{
|
{
|
||||||
id: "available",
|
id: "available",
|
||||||
header: I18N.available,
|
header: I18N.available,
|
||||||
@@ -1087,17 +1094,14 @@ function CandidateSlotsGrid({
|
|||||||
header: I18N.actions,
|
header: I18N.actions,
|
||||||
width: 72,
|
width: 72,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (slot) => canDecide ? (
|
render: (slot) => <TableActionGroup actions={[{
|
||||||
<Button
|
id: "decide",
|
||||||
className="scheduling-icon-button"
|
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
|
||||||
type="button"
|
icon: <Check aria-hidden="true" size={16} />,
|
||||||
title={i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label })}
|
applicable: canDecide,
|
||||||
aria-label={i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label })}
|
disabled: saving,
|
||||||
disabled={saving}
|
onClick: () => onDecide(slot)
|
||||||
onClick={() => onDecide(slot)}>
|
}]} />
|
||||||
<Check aria-hidden="true" size={16} />
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1233,17 +1237,24 @@ function isoFromLocal(value: string): string {
|
|||||||
return new Date(value).toISOString();
|
return new Date(value).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
const SHORT_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
year: undefined,
|
||||||
}
|
month: undefined,
|
||||||
|
day: undefined,
|
||||||
|
hour: undefined,
|
||||||
|
minute: undefined,
|
||||||
|
timeZoneName: undefined,
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short"
|
||||||
|
};
|
||||||
|
|
||||||
function formatRange(start: string, end: string): string {
|
function formatRange(start: string, end: string): string {
|
||||||
return `${formatDateTime(start)} – ${formatDateTime(end)}`;
|
return `${formatDateTime(start, SHORT_DATE_TIME_OPTIONS)} – ${formatDateTime(end, SHORT_DATE_TIME_OPTIONS)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelevantDate(request: SchedulingRequest): string {
|
function formatRelevantDate(request: SchedulingRequest): string {
|
||||||
const timestamp = schedulingRelevantTimestamp(request);
|
const timestamp = schedulingRelevantTimestamp(request);
|
||||||
return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString()) : "";
|
return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString(), SHORT_DATE_TIME_OPTIONS) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
|
|||||||
@@ -89,15 +89,6 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-icon-button.btn {
|
|
||||||
width: 34px;
|
|
||||||
min-width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-alert,
|
.scheduling-alert,
|
||||||
.scheduling-success {
|
.scheduling-success {
|
||||||
margin: 10px 14px 0;
|
margin: 10px 14px 0;
|
||||||
@@ -107,8 +98,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-alert {
|
.scheduling-alert {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger-border-strong);
|
||||||
color: var(--danger);
|
color: var(--danger-text-strong);
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,30 +136,16 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scheduling-request-list {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.scheduling-list-item {
|
.scheduling-list-item {
|
||||||
width: 100%;
|
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0 0 4px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 7px;
|
|
||||||
color: var(--text);
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-list-item:hover {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scheduling-list-item.is-selected {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-list-item > span {
|
.scheduling-list-item > span {
|
||||||
@@ -247,7 +224,7 @@
|
|||||||
|
|
||||||
.scheduling-freebusy.busy,
|
.scheduling-freebusy.busy,
|
||||||
.scheduling-freebusy.error {
|
.scheduling-freebusy.error {
|
||||||
color: var(--danger);
|
color: var(--danger-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-columns {
|
.scheduling-columns {
|
||||||
@@ -317,7 +294,7 @@
|
|||||||
border: var(--border-line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--input-bg);
|
background: var(--control-bg);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user