Implement governed voting module

This commit is contained in:
2026-08-01 20:57:25 +02:00
commit c176c5cfcb
26 changed files with 3758 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Voting module."""
__version__ = "0.1.14"
+1
View File
@@ -0,0 +1 @@
"""Voting backend."""
@@ -0,0 +1,13 @@
from govoplan_voting.backend.db.models import (
VotingBallotRevision,
VotingCastRecord,
VotingCommandReplay,
VotingLifecycleEvent,
)
__all__ = [
"VotingBallotRevision",
"VotingCastRecord",
"VotingCommandReplay",
"VotingLifecycleEvent",
]
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class VotingBallotRevision(Base, TimestampMixin):
__tablename__ = "voting_ballot_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "ballot_id", "revision", name="uq_voting_ballot_revision"
),
Index("ix_voting_ballot_current", "tenant_id", "ballot_id", "superseded_at"),
Index("ix_voting_ballot_catalogue", "tenant_id", "state", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("voting_ballot_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
assurance_profile: Mapped[str] = mapped_column(
String(40), nullable=False, index=True
)
method: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
definition_sha256: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
)
electorate_sha256: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class VotingCastRecord(Base, TimestampMixin):
__tablename__ = "voting_cast_records"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"ballot_id",
"elector_id",
"generation",
name="uq_voting_cast_generation",
),
UniqueConstraint(
"tenant_id",
"ballot_id",
"idempotency_key",
name="uq_voting_cast_idempotency",
),
Index(
"ix_voting_cast_active",
"tenant_id",
"ballot_id",
"elector_id",
"superseded_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
elector_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
generation: Mapped[int] = mapped_column(Integer, nullable=False)
selections: Mapped[list[str]] = mapped_column(JSON, nullable=False)
weight: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
cast_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
class VotingLifecycleEvent(Base, TimestampMixin):
__tablename__ = "voting_lifecycle_events"
__table_args__ = (
UniqueConstraint(
"tenant_id", "ballot_id", "sequence", name="uq_voting_event_sequence"
),
Index("ix_voting_event_history", "tenant_id", "ballot_id", "sequence"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
class VotingCommandReplay(Base, TimestampMixin):
__tablename__ = "voting_command_replays"
__table_args__ = (
UniqueConstraint(
"tenant_id", "operation", "idempotency_key", name="uq_voting_command_replay"
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
operation: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
__all__ = [
"VotingBallotRevision",
"VotingCastRecord",
"VotingCommandReplay",
"VotingLifecycleEvent",
]
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
from govoplan_core.db.base import Base
from govoplan_voting.backend.db import models as voting_models
from govoplan_voting.backend.service import SqlVotingBallots
MODULE_ID = "voting"
MODULE_NAME = "Voting"
MODULE_VERSION = "0.1.14"
READ_SCOPE = "voting:ballot:read"
MANAGE_SCOPE = "voting:ballot:manage"
CAST_SCOPE = "voting:ballot:cast"
CERTIFY_SCOPE = "voting:ballot:certify"
ADMIN_SCOPE = "voting:ballot:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(context: ModuleContext):
from govoplan_voting.backend.router import configure_registry, router
configure_registry(context.registry)
return router
def _ballots(context: ModuleContext) -> SqlVotingBallots:
return SqlVotingBallots(context.registry)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(voting_models.VotingBallotRevision).filter(
voting_models.VotingBallotRevision.tenant_id == tenant_id,
voting_models.VotingBallotRevision.superseded_at.is_(None),
)
return {
"voting_ballots": current.count(),
"voting_open_ballots": current.filter(
voting_models.VotingBallotRevision.state == "open"
).count(),
}
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=(
"committee",
"decisions",
"encryption",
"forms",
"identity_trust",
"policy",
"reporting",
"workflow_engine",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_VOTING_BALLOTS, version="0.1.0"),
),
permissions=(
_permission(
READ_SCOPE,
"View ballots",
"Read ballot definitions, status, aggregate results, and history.",
),
_permission(
MANAGE_SCOPE,
"Manage ballots",
"Create, revise, open, and close governed ballots.",
),
_permission(
CAST_SCOPE,
"Cast votes",
"Cast or replace the current principal's eligible vote.",
),
_permission(
CERTIFY_SCOPE,
"Certify ballots",
"Certify, challenge, and reconcile governed ballot results.",
),
_permission(
ADMIN_SCOPE,
"Administer voting",
"Annul ballots and configure Voting assurance providers.",
),
),
role_templates=(
RoleTemplate(
slug="voting_officer",
name="Voting officer",
description="Manage and certify governed ballots.",
permissions=(READ_SCOPE, MANAGE_SCOPE, CAST_SCOPE, CERTIFY_SCOPE),
),
RoleTemplate(
slug="voter",
name="Voter",
description="Read eligible ballots and cast a vote.",
permissions=(READ_SCOPE, CAST_SCOPE),
),
RoleTemplate(
slug="voting_admin",
name="Voting administrator",
description="Administer Voting and annul invalid ballots.",
permissions=(
READ_SCOPE,
MANAGE_SCOPE,
CAST_SCOPE,
CERTIFY_SCOPE,
ADMIN_SCOPE,
),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/voting",
label="Voting",
icon="vote",
required_any=(READ_SCOPE,),
order=39,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/voting-webui",
routes=(
FrontendRoute(
path="/voting",
component="VotingPage",
required_any=(READ_SCOPE,),
order=39,
),
),
nav_items=(
NavItem(
path="/voting",
label="Voting",
icon="vote",
required_any=(READ_SCOPE,),
order=39,
),
),
view_surfaces=(
ViewSurface(
id="voting.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Voting navigation",
order=10,
),
ViewSurface(
id="voting.catalogue",
module_id=MODULE_ID,
kind="route",
label="Voting ballot catalogue",
order=20,
),
ViewSurface(
id="voting.ballot",
module_id=MODULE_ID,
kind="section",
label="Voting ballot workspace",
parent_id="voting.catalogue",
order=30,
),
),
),
capability_factories={CAPABILITY_VOTING_BALLOTS: _ballots},
capability_documentation={
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
label="Governed ballots",
summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.",
contract_version="0.1.0",
)
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
voting_models.VotingCommandReplay,
voting_models.VotingLifecycleEvent,
voting_models.VotingCastRecord,
voting_models.VotingBallotRevision,
label=MODULE_NAME,
),
retirement_notes="Destructive retirement requires a verified snapshot and removes ballot definitions, recorded votes, receipts, results, and certification history.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
voting_models.VotingBallotRevision,
voting_models.VotingCastRecord,
voting_models.VotingLifecycleEvent,
voting_models.VotingCommandReplay,
label=MODULE_NAME,
),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="voting.assurance",
title="Voting assurance and certification",
summary="Operate recorded ballots and provider-backed confidential or secret voting without conflating the assurance profiles.",
body=(
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
"Closure, certification, challenge, and annulment remain separate auditable transitions."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
links=(
DocumentationLink(
label="Voting domain and assurance boundary",
href="govoplan-voting/docs/VOTING_DOMAIN.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/VOTING_DOMAIN.md",
test_ref="tests/test_voting.py",
known_limits=(
"The native profile is recorded and reconstructable, not cryptographically secret.",
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
),
supported_authority_modes=("native_authoritative", "external_authoritative"),
owned_concepts=(
"ballot definition",
"frozen electorate",
"vote receipt",
"tally",
"ballot certification",
"ballot challenge and annulment",
),
non_owned_concepts=(
"committee deliberation",
"informal poll response",
"formal institutional decision",
"cryptographic key custody",
),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/VOTING_DOMAIN.md",),
recovery_docs=("docs/VOTING_DOMAIN.md",),
security_docs=("docs/VOTING_DOMAIN.md",),
operations_docs=("docs/VOTING_DOMAIN.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Voting migrations."""
@@ -0,0 +1,196 @@
"""v0.1.14 Voting baseline.
Revision ID: 7a8b9c0d1e2f
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "7a8b9c0d1e2f"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"voting_ballot_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("ballot_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("assurance_profile", sa.String(length=40), nullable=False),
sa.Column("method", sa.String(length=40), nullable=False),
sa.Column("definition_sha256", sa.String(length=64), nullable=True),
sa.Column("electorate_sha256", sa.String(length=64), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["voting_ballot_revisions.id"],
name=op.f(
"fk_voting_ballot_revisions_previous_revision_id_voting_ballot_revisions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_ballot_revisions")),
sa.UniqueConstraint(
"tenant_id", "ballot_id", "revision", name="uq_voting_ballot_revision"
),
)
for column in (
"tenant_id",
"ballot_id",
"previous_revision_id",
"state",
"assurance_profile",
"method",
"definition_sha256",
"electorate_sha256",
"recorded_at",
"superseded_at",
"created_by",
):
op.create_index(
op.f(f"ix_voting_ballot_revisions_{column}"),
"voting_ballot_revisions",
[column],
unique=False,
)
op.create_index(
"ix_voting_ballot_current",
"voting_ballot_revisions",
["tenant_id", "ballot_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_voting_ballot_catalogue",
"voting_ballot_revisions",
["tenant_id", "state", "recorded_at"],
unique=False,
)
op.create_table(
"voting_cast_records",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("ballot_id", sa.String(length=36), nullable=False),
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
sa.Column("elector_id", sa.String(length=255), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("selections", sa.JSON(), nullable=False),
sa.Column("weight", sa.Integer(), nullable=False),
sa.Column("cast_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("receipt_sha256", sa.String(length=64), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_cast_records")),
sa.UniqueConstraint(
"tenant_id",
"ballot_id",
"elector_id",
"generation",
name="uq_voting_cast_generation",
),
sa.UniqueConstraint(
"tenant_id",
"ballot_id",
"idempotency_key",
name="uq_voting_cast_idempotency",
),
)
for column in (
"tenant_id",
"ballot_id",
"elector_id",
"cast_at",
"superseded_at",
"receipt_sha256",
"actor_id",
):
op.create_index(
op.f(f"ix_voting_cast_records_{column}"),
"voting_cast_records",
[column],
unique=False,
)
op.create_index(
"ix_voting_cast_active",
"voting_cast_records",
["tenant_id", "ballot_id", "elector_id", "superseded_at"],
unique=False,
)
op.create_table(
"voting_lifecycle_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("ballot_id", sa.String(length=36), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=60), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_lifecycle_events")),
sa.UniqueConstraint(
"tenant_id", "ballot_id", "sequence", name="uq_voting_event_sequence"
),
)
for column in ("tenant_id", "ballot_id", "event_type", "recorded_at", "actor_id"):
op.create_index(
op.f(f"ix_voting_lifecycle_events_{column}"),
"voting_lifecycle_events",
[column],
unique=False,
)
op.create_index(
"ix_voting_event_history",
"voting_lifecycle_events",
["tenant_id", "ballot_id", "sequence"],
unique=False,
)
op.create_table(
"voting_command_replays",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("operation", sa.String(length=60), nullable=False),
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("response", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_command_replays")),
sa.UniqueConstraint(
"tenant_id", "operation", "idempotency_key", name="uq_voting_command_replay"
),
)
for column in ("tenant_id", "operation"):
op.create_index(
op.f(f"ix_voting_command_replays_{column}"),
"voting_command_replays",
[column],
unique=False,
)
def downgrade() -> None:
op.drop_table("voting_command_replays")
op.drop_table("voting_lifecycle_events")
op.drop_table("voting_cast_records")
op.drop_table("voting_ballot_revisions")
@@ -0,0 +1 @@
"""Voting migration revisions."""
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
from typing import Any
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_voting.backend.schemas import (
VotingCastRequest,
VotingCertificationRequest,
VotingListResponse,
VotingReasonedTransitionRequest,
VotingTransitionRequest,
VotingWriteRequest,
)
from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError
router = APIRouter(prefix="/voting", tags=["voting"])
_registry: object | None = None
def configure_registry(registry: object | None) -> None:
global _registry
_registry = registry
def _service() -> SqlVotingBallots:
return SqlVotingBallots(_registry)
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
message = str(exc)
if isinstance(exc, LookupError):
return HTTPException(status_code=404, detail=message)
return HTTPException(
status_code=409
if "conflict" in message.lower() or "idempotency" in message.lower()
else 400,
detail=message,
)
@router.get("", response_model=VotingListResponse)
def api_list_ballots(
ballot_state: str | None = Query(default=None, alias="state"),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> VotingListResponse:
from govoplan_voting.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
return VotingListResponse(
ballots=list(
_service().list_ballots(session, principal, state=ballot_state, limit=limit)
)
)
@router.get("/{ballot_id}", response_model=dict[str, Any])
def api_get_ballot(
ballot_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
item = _service().get_ballot(session, principal, ballot_id=ballot_id)
if item is None:
raise HTTPException(status_code=404, detail="Voting ballot not found")
return dict(item)
@router.get("/{ballot_id}/history", response_model=list[dict[str, Any]])
def api_get_ballot_history(
ballot_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[dict[str, Any]]:
from govoplan_voting.backend.manifest import READ_SCOPE
_require(principal, READ_SCOPE)
if _service().get_ballot(session, principal, ballot_id=ballot_id) is None:
raise HTTPException(status_code=404, detail="Voting ballot not found")
return [
dict(item)
for item in _service().history(session, principal, ballot_id=ballot_id)
]
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
def api_create_ballot(
payload: VotingWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import MANAGE_SCOPE
_require(principal, MANAGE_SCOPE)
try:
result = _service().create_ballot(
session,
principal,
command=payload.ballot.to_command(),
idempotency_key=payload.idempotency_key,
)
session.commit()
item = _service().get_ballot(session, principal, ballot_id=result.id)
return dict(item or {})
except (VotingStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.put("/{ballot_id}", response_model=dict[str, Any])
def api_update_ballot(
ballot_id: str,
payload: VotingWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import MANAGE_SCOPE
_require(principal, MANAGE_SCOPE)
if payload.expected_revision is None:
raise HTTPException(status_code=400, detail="expected_revision is required")
try:
result = _service().update_draft(
session,
principal,
ballot_id=ballot_id,
command=payload.ballot.to_command(),
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return dict(
_service().get_ballot(session, principal, ballot_id=result.id) or {}
)
except (VotingStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{ballot_id}/open", response_model=dict[str, Any])
def api_open_ballot(
ballot_id: str,
payload: VotingTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import MANAGE_SCOPE
_require(principal, MANAGE_SCOPE)
return _transition(
session,
principal,
ballot_id=ballot_id,
callback=lambda: _service().open_ballot(
session,
principal,
ballot_id=ballot_id,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
),
)
@router.post("/{ballot_id}/cast", response_model=dict[str, Any])
def api_cast_ballot(
ballot_id: str,
payload: VotingCastRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import CAST_SCOPE
_require(principal, CAST_SCOPE)
try:
result = _service().cast_ballot(
session, principal, ballot_id=ballot_id, command=payload.to_command()
)
session.commit()
return {
"ballot_id": result.ballot_id,
"revision": result.revision,
"receipt_sha256": result.receipt_sha256,
"cast_at": result.cast_at,
"replaced_previous": result.replaced_previous,
"replayed": result.replayed,
}
except (VotingStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{ballot_id}/close", response_model=dict[str, Any])
def api_close_ballot(
ballot_id: str,
payload: VotingTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import MANAGE_SCOPE
_require(principal, MANAGE_SCOPE)
try:
result = _service().close_ballot(
session,
principal,
ballot_id=ballot_id,
expected_revision=payload.expected_revision,
idempotency_key=payload.idempotency_key,
)
session.commit()
return {
"ballot": dict(
_service().get_ballot(session, principal, ballot_id=ballot_id) or {}
),
"result": {
"counts": dict(result.counts),
"weighted_counts": dict(result.weighted_counts),
"cast_count": result.cast_count,
"cast_weight": result.cast_weight,
"eligible_count": result.eligible_count,
"eligible_weight": result.eligible_weight,
"quorum_met": result.quorum_met,
"threshold_met": result.threshold_met,
"winning_options": list(result.winning_options),
"result_sha256": result.result_sha256,
"evidence": list(result.evidence),
},
}
except (VotingStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/{ballot_id}/certify", response_model=dict[str, Any])
def api_certify_ballot(
ballot_id: str,
payload: VotingCertificationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import CERTIFY_SCOPE
_require(principal, CERTIFY_SCOPE)
return _transition(
session,
principal,
ballot_id=ballot_id,
callback=lambda: _service().certify_ballot(
session,
principal,
ballot_id=ballot_id,
expected_revision=payload.expected_revision,
evidence=payload.evidence,
idempotency_key=payload.idempotency_key,
),
)
@router.post("/{ballot_id}/challenge", response_model=dict[str, Any])
def api_challenge_ballot(
ballot_id: str,
payload: VotingReasonedTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import CERTIFY_SCOPE
_require(principal, CERTIFY_SCOPE)
return _transition(
session,
principal,
ballot_id=ballot_id,
callback=lambda: _service().challenge_ballot(
session,
principal,
ballot_id=ballot_id,
expected_revision=payload.expected_revision,
reason=payload.reason,
idempotency_key=payload.idempotency_key,
),
)
@router.post("/{ballot_id}/annul", response_model=dict[str, Any])
def api_annul_ballot(
ballot_id: str,
payload: VotingReasonedTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
from govoplan_voting.backend.manifest import ADMIN_SCOPE
_require(principal, ADMIN_SCOPE)
return _transition(
session,
principal,
ballot_id=ballot_id,
callback=lambda: _service().annul_ballot(
session,
principal,
ballot_id=ballot_id,
expected_revision=payload.expected_revision,
reason=payload.reason,
idempotency_key=payload.idempotency_key,
),
)
def _transition(
session: Session,
principal: ApiPrincipal,
*,
ballot_id: str,
callback,
) -> dict[str, Any]:
try:
callback()
session.commit()
return dict(
_service().get_ballot(session, principal, ballot_id=ballot_id) or {}
)
except (VotingStoreError, LookupError) as exc:
session.rollback()
raise _error(exc) from exc
__all__ = ["configure_registry", "router"]
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.core.voting import (
VotingBallotCreateCommand,
VotingCastCommand,
VotingElector,
VotingOption,
)
class VotingOptionInput(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=120)
label: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=2000)
class VotingElectorInput(BaseModel):
model_config = ConfigDict(extra="forbid")
subject_id: str = Field(min_length=1, max_length=255)
label: str | None = Field(default=None, max_length=255)
weight: int = Field(default=1, ge=1, le=1_000_000)
provenance: dict[str, Any] = Field(default_factory=dict)
class VotingBallotInput(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=10_000)
method: Literal["single_choice", "approval", "yes_no_abstain"] = "single_choice"
assurance_profile: Literal[
"recorded", "confidential", "secret", "external_certified"
] = "recorded"
options: list[VotingOptionInput] = Field(min_length=2, max_length=200)
electorate: list[VotingElectorInput] = Field(min_length=1, max_length=100_000)
context_module: str | None = Field(default=None, max_length=120)
context_resource_type: str | None = Field(default=None, max_length=120)
context_resource_id: str | None = Field(default=None, max_length=255)
quorum_weight: int = Field(default=0, ge=0)
threshold_numerator: int = Field(default=1, ge=1)
threshold_denominator: int = Field(default=2, ge=1)
allow_replacement: bool = True
opens_at: datetime | None = None
closes_at: datetime | None = None
provider_id: str | None = Field(default=None, max_length=80)
provider_ballot_ref: str | None = Field(default=None, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_threshold(self) -> "VotingBallotInput":
if self.threshold_numerator > self.threshold_denominator:
raise ValueError("threshold_numerator cannot exceed threshold_denominator")
return self
def to_command(self) -> VotingBallotCreateCommand:
return VotingBallotCreateCommand(
title=self.title,
description=self.description,
method=self.method,
assurance_profile=self.assurance_profile,
options=tuple(
VotingOption(
key=item.key,
label=item.label,
description=item.description,
)
for item in self.options
),
electorate=tuple(
VotingElector(
subject_id=item.subject_id,
label=item.label,
weight=item.weight,
provenance=item.provenance,
)
for item in self.electorate
),
context_module=self.context_module,
context_resource_type=self.context_resource_type,
context_resource_id=self.context_resource_id,
quorum_weight=self.quorum_weight,
threshold_numerator=self.threshold_numerator,
threshold_denominator=self.threshold_denominator,
allow_replacement=self.allow_replacement,
opens_at=self.opens_at,
closes_at=self.closes_at,
provider_id=self.provider_id,
provider_ballot_ref=self.provider_ballot_ref,
metadata=self.metadata,
)
class VotingWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
ballot: VotingBallotInput
expected_revision: int | None = Field(default=None, ge=1)
idempotency_key: str = Field(min_length=1, max_length=160)
class VotingTransitionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
idempotency_key: str = Field(min_length=1, max_length=160)
class VotingCastRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
selections: list[str] = Field(min_length=1, max_length=200)
idempotency_key: str = Field(min_length=1, max_length=160)
def to_command(self) -> VotingCastCommand:
return VotingCastCommand(
selections=tuple(self.selections),
idempotency_key=self.idempotency_key,
)
class VotingCertificationRequest(VotingTransitionRequest):
evidence: list[dict[str, Any]] = Field(default_factory=list, max_length=500)
class VotingReasonedTransitionRequest(VotingTransitionRequest):
reason: str = Field(min_length=1, max_length=4000)
class VotingListResponse(BaseModel):
ballots: list[dict[str, Any]]
__all__ = [
"VotingBallotInput",
"VotingCastRequest",
"VotingCertificationRequest",
"VotingListResponse",
"VotingReasonedTransitionRequest",
"VotingTransitionRequest",
"VotingWriteRequest",
]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@