401 lines
14 KiB
Python
401 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
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_poll.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
|
from govoplan_poll.backend.schemas import (
|
|
PollCreateRequest,
|
|
PollDecisionRequest,
|
|
PollInvitationCreateRequest,
|
|
PollInvitationListResponse,
|
|
PollInvitationResponse,
|
|
PollListResponse,
|
|
PollResponse,
|
|
PollResponseItem,
|
|
PollResponseListResponse,
|
|
PollResultSummaryResponse,
|
|
PollStatusResponse,
|
|
PollSubmitResponseRequest,
|
|
PollUpdateRequest,
|
|
)
|
|
from govoplan_poll.backend.service import (
|
|
PollError,
|
|
close_poll,
|
|
create_poll_invitation,
|
|
create_poll,
|
|
decide_poll,
|
|
get_poll_by_invitation_token,
|
|
get_visible_poll,
|
|
list_poll_responses,
|
|
list_poll_invitations,
|
|
list_visible_polls,
|
|
open_poll,
|
|
poll_invitation_response,
|
|
poll_response,
|
|
poll_response_item,
|
|
poll_result_summary_by_id,
|
|
require_visible_poll_results,
|
|
revoke_poll_invitation,
|
|
submit_poll_response,
|
|
submit_poll_response_with_token,
|
|
update_poll,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/poll", tags=["poll"])
|
|
|
|
|
|
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 _poll_http_error(exc: PollError) -> HTTPException:
|
|
if str(exc) == "Poll not found":
|
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
if str(exc) == "Poll invitation not found":
|
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
if str(exc) == "Poll results are not visible":
|
|
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
|
|
|
|
def _poll_response(poll) -> PollResponse:
|
|
return PollResponse.model_validate(poll_response(poll))
|
|
|
|
|
|
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
|
candidates = (
|
|
principal.account_id,
|
|
principal.membership_id,
|
|
getattr(principal.user, "id", None),
|
|
principal.identity_id,
|
|
principal.principal.service_account_id,
|
|
)
|
|
return tuple(dict.fromkeys(str(value) for value in candidates if value))
|
|
|
|
|
|
def _can_manage_polls(principal: ApiPrincipal) -> bool:
|
|
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
|
|
|
|
|
|
def _can_view_sensitive_poll_data(principal: ApiPrincipal, poll: object) -> bool:
|
|
return _can_manage_polls(principal) or getattr(poll, "created_by_user_id", None) in _principal_actor_ids(principal)
|
|
|
|
|
|
def _require_sensitive_poll_data_access(principal: ApiPrincipal, poll: object) -> None:
|
|
if not _can_view_sensitive_poll_data(principal, poll):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Poll respondent and invitation data is restricted to poll organizers",
|
|
)
|
|
|
|
|
|
def _require_sensitive_poll_data_scope(principal: ApiPrincipal) -> None:
|
|
if not (has_scope(principal, READ_SCOPE) or _can_manage_polls(principal)):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {READ_SCOPE}")
|
|
|
|
|
|
@router.get("/polls", response_model=PollListResponse)
|
|
def api_list_polls(
|
|
status_filter: str | None = Query(default=None, alias="status"),
|
|
kind: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollListResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
polls = list_visible_polls(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
status=status_filter,
|
|
kind=kind,
|
|
)
|
|
return PollListResponse(polls=[_poll_response(poll) for poll in polls])
|
|
|
|
|
|
@router.post("/polls", response_model=PollResponse, status_code=status.HTTP_201_CREATED)
|
|
def api_create_poll(
|
|
payload: PollCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
poll = create_poll(session, tenant_id=principal.tenant_id, user_id=principal.account_id, payload=payload)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = _poll_response(poll)
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.get("/polls/{poll_id}", response_model=PollResponse)
|
|
def api_get_poll(
|
|
poll_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
try:
|
|
return _poll_response(
|
|
get_visible_poll(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
|
|
|
|
@router.patch("/polls/{poll_id}", response_model=PollResponse)
|
|
def api_update_poll(
|
|
poll_id: str,
|
|
payload: PollUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
poll = update_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = _poll_response(poll)
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.post("/polls/{poll_id}/open", response_model=PollStatusResponse)
|
|
def api_open_poll(
|
|
poll_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollStatusResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
poll = open_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = PollStatusResponse(poll=_poll_response(poll))
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.post("/polls/{poll_id}/close", response_model=PollStatusResponse)
|
|
def api_close_poll(
|
|
poll_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollStatusResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
poll = close_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = PollStatusResponse(poll=_poll_response(poll))
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
|
|
def api_decide_poll(
|
|
poll_id: str,
|
|
payload: PollDecisionRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollStatusResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
poll = decide_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = PollStatusResponse(poll=_poll_response(poll))
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.post("/polls/{poll_id}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
|
|
def api_submit_poll_response(
|
|
poll_id: str,
|
|
payload: PollSubmitResponseRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollResponseItem:
|
|
_require_scope(principal, RESPOND_SCOPE)
|
|
try:
|
|
get_visible_poll(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
actor_payload = payload.model_copy(
|
|
update={
|
|
"respondent_id": principal.account_id,
|
|
"respondent_label": principal.display_name or principal.email,
|
|
"metadata": {key: value for key, value in payload.metadata.items() if key != "invitation_id"},
|
|
}
|
|
)
|
|
response = submit_poll_response(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
payload=actor_payload,
|
|
)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
result = PollResponseItem.model_validate(poll_response_item(response))
|
|
session.commit()
|
|
return result
|
|
|
|
|
|
@router.get("/polls/{poll_id}/responses", response_model=PollResponseListResponse)
|
|
def api_list_poll_responses(
|
|
poll_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollResponseListResponse:
|
|
_require_sensitive_poll_data_scope(principal)
|
|
try:
|
|
poll = get_visible_poll(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
_require_sensitive_poll_data_access(principal, poll)
|
|
require_visible_poll_results(
|
|
session,
|
|
poll=poll,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
responses = list_poll_responses(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
return PollResponseListResponse(responses=[PollResponseItem.model_validate(poll_response_item(response)) for response in responses])
|
|
|
|
|
|
@router.get("/polls/{poll_id}/summary", response_model=PollResultSummaryResponse)
|
|
def api_poll_summary(
|
|
poll_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollResultSummaryResponse:
|
|
_require_scope(principal, READ_SCOPE)
|
|
try:
|
|
poll = get_visible_poll(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
require_visible_poll_results(
|
|
session,
|
|
poll=poll,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id))
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
|
|
|
|
@router.post("/polls/{poll_id}/invitations", response_model=PollInvitationResponse, status_code=status.HTTP_201_CREATED)
|
|
def api_create_poll_invitation(
|
|
poll_id: str,
|
|
payload: PollInvitationCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollInvitationResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
invitation, token = create_poll_invitation(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.get("/polls/{poll_id}/invitations", response_model=PollInvitationListResponse)
|
|
def api_list_poll_invitations(
|
|
poll_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollInvitationListResponse:
|
|
_require_sensitive_poll_data_scope(principal)
|
|
try:
|
|
poll = get_visible_poll(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
actor_ids=_principal_actor_ids(principal),
|
|
can_manage=_can_manage_polls(principal),
|
|
)
|
|
_require_sensitive_poll_data_access(principal, poll)
|
|
invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
return PollInvitationListResponse(
|
|
invitations=[PollInvitationResponse.model_validate(poll_invitation_response(invitation)) for invitation in invitations]
|
|
)
|
|
|
|
|
|
@router.delete("/polls/{poll_id}/invitations/{invitation_id}", response_model=PollInvitationResponse)
|
|
def api_revoke_poll_invitation(
|
|
poll_id: str,
|
|
invitation_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> PollInvitationResponse:
|
|
_require_scope(principal, WRITE_SCOPE)
|
|
try:
|
|
invitation = revoke_poll_invitation(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
poll_id=poll_id,
|
|
invitation_id=invitation_id,
|
|
)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
response = PollInvitationResponse.model_validate(poll_invitation_response(invitation))
|
|
session.commit()
|
|
return response
|
|
|
|
|
|
@router.get("/public/{token}", response_model=PollResponse)
|
|
def api_get_public_poll(
|
|
token: str,
|
|
session: Session = Depends(get_session),
|
|
) -> PollResponse:
|
|
try:
|
|
return _poll_response(get_poll_by_invitation_token(session, token=token))
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
|
|
|
|
@router.post("/public/{token}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
|
|
def api_submit_public_poll_response(
|
|
token: str,
|
|
payload: PollSubmitResponseRequest,
|
|
session: Session = Depends(get_session),
|
|
) -> PollResponseItem:
|
|
try:
|
|
response = submit_poll_response_with_token(session, token=token, payload=payload)
|
|
except PollError as exc:
|
|
raise _poll_http_error(exc) from exc
|
|
result = PollResponseItem.model_validate(poll_response_item(response))
|
|
session.commit()
|
|
return result
|