refactor: split calendar source setup orchestration
This commit is contained in:
@@ -99,6 +99,15 @@ from govoplan_core.db.session import get_session
|
||||
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
def _caldav_discovery_http_error(
|
||||
exc: CalendarError | CalDAVError,
|
||||
) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
@@ -492,7 +501,7 @@ def api_discover_caldav_calendars(
|
||||
calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload)
|
||||
return CalendarCalDavDiscoveryResponse(calendars=calendars)
|
||||
except (CalendarError, CalDAVError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
raise _caldav_discovery_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -200,6 +200,48 @@ class _RemoteSyncSnapshot:
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CalDAVDiscoveryAuth:
|
||||
auth_type: str
|
||||
username: str | None
|
||||
credential_ref: str | None
|
||||
secret: str | None = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SyncSourceCreationPlan:
|
||||
source_kind: str
|
||||
collection_url: str
|
||||
sync_direction: str
|
||||
auth_type: str
|
||||
username: str | None
|
||||
credential_ref: str | None
|
||||
has_inline_secret: bool
|
||||
|
||||
def build_source(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
calendar_id: str,
|
||||
payload: CalendarSyncSourceCreateRequest,
|
||||
) -> CalendarSyncSource:
|
||||
return CalendarSyncSource(
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
source_kind=self.source_kind,
|
||||
collection_url=self.collection_url,
|
||||
display_name=payload.display_name,
|
||||
auth_type=self.auth_type,
|
||||
username=self.username,
|
||||
credential_ref=self.credential_ref,
|
||||
sync_enabled=payload.sync_enabled,
|
||||
sync_interval_seconds=payload.sync_interval_seconds,
|
||||
sync_direction=self.sync_direction,
|
||||
conflict_policy=payload.conflict_policy,
|
||||
metadata_=dict(payload.metadata),
|
||||
)
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return slug or "calendar"
|
||||
@@ -892,44 +934,149 @@ def get_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> Ca
|
||||
raise CalendarError("CalDAV sync source not found") from exc
|
||||
|
||||
|
||||
def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: CalendarCalDavDiscoveryRequest) -> list[dict[str, Any]]:
|
||||
source = get_caldav_source(session, tenant_id=tenant_id, source_id=payload.source_id) if payload.source_id else None
|
||||
auth_type = payload.auth_type or (source.auth_type if source else "none")
|
||||
username = payload.username if payload.username is not None else (source.username if source else None)
|
||||
credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None)
|
||||
resolved_envelope = _resolve_core_calendar_credential(
|
||||
def _resolve_caldav_discovery_auth(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: CalendarCalDavDiscoveryRequest,
|
||||
source: CalendarSyncSource | None,
|
||||
) -> _CalDAVDiscoveryAuth:
|
||||
auth_type, username, credential_ref = _caldav_discovery_inputs(
|
||||
payload,
|
||||
source,
|
||||
)
|
||||
resolved_envelope = _caldav_discovery_envelope(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
source=source,
|
||||
credential_ref=credential_ref,
|
||||
)
|
||||
username = username or _credential_username(resolved_envelope)
|
||||
secret = _caldav_discovery_secret(
|
||||
session,
|
||||
payload=payload,
|
||||
source=source,
|
||||
auth_type=auth_type,
|
||||
credential_ref=credential_ref,
|
||||
resolved_envelope=resolved_envelope,
|
||||
)
|
||||
_validate_caldav_discovery_auth(
|
||||
auth_type=auth_type,
|
||||
username=username,
|
||||
secret=secret,
|
||||
)
|
||||
return _CalDAVDiscoveryAuth(
|
||||
auth_type=auth_type,
|
||||
username=username,
|
||||
credential_ref=credential_ref,
|
||||
secret=secret,
|
||||
)
|
||||
|
||||
|
||||
def _caldav_discovery_inputs(
|
||||
payload: CalendarCalDavDiscoveryRequest,
|
||||
source: CalendarSyncSource | None,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
return (
|
||||
payload.auth_type or (source.auth_type if source else "none"),
|
||||
(
|
||||
payload.username
|
||||
if payload.username is not None
|
||||
else (source.username if source else None)
|
||||
),
|
||||
(
|
||||
payload.credential_ref
|
||||
if payload.credential_ref is not None
|
||||
else (source.credential_ref if source else None)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _caldav_discovery_envelope(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: CalendarCalDavDiscoveryRequest,
|
||||
source: CalendarSyncSource | None,
|
||||
credential_ref: str | None,
|
||||
) -> ResolvedCredentialEnvelope | None:
|
||||
resolved = _resolve_core_calendar_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id if source else None,
|
||||
credential_ref=credential_ref,
|
||||
)
|
||||
if payload.credential_ref is not None and resolved_envelope is None and (
|
||||
if payload.credential_ref is not None and resolved is None and (
|
||||
source is None or payload.credential_ref != source.credential_ref
|
||||
):
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
|
||||
)
|
||||
if not username and resolved_envelope is not None:
|
||||
username = _credential_username(resolved_envelope)
|
||||
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||
if secret is None and resolved_envelope is not None:
|
||||
secret = _credential_secret(resolved_envelope, auth_type=auth_type)
|
||||
elif secret is None and source is not None and credential_ref == source.credential_ref:
|
||||
secret = resolve_caldav_secret(session, source=source)
|
||||
return resolved
|
||||
|
||||
|
||||
def _caldav_discovery_secret(
|
||||
session: Session,
|
||||
*,
|
||||
payload: CalendarCalDavDiscoveryRequest,
|
||||
source: CalendarSyncSource | None,
|
||||
auth_type: str,
|
||||
credential_ref: str | None,
|
||||
resolved_envelope: ResolvedCredentialEnvelope | None,
|
||||
) -> str | None:
|
||||
secret = caldav_secret_from_payload(
|
||||
auth_type=auth_type,
|
||||
password=payload.password,
|
||||
bearer_token=payload.bearer_token,
|
||||
)
|
||||
if secret is not None:
|
||||
return secret
|
||||
if resolved_envelope is not None:
|
||||
return _credential_secret(resolved_envelope, auth_type=auth_type)
|
||||
if source is not None and credential_ref == source.credential_ref:
|
||||
return resolve_caldav_secret(session, source=source)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_caldav_discovery_auth(
|
||||
*,
|
||||
auth_type: str,
|
||||
username: str | None,
|
||||
secret: str | None,
|
||||
) -> None:
|
||||
if auth_type == "basic":
|
||||
if not username:
|
||||
raise CalendarError("CalDAV discovery with basic auth requires a username")
|
||||
if not secret:
|
||||
raise CalendarError("CalDAV discovery with basic auth requires a password or credential reference")
|
||||
client = CalDAVClient(collection_url=payload.url, username=username, password=secret)
|
||||
elif auth_type == "bearer":
|
||||
if not secret:
|
||||
raise CalendarError("CalDAV discovery with bearer auth requires a token or credential reference")
|
||||
client = CalDAVClient(collection_url=payload.url, bearer_token=secret)
|
||||
else:
|
||||
client = CalDAVClient(collection_url=payload.url)
|
||||
if auth_type == "bearer" and not secret:
|
||||
raise CalendarError(
|
||||
"CalDAV discovery with bearer auth requires a token or credential reference"
|
||||
)
|
||||
|
||||
|
||||
def _caldav_discovery_client(
|
||||
url: str,
|
||||
auth: _CalDAVDiscoveryAuth,
|
||||
) -> CalDAVClient:
|
||||
if auth.auth_type == "basic":
|
||||
return CalDAVClient(
|
||||
collection_url=url,
|
||||
username=auth.username,
|
||||
password=auth.secret,
|
||||
)
|
||||
if auth.auth_type == "bearer":
|
||||
return CalDAVClient(
|
||||
collection_url=url,
|
||||
bearer_token=auth.secret,
|
||||
)
|
||||
return CalDAVClient(collection_url=url)
|
||||
|
||||
|
||||
def _caldav_discovery_response(
|
||||
calendars: Iterable[object],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"collection_url": calendar.collection_url,
|
||||
@@ -939,21 +1086,106 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
"ctag": calendar.ctag,
|
||||
"sync_token": calendar.sync_token,
|
||||
}
|
||||
for calendar in client.discover_calendars()
|
||||
for calendar in calendars
|
||||
]
|
||||
|
||||
|
||||
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
||||
if payload.credential_ref is not None and not _core_credential_id(payload.credential_ref):
|
||||
def discover_caldav_calendars(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: CalendarCalDavDiscoveryRequest,
|
||||
) -> list[dict[str, Any]]:
|
||||
source = (
|
||||
get_caldav_source(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=payload.source_id,
|
||||
)
|
||||
if payload.source_id
|
||||
else None
|
||||
)
|
||||
auth = _resolve_caldav_discovery_auth(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
source=source,
|
||||
)
|
||||
client = _caldav_discovery_client(payload.url, auth)
|
||||
return _caldav_discovery_response(client.discover_calendars())
|
||||
|
||||
|
||||
def _plan_sync_source_creation(
|
||||
payload: CalendarSyncSourceCreateRequest,
|
||||
) -> _SyncSourceCreationPlan:
|
||||
if payload.credential_ref is not None and not _core_credential_id(
|
||||
payload.credential_ref
|
||||
):
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are accepted only for visible reusable credential envelopes"
|
||||
)
|
||||
if payload.credential_ref is not None and (
|
||||
has_inline_secret = (
|
||||
payload.password is not None or payload.bearer_token is not None
|
||||
):
|
||||
raise CalendarError("Select a reusable credential or enter a new secret, not both")
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
calendar = (
|
||||
)
|
||||
if payload.credential_ref is not None and has_inline_secret:
|
||||
raise CalendarError(
|
||||
"Select a reusable credential or enter a new secret, not both"
|
||||
)
|
||||
source_kind = normalize_source_kind(payload.source_kind)
|
||||
collection_url = normalize_sync_source_url(
|
||||
source_kind,
|
||||
payload.collection_url,
|
||||
)
|
||||
_validate_sync_source_create_auth(source_kind, payload.auth_type)
|
||||
return _SyncSourceCreationPlan(
|
||||
source_kind=source_kind,
|
||||
collection_url=collection_url,
|
||||
sync_direction=(
|
||||
"inbound"
|
||||
if source_kind in READ_ONLY_SYNC_SOURCE_KINDS
|
||||
else payload.sync_direction
|
||||
),
|
||||
auth_type=payload.auth_type,
|
||||
username=payload.username,
|
||||
credential_ref=payload.credential_ref,
|
||||
has_inline_secret=has_inline_secret,
|
||||
)
|
||||
|
||||
|
||||
def _validate_sync_source_create_auth(
|
||||
source_kind: str,
|
||||
auth_type: str,
|
||||
) -> None:
|
||||
if source_kind == "graph" and auth_type != "bearer":
|
||||
raise CalendarError(
|
||||
"Microsoft Graph calendar sync requires bearer token authentication"
|
||||
)
|
||||
if source_kind in {"ics", "webcal"} and auth_type not in {
|
||||
"none",
|
||||
"basic",
|
||||
"bearer",
|
||||
}:
|
||||
raise CalendarError(
|
||||
"ICS/webcal subscriptions support none, basic, or bearer authentication"
|
||||
)
|
||||
if source_kind == "ews" and auth_type not in {"basic", "bearer"}:
|
||||
raise CalendarError(
|
||||
"Exchange Web Services sync requires basic or bearer authentication"
|
||||
)
|
||||
|
||||
|
||||
def _lock_calendar_for_sync_source(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
calendar_id: str,
|
||||
) -> CalendarCollection:
|
||||
calendar = get_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
)
|
||||
return (
|
||||
session.query(CalendarCollection)
|
||||
.filter(
|
||||
CalendarCollection.id == calendar.id,
|
||||
@@ -964,51 +1196,68 @@ def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None,
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
existing_calendar_source = (
|
||||
|
||||
|
||||
def _ensure_calendar_has_no_sync_source(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
calendar_id: str,
|
||||
) -> None:
|
||||
existing = (
|
||||
session.query(CalendarSyncSource.id)
|
||||
.filter(
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.calendar_id == calendar.id,
|
||||
CalendarSyncSource.calendar_id == calendar_id,
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_calendar_source is not None:
|
||||
if existing is not None:
|
||||
raise CalendarError(
|
||||
"A calendar can have only one active synchronization source"
|
||||
)
|
||||
source_kind = normalize_source_kind(payload.source_kind)
|
||||
collection_url = normalize_sync_source_url(source_kind, payload.collection_url)
|
||||
retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url)
|
||||
existing = active_sync_source_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url)
|
||||
if existing is not None:
|
||||
existing_calendar = existing.calendar
|
||||
calendar_name = existing_calendar.name if existing_calendar and existing_calendar.deleted_at is None else "another calendar"
|
||||
raise CalendarError(f"{sync_source_label(source_kind)} source is already linked to {calendar_name}")
|
||||
sync_direction = "inbound" if source_kind in READ_ONLY_SYNC_SOURCE_KINDS else payload.sync_direction
|
||||
if source_kind == "graph" and payload.auth_type != "bearer":
|
||||
raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication")
|
||||
if source_kind in {"ics", "webcal"} and payload.auth_type not in {"none", "basic", "bearer"}:
|
||||
raise CalendarError("ICS/webcal subscriptions support none, basic, or bearer authentication")
|
||||
if source_kind == "ews" and payload.auth_type not in {"basic", "bearer"}:
|
||||
raise CalendarError("Exchange Web Services sync requires basic or bearer authentication")
|
||||
source = CalendarSyncSource(
|
||||
|
||||
|
||||
def _ensure_sync_source_url_available(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
plan: _SyncSourceCreationPlan,
|
||||
) -> None:
|
||||
retire_stale_sync_sources_for_url(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar.id,
|
||||
source_kind=source_kind,
|
||||
collection_url=collection_url,
|
||||
display_name=payload.display_name,
|
||||
auth_type=payload.auth_type,
|
||||
username=payload.username,
|
||||
credential_ref=payload.credential_ref,
|
||||
sync_enabled=payload.sync_enabled,
|
||||
sync_interval_seconds=payload.sync_interval_seconds,
|
||||
sync_direction=sync_direction,
|
||||
conflict_policy=payload.conflict_policy,
|
||||
metadata_=payload.metadata,
|
||||
source_kind=plan.source_kind,
|
||||
collection_url=plan.collection_url,
|
||||
)
|
||||
session.add(source)
|
||||
session.flush()
|
||||
existing = active_sync_source_for_url(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_kind=plan.source_kind,
|
||||
collection_url=plan.collection_url,
|
||||
)
|
||||
if existing is None:
|
||||
return
|
||||
existing_calendar = existing.calendar
|
||||
calendar_name = (
|
||||
existing_calendar.name
|
||||
if existing_calendar and existing_calendar.deleted_at is None
|
||||
else "another calendar"
|
||||
)
|
||||
raise CalendarError(
|
||||
f"{sync_source_label(plan.source_kind)} source is already linked to {calendar_name}"
|
||||
)
|
||||
|
||||
|
||||
def _persist_sync_source_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
source: CalendarSyncSource,
|
||||
payload: CalendarSyncSourceCreateRequest,
|
||||
) -> None:
|
||||
if source.credential_ref:
|
||||
_require_visible_core_calendar_credential(
|
||||
session,
|
||||
@@ -1023,10 +1272,58 @@ def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
source.username = _credential_username(resolved) if resolved is not None else None
|
||||
credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||
source.username = _credential_username(resolved)
|
||||
credential_value = caldav_secret_from_payload(
|
||||
auth_type=source.auth_type,
|
||||
password=payload.password,
|
||||
bearer_token=payload.bearer_token,
|
||||
)
|
||||
if credential_value is not None:
|
||||
source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=user_id, source=source, secret=credential_value)
|
||||
source.credential_ref = store_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
source=source,
|
||||
secret=credential_value,
|
||||
)
|
||||
|
||||
def create_sync_source(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
payload: CalendarSyncSourceCreateRequest,
|
||||
) -> CalendarSyncSource:
|
||||
plan = _plan_sync_source_creation(payload)
|
||||
calendar = _lock_calendar_for_sync_source(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=payload.calendar_id,
|
||||
)
|
||||
_ensure_calendar_has_no_sync_source(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar.id,
|
||||
)
|
||||
_ensure_sync_source_url_available(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
plan=plan,
|
||||
)
|
||||
source = plan.build_source(
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar.id,
|
||||
payload=payload,
|
||||
)
|
||||
session.add(source)
|
||||
session.flush()
|
||||
_persist_sync_source_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
source=source,
|
||||
payload=payload,
|
||||
)
|
||||
source.next_sync_at = utcnow() if source.sync_enabled else None
|
||||
mark_calendar_sync_source(calendar, source)
|
||||
session.flush()
|
||||
|
||||
Reference in New Issue
Block a user