Implement governed voting module
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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 @@
|
|||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, votingModule } from "./module";
|
||||||
|
export * from "./api/voting";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user