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