Implement governed voting module
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN Voting module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
+196
@@ -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."""
|
||||
@@ -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"]
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user