Compare commits
2 Commits
bb7a18ef48
...
f4d5cac40d
| Author | SHA1 | Date | |
|---|---|---|---|
| f4d5cac40d | |||
| e7f3b1d7bf |
263
src/govoplan_poll/backend/capabilities.py
Normal file
263
src/govoplan_poll/backend/capabilities.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
|
from govoplan_core.core.poll import (
|
||||||
|
PollCapabilityError,
|
||||||
|
PollCreateCommand,
|
||||||
|
PollInvitationCommand,
|
||||||
|
PollInvitationRef,
|
||||||
|
PollOptionRef,
|
||||||
|
PollRef,
|
||||||
|
PollResponseRef,
|
||||||
|
PollSchedulingProvider,
|
||||||
|
PollSubmitResponseCommand,
|
||||||
|
PollUpdateCommand,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.schemas import (
|
||||||
|
PollAnswerInput,
|
||||||
|
PollCreateRequest,
|
||||||
|
PollDecisionRequest,
|
||||||
|
PollInvitationCreateRequest,
|
||||||
|
PollOptionInput,
|
||||||
|
PollSubmitResponseRequest,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.service import (
|
||||||
|
PollError,
|
||||||
|
close_poll,
|
||||||
|
create_poll,
|
||||||
|
create_poll_invitation,
|
||||||
|
decide_poll,
|
||||||
|
get_poll,
|
||||||
|
list_poll_responses,
|
||||||
|
open_poll,
|
||||||
|
poll_result_summary_by_id,
|
||||||
|
submit_poll_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_ref(poll: object) -> PollRef:
|
||||||
|
return PollRef(
|
||||||
|
id=poll.id,
|
||||||
|
status=poll.status,
|
||||||
|
options=tuple(PollOptionRef(id=option.id, position=option.position) for option in poll.options),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _response_invitation_id(response: object) -> str | None:
|
||||||
|
value = (response.metadata_ or {}).get("invitation_id")
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
class SqlPollSchedulingProvider(PollSchedulingProvider):
|
||||||
|
def create_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
command: PollCreateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
payload = PollCreateRequest(
|
||||||
|
title=command.title,
|
||||||
|
description=command.description,
|
||||||
|
kind=command.kind,
|
||||||
|
status=command.status,
|
||||||
|
visibility=command.visibility,
|
||||||
|
result_visibility=command.result_visibility,
|
||||||
|
context_module=command.context_module,
|
||||||
|
context_resource_type=command.context_resource_type,
|
||||||
|
context_resource_id=command.context_resource_id,
|
||||||
|
workflow_state=command.workflow_state,
|
||||||
|
workflow_steps=[dict(step) for step in command.workflow_steps],
|
||||||
|
allow_anonymous=command.allow_anonymous,
|
||||||
|
allow_response_update=command.allow_response_update,
|
||||||
|
min_choices=command.min_choices,
|
||||||
|
max_choices=command.max_choices,
|
||||||
|
opens_at=command.opens_at,
|
||||||
|
closes_at=command.closes_at,
|
||||||
|
options=[
|
||||||
|
PollOptionInput(
|
||||||
|
key=option.key,
|
||||||
|
label=option.label,
|
||||||
|
description=option.description,
|
||||||
|
value=dict(option.value) if option.value is not None else None,
|
||||||
|
metadata=dict(option.metadata),
|
||||||
|
)
|
||||||
|
for option in command.options
|
||||||
|
],
|
||||||
|
metadata=dict(command.metadata),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
poll = create_poll(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return _poll_ref(poll)
|
||||||
|
|
||||||
|
def create_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
payload = PollInvitationCreateRequest(
|
||||||
|
respondent_id=command.respondent_id,
|
||||||
|
respondent_label=command.respondent_label,
|
||||||
|
email=command.email,
|
||||||
|
expires_at=command.expires_at,
|
||||||
|
metadata=dict(command.metadata),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
invitation, token = create_poll_invitation(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return PollInvitationRef(id=invitation.id, token=token)
|
||||||
|
|
||||||
|
def submit_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollSubmitResponseCommand,
|
||||||
|
) -> PollResponseRef:
|
||||||
|
payload = PollSubmitResponseRequest(
|
||||||
|
respondent_id=command.respondent_id,
|
||||||
|
respondent_label=command.respondent_label,
|
||||||
|
answers=[
|
||||||
|
PollAnswerInput(
|
||||||
|
option_id=answer.option_id,
|
||||||
|
option_key=answer.option_key,
|
||||||
|
value=answer.value,
|
||||||
|
rank=answer.rank,
|
||||||
|
)
|
||||||
|
for answer in command.answers
|
||||||
|
],
|
||||||
|
metadata=dict(command.metadata),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = submit_poll_response(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return PollResponseRef(
|
||||||
|
invitation_id=_response_invitation_id(response),
|
||||||
|
submitted_at=response.submitted_at,
|
||||||
|
respondent_id=response.respondent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollUpdateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
try:
|
||||||
|
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
poll.title = command.title
|
||||||
|
poll.description = command.description
|
||||||
|
poll.visibility = command.visibility
|
||||||
|
poll.result_visibility = command.result_visibility
|
||||||
|
poll.allow_anonymous = command.allow_anonymous
|
||||||
|
poll.allow_response_update = command.allow_response_update
|
||||||
|
poll.closes_at = command.closes_at
|
||||||
|
session.flush()
|
||||||
|
return _poll_ref(poll)
|
||||||
|
|
||||||
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
return self._transition(open_poll, session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
|
||||||
|
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
return self._transition(close_poll, session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
|
||||||
|
def decide_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str | None,
|
||||||
|
) -> PollRef:
|
||||||
|
try:
|
||||||
|
poll = decide_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
payload=PollDecisionRequest(option_id=option_id),
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return _poll_ref(poll)
|
||||||
|
|
||||||
|
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
try:
|
||||||
|
return _poll_ref(get_poll(session, tenant_id=tenant_id, poll_id=poll_id))
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
|
||||||
|
def set_workflow_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
workflow_state: str,
|
||||||
|
workflow_steps: Sequence[Mapping[str, object]],
|
||||||
|
context_module: str,
|
||||||
|
context_resource_type: str,
|
||||||
|
context_resource_id: str,
|
||||||
|
) -> PollRef:
|
||||||
|
try:
|
||||||
|
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
poll.workflow_state = workflow_state
|
||||||
|
poll.workflow_steps = [dict(step) for step in workflow_steps]
|
||||||
|
poll.context_module = context_module
|
||||||
|
poll.context_resource_type = context_resource_type
|
||||||
|
poll.context_resource_id = context_resource_id
|
||||||
|
session.flush()
|
||||||
|
return _poll_ref(poll)
|
||||||
|
|
||||||
|
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
|
||||||
|
try:
|
||||||
|
return poll_result_summary_by_id(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
|
||||||
|
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> tuple[PollResponseRef, ...]:
|
||||||
|
try:
|
||||||
|
responses = list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return tuple(
|
||||||
|
PollResponseRef(
|
||||||
|
invitation_id=_response_invitation_id(response),
|
||||||
|
submitted_at=response.submitted_at,
|
||||||
|
respondent_id=response.respondent_id,
|
||||||
|
)
|
||||||
|
for response in responses
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
try:
|
||||||
|
poll = callback(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return _poll_ref(poll)
|
||||||
@@ -14,6 +14,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||||
from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata
|
from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata
|
||||||
|
|
||||||
MODULE_ID = "poll"
|
MODULE_ID = "poll"
|
||||||
@@ -99,6 +100,13 @@ def _poll_router(_context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_scheduling_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
||||||
|
|
||||||
|
return SqlPollSchedulingProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
@@ -117,6 +125,7 @@ manifest = ModuleManifest(
|
|||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_poll_router,
|
route_factory=_poll_router,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
capability_factories={CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider},
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_poll.backend.manifest import READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
from govoplan_poll.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
||||||
from govoplan_poll.backend.schemas import (
|
from govoplan_poll.backend.schemas import (
|
||||||
PollCreateRequest,
|
PollCreateRequest,
|
||||||
PollDecisionRequest,
|
PollDecisionRequest,
|
||||||
@@ -28,15 +28,16 @@ from govoplan_poll.backend.service import (
|
|||||||
create_poll,
|
create_poll,
|
||||||
decide_poll,
|
decide_poll,
|
||||||
get_poll_by_invitation_token,
|
get_poll_by_invitation_token,
|
||||||
get_poll,
|
get_visible_poll,
|
||||||
list_poll_responses,
|
list_poll_responses,
|
||||||
list_poll_invitations,
|
list_poll_invitations,
|
||||||
list_polls,
|
list_visible_polls,
|
||||||
open_poll,
|
open_poll,
|
||||||
poll_invitation_response,
|
poll_invitation_response,
|
||||||
poll_response,
|
poll_response,
|
||||||
poll_response_item,
|
poll_response_item,
|
||||||
poll_result_summary_by_id,
|
poll_result_summary_by_id,
|
||||||
|
require_visible_poll_results,
|
||||||
revoke_poll_invitation,
|
revoke_poll_invitation,
|
||||||
submit_poll_response,
|
submit_poll_response,
|
||||||
submit_poll_response_with_token,
|
submit_poll_response_with_token,
|
||||||
@@ -57,6 +58,8 @@ def _poll_http_error(exc: PollError) -> HTTPException:
|
|||||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
if str(exc) == "Poll invitation not found":
|
if str(exc) == "Poll invitation not found":
|
||||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
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))
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +67,38 @@ def _poll_response(poll) -> PollResponse:
|
|||||||
return PollResponse.model_validate(poll_response(poll))
|
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)
|
@router.get("/polls", response_model=PollListResponse)
|
||||||
def api_list_polls(
|
def api_list_polls(
|
||||||
status_filter: str | None = Query(default=None, alias="status"),
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
@@ -72,7 +107,14 @@ def api_list_polls(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> PollListResponse:
|
) -> PollListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
polls = list_polls(session, tenant_id=principal.tenant_id, status=status_filter, kind=kind)
|
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])
|
return PollListResponse(polls=[_poll_response(poll) for poll in polls])
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +129,9 @@ def api_create_poll(
|
|||||||
poll = create_poll(session, tenant_id=principal.tenant_id, user_id=principal.account_id, payload=payload)
|
poll = create_poll(session, tenant_id=principal.tenant_id, user_id=principal.account_id, payload=payload)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return _poll_response(poll)
|
response = _poll_response(poll)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/polls/{poll_id}", response_model=PollResponse)
|
@router.get("/polls/{poll_id}", response_model=PollResponse)
|
||||||
@@ -98,7 +142,15 @@ def api_get_poll(
|
|||||||
) -> PollResponse:
|
) -> PollResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
try:
|
try:
|
||||||
return _poll_response(get_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id))
|
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:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
|
|
||||||
@@ -112,9 +164,12 @@ def api_update_poll(
|
|||||||
) -> PollResponse:
|
) -> PollResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
return _poll_response(update_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload))
|
poll = update_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from 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)
|
@router.post("/polls/{poll_id}/open", response_model=PollStatusResponse)
|
||||||
@@ -128,7 +183,9 @@ def api_open_poll(
|
|||||||
poll = open_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
poll = open_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollStatusResponse(poll=_poll_response(poll))
|
response = PollStatusResponse(poll=_poll_response(poll))
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/polls/{poll_id}/close", response_model=PollStatusResponse)
|
@router.post("/polls/{poll_id}/close", response_model=PollStatusResponse)
|
||||||
@@ -142,7 +199,9 @@ def api_close_poll(
|
|||||||
poll = close_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
poll = close_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollStatusResponse(poll=_poll_response(poll))
|
response = PollStatusResponse(poll=_poll_response(poll))
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
|
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
|
||||||
@@ -157,7 +216,9 @@ def api_decide_poll(
|
|||||||
poll = decide_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
poll = decide_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollStatusResponse(poll=_poll_response(poll))
|
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)
|
@router.post("/polls/{poll_id}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -169,10 +230,31 @@ def api_submit_poll_response(
|
|||||||
) -> PollResponseItem:
|
) -> PollResponseItem:
|
||||||
_require_scope(principal, RESPOND_SCOPE)
|
_require_scope(principal, RESPOND_SCOPE)
|
||||||
try:
|
try:
|
||||||
response = submit_poll_response(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
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:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollResponseItem.model_validate(poll_response_item(response))
|
result = PollResponseItem.model_validate(poll_response_item(response))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/polls/{poll_id}/responses", response_model=PollResponseListResponse)
|
@router.get("/polls/{poll_id}/responses", response_model=PollResponseListResponse)
|
||||||
@@ -181,8 +263,22 @@ def api_list_poll_responses(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> PollResponseListResponse:
|
) -> PollResponseListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_sensitive_poll_data_scope(principal)
|
||||||
try:
|
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)
|
responses = list_poll_responses(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
@@ -197,6 +293,19 @@ def api_poll_summary(
|
|||||||
) -> PollResultSummaryResponse:
|
) -> PollResultSummaryResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
try:
|
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))
|
return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id))
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
@@ -214,7 +323,9 @@ def api_create_poll_invitation(
|
|||||||
invitation, token = create_poll_invitation(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
invitation, token = create_poll_invitation(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
|
response = PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/polls/{poll_id}/invitations", response_model=PollInvitationListResponse)
|
@router.get("/polls/{poll_id}/invitations", response_model=PollInvitationListResponse)
|
||||||
@@ -223,8 +334,16 @@ def api_list_poll_invitations(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> PollInvitationListResponse:
|
) -> PollInvitationListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_sensitive_poll_data_scope(principal)
|
||||||
try:
|
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)
|
invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
@@ -250,7 +369,9 @@ def api_revoke_poll_invitation(
|
|||||||
)
|
)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollInvitationResponse.model_validate(poll_invitation_response(invitation))
|
response = PollInvitationResponse.model_validate(poll_invitation_response(invitation))
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/public/{token}", response_model=PollResponse)
|
@router.get("/public/{token}", response_model=PollResponse)
|
||||||
@@ -274,4 +395,6 @@ def api_submit_public_poll_response(
|
|||||||
response = submit_poll_response_with_token(session, token=token, payload=payload)
|
response = submit_poll_response_with_token(session, token=token, payload=payload)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
return PollResponseItem.model_validate(poll_response_item(response))
|
result = PollResponseItem.model_validate(poll_response_item(response))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|||||||
@@ -211,6 +211,126 @@ def get_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
|||||||
return poll
|
return poll
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
|
return tuple(dict.fromkeys(value for value in values if value))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_active_invitation(session: Session, *, poll: Poll, actor_ids: tuple[str, ...]) -> bool:
|
||||||
|
ids = _actor_ids(actor_ids)
|
||||||
|
if not ids:
|
||||||
|
return False
|
||||||
|
invitations = (
|
||||||
|
session.query(PollInvitation)
|
||||||
|
.filter(
|
||||||
|
PollInvitation.tenant_id == poll.tenant_id,
|
||||||
|
PollInvitation.poll_id == poll.id,
|
||||||
|
PollInvitation.respondent_id.in_(ids),
|
||||||
|
PollInvitation.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
return any(
|
||||||
|
invitation.expires_at is None or response_datetime(invitation.expires_at) > now
|
||||||
|
for invitation in invitations
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def poll_is_visible(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
poll: Poll,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
can_manage: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
ids = _actor_ids(actor_ids)
|
||||||
|
if can_manage or (poll.created_by_user_id is not None and poll.created_by_user_id in ids):
|
||||||
|
return True
|
||||||
|
if poll.visibility in {"tenant", "public"}:
|
||||||
|
return True
|
||||||
|
if poll.visibility in {"private", "unlisted"}:
|
||||||
|
return _has_active_invitation(session, poll=poll, actor_ids=ids)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_visible_polls(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
can_manage: bool = False,
|
||||||
|
status: str | None = None,
|
||||||
|
kind: str | None = None,
|
||||||
|
) -> list[Poll]:
|
||||||
|
return [
|
||||||
|
poll
|
||||||
|
for poll in list_polls(session, tenant_id=tenant_id, status=status, kind=kind)
|
||||||
|
if poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_visible_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
can_manage: bool = False,
|
||||||
|
) -> Poll:
|
||||||
|
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
if not poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage):
|
||||||
|
# Do not disclose the existence of a private or unlisted poll.
|
||||||
|
raise PollError("Poll not found")
|
||||||
|
return poll
|
||||||
|
|
||||||
|
|
||||||
|
def poll_results_are_visible(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
poll: Poll,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
can_manage: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
ids = _actor_ids(actor_ids)
|
||||||
|
if can_manage or (poll.created_by_user_id is not None and poll.created_by_user_id in ids):
|
||||||
|
return True
|
||||||
|
if poll.result_visibility == "public":
|
||||||
|
return True
|
||||||
|
if poll.result_visibility == "after_close":
|
||||||
|
if poll.status in {"closed", "decided", "archived"}:
|
||||||
|
return True
|
||||||
|
return poll.closes_at is not None and response_datetime(poll.closes_at) <= _now()
|
||||||
|
if poll.result_visibility == "after_response" and ids:
|
||||||
|
return (
|
||||||
|
session.query(PollResponse)
|
||||||
|
.filter(
|
||||||
|
PollResponse.tenant_id == poll.tenant_id,
|
||||||
|
PollResponse.poll_id == poll.id,
|
||||||
|
PollResponse.respondent_id.in_(ids),
|
||||||
|
PollResponse.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def require_visible_poll_results(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
poll: Poll,
|
||||||
|
actor_ids: tuple[str, ...],
|
||||||
|
can_manage: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not poll_results_are_visible(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
actor_ids=actor_ids,
|
||||||
|
can_manage=can_manage,
|
||||||
|
):
|
||||||
|
raise PollError("Poll results are not visible")
|
||||||
|
|
||||||
|
|
||||||
def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollUpdateRequest) -> Poll:
|
def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollUpdateRequest) -> Poll:
|
||||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
if poll.status in {"closed", "decided", "archived"}:
|
if poll.status in {"closed", "decided", "archived"}:
|
||||||
@@ -379,8 +499,31 @@ def _existing_response(session: Session, *, poll: Poll, respondent_id: str | Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
||||||
|
"""Serialize the read-or-create path for identified respondents."""
|
||||||
|
|
||||||
|
poll = (
|
||||||
|
session.query(Poll)
|
||||||
|
.filter(
|
||||||
|
Poll.tenant_id == tenant_id,
|
||||||
|
Poll.id == poll_id,
|
||||||
|
Poll.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if poll is None:
|
||||||
|
raise PollError("Poll not found")
|
||||||
|
return poll
|
||||||
|
|
||||||
|
|
||||||
def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
||||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
poll = _lock_poll_for_response(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
)
|
||||||
_assert_poll_accepts_responses(poll)
|
_assert_poll_accepts_responses(poll)
|
||||||
if payload.respondent_id is None and not poll.allow_anonymous:
|
if payload.respondent_id is None and not poll.allow_anonymous:
|
||||||
raise PollError("Anonymous responses are not allowed for this poll")
|
raise PollError("Anonymous responses are not allowed for this poll")
|
||||||
@@ -561,7 +704,7 @@ def get_poll_by_invitation_token(session: Session, *, token: str) -> Poll:
|
|||||||
def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
||||||
invitation = get_poll_invitation_by_token(session, token=token)
|
invitation = get_poll_invitation_by_token(session, token=token)
|
||||||
metadata = dict(payload.metadata)
|
metadata = dict(payload.metadata)
|
||||||
metadata.setdefault("invitation_id", invitation.id)
|
metadata["invitation_id"] = invitation.id
|
||||||
response_payload = PollSubmitResponseRequest(
|
response_payload = PollSubmitResponseRequest(
|
||||||
respondent_id=invitation.respondent_id or f"invitation:{invitation.id}",
|
respondent_id=invitation.respondent_id or f"invitation:{invitation.id}",
|
||||||
respondent_label=payload.respondent_label or invitation.respondent_label or invitation.email,
|
respondent_label=payload.respondent_label or invitation.respondent_label or invitation.email,
|
||||||
|
|||||||
294
tests/test_authorization.py
Normal file
294
tests/test_authorization.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
||||||
|
from govoplan_poll.backend.router import (
|
||||||
|
api_create_poll,
|
||||||
|
api_get_poll,
|
||||||
|
api_list_poll_invitations,
|
||||||
|
api_list_poll_responses,
|
||||||
|
api_list_polls,
|
||||||
|
api_poll_summary,
|
||||||
|
api_submit_poll_response,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.schemas import (
|
||||||
|
PollAnswerInput,
|
||||||
|
PollCreateRequest,
|
||||||
|
PollInvitationCreateRequest,
|
||||||
|
PollSubmitResponseRequest,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.service import close_poll, create_poll, create_poll_invitation, open_poll
|
||||||
|
|
||||||
|
|
||||||
|
READ_SCOPE = "poll:poll:read"
|
||||||
|
WRITE_SCOPE = "poll:poll:write"
|
||||||
|
RESPOND_SCOPE = "poll:response:write"
|
||||||
|
|
||||||
|
|
||||||
|
class PollAuthorizationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _principal(
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
user_id: str | None = None,
|
||||||
|
scopes: set[str] | None = None,
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
membership_id = user_id or f"membership-{account_id}"
|
||||||
|
display_name = f"Actor {account_id}"
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=membership_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=frozenset(scopes or {READ_SCOPE, RESPOND_SCOPE}),
|
||||||
|
display_name=display_name,
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id=account_id),
|
||||||
|
user=SimpleNamespace(id=membership_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _poll(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner_id: str = "owner",
|
||||||
|
visibility: str = "tenant",
|
||||||
|
result_visibility: str = "after_close",
|
||||||
|
title: str = "Decision",
|
||||||
|
) -> Poll:
|
||||||
|
poll = create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=owner_id,
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title=title,
|
||||||
|
kind="yes_no",
|
||||||
|
visibility=visibility,
|
||||||
|
result_visibility=result_visibility,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
open_poll(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
||||||
|
return poll
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _answer(*, claimed_respondent: str = "victim") -> PollSubmitResponseRequest:
|
||||||
|
return PollSubmitResponseRequest(
|
||||||
|
respondent_id=claimed_respondent,
|
||||||
|
respondent_label="Claimed victim",
|
||||||
|
answers=[PollAnswerInput(option_key="yes")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authenticated_response_identity_is_bound_to_actor(self) -> None:
|
||||||
|
poll = self._poll(result_visibility="public")
|
||||||
|
first_actor = self._principal("account-1")
|
||||||
|
second_actor = self._principal("account-2")
|
||||||
|
|
||||||
|
first = api_submit_poll_response(
|
||||||
|
poll.id,
|
||||||
|
self._answer(),
|
||||||
|
session=self.session,
|
||||||
|
principal=first_actor,
|
||||||
|
)
|
||||||
|
second = api_submit_poll_response(
|
||||||
|
poll.id,
|
||||||
|
self._answer(),
|
||||||
|
session=self.session,
|
||||||
|
principal=second_actor,
|
||||||
|
)
|
||||||
|
updated_first = api_submit_poll_response(
|
||||||
|
poll.id,
|
||||||
|
PollSubmitResponseRequest(
|
||||||
|
respondent_id="account-2",
|
||||||
|
answers=[PollAnswerInput(option_key="no")],
|
||||||
|
),
|
||||||
|
session=self.session,
|
||||||
|
principal=first_actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(first.respondent_id, "account-1")
|
||||||
|
self.assertEqual(first.respondent_label, "Actor account-1")
|
||||||
|
self.assertEqual(second.respondent_id, "account-2")
|
||||||
|
self.assertNotEqual(first.id, second.id)
|
||||||
|
self.assertEqual(updated_first.id, first.id)
|
||||||
|
self.assertEqual(updated_first.answers[0]["option_key"], "no")
|
||||||
|
|
||||||
|
cross_tenant_actor = self._principal("account-1", tenant_id="tenant-2")
|
||||||
|
with self.assertRaises(HTTPException) as cross_tenant:
|
||||||
|
api_submit_poll_response(
|
||||||
|
poll.id,
|
||||||
|
self._answer(),
|
||||||
|
session=self.session,
|
||||||
|
principal=cross_tenant_actor,
|
||||||
|
)
|
||||||
|
self.assertEqual(cross_tenant.exception.status_code, 404)
|
||||||
|
|
||||||
|
def test_create_route_persists_after_request_session_closes(self) -> None:
|
||||||
|
created = api_create_poll(
|
||||||
|
PollCreateRequest(title="Persisted poll", kind="yes_no", visibility="tenant"),
|
||||||
|
session=self.session,
|
||||||
|
principal=self._principal("owner", scopes={WRITE_SCOPE}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session.close()
|
||||||
|
self.session = self.Session()
|
||||||
|
persisted = self.session.query(Poll).filter(Poll.id == created.id).one_or_none()
|
||||||
|
|
||||||
|
self.assertIsNotNone(persisted)
|
||||||
|
self.assertEqual(persisted.title, "Persisted poll")
|
||||||
|
|
||||||
|
def test_authenticated_response_cannot_claim_an_invitation(self) -> None:
|
||||||
|
poll = self._poll(result_visibility="public")
|
||||||
|
|
||||||
|
response = api_submit_poll_response(
|
||||||
|
poll.id,
|
||||||
|
self._answer().model_copy(
|
||||||
|
update={"metadata": {"invitation_id": "forged-invitation", "client_note": "kept"}}
|
||||||
|
),
|
||||||
|
session=self.session,
|
||||||
|
principal=self._principal("account-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.metadata, {"client_note": "kept"})
|
||||||
|
|
||||||
|
def test_private_poll_requires_ownership_management_or_active_assignment(self) -> None:
|
||||||
|
poll = self._poll(visibility="private")
|
||||||
|
reader = self._principal("reader")
|
||||||
|
|
||||||
|
listed = api_list_polls(
|
||||||
|
status_filter=None,
|
||||||
|
kind=None,
|
||||||
|
session=self.session,
|
||||||
|
principal=reader,
|
||||||
|
)
|
||||||
|
self.assertEqual(listed.polls, [])
|
||||||
|
with self.assertRaises(HTTPException) as hidden:
|
||||||
|
api_get_poll(poll.id, session=self.session, principal=reader)
|
||||||
|
self.assertEqual(hidden.exception.status_code, 404)
|
||||||
|
|
||||||
|
invitation, _token = create_poll_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollInvitationCreateRequest(respondent_id=reader.membership_id),
|
||||||
|
)
|
||||||
|
visible = api_get_poll(poll.id, session=self.session, principal=reader)
|
||||||
|
self.assertEqual(visible.id, poll.id)
|
||||||
|
|
||||||
|
invitation.revoked_at = invitation.created_at
|
||||||
|
self.session.flush()
|
||||||
|
with self.assertRaises(HTTPException) as revoked:
|
||||||
|
api_get_poll(poll.id, session=self.session, principal=reader)
|
||||||
|
self.assertEqual(revoked.exception.status_code, 404)
|
||||||
|
|
||||||
|
manager = self._principal("manager", scopes={READ_SCOPE, WRITE_SCOPE})
|
||||||
|
self.assertEqual(api_get_poll(poll.id, session=self.session, principal=manager).id, poll.id)
|
||||||
|
|
||||||
|
other_tenant = self._principal("owner", tenant_id="tenant-2", scopes={READ_SCOPE, WRITE_SCOPE})
|
||||||
|
with self.assertRaises(HTTPException) as cross_tenant:
|
||||||
|
api_get_poll(poll.id, session=self.session, principal=other_tenant)
|
||||||
|
self.assertEqual(cross_tenant.exception.status_code, 404)
|
||||||
|
|
||||||
|
def test_after_response_results_do_not_leak_to_other_actors(self) -> None:
|
||||||
|
poll = self._poll(result_visibility="after_response")
|
||||||
|
respondent = self._principal("respondent")
|
||||||
|
bystander = self._principal("bystander")
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as before_response:
|
||||||
|
api_poll_summary(poll.id, session=self.session, principal=respondent)
|
||||||
|
self.assertEqual(before_response.exception.status_code, 403)
|
||||||
|
|
||||||
|
api_submit_poll_response(poll.id, self._answer(), session=self.session, principal=respondent)
|
||||||
|
self.assertEqual(api_poll_summary(poll.id, session=self.session, principal=respondent).response_count, 1)
|
||||||
|
with self.assertRaises(HTTPException) as own_raw_responses:
|
||||||
|
api_list_poll_responses(poll.id, session=self.session, principal=respondent)
|
||||||
|
self.assertEqual(own_raw_responses.exception.status_code, 403)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as other_actor_summary:
|
||||||
|
api_poll_summary(poll.id, session=self.session, principal=bystander)
|
||||||
|
self.assertEqual(other_actor_summary.exception.status_code, 403)
|
||||||
|
with self.assertRaises(HTTPException) as other_actor_responses:
|
||||||
|
api_list_poll_responses(poll.id, session=self.session, principal=bystander)
|
||||||
|
self.assertEqual(other_actor_responses.exception.status_code, 403)
|
||||||
|
|
||||||
|
def test_participants_can_see_aggregate_but_not_raw_responses_or_invitation_roster(self) -> None:
|
||||||
|
poll = self._poll(result_visibility="public")
|
||||||
|
first_actor = self._principal("account-1")
|
||||||
|
second_actor = self._principal("account-2")
|
||||||
|
create_poll_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollInvitationCreateRequest(respondent_id=first_actor.membership_id, email="one@example.test"),
|
||||||
|
)
|
||||||
|
create_poll_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollInvitationCreateRequest(respondent_id=second_actor.membership_id, email="two@example.test"),
|
||||||
|
)
|
||||||
|
api_submit_poll_response(poll.id, self._answer(), session=self.session, principal=first_actor)
|
||||||
|
api_submit_poll_response(poll.id, self._answer(), session=self.session, principal=second_actor)
|
||||||
|
|
||||||
|
self.assertEqual(api_poll_summary(poll.id, session=self.session, principal=first_actor).response_count, 2)
|
||||||
|
with self.assertRaises(HTTPException) as raw_responses:
|
||||||
|
api_list_poll_responses(poll.id, session=self.session, principal=first_actor)
|
||||||
|
self.assertEqual(raw_responses.exception.status_code, 403)
|
||||||
|
with self.assertRaises(HTTPException) as invitation_roster:
|
||||||
|
api_list_poll_invitations(poll.id, session=self.session, principal=first_actor)
|
||||||
|
self.assertEqual(invitation_roster.exception.status_code, 403)
|
||||||
|
|
||||||
|
organizer = self._principal("owner", scopes={READ_SCOPE})
|
||||||
|
self.assertEqual(len(api_list_poll_responses(poll.id, session=self.session, principal=organizer).responses), 2)
|
||||||
|
self.assertEqual(len(api_list_poll_invitations(poll.id, session=self.session, principal=organizer).invitations), 2)
|
||||||
|
manager = self._principal("manager", scopes={WRITE_SCOPE})
|
||||||
|
self.assertEqual(len(api_list_poll_responses(poll.id, session=self.session, principal=manager).responses), 2)
|
||||||
|
self.assertEqual(len(api_list_poll_invitations(poll.id, session=self.session, principal=manager).invitations), 2)
|
||||||
|
|
||||||
|
def test_after_close_and_organizer_result_visibility_are_enforced(self) -> None:
|
||||||
|
after_close = self._poll(result_visibility="after_close", title="After close")
|
||||||
|
reader = self._principal("reader")
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as still_open:
|
||||||
|
api_poll_summary(after_close.id, session=self.session, principal=reader)
|
||||||
|
self.assertEqual(still_open.exception.status_code, 403)
|
||||||
|
close_poll(self.session, tenant_id="tenant-1", poll_id=after_close.id)
|
||||||
|
self.assertEqual(api_poll_summary(after_close.id, session=self.session, principal=reader).response_count, 0)
|
||||||
|
|
||||||
|
organizer_only = self._poll(result_visibility="organizer", title="Organizer only")
|
||||||
|
api_submit_poll_response(organizer_only.id, self._answer(), session=self.session, principal=reader)
|
||||||
|
with self.assertRaises(HTTPException) as participant_denied:
|
||||||
|
api_poll_summary(organizer_only.id, session=self.session, principal=reader)
|
||||||
|
self.assertEqual(participant_denied.exception.status_code, 403)
|
||||||
|
|
||||||
|
owner = self._principal("owner", scopes={READ_SCOPE})
|
||||||
|
self.assertEqual(api_poll_summary(organizer_only.id, session=self.session, principal=owner).response_count, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider
|
||||||
|
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
||||||
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
||||||
from govoplan_poll.backend.schemas import (
|
from govoplan_poll.backend.schemas import (
|
||||||
PollAnswerInput,
|
PollAnswerInput,
|
||||||
@@ -43,6 +46,15 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_response_projection_and_submission_extension_are_backward_compatible(self) -> None:
|
||||||
|
submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
|
||||||
|
legacy_ref = PollResponseRef(None, submitted_at)
|
||||||
|
provider = SqlPollSchedulingProvider()
|
||||||
|
|
||||||
|
self.assertIsNone(legacy_ref.respondent_id)
|
||||||
|
self.assertIsInstance(provider, PollSchedulingProvider)
|
||||||
|
self.assertIsInstance(provider, PollResponseSubmissionProvider)
|
||||||
|
|
||||||
def test_single_choice_response_can_update_existing_respondent(self) -> None:
|
def test_single_choice_response_can_update_existing_respondent(self) -> None:
|
||||||
poll = create_poll(
|
poll = create_poll(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -66,13 +78,24 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
poll_id=poll.id,
|
poll_id=poll.id,
|
||||||
payload=PollSubmitResponseRequest(respondent_id="person-1", answers=[PollAnswerInput(option_key="b")]),
|
payload=PollSubmitResponseRequest(
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[PollAnswerInput(option_key="b")],
|
||||||
|
metadata={"invitation_id": 42},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
summary = poll_result_summary_by_id(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
summary = poll_result_summary_by_id(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
||||||
|
|
||||||
self.assertEqual(first.id, second.id)
|
self.assertEqual(first.id, second.id)
|
||||||
self.assertEqual(summary["response_count"], 1)
|
self.assertEqual(summary["response_count"], 1)
|
||||||
self.assertEqual(summary["leading_option_ids"], [second.answers[0]["option_id"]])
|
self.assertEqual(summary["leading_option_ids"], [second.answers[0]["option_id"]])
|
||||||
|
response_ref = SqlPollSchedulingProvider().list_responses(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
)[0]
|
||||||
|
self.assertIsNone(response_ref.invitation_id)
|
||||||
|
self.assertEqual(response_ref.respondent_id, "person-1")
|
||||||
|
|
||||||
def test_anonymous_response_requires_poll_policy(self) -> None:
|
def test_anonymous_response_requires_poll_policy(self) -> None:
|
||||||
poll = create_poll(
|
poll = create_poll(
|
||||||
@@ -267,13 +290,24 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
response = submit_poll_response_with_token(
|
response = submit_poll_response_with_token(
|
||||||
self.session,
|
self.session,
|
||||||
token=token,
|
token=token,
|
||||||
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]),
|
payload=PollSubmitResponseRequest(
|
||||||
|
answers=[PollAnswerInput(option_key="yes")],
|
||||||
|
metadata={"invitation_id": "forged-invitation", "client_note": "kept"},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.respondent_id, f"invitation:{invitation.id}")
|
self.assertEqual(response.respondent_id, f"invitation:{invitation.id}")
|
||||||
self.assertEqual(response.respondent_label, "External participant")
|
self.assertEqual(response.respondent_label, "External participant")
|
||||||
self.assertEqual(response.metadata_["invitation_id"], invitation.id)
|
self.assertEqual(response.metadata_["invitation_id"], invitation.id)
|
||||||
|
self.assertEqual(response.metadata_["client_note"], "kept")
|
||||||
self.assertIsNotNone(invitation.last_used_at)
|
self.assertIsNotNone(invitation.last_used_at)
|
||||||
|
response_ref = SqlPollSchedulingProvider().list_responses(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
)[0]
|
||||||
|
self.assertEqual(response_ref.invitation_id, invitation.id)
|
||||||
|
self.assertEqual(response_ref.respondent_id, f"invitation:{invitation.id}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user