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
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.ruff_cache/
node_modules/
dist/
coverage/
+17
View File
@@ -0,0 +1,17 @@
# GovOPlaN Voting Codex Guide
## Scope
This module owns governed ballot definitions, frozen electorates, vote
receipts, tallying, certification, challenge, and annulment. Committee owns
deliberation, Poll owns informal preferences, Decisions owns the resulting
institutional decision, and Encryption owns key custody. Do not claim ballot
secrecy or legal certification unless an explicit provider supplies and proves
that assurance profile.
## Documentation Contract
- Update manifest-driven user/admin documentation with behavior changes.
- Keep Gitea issues as the delivery source of truth.
- Keep recorded, confidential, secret, and externally certified assurance
profiles explicit and fail closed when a required provider is unavailable.
+19
View File
@@ -0,0 +1,19 @@
# GovOPlaN Voting
`govoplan-voting` owns governed ballot definitions, frozen electorate
snapshots, ballot casting and replacement, deterministic tallying, result
certification, challenges, annulment, and provider evidence.
The native `recorded` assurance profile is auditable but not secret: active
ballots are stored so that a result can be reconstructed. Confidential,
secret, and externally certified profiles fail closed unless an explicit
`voting.provider.<provider>` capability is installed. Providers return only
aggregate results and evidence; raw secret ballots and provider credentials
do not enter the Voting database.
Committee and other modules retain their deliberation or business context and
refer to Voting ballots through the `voting.ballots` capability. Informal
preference and availability collection remains in `govoplan-poll`.
See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations,
security, recovery, and integration details.
+80
View File
@@ -0,0 +1,80 @@
# Voting domain, assurance, and operations
## Ownership boundary
Voting owns the protocol and authoritative state of a governed ballot:
- immutable ballot definitions after opening
- frozen electorate entries, weights, and provenance
- eligibility enforcement, vote replacement, and receipts
- deterministic tally, quorum, threshold, closure, and certification
- challenge, annulment, command replay, and lifecycle evidence
- provider-neutral handoff for confidential, secret, or externally certified
voting
Committee owns the body, meeting, agenda, deliberation, and minute context. It
stores a Voting ballot reference and a verified aggregate result for meeting
records. Poll owns informal preference, response, and availability collection.
Decisions owns the resulting formal institutional outcome. Encryption and
Identity Trust own cryptographic key and device primitives rather than the
voting ceremony.
## Assurance profiles
The native `recorded` profile stores each current vote with its elector,
selection, weight, generation, and receipt hash. Superseded votes remain
available for authorized reconstruction. It is auditable but **not secret**.
The `confidential`, `secret`, and `external_certified` profiles require a
provider exposed as `voting.provider.<provider-id>`. Voting refuses to open the
ballot when that provider is unavailable. The provider keeps raw ballots and
credentials inside its own assurance boundary and returns aggregate counts,
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
provider or deployment satisfies legal or certification requirements merely
because the adapter contract is implemented.
## Lifecycle and concurrency
Ballots move through `draft -> open -> closed -> certified`. A closed or
certified result can be challenged. An administrator can annul an invalid
ballot from any non-annulled lifecycle state. Opening freezes SHA-256 hashes of
the definition and electorate. Each state change creates a new immutable
revision and lifecycle event. Mutating commands require an expected revision
and an idempotency key; a key replay returns the first outcome and a key reused
with another payload is rejected.
Recorded voters can replace a vote only when the frozen definition allows it.
The previous generation is retained and excluded from the active tally.
Lifecycle events contain the receipt hash and generation, not the selection.
## Operations and recovery
Back up these tables as one consistency unit:
- `voting_ballot_revisions`
- `voting_cast_records`
- `voting_lifecycle_events`
- `voting_command_replays`
A restore is valid only when current revisions are unique per tenant and
ballot, cast generations are unique per elector, every active cast references
the current frozen definition hash, result hashes can be recomputed, and event
sequences are contiguous. Provider-backed restores also require the external
provider evidence and ballot reference to remain resolvable. Do not reopen a
restored external ballot merely to compensate for missing provider state.
Before destructive retirement, stop new casts, retain a verified database
snapshot, export result/certification evidence under the applicable retention
policy, and reconcile any Committee or Decision references. Horizontal nodes
must use the shared database; no authoritative Voting state is stored on a
node-local filesystem.
## Security checks
- tenant predicates apply to every ballot, cast, event, and replay lookup
- the authenticated account must match the frozen elector subject
- native casting fails closed for non-recorded assurance profiles
- raw selections are never returned by list, detail, result, or history APIs
- provider result keys must exactly match frozen options
- external results require evidence and cannot exceed the frozen electorate
- certification and annulment use separate permissions
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-voting"
version = "0.1.14"
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14", "govoplan-access>=0.1.8"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_voting = ["py.typed"]
[project.entry-points."govoplan.modules"]
"voting" = "govoplan_voting.backend.manifest:get_manifest"
+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 @@
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_voting.backend.manifest import get_manifest
class VotingMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_voting_runtime_tables(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-voting-") as directory:
url = f"sqlite:///{Path(directory) / 'voting.db'}"
migrate_database(
database_url=url,
enabled_modules=("voting",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
tables = set(inspect(engine).get_table_names())
self.assertTrue(
{
"voting_ballot_revisions",
"voting_cast_records",
"voting_lifecycle_events",
"voting_command_replays",
}.issubset(tables)
)
with engine.connect() as connection:
self.assertIn(
"7a8b9c0d1e2f",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
from dataclasses import dataclass, replace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.voting import (
VotingBallotCreateCommand,
VotingCastCommand,
VotingElector,
VotingOption,
)
from govoplan_core.db.base import Base
from govoplan_voting.backend.db.models import VotingCastRecord
from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError
@dataclass
class Principal:
tenant_id: str
account_id: str
def command(*, assurance: str = "recorded", provider_id: str | None = None):
return VotingBallotCreateCommand(
title="Budget choice",
method="single_choice",
assurance_profile=assurance,
options=(VotingOption("a", "Option A"), VotingOption("b", "Option B")),
electorate=(
VotingElector("alice", "Alice", 2, {"source": "mandate:1"}),
VotingElector("bob", "Bob", 1, {"source": "mandate:2"}),
),
quorum_weight=2,
threshold_numerator=1,
threshold_denominator=2,
provider_id=provider_id,
provider_ballot_ref="provider-ballot-1" if provider_id else None,
)
class VotingTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
self.service = SqlVotingBallots()
self.manager = Principal("tenant-1", "manager")
def tearDown(self) -> None:
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def create_open(self, session):
created = self.service.create_ballot(
session,
self.manager,
command=command(),
idempotency_key="create-1",
)
opened = self.service.open_ballot(
session,
self.manager,
ballot_id=created.id,
expected_revision=created.revision,
idempotency_key="open-1",
)
return opened
def test_recorded_ballot_freezes_replaces_tallies_and_certifies(self) -> None:
with self.Session() as session:
opened = self.create_open(session)
detail = self.service.get_ballot(session, self.manager, ballot_id=opened.id)
self.assertEqual(64, len(str(detail["definition_sha256"])))
self.assertEqual(64, len(str(detail["electorate_sha256"])))
first = self.service.cast_ballot(
session,
Principal("tenant-1", "alice"),
ballot_id=opened.id,
command=VotingCastCommand(("a",), "cast-alice-1"),
)
replay = self.service.cast_ballot(
session,
Principal("tenant-1", "alice"),
ballot_id=opened.id,
command=VotingCastCommand(("a",), "cast-alice-1"),
)
self.assertEqual(first.receipt_sha256, replay.receipt_sha256)
self.assertTrue(replay.replayed)
replaced = self.service.cast_ballot(
session,
Principal("tenant-1", "alice"),
ballot_id=opened.id,
command=VotingCastCommand(("b",), "cast-alice-2"),
)
self.assertTrue(replaced.replaced_previous)
self.service.cast_ballot(
session,
Principal("tenant-1", "bob"),
ballot_id=opened.id,
command=VotingCastCommand(("b",), "cast-bob-1"),
)
result = self.service.close_ballot(
session,
self.manager,
ballot_id=opened.id,
expected_revision=opened.revision,
idempotency_key="close-1",
)
self.assertEqual({"a": 0, "b": 2}, dict(result.counts))
self.assertEqual({"a": 0, "b": 3}, dict(result.weighted_counts))
self.assertTrue(result.quorum_met)
self.assertTrue(result.threshold_met)
certified = self.service.certify_ballot(
session,
self.manager,
ballot_id=opened.id,
expected_revision=result.revision,
evidence=(),
idempotency_key="certify-1",
)
self.assertEqual("certified", certified.state)
events = self.service.history(session, self.manager, ballot_id=opened.id)
self.assertEqual(
[
"ballot.created",
"ballot.opened",
"ballot.vote_cast",
"ballot.vote_cast",
"ballot.vote_cast",
"ballot.closed",
"ballot.certified",
],
[item["event_type"] for item in events],
)
active = (
session.query(VotingCastRecord)
.filter(
VotingCastRecord.ballot_id == opened.id,
VotingCastRecord.superseded_at.is_(None),
)
.count()
)
self.assertEqual(2, active)
def test_eligibility_tenant_and_revision_are_enforced(self) -> None:
with self.Session() as session:
opened = self.create_open(session)
with self.assertRaisesRegex(VotingStoreError, "frozen electorate"):
self.service.cast_ballot(
session,
Principal("tenant-1", "mallory"),
ballot_id=opened.id,
command=VotingCastCommand(("a",), "cast-mallory"),
)
self.assertIsNone(
self.service.get_ballot(
session,
Principal("tenant-2", "manager"),
ballot_id=opened.id,
)
)
with self.assertRaisesRegex(VotingStoreError, "revision conflict"):
self.service.close_ballot(
session,
self.manager,
ballot_id=opened.id,
expected_revision=1,
idempotency_key="close-stale",
)
def test_secret_profile_fails_closed_without_provider(self) -> None:
with self.Session() as session:
created = self.service.create_ballot(
session,
self.manager,
command=command(assurance="secret", provider_id="certified"),
idempotency_key="create-secret",
)
with self.assertRaisesRegex(
VotingStoreError, "requires an available external provider"
):
self.service.open_ballot(
session,
self.manager,
ballot_id=created.id,
expected_revision=created.revision,
idempotency_key="open-secret",
)
def test_idempotency_key_cannot_change_command(self) -> None:
with self.Session() as session:
self.service.create_ballot(
session,
self.manager,
command=command(),
idempotency_key="create-replay",
)
changed = replace(command(), title="Different")
with self.assertRaisesRegex(VotingStoreError, "idempotency key"):
self.service.create_ballot(
session,
self.manager,
command=changed,
idempotency_key="create-replay",
)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@govoplan/voting-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/voting.css": "./src/styles/voting.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type VotingOption = { key: string; label: string; description?: string | null };
export type VotingElector = { subject_id: string; label?: string | null; weight: number; provenance: Record<string, unknown> };
export type VotingBallot = {
id: string;
revision: number;
state: "draft" | "open" | "closed" | "certified" | "challenged" | "annulled";
title: string;
description?: string | null;
method: "single_choice" | "approval" | "yes_no_abstain";
assurance_profile: "recorded" | "confidential" | "secret" | "external_certified";
options: VotingOption[];
electorate: VotingElector[];
context: { module?: string | null; resource_type?: string | null; resource_id?: string | null };
quorum_weight: number;
threshold_numerator: number;
threshold_denominator: number;
allow_replacement: boolean;
opens_at?: string | null;
closes_at?: string | null;
provider_id?: string | null;
provider_ballot_ref?: string | null;
definition_sha256?: string | null;
electorate_sha256?: string | null;
result?: VotingResult | null;
certification?: Record<string, unknown> | null;
metadata: Record<string, unknown>;
};
export type VotingResult = {
counts: Record<string, number>;
weighted_counts: Record<string, number>;
cast_count: number;
cast_weight: number;
eligible_count: number;
eligible_weight: number;
quorum_met: boolean;
threshold_met: boolean;
winning_options: string[];
result_sha256: string;
evidence: Array<Record<string, unknown>>;
};
export type VotingBallotDraft = Omit<VotingBallot, "id" | "revision" | "state" | "context" | "definition_sha256" | "electorate_sha256" | "result" | "certification"> & {
context_module?: string | null;
context_resource_type?: string | null;
context_resource_id?: string | null;
};
export type VotingEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record<string, unknown> };
export function listVotingBallots(settings: ApiSettings, state?: string, signal?: AbortSignal): Promise<{ ballots: VotingBallot[] }> {
return apiFetch(settings, apiPath("/api/v1/voting", { state, limit: 200 }), { signal });
}
export function getVotingBallot(settings: ApiSettings, ballotId: string, signal?: AbortSignal): Promise<VotingBallot> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}`, { signal });
}
export function listVotingHistory(settings: ApiSettings, ballotId: string, signal?: AbortSignal): Promise<VotingEvent[]> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}/history`, { signal });
}
export function createVotingBallot(settings: ApiSettings, ballot: VotingBallotDraft): Promise<VotingBallot> {
return apiFetch(settings, "/api/v1/voting", {
method: "POST",
body: JSON.stringify({ ballot, idempotency_key: crypto.randomUUID() })
});
}
export function updateVotingBallot(settings: ApiSettings, ballotId: string, revision: number, ballot: VotingBallotDraft): Promise<VotingBallot> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}`, {
method: "PUT",
body: JSON.stringify({ ballot, expected_revision: revision, idempotency_key: crypto.randomUUID() })
});
}
export function transitionVotingBallot(
settings: ApiSettings,
ballot: VotingBallot,
action: "open" | "close" | "certify",
extra: Record<string, unknown> = {}
): Promise<VotingBallot | { ballot: VotingBallot; result: VotingResult }> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballot.id)}/${action}`, {
method: "POST",
body: JSON.stringify({ expected_revision: ballot.revision, idempotency_key: crypto.randomUUID(), ...extra })
});
}
export function reasonedVotingTransition(settings: ApiSettings, ballot: VotingBallot, action: "challenge" | "annul", reason: string): Promise<VotingBallot> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballot.id)}/${action}`, {
method: "POST",
body: JSON.stringify({ expected_revision: ballot.revision, idempotency_key: crypto.randomUUID(), reason })
});
}
export function castVotingBallot(settings: ApiSettings, ballotId: string, selections: string[]): Promise<{ receipt_sha256: string; replaced_previous: boolean; replayed: boolean }> {
return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}/cast`, {
method: "POST",
body: JSON.stringify({ selections, idempotency_key: crypto.randomUUID() })
});
}
@@ -0,0 +1,198 @@
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
createVotingBallot,
updateVotingBallot,
type VotingBallot,
type VotingBallotDraft
} from "../../api/voting";
export default function VotingBallotDialog({
open,
settings,
ballot,
currentAccountId,
onClose,
onSaved
}: {
open: boolean;
settings: ApiSettings;
ballot: VotingBallot | null;
currentAccountId: string;
onClose: () => void;
onSaved: (ballot: VotingBallot) => void;
}) {
const [draft, setDraft] = useState<VotingBallotDraft>(() => initialDraft(currentAccountId, ballot));
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setDraft(initialDraft(currentAccountId, ballot));
setBusy(false);
setError("");
}, [ballot, currentAccountId, open]);
const valid = useMemo(() => Boolean(
draft.title.trim()
&& draft.options.length >= 2
&& draft.options.every((item) => item.key.trim() && item.label.trim())
&& draft.electorate.length > 0
&& draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0)
&& (draft.assurance_profile === "recorded" || (draft.provider_id?.trim() && draft.provider_ballot_ref?.trim()))
), [draft]);
async function save() {
if (!valid) return;
setBusy(true);
setError("");
const payload: VotingBallotDraft = {
...draft,
title: draft.title.trim(),
description: draft.description?.trim() || null,
options: draft.options.map((item) => ({ ...item, key: item.key.trim(), label: item.label.trim(), description: item.description?.trim() || null })),
electorate: draft.electorate.map((item) => ({ ...item, subject_id: item.subject_id.trim(), label: item.label?.trim() || null })),
provider_id: draft.assurance_profile === "recorded" ? null : draft.provider_id?.trim() || null,
provider_ballot_ref: draft.assurance_profile === "recorded" ? null : draft.provider_ballot_ref?.trim() || null
};
try {
const saved = ballot
? await updateVotingBallot(settings, ballot.id, ballot.revision, payload)
: await createVotingBallot(settings, payload);
onSaved(saved);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The ballot could not be saved.");
} finally {
setBusy(false);
}
}
return (
<Dialog
open={open}
title={ballot ? `Edit ${ballot.title}` : "New ballot"}
onClose={onClose}
closeDisabled={busy}
portal
className="voting-ballot-dialog"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid}>{busy ? "Saving" : "Save ballot"}</Button>
</>
}>
<div className="voting-ballot-editor">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="voting-editor-grid">
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value as VotingBallotDraft["method"] })}>
<option value="single_choice">Single choice</option>
<option value="approval">Approval</option>
<option value="yes_no_abstain">Yes / no / abstain</option>
</select>
</FormField>
<FormField label="Description" className="voting-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="Assurance profile">
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => setDraft({ ...draft, assurance_profile: event.target.value as VotingBallotDraft["assurance_profile"] })}>
<option value="recorded">Recorded</option>
<option value="confidential">Confidential provider</option>
<option value="secret">Secret provider</option>
<option value="external_certified">Externally certified</option>
</select>
</FormField>
<FormField label="Quorum weight"><input type="number" min={0} value={draft.quorum_weight} disabled={busy} onChange={(event) => setDraft({ ...draft, quorum_weight: Number(event.target.value) })} /></FormField>
<FormField label="Threshold numerator"><input type="number" min={1} value={draft.threshold_numerator} disabled={busy} onChange={(event) => setDraft({ ...draft, threshold_numerator: Number(event.target.value) })} /></FormField>
<FormField label="Threshold denominator"><input type="number" min={1} value={draft.threshold_denominator} disabled={busy} onChange={(event) => setDraft({ ...draft, threshold_denominator: Number(event.target.value) })} /></FormField>
<div className="voting-editor-toggle"><ToggleSwitch label="Allow vote replacement" checked={draft.allow_replacement} disabled={busy} onChange={(allow_replacement) => setDraft({ ...draft, allow_replacement })} /></div>
{draft.assurance_profile !== "recorded" && <>
<FormField label="Provider id"><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField>
<FormField label="Provider ballot reference"><input value={draft.provider_ballot_ref ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_ballot_ref: event.target.value })} /></FormField>
</>}
</div>
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
<div className="voting-editor-list">
{draft.options.map((option, index) => <div className="voting-option-row" key={`${index}:${option.key}`}>
<FormField label="Key"><input value={option.key} disabled={busy} onChange={(event) => patchOption(index, { key: event.target.value })} /></FormField>
<FormField label="Label"><input value={option.label} disabled={busy} onChange={(event) => patchOption(index, { label: event.target.value })} /></FormField>
<FormField label="Description"><input value={option.description ?? ""} disabled={busy} onChange={(event) => patchOption(index, { description: event.target.value })} /></FormField>
<IconButton label={`Remove ${option.label || "option"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.options.length <= 2} onClick={() => setDraft({ ...draft, options: draft.options.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
<EditorHeading title="Electorate" onAdd={() => setDraft({ ...draft, electorate: [...draft.electorate, { subject_id: "", label: "", weight: 1, provenance: {} }] })} disabled={busy} />
<div className="voting-editor-list">
{draft.electorate.map((elector, index) => <div className="voting-elector-row" key={`${index}:${elector.subject_id}`}>
<FormField label="Account id"><input value={elector.subject_id} disabled={busy} onChange={(event) => patchElector(index, { subject_id: event.target.value })} /></FormField>
<FormField label="Label"><input value={elector.label ?? ""} disabled={busy} onChange={(event) => patchElector(index, { label: event.target.value })} /></FormField>
<FormField label="Weight"><input type="number" min={1} value={elector.weight} disabled={busy} onChange={(event) => patchElector(index, { weight: Number(event.target.value) })} /></FormField>
<IconButton label={`Remove ${elector.label || "elector"}`} icon={<Trash2 size={16} />} variant="danger" disabled={busy || draft.electorate.length <= 1} onClick={() => setDraft({ ...draft, electorate: draft.electorate.filter((_, itemIndex) => itemIndex !== index) })} />
</div>)}
</div>
</div>
</Dialog>
);
function patchOption(index: number, patch: Partial<VotingBallotDraft["options"][number]>) {
setDraft((current) => ({ ...current, options: current.options.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
function patchElector(index: number, patch: Partial<VotingBallotDraft["electorate"][number]>) {
setDraft((current) => ({ ...current, electorate: current.electorate.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item) }));
}
}
function EditorHeading({ title, onAdd, disabled }: { title: string; onAdd: () => void; disabled: boolean }) {
return <div className="voting-editor-heading"><h3>{title}</h3><Button onClick={onAdd} disabled={disabled}><Plus size={16} aria-hidden="true" />Add</Button></div>;
}
function initialDraft(accountId: string, ballot: VotingBallot | null): VotingBallotDraft {
if (ballot) return {
title: ballot.title,
description: ballot.description,
method: ballot.method,
assurance_profile: ballot.assurance_profile,
options: structuredClone(ballot.options),
electorate: structuredClone(ballot.electorate),
context_module: ballot.context?.module,
context_resource_type: ballot.context?.resource_type,
context_resource_id: ballot.context?.resource_id,
quorum_weight: ballot.quorum_weight,
threshold_numerator: ballot.threshold_numerator,
threshold_denominator: ballot.threshold_denominator,
allow_replacement: ballot.allow_replacement,
opens_at: ballot.opens_at,
closes_at: ballot.closes_at,
provider_id: ballot.provider_id,
provider_ballot_ref: ballot.provider_ballot_ref,
metadata: { ...ballot.metadata }
};
return {
title: "",
description: "",
method: "single_choice",
assurance_profile: "recorded",
options: [{ key: "yes", label: "Yes" }, { key: "no", label: "No" }],
electorate: [{ subject_id: accountId, label: "", weight: 1, provenance: {} }],
quorum_weight: 0,
threshold_numerator: 1,
threshold_denominator: 2,
allow_replacement: true,
opens_at: null,
closes_at: null,
provider_id: null,
provider_ballot_ref: null,
metadata: {}
};
}
+287
View File
@@ -0,0 +1,287 @@
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
castVotingBallot,
getVotingBallot,
listVotingBallots,
listVotingHistory,
reasonedVotingTransition,
transitionVotingBallot,
type VotingBallot,
type VotingEvent
} from "../../api/voting";
import VotingBallotDialog from "./VotingBallotDialog";
export default function VotingPage({ settings, auth }: PlatformRouteContext) {
const [items, setItems] = useState<VotingBallot[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<VotingBallot | null>(null);
const [history, setHistory] = useState<VotingEvent[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [editing, setEditing] = useState<VotingBallot | "new" | null>(null);
const [reasonAction, setReasonAction] = useState<"challenge" | "annul" | null>(null);
const [selections, setSelections] = useState<string[]>([]);
const canManage = hasScope(auth, "voting:ballot:manage");
const canCast = hasScope(auth, "voting:ballot:cast");
const canCertify = hasScope(auth, "voting:ballot:certify");
const canAdmin = hasScope(auth, "voting:ballot:admin");
const loadList = useCallback(async (signal?: AbortSignal, preferredId?: string) => {
setLoading(true);
setError("");
try {
const result = await listVotingBallots(settings, undefined, signal);
setItems(result.ballots);
setSelectedId((current) => preferredId ?? current ?? result.ballots[0]?.id ?? null);
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
const controller = new AbortController();
void loadList(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Ballots could not be loaded."));
});
return () => controller.abort();
}, [loadList]);
useEffect(() => {
if (!selectedId) {
setSelected(null);
setHistory([]);
return;
}
const controller = new AbortController();
Promise.all([
getVotingBallot(settings, selectedId, controller.signal),
listVotingHistory(settings, selectedId, controller.signal)
]).then(([ballot, events]) => {
setSelected(ballot);
setHistory(events);
setSelections([]);
}).catch((reason) => {
if ((reason as Error).name !== "AbortError") setError(message(reason, "Ballot details could not be loaded."));
});
return () => controller.abort();
}, [selectedId, settings]);
const eligible = useMemo(() => Boolean(
selected?.electorate.some((item) => item.subject_id === auth.account.id)
), [auth.account.id, selected]);
async function run(action: "open" | "close" | "certify") {
if (!selected) return;
setBusy(true);
setError("");
setNotice("");
try {
await transitionVotingBallot(settings, selected, action, action === "certify" ? { evidence: [] } : {});
await reloadSelected(`${humanize(action)} completed.`);
} catch (reason) {
setError(message(reason, `The ballot could not be ${action}ed.`));
} finally {
setBusy(false);
}
}
async function cast() {
if (!selected || selections.length === 0) return;
setBusy(true);
setError("");
setNotice("");
try {
const receipt = await castVotingBallot(settings, selected.id, selections);
setNotice(`${receipt.replaced_previous ? "Vote replaced" : "Vote recorded"}. Receipt ${receipt.receipt_sha256.slice(0, 12)}...`);
const events = await listVotingHistory(settings, selected.id);
setHistory(events);
} catch (reason) {
setError(message(reason, "The vote could not be recorded."));
} finally {
setBusy(false);
}
}
async function reloadSelected(success?: string) {
if (!selectedId) return;
const [ballot, events] = await Promise.all([
getVotingBallot(settings, selectedId),
listVotingHistory(settings, selectedId)
]);
setSelected(ballot);
setHistory(events);
await loadList(undefined, selectedId);
if (success) setNotice(success);
}
return (
<main className="voting-page">
<div className="voting-shell">
<aside className="voting-catalogue">
<div className="voting-toolbar">
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void loadList()} />
{canManage && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
</div>
<PageScrollViewport className="voting-list-viewport">
{loading && <LoadingIndicator label="Loading ballots" />}
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
<div className="voting-list" role="list">
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
</button>)}
</div>
</PageScrollViewport>
</aside>
<section className="voting-workspace">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{!selected && !loading && <div className="voting-empty">Select a ballot.</div>}
{selected && <PageScrollViewport className="voting-detail-viewport">
<div className="voting-detail-heading">
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
<div className="voting-actions">
<StatusBadge status={statusTone(selected.state)} label={humanize(selected.state)} />
{canManage && selected.state === "draft" && <IconButton label={`Edit ${selected.title}`} icon={<Pencil size={16} />} disabled={busy} onClick={() => setEditing(selected)} />}
{canManage && selected.state === "draft" && <Button variant="primary" disabled={busy} onClick={() => void run("open")}><CheckCircle2 size={16} aria-hidden="true" />Open</Button>}
{canManage && selected.state === "open" && <Button variant="primary" disabled={busy} onClick={() => void run("close")}><XCircle size={16} aria-hidden="true" />Close and tally</Button>}
{canCertify && selected.state === "closed" && <Button variant="primary" disabled={busy} onClick={() => void run("certify")}><ShieldCheck size={16} aria-hidden="true" />Certify</Button>}
{canCertify && ["closed", "certified"].includes(selected.state) && <Button disabled={busy} onClick={() => setReasonAction("challenge")}>Challenge</Button>}
{canAdmin && selected.state !== "annulled" && <Button variant="danger" disabled={busy} onClick={() => setReasonAction("annul")}>Annul</Button>}
</div>
</div>
<div className="voting-metrics">
<Metric label="Electors" value={selected.electorate.length} />
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
<Metric label="Quorum" value={selected.quorum_weight} />
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
</div>
{selected.description && <p className="voting-description">{selected.description}</p>}
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
<h3>Cast vote</h3>
<div className="voting-options">
{selected.options.map((option) => <label key={option.key}>
<input
type={selected.method === "approval" ? "checkbox" : "radio"}
name="voting-selection"
checked={selections.includes(option.key)}
onChange={(event) => setSelections((current) => selected.method === "approval"
? event.target.checked ? [...current, option.key] : current.filter((item) => item !== option.key)
: [option.key])}
/>
<span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>
</label>)}
</div>
<Button variant="primary" disabled={busy || selections.length === 0} onClick={() => void cast()}>Submit vote</Button>
</section>}
<section className="voting-section">
<h3>Options</h3>
<div className="voting-option-results">
{selected.options.map((option) => <div key={option.key}>
<span><strong>{option.label}</strong><small>{option.key}</small></span>
{selected.result && <span>{selected.result.counts[option.key] ?? 0} votes / {selected.result.weighted_counts[option.key] ?? 0} weight</span>}
</div>)}
</div>
</section>
{selected.result && <section className="voting-section">
<h3>Result</h3>
<div className="voting-metrics">
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
</div>
<Hash label="Result hash" value={selected.result.result_sha256} />
</section>}
<section className="voting-section voting-assurance">
<h3>Frozen assurance</h3>
<Hash label="Definition" value={selected.definition_sha256} />
<Hash label="Electorate" value={selected.electorate_sha256} />
</section>
<section className="voting-section">
<h3>History</h3>
<div className="voting-history">
{history.map((item) => <div key={item.sequence}><span>{item.sequence}</span><strong>{humanize(item.event_type)}</strong><time>{new Date(item.recorded_at).toLocaleString()}</time></div>)}
</div>
</section>
</PageScrollViewport>}
</section>
</div>
{editing && <VotingBallotDialog
open
settings={settings}
ballot={editing === "new" ? null : editing}
currentAccountId={auth.account.id}
onClose={() => setEditing(null)}
onSaved={(ballot) => {
setEditing(null);
setSelectedId(ballot.id);
void loadList(undefined, ballot.id);
}}
/>}
{selected && reasonAction && <ReasonDialog
action={reasonAction}
busy={busy}
onClose={() => setReasonAction(null)}
onConfirm={async (reason) => {
setBusy(true);
setError("");
try {
await reasonedVotingTransition(settings, selected, reasonAction, reason);
setReasonAction(null);
await reloadSelected(`${humanize(reasonAction)} recorded.`);
} catch (failure) {
setError(message(failure, "The transition could not be recorded."));
} finally {
setBusy(false);
}
}}
/>}
</main>
);
}
function Metric({ label, value }: { label: string; value: string | number }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
function Hash({ label, value }: { label: string; value?: string | null }) {
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
}
function ReasonDialog({ action, busy, onClose, onConfirm }: { action: "challenge" | "annul"; busy: boolean; onClose: () => void; onConfirm: (reason: string) => Promise<void> }) {
const [reason, setReason] = useState("");
return <Dialog open title={`${humanize(action)} ballot`} onClose={onClose} closeDisabled={busy} portal footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant={action === "annul" ? "danger" : "primary"} disabled={busy || !reason.trim()} onClick={() => void onConfirm(reason.trim())}>Confirm</Button></>}>
<FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>
</Dialog>;
}
function statusTone(state: VotingBallot["state"]): "active" | "inactive" | "warning" {
if (state === "open" || state === "certified") return "active";
if (state === "challenged") return "warning";
return "inactive";
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function message(reason: unknown, fallback: string): string {
return reason instanceof Error ? reason.message : fallback;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, votingModule } from "./module";
export * from "./api/voting";
+40
View File
@@ -0,0 +1,40 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/voting.css";
const VotingPage = lazy(() => import("./features/voting/VotingPage"));
export const votingModule: PlatformWebModule = {
id: "voting",
label: "Voting",
version: "0.1.14",
dependencies: ["access"],
optionalDependencies: ["committee", "decisions", "encryption", "forms", "identity_trust", "policy", "reporting", "workflow_engine"],
routes: [
{
path: "/voting",
anyOf: ["voting:ballot:read"],
order: 39,
surfaceId: "voting.catalogue",
render: (context) => createElement(VotingPage, context)
}
],
navItems: [
{
to: "/voting",
label: "Voting",
iconName: "vote",
anyOf: ["voting:ballot:read"],
order: 39,
surfaceId: "voting.navigation"
}
],
viewSurfaces: [
{ id: "voting.navigation", moduleId: "voting", kind: "navigation", label: "Voting navigation", order: 10 },
{ id: "voting.catalogue", moduleId: "voting", kind: "route", label: "Voting ballot catalogue", order: 20 },
{ id: "voting.ballot", moduleId: "voting", kind: "section", label: "Voting ballot workspace", parentId: "voting.catalogue", order: 30 }
]
};
export default votingModule;
+277
View File
@@ -0,0 +1,277 @@
.voting-page {
min-height: 0;
height: 100%;
overflow: hidden;
}
.voting-shell {
display: grid;
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--surface, #fff);
}
.voting-catalogue {
display: flex;
min-height: 0;
flex-direction: column;
border-right: 1px solid var(--border-color, #d8dde3);
}
.voting-toolbar,
.voting-detail-heading,
.voting-actions,
.voting-editor-heading {
display: flex;
align-items: center;
gap: 8px;
}
.voting-toolbar {
min-height: 50px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-list-viewport,
.voting-detail-viewport {
min-height: 0;
flex: 1;
}
.voting-list {
display: flex;
flex-direction: column;
padding: 6px;
}
.voting-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 52px;
padding: 7px 8px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.voting-list-row:hover,
.voting-list-row.is-selected {
background: var(--hover-bg, rgba(54, 99, 135, 0.1));
}
.voting-list-row > span:first-child,
.voting-option-results > div > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
}
.voting-list-row small,
.voting-option-results small {
color: var(--text-muted, #65717e);
}
.voting-workspace {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.voting-detail-viewport > div {
padding: 16px 20px 28px;
}
.voting-detail-heading {
justify-content: space-between;
min-height: 44px;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-detail-heading h2,
.voting-editor-heading h3,
.voting-section h3,
.voting-cast-panel h3 {
margin: 0;
font-size: 1rem;
letter-spacing: 0;
}
.voting-detail-heading > div:first-child span {
color: var(--text-muted, #65717e);
font-size: 0.82rem;
}
.voting-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.voting-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 10px;
margin: 16px 0;
}
.voting-metrics > div {
display: flex;
min-width: 0;
flex-direction: column;
padding: 10px 12px;
border: 1px solid var(--border-color, #d8dde3);
border-radius: 4px;
}
.voting-metrics span,
.voting-hash span {
color: var(--text-muted, #65717e);
font-size: 0.75rem;
text-transform: uppercase;
}
.voting-description {
max-width: 80ch;
}
.voting-section,
.voting-cast-panel {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--border-color, #d8dde3);
}
.voting-options,
.voting-option-results,
.voting-history {
display: flex;
flex-direction: column;
gap: 6px;
margin: 10px 0;
}
.voting-options label,
.voting-option-results > div,
.voting-history > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 38px;
padding: 7px 9px;
background: var(--surface-muted, rgba(127, 137, 147, 0.08));
}
.voting-options label > span {
display: flex;
flex-direction: column;
}
.voting-history > div {
grid-template-columns: 32px minmax(0, 1fr) auto;
}
.voting-history time {
color: var(--text-muted, #65717e);
font-size: 0.82rem;
}
.voting-hash {
display: grid;
grid-template-columns: 100px minmax(0, 1fr);
align-items: center;
gap: 10px;
margin-top: 8px;
}
.voting-hash code {
overflow: hidden;
text-overflow: ellipsis;
}
.voting-empty {
padding: 24px;
color: var(--text-muted, #65717e);
}
.voting-ballot-dialog {
width: min(1040px, calc(100vw - 32px));
height: min(820px, calc(100vh - 32px));
}
.voting-ballot-editor {
display: flex;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: auto;
}
.voting-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.voting-editor-wide {
grid-column: 1 / -1;
}
.voting-editor-toggle {
display: flex;
align-items: flex-end;
padding-bottom: 5px;
}
.voting-editor-heading {
justify-content: space-between;
margin-top: 8px;
}
.voting-editor-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.voting-option-row,
.voting-elector-row {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(160px, 1fr) minmax(180px, 1.2fr) 34px;
align-items: end;
gap: 8px;
}
.voting-elector-row {
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
}
@media (max-width: 800px) {
.voting-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(160px, 34%) minmax(0, 1fr);
}
.voting-catalogue {
border-right: 0;
border-bottom: 1px solid var(--border-color, #d8dde3);
}
.voting-metrics,
.voting-editor-grid,
.voting-option-row,
.voting-elector-row {
grid-template-columns: 1fr;
}
.voting-editor-wide {
grid-column: auto;
}
}