245 lines
9.4 KiB
Python
245 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_notifications.backend.manifest import ADMIN_SCOPE, DISPATCH_SCOPE, READ_SCOPE, WRITE_SCOPE
|
|
from govoplan_notifications.backend.schemas import (
|
|
NotificationCreateRequest,
|
|
NotificationDeliverPendingRequest,
|
|
NotificationDeliveryResultResponse,
|
|
NotificationListResponse,
|
|
NotificationPreferencesResponse,
|
|
NotificationPreferencesUpdateRequest,
|
|
NotificationResponse,
|
|
NotificationSummaryResponse,
|
|
NotificationUpdateRequest,
|
|
)
|
|
from govoplan_notifications.backend.service import (
|
|
NotificationError,
|
|
create_notification,
|
|
deliver_notification,
|
|
deliver_pending,
|
|
get_notification,
|
|
get_notification_preferences,
|
|
list_notifications,
|
|
notification_preferences_response,
|
|
notification_response,
|
|
notification_summary,
|
|
update_notification_preferences,
|
|
update_notification,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
|
|
|
|
|
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}")
|
|
|
|
|
|
def _notification_http_error(exc: NotificationError) -> HTTPException:
|
|
if str(exc) == "Notification not found":
|
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
|
|
|
|
def _response(notification) -> NotificationResponse:
|
|
return NotificationResponse.model_validate(notification_response(notification))
|
|
|
|
|
|
def _principal_recipient_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
|
"""Return the stable actor identifiers accepted for personal notifications.
|
|
|
|
Existing producers use both tenant membership/user ids and account ids. Both
|
|
identify the same authenticated actor; identity/service-account ids are
|
|
included when present so producers can address those stable identities too.
|
|
"""
|
|
|
|
candidates = (
|
|
getattr(principal.user, "id", None),
|
|
principal.membership_id,
|
|
principal.account_id,
|
|
principal.identity_id,
|
|
principal.principal.service_account_id,
|
|
)
|
|
return tuple(dict.fromkeys(str(value) for value in candidates if value))
|
|
|
|
|
|
def _recipient_ids_for_view(principal: ApiPrincipal, view: Literal["personal", "tenant"]) -> tuple[str, ...] | None:
|
|
if view == "tenant":
|
|
_require_scope(principal, ADMIN_SCOPE)
|
|
return None
|
|
return _principal_recipient_ids(principal)
|
|
|
|
|
|
@router.get("", response_model=NotificationListResponse)
|
|
def api_list_notifications(
|
|
status_filter: str | None = Query(default=None, alias="status"),
|
|
channel: str | None = None,
|
|
source_module: str | None = None,
|
|
recipient_id: str | None = None,
|
|
view: Literal["personal", "tenant"] = Query(default="personal"),
|
|
limit: int = Query(default=100, ge=1, le=500),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationListResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
recipient_ids = _recipient_ids_for_view(principal, view)
|
|
if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications")
|
|
notifications = list_notifications(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
status=status_filter,
|
|
channel=channel,
|
|
source_module=source_module,
|
|
recipient_id=recipient_id,
|
|
recipient_ids=recipient_ids,
|
|
limit=limit,
|
|
)
|
|
return NotificationListResponse(notifications=[_response(notification) for notification in notifications])
|
|
|
|
|
|
@router.get("/summary", response_model=NotificationSummaryResponse)
|
|
def api_notification_summary(
|
|
view: Literal["personal", "tenant"] = Query(default="personal"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationSummaryResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
return NotificationSummaryResponse.model_validate(
|
|
notification_summary(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
recipient_ids=_recipient_ids_for_view(principal, view),
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/preferences/me", response_model=NotificationPreferencesResponse)
|
|
def api_get_my_notification_preferences(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationPreferencesResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
preference = get_notification_preferences(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
|
|
return NotificationPreferencesResponse.model_validate(notification_preferences_response(preference))
|
|
|
|
|
|
@router.put("/preferences/me", response_model=NotificationPreferencesResponse)
|
|
def api_update_my_notification_preferences(
|
|
payload: NotificationPreferencesUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationPreferencesResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
preference = update_notification_preferences(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
|
session.commit()
|
|
return NotificationPreferencesResponse.model_validate(notification_preferences_response(preference))
|
|
|
|
|
|
@router.post("", response_model=NotificationResponse, status_code=status.HTTP_201_CREATED)
|
|
def api_create_notification(
|
|
payload: NotificationCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
notification = create_notification(session, tenant_id=principal.tenant_id, payload=payload)
|
|
except NotificationError as exc:
|
|
raise _notification_http_error(exc) from exc
|
|
session.commit()
|
|
return _response(notification)
|
|
|
|
|
|
@router.post("/deliver-pending", response_model=NotificationDeliveryResultResponse)
|
|
def api_deliver_pending(
|
|
payload: NotificationDeliverPendingRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationDeliveryResultResponse:
|
|
_require_scope(principal, DISPATCH_SCOPE)
|
|
if payload.tenant_id is not None:
|
|
_require_scope(principal, ADMIN_SCOPE)
|
|
result = deliver_pending(session, tenant_id=payload.tenant_id or principal.tenant_id, limit=payload.limit)
|
|
session.commit()
|
|
return NotificationDeliveryResultResponse.model_validate(result)
|
|
|
|
|
|
@router.get("/{notification_id}", response_model=NotificationResponse)
|
|
def api_get_notification(
|
|
notification_id: str,
|
|
view: Literal["personal", "tenant"] = Query(default="personal"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
try:
|
|
return _response(
|
|
get_notification(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
notification_id=notification_id,
|
|
recipient_ids=_recipient_ids_for_view(principal, view),
|
|
)
|
|
)
|
|
except NotificationError as exc:
|
|
raise _notification_http_error(exc) from exc
|
|
|
|
|
|
@router.patch("/{notification_id}", response_model=NotificationResponse)
|
|
def api_update_notification(
|
|
notification_id: str,
|
|
payload: NotificationUpdateRequest,
|
|
view: Literal["personal", "tenant"] = Query(default="personal"),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
notification = update_notification(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
notification_id=notification_id,
|
|
payload=payload,
|
|
recipient_ids=_recipient_ids_for_view(principal, view),
|
|
)
|
|
except NotificationError as exc:
|
|
raise _notification_http_error(exc) from exc
|
|
session.commit()
|
|
return _response(notification)
|
|
|
|
|
|
@router.post("/{notification_id}/deliver", response_model=NotificationDeliveryResultResponse)
|
|
def api_deliver_notification(
|
|
notification_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> NotificationDeliveryResultResponse:
|
|
_require_scope(principal, DISPATCH_SCOPE)
|
|
try:
|
|
get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id)
|
|
notification = deliver_notification(session, notification_id=notification_id)
|
|
except NotificationError as exc:
|
|
raise _notification_http_error(exc) from exc
|
|
session.commit()
|
|
sent = 1 if notification.status == "sent" else 0
|
|
failed = 1 if notification.status == "failed" else 0
|
|
skipped = 1 if notification.status == "skipped" else 0
|
|
return NotificationDeliveryResultResponse(
|
|
notification=_response(notification),
|
|
processed=1,
|
|
sent=sent,
|
|
failed=failed,
|
|
skipped=skipped,
|
|
errors=[notification.last_error] if notification.last_error else [],
|
|
)
|