Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00650aee81 | ||
|
|
64ff8da814 | ||
|
|
7e03fe62ac | ||
|
|
13bdeccdab | ||
|
|
89cc5f0189 | ||
|
|
dc9fdc9143 | ||
|
|
f0f0286866 | ||
|
|
e3c0db76c9 | ||
|
|
5608022b3f | ||
|
|
d8224e4676 |
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-voting"
|
||||
version = "0.1.16"
|
||||
version = "0.1.20"
|
||||
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.16", "govoplan-access>=0.1.16"]
|
||||
dependencies = ["govoplan-core>=0.1.18", "govoplan-access>=0.1.18"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Voting module."""
|
||||
|
||||
__version__ = "0.1.16"
|
||||
__version__ = "0.1.20"
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_voting.backend.db.models import (
|
||||
VotingBallotRevision,
|
||||
VotingCastRecord,
|
||||
VotingConfidentialBallot,
|
||||
VotingConfidentialCast,
|
||||
VotingLifecycleEvent,
|
||||
)
|
||||
|
||||
|
||||
VOTING_DSAR_CAPABILITY = dsar_capability_name("voting")
|
||||
_MAX_RECORDS = 5_000
|
||||
_MAX_SELECTIONS = 1_000
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
elector_ids: tuple[str, ...]
|
||||
actor_ids: tuple[str, ...]
|
||||
ballot_id: str | None
|
||||
|
||||
|
||||
class VotingDsarProvider:
|
||||
provider_id = "voting"
|
||||
module_id = "voting"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
personal_casts = db.query(VotingCastRecord).filter(
|
||||
VotingCastRecord.tenant_id == tenant_id,
|
||||
VotingCastRecord.elector_id.in_(selectors.elector_ids),
|
||||
)
|
||||
actor_casts = db.query(VotingCastRecord).filter(
|
||||
VotingCastRecord.tenant_id == tenant_id,
|
||||
VotingCastRecord.actor_id.in_(selectors.actor_ids),
|
||||
~VotingCastRecord.elector_id.in_(selectors.elector_ids),
|
||||
)
|
||||
ballots = db.query(VotingBallotRevision).filter(
|
||||
VotingBallotRevision.tenant_id == tenant_id,
|
||||
VotingBallotRevision.created_by.in_(selectors.actor_ids),
|
||||
)
|
||||
events = db.query(VotingLifecycleEvent).filter(
|
||||
VotingLifecycleEvent.tenant_id == tenant_id,
|
||||
VotingLifecycleEvent.actor_id.in_(selectors.actor_ids),
|
||||
)
|
||||
confidential = (
|
||||
db.query(VotingConfidentialCast, VotingConfidentialBallot)
|
||||
.join(
|
||||
VotingConfidentialBallot,
|
||||
VotingConfidentialCast.provider_ballot_id
|
||||
== VotingConfidentialBallot.id,
|
||||
)
|
||||
.filter(
|
||||
VotingConfidentialCast.tenant_id == tenant_id,
|
||||
VotingConfidentialBallot.tenant_id == tenant_id,
|
||||
VotingConfidentialCast.elector_id.in_(selectors.elector_ids),
|
||||
)
|
||||
)
|
||||
if selectors.ballot_id:
|
||||
personal_casts = personal_casts.filter(
|
||||
VotingCastRecord.ballot_id == selectors.ballot_id
|
||||
)
|
||||
actor_casts = actor_casts.filter(
|
||||
VotingCastRecord.ballot_id == selectors.ballot_id
|
||||
)
|
||||
ballots = ballots.filter(
|
||||
VotingBallotRevision.ballot_id == selectors.ballot_id
|
||||
)
|
||||
events = events.filter(
|
||||
VotingLifecycleEvent.ballot_id == selectors.ballot_id
|
||||
)
|
||||
confidential = confidential.filter(
|
||||
VotingConfidentialBallot.ballot_id == selectors.ballot_id
|
||||
)
|
||||
|
||||
records.extend(
|
||||
_recorded_cast(row)
|
||||
for row in _limited(
|
||||
personal_casts,
|
||||
VotingCastRecord.cast_at,
|
||||
VotingCastRecord.id,
|
||||
label="recorded cast",
|
||||
)
|
||||
)
|
||||
records.extend(
|
||||
_cast_actor_attribution(row)
|
||||
for row in _limited(
|
||||
actor_casts,
|
||||
VotingCastRecord.cast_at,
|
||||
VotingCastRecord.id,
|
||||
label="cast actor attribution",
|
||||
)
|
||||
)
|
||||
confidential_rows = (
|
||||
confidential.order_by(
|
||||
VotingConfidentialCast.cast_at,
|
||||
VotingConfidentialCast.id,
|
||||
)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(confidential_rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Voting DSAR confidential-cast limit exceeded; narrow selectors."
|
||||
)
|
||||
records.extend(
|
||||
_confidential_cast_receipt(cast, ballot)
|
||||
for cast, ballot in confidential_rows
|
||||
)
|
||||
records.extend(
|
||||
_ballot_actor_attribution(row)
|
||||
for row in _limited(
|
||||
ballots,
|
||||
VotingBallotRevision.recorded_at,
|
||||
VotingBallotRevision.id,
|
||||
label="ballot attribution",
|
||||
)
|
||||
)
|
||||
records.extend(
|
||||
_event_actor_attribution(row)
|
||||
for row in _limited(
|
||||
events,
|
||||
VotingLifecycleEvent.recorded_at,
|
||||
VotingLifecycleEvent.id,
|
||||
label="lifecycle attribution",
|
||||
)
|
||||
)
|
||||
if len(records) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Voting DSAR combined result limit exceeded; narrow the selectors."
|
||||
)
|
||||
return tuple(
|
||||
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||
)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Voting DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"voting:retain:{record.resource_type}:{record.resource_id}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Retain {record.title}",
|
||||
rationale=record.retention_reason
|
||||
or "Ballot evidence must retain integrity.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Voting DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind != "retain":
|
||||
raise ValueError("Voting DSAR publishes retain-only actions.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Ballot participation and lifecycle evidence remains unchanged "
|
||||
"to preserve integrity, certification, and challenge history."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
values = {
|
||||
"account_id": _coalesce(
|
||||
subject.account_id,
|
||||
references.get("voting.account"),
|
||||
references.get("access.account"),
|
||||
),
|
||||
"membership_id": _coalesce(
|
||||
subject.membership_id,
|
||||
references.get("voting.membership"),
|
||||
references.get("tenancy.membership"),
|
||||
),
|
||||
"identity_id": _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("voting.identity"),
|
||||
references.get("identity.id"),
|
||||
),
|
||||
"elector_id": _coalesce(
|
||||
references.get("voting.elector"),
|
||||
references.get("voting.elector_id"),
|
||||
),
|
||||
"actor_id": _coalesce(
|
||||
references.get("voting.actor"),
|
||||
references.get("voting.created_by"),
|
||||
),
|
||||
"ballot_id": _coalesce(
|
||||
references.get("voting.ballot"),
|
||||
references.get("voting.ballot_id"),
|
||||
),
|
||||
}
|
||||
if any(value is _CONFLICT for value in values.values()):
|
||||
return None
|
||||
base_ids = tuple(
|
||||
dict.fromkeys(
|
||||
value
|
||||
for value in (
|
||||
_optional_string(values["account_id"]),
|
||||
_prefixed("account", values["account_id"]),
|
||||
_optional_string(values["membership_id"]),
|
||||
_prefixed("membership", values["membership_id"]),
|
||||
_optional_string(values["identity_id"]),
|
||||
_prefixed("identity", values["identity_id"]),
|
||||
)
|
||||
if value
|
||||
)
|
||||
)
|
||||
direct_elector = _optional_string(values["elector_id"])
|
||||
direct_actor = _optional_string(values["actor_id"])
|
||||
for direct in (direct_elector, direct_actor):
|
||||
if direct and base_ids and direct not in base_ids:
|
||||
return None
|
||||
if not base_ids and direct_elector and direct_actor and direct_elector != direct_actor:
|
||||
return None
|
||||
elector_ids = base_ids or ((direct_elector or direct_actor,) if direct_elector or direct_actor else ())
|
||||
actor_ids = base_ids or ((direct_actor or direct_elector,) if direct_actor or direct_elector else ())
|
||||
if not elector_ids:
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
elector_ids=elector_ids,
|
||||
actor_ids=actor_ids,
|
||||
ballot_id=_optional_string(values["ballot_id"]),
|
||||
)
|
||||
|
||||
|
||||
def _recorded_cast(row: VotingCastRecord) -> DsarRecordRef:
|
||||
if not isinstance(row.selections, list) or len(row.selections) > _MAX_SELECTIONS:
|
||||
raise ValueError("Voting recorded selections exceed the DSAR bound.")
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="recorded_ballot_cast",
|
||||
resource_id=row.id,
|
||||
category="identified_recorded_vote",
|
||||
title="Recorded ballot participation",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"generation": row.generation,
|
||||
"selections": [str(item)[:255] for item in row.selections],
|
||||
"weight": row.weight,
|
||||
"cast_at": _iso(row.cast_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"receipt_sha256": row.receipt_sha256,
|
||||
"assurance": "recorded_and_reconstructable",
|
||||
},
|
||||
observed_at=_aware(row.cast_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Recorded votes are attributable, reconstructable ballot evidence."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _cast_actor_attribution(row: VotingCastRecord) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="recorded_cast_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="ballot_operator_attribution",
|
||||
title="Recorded cast actor attribution",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"generation": row.generation,
|
||||
"cast_at": _iso(row.cast_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_cast_for_elector",
|
||||
},
|
||||
observed_at=_aware(row.cast_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Cast actor attribution is immutable ballot evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _confidential_cast_receipt(
|
||||
row: VotingConfidentialCast,
|
||||
ballot: VotingConfidentialBallot,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="confidential_ballot_participation",
|
||||
resource_id=row.id,
|
||||
category="confidential_vote_participation_receipt",
|
||||
title="Confidential ballot participation",
|
||||
data={
|
||||
"ballot_id": ballot.ballot_id,
|
||||
"provider_ballot_ref": ballot.provider_ballot_ref,
|
||||
"assurance_profile": ballot.assurance_profile,
|
||||
"method": ballot.method,
|
||||
"ballot_state": ballot.state,
|
||||
"generation": row.generation,
|
||||
"weight": row.weight,
|
||||
"cast_at": _iso(row.cast_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"receipt_sha256": row.receipt_sha256,
|
||||
"selections_disclosed": False,
|
||||
},
|
||||
observed_at=_aware(row.cast_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Confidential participation receipts are retained without ciphertext or "
|
||||
"selection disclosure."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ballot_actor_attribution(row: VotingBallotRevision) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="ballot_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="ballot_governance_attribution",
|
||||
title="Ballot revision actor attribution",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"revision": row.revision,
|
||||
"state": row.state,
|
||||
"assurance_profile": row.assurance_profile,
|
||||
"method": row.method,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_ballot_revision",
|
||||
},
|
||||
observed_at=_aware(row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Ballot revision attribution is governance evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _event_actor_attribution(row: VotingLifecycleEvent) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="voting_lifecycle_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="ballot_governance_attribution",
|
||||
title="Voting lifecycle actor attribution",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"sequence": row.sequence,
|
||||
"event_type": row.event_type,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
},
|
||||
observed_at=_aware(row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Voting lifecycle attribution is governance evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _limited(query, first, second, *, label: str):
|
||||
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(f"Voting DSAR {label} limit exceeded; narrow selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _prefixed(prefix: str, value: object) -> str | None:
|
||||
normalized = _optional_string(value)
|
||||
return f"{prefix}:{normalized}" if normalized else None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Voting DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
_RESOURCE_TYPES = {
|
||||
"recorded_ballot_cast",
|
||||
"recorded_cast_actor_attribution",
|
||||
"confidential_ballot_participation",
|
||||
"ballot_actor_attribution",
|
||||
"voting_lifecycle_actor_attribution",
|
||||
}
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "voting" or record.module_id != "voting":
|
||||
raise ValueError("Voting DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||
raise ValueError("Voting DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "voting" or action.module_id != "voting":
|
||||
raise ValueError("Voting DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("voting:retain:"):
|
||||
raise ValueError("Voting DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["VOTING_DSAR_CAPABILITY", "VotingDsarProvider"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'voting.assurance': {'privacy_notes': ['Aufgezeichnete Stimmzettel sind rekonstruierbar und '
|
||||
'dürfen niemals als geheim bezeichnet werden.',
|
||||
'Von Anbietern unterstützte Profile zeigen nur das '
|
||||
'Aggregat, den Empfang, den Hash und die Nachweise, die '
|
||||
'der Anbietervertrag erlaubt.',
|
||||
'Der gebündelte lokale vertrauliche Anbieter ist '
|
||||
'serverlesbar und unzertifiziert trotz verschlüsselter '
|
||||
'gespeicherter Auswahl.']},
|
||||
'voting.data-subject-requests': {'consequence_classes': {'export_confidential_receipt': 'Gibt '
|
||||
'Teilnahme-Metadaten '
|
||||
'ohne '
|
||||
'Auswahl '
|
||||
'oder '
|
||||
'Geheimtext '
|
||||
'zurück.',
|
||||
'export_recorded_vote': 'Gibt die '
|
||||
'rekonstruierbaren '
|
||||
'aufgezeichneten '
|
||||
'Auswahlen des '
|
||||
'Subjekts '
|
||||
'zurück.',
|
||||
'retain_ballot_evidence': 'Bewahrt die '
|
||||
'Integrität '
|
||||
'der '
|
||||
'Stimmzettel '
|
||||
'und die '
|
||||
'Geschichte '
|
||||
'der '
|
||||
'Herausforderungen.'}},
|
||||
'voting.reference.fields-and-consequences': {'consequence_classes': {'cast': 'Registriert oder '
|
||||
'ersetzt eine '
|
||||
'autorisierte Stimme '
|
||||
'und gibt eine '
|
||||
'datenschutzbeschränkte '
|
||||
'Quittung zurück.',
|
||||
'certify': 'Fügt '
|
||||
'Zertifizierungsnachweise '
|
||||
'hinzu, ohne das '
|
||||
'eingefrorene '
|
||||
'Ergebnis neu zu '
|
||||
'schreiben.',
|
||||
'challenge_or_annul': 'Hängt '
|
||||
'einen '
|
||||
'begründeten '
|
||||
'Governance-Übergang '
|
||||
'an, '
|
||||
'während '
|
||||
'vorherige '
|
||||
'Nachweise '
|
||||
'beibehalten '
|
||||
'werden.',
|
||||
'close': 'Stoppt das Gießen '
|
||||
'und zeichnet die '
|
||||
'Aggregatzahl auf.',
|
||||
'open': 'Friert die '
|
||||
'Stimmzetteldefinition '
|
||||
'und Wählerschaft '
|
||||
'ein und erlaubt '
|
||||
'autorisiertes '
|
||||
'Casting.'}}}
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_voting.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
@@ -16,6 +19,7 @@ from govoplan_core.core.module_guards import (
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -27,6 +31,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
@@ -37,6 +42,10 @@ from govoplan_core.core.voting import (
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_voting.backend.db import models as voting_models
|
||||
from govoplan_voting.backend.dsar_provider import (
|
||||
VOTING_DSAR_CAPABILITY,
|
||||
VotingDsarProvider,
|
||||
)
|
||||
from govoplan_voting.backend.service import SqlVotingBallots
|
||||
from govoplan_voting.backend.local_confidential_provider import (
|
||||
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||
@@ -46,7 +55,7 @@ from govoplan_voting.backend.local_confidential_provider import (
|
||||
|
||||
MODULE_ID = "voting"
|
||||
MODULE_NAME = "Voting"
|
||||
MODULE_VERSION = "0.1.16"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
READ_SCOPE = "voting:ballot:read"
|
||||
MANAGE_SCOPE = "voting:ballot:manage"
|
||||
CAST_SCOPE = "voting:ballot:cast"
|
||||
@@ -95,6 +104,10 @@ def _local_confidential_provider(
|
||||
return LocalConfidentialVotingProvider(context.registry)
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> VotingDsarProvider:
|
||||
return VotingDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
current = session.query(voting_models.VotingBallotRevision).filter(
|
||||
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
||||
@@ -124,6 +137,7 @@ manifest = ModuleManifest(
|
||||
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=VOTING_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -222,6 +236,17 @@ manifest = ModuleManifest(
|
||||
order=39,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="meetings-decisions",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.meetings_decisions",
|
||||
icon="calendar",
|
||||
description="i18n:govoplan-core.product_area.meetings_decisions_description",
|
||||
surface_ids=("voting.nav.voting", "voting.route.voting"),
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="voting.navigation",
|
||||
@@ -252,13 +277,22 @@ manifest = ModuleManifest(
|
||||
voting_provider_capability(
|
||||
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
||||
): _local_confidential_provider,
|
||||
VOTING_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
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",
|
||||
)
|
||||
),
|
||||
VOTING_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Voting data-subject request provider",
|
||||
summary=(
|
||||
"Exports identified recorded votes, confidential participation "
|
||||
"receipts, and minimized actor attribution without weakening secrecy."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
@@ -289,6 +323,67 @@ manifest = ModuleManifest(
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="voting.data-subject-requests",
|
||||
title="Voting data-subject requests",
|
||||
summary=(
|
||||
"Distinguish reconstructable recorded votes from confidential "
|
||||
"participation when exporting a subject's ballot data."
|
||||
),
|
||||
body=(
|
||||
"Voting correlates exact tenant and elector identifiers and can narrow "
|
||||
"an already verified search to one ballot. Recorded ballots are "
|
||||
"explicitly attributable and reconstructable, so a subject receives "
|
||||
"their own bounded selections, weight, generation, timestamps, and "
|
||||
"receipt. Confidential ballots return participation, assurance, "
|
||||
"generation, timing, and receipt metadata only. Ciphertext, encryption "
|
||||
"envelopes, resource-key references, electorate payloads, and choices "
|
||||
"are never disclosed. Acting on another elector's cast produces only "
|
||||
"minimized actor attribution. Ballot identifiers alone reveal no "
|
||||
"personal participation. All voting records are retained to preserve "
|
||||
"integrity, certification, recount, and challenge evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("core", "committee", "identity_trust", "encryption"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Betroffenenanfragen für Abstimmungen",
|
||||
"summary": (
|
||||
"Beim Export von Abstimmungsdaten einer Person rekonstruierbare aufgezeichnete Stimmen von vertraulicher Teilnahme "
|
||||
"unterscheiden."
|
||||
),
|
||||
"body": (
|
||||
"Voting korreliert exakte Mandanten- und Wahlberechtigtenkennungen und kann eine bereits verifizierte Suche auf eine "
|
||||
"Abstimmung begrenzen. Aufgezeichnete Abstimmungen sind ausdrücklich zurechenbar und rekonstruierbar; eine betroffene "
|
||||
"Person erhält daher ihre eigenen begrenzten Auswahlwerte, Gewichtung, Generation, Zeitpunkte und Quittung. Vertrauliche "
|
||||
"Abstimmungen liefern nur Metadaten zu Teilnahme, Zusicherungsprofil, Generation, Zeitpunkt und Quittung. Chiffrat, "
|
||||
"Verschlüsselungshüllen, Ressourcenschlüsselverweise, Wählerschaftsdaten und Auswahlwerte werden niemals offengelegt. "
|
||||
"Das Handeln für die Stimmabgabe einer anderen Person erzeugt nur eine minimierte Akteurszuordnung. Eine "
|
||||
"Abstimmungskennung allein verrät keine persönliche Teilnahme. Alle Abstimmungsdatensätze werden zum Schutz von Integrität, "
|
||||
"Zertifizierung, Nachzählung und Anfechtungsnachweisen aufbewahrt."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"voting.ballot",
|
||||
"privacy.data-subject-requests",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"export_recorded_vote": (
|
||||
"Returns the subject's reconstructable recorded selections."
|
||||
),
|
||||
"export_confidential_receipt": (
|
||||
"Returns participation metadata without choices or ciphertext."
|
||||
),
|
||||
"retain_ballot_evidence": (
|
||||
"Preserves ballot integrity and challenge history."
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="voting.assurance",
|
||||
title="Voting assurance and certification",
|
||||
@@ -303,6 +398,18 @@ manifest = ModuleManifest(
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("voting",),
|
||||
any_scopes=(
|
||||
READ_SCOPE,
|
||||
MANAGE_SCOPE,
|
||||
CAST_SCOPE,
|
||||
CERTIFY_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Voting domain and assurance boundary",
|
||||
@@ -320,7 +427,26 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Abstimmungszusicherung und Zertifizierung durchführen",
|
||||
"summary": (
|
||||
"Aufgezeichnete Abstimmungen und providergestützte vertrauliche oder geheime Abstimmungen betreiben, ohne ihre "
|
||||
"Zusicherungsprofile zu vermischen."
|
||||
),
|
||||
"body": (
|
||||
"Das Öffnen einer Abstimmung friert Definition und Wählerschaftshashes ein. Native aufgezeichnete Abstimmungen bewahren "
|
||||
"aktive Stimmdatensätze zur Rekonstruktion; sie sind nicht geheim. Vertrauliche, geheime und extern zertifizierte Profile "
|
||||
"verlangen einen installierten Provider und bewahren nur aggregierte Ergebnisse, Quittungen, Hashes und Nachweise. "
|
||||
"Provider-Zusicherung, Protokollkennung, Zertifikatsnachweis und Gültigkeit werden beim Öffnen festgelegt und vor "
|
||||
"Provider-Wirkungen erneut geprüft. Der mitgelieferte lokale vertrauliche Provider verschlüsselt rohe Auswahlwerte über "
|
||||
"Encryption und unterstützt interaktive Stimmabgabe, bleibt aber serverlesbar und nicht zertifiziert. Schließung, "
|
||||
"Zertifizierung, Anfechtung und Aufhebung bleiben getrennte auditierbare Übergänge."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"voting.navigation",
|
||||
@@ -359,7 +485,27 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Abstimmungsfelder, Zusicherung und Folgen des Lebenszyklus",
|
||||
"summary": (
|
||||
"Semantik von eingefrorener Wählerschaft, Schwellenwert, Provider, Quittung, Auszählung, Zertifizierung, Anfechtung und "
|
||||
"Aufhebung."
|
||||
),
|
||||
"body": (
|
||||
"Das Öffnen friert exakte Optionen, Methode, Wählerschaft, Gewichtungen, Quorum, Schwellenwert, Ersetzungsregel, "
|
||||
"Zusicherungsprofil und Providerbindung ein. Aufgezeichnete Abstimmungen bleiben zurechenbar und rekonstruierbar. "
|
||||
"Vertrauliche, geheime und extern zertifizierte Profile sind ausschließlich Zusicherungen ihres installierten Providers; "
|
||||
"der lokale vertrauliche Provider ist serverlesbar und nicht zertifiziert. Eine Stimmabgabe zeichnet eine Stimme nur auf "
|
||||
"oder ersetzt sie, wenn die eingefrorene Definition dies erlaubt, und liefert eine Quittung. Schließen verhindert weitere "
|
||||
"Stimmabgaben und zeichnet die Auszählung auf. Zertifizieren ergänzt Nachweise, ohne das Ergebnis umzuschreiben. Anfechtung "
|
||||
"und Aufhebung sind getrennt begründete, auditierbare Übergänge und löschen niemals eingefrorene Definition, Quittungen "
|
||||
"oder frühere Historie."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"voting.field.assurance-profile",
|
||||
@@ -424,5 +570,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_voting.backend.db.models import (
|
||||
VotingBallotRevision,
|
||||
VotingCastRecord,
|
||||
VotingConfidentialBallot,
|
||||
VotingConfidentialCast,
|
||||
VotingLifecycleEvent,
|
||||
)
|
||||
from govoplan_voting.backend.dsar_provider import (
|
||||
VOTING_DSAR_CAPABILITY,
|
||||
VotingDsarProvider,
|
||||
)
|
||||
from govoplan_voting.backend.manifest import manifest
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 15, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: VotingDsarProvider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def capability_names(self):
|
||||
return (VOTING_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
if name != VOTING_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return "voting"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type("State", (), {"effective_modules": ("voting",)})()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
if name != VOTING_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "voting"})(),)
|
||||
|
||||
|
||||
class VotingDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = VotingDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
self.session.add(
|
||||
VotingBallotRevision(
|
||||
id="ballot-revision-1",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
revision=1,
|
||||
state="closed",
|
||||
assurance_profile="recorded",
|
||||
method="single_choice",
|
||||
definition_sha256="definition-hash-do-not-export",
|
||||
electorate_sha256="electorate-hash-do-not-export",
|
||||
recorded_at=NOW,
|
||||
payload={
|
||||
"electorate": "ballot-electorate-do-not-export",
|
||||
"options": "ballot-options-do-not-export",
|
||||
},
|
||||
created_by="account-1",
|
||||
)
|
||||
)
|
||||
self.session.add_all(
|
||||
(
|
||||
VotingCastRecord(
|
||||
id="recorded-cast-1",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
definition_sha256="cast-definition-hash-do-not-export",
|
||||
elector_id="account-1",
|
||||
generation=1,
|
||||
selections=["option-a"],
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="cast-idempotency-do-not-export",
|
||||
receipt_sha256="recorded-receipt-1",
|
||||
actor_id="account-1",
|
||||
),
|
||||
VotingCastRecord(
|
||||
id="recorded-cast-other",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
definition_sha256="other-definition",
|
||||
elector_id="account-other",
|
||||
generation=1,
|
||||
selections=["private-other-selection-do-not-export"],
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="other-idempotency",
|
||||
receipt_sha256="other-receipt",
|
||||
actor_id="account-other",
|
||||
),
|
||||
VotingCastRecord(
|
||||
id="recorded-cast-proxy",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
definition_sha256="proxy-definition",
|
||||
elector_id="account-proxy-subject",
|
||||
generation=1,
|
||||
selections=["proxy-selection-do-not-export"],
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="proxy-idempotency-do-not-export",
|
||||
receipt_sha256="proxy-receipt-do-not-export",
|
||||
actor_id="account-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
confidential_ballot = VotingConfidentialBallot(
|
||||
id="confidential-ballot-row",
|
||||
tenant_id="tenant-1",
|
||||
provider_ballot_ref="provider-ballot-1",
|
||||
ballot_id="ballot-confidential",
|
||||
definition_sha256="confidential-definition-do-not-export",
|
||||
electorate_sha256="confidential-electorate-hash-do-not-export",
|
||||
assurance_profile="confidential",
|
||||
method="single_choice",
|
||||
state="closed",
|
||||
options=[{"private": "confidential-options-do-not-export"}],
|
||||
electorate=[{"private": "confidential-electorate-do-not-export"}],
|
||||
allow_replacement=True,
|
||||
quorum_weight=1,
|
||||
threshold_numerator=1,
|
||||
threshold_denominator=2,
|
||||
vault_id="vault-do-not-export",
|
||||
preparation_idempotency_key="preparation-idempotency-do-not-export",
|
||||
preparation_request_sha256="preparation-hash-do-not-export",
|
||||
prepared_at=NOW,
|
||||
result={"private": "confidential-result-do-not-export"},
|
||||
)
|
||||
self.session.add(confidential_ballot)
|
||||
self.session.flush()
|
||||
self.session.add(
|
||||
VotingConfidentialCast(
|
||||
id="confidential-cast-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_ballot_id="confidential-ballot-row",
|
||||
elector_id="account-1",
|
||||
generation=1,
|
||||
definition_sha256="confidential-cast-definition-do-not-export",
|
||||
ciphertext=b"ciphertext-do-not-export",
|
||||
encryption_envelope_id="envelope-do-not-export",
|
||||
encryption_resource_id="resource-key-do-not-export",
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="confidential-idempotency-do-not-export",
|
||||
request_sha256="confidential-request-hash-do-not-export",
|
||||
receipt_sha256="confidential-receipt-1",
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
VotingLifecycleEvent(
|
||||
id="lifecycle-1",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
sequence=1,
|
||||
event_type="ballot.opened",
|
||||
recorded_at=NOW,
|
||||
actor_id="account-1",
|
||||
payload={"secret": "lifecycle-payload-do-not-export"},
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _subject() -> DsarSubjectRef:
|
||||
return DsarSubjectRef(account_id="account-1")
|
||||
|
||||
def test_search_separates_recorded_and_confidential_disclosure(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"recorded_ballot_cast",
|
||||
"recorded_cast_actor_attribution",
|
||||
"confidential_ballot_participation",
|
||||
"ballot_actor_attribution",
|
||||
"voting_lifecycle_actor_attribution",
|
||||
},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertIn("option-a", exported)
|
||||
self.assertIn("recorded-receipt-1", exported)
|
||||
self.assertIn("confidential-receipt-1", exported)
|
||||
self.assertIn('"selections_disclosed": false', exported)
|
||||
for excluded in (
|
||||
"private-other-selection-do-not-export",
|
||||
"proxy-selection-do-not-export",
|
||||
"proxy-receipt-do-not-export",
|
||||
"ciphertext-do-not-export",
|
||||
"envelope-do-not-export",
|
||||
"resource-key-do-not-export",
|
||||
"confidential-options-do-not-export",
|
||||
"confidential-electorate-do-not-export",
|
||||
"confidential-result-do-not-export",
|
||||
"ballot-electorate-do-not-export",
|
||||
"lifecycle-payload-do-not-export",
|
||||
"cast-idempotency-do-not-export",
|
||||
):
|
||||
self.assertNotIn(excluded, exported)
|
||||
|
||||
def test_ballot_narrowing_and_conflicting_elector_fail_closed(self) -> None:
|
||||
narrowed = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"voting.ballot": "ballot-confidential"},
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"voting.elector": "account-other"},
|
||||
),
|
||||
)
|
||||
ballot_only = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"voting.ballot": "ballot-1"}
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
["confidential_ballot_participation"],
|
||||
[record.resource_type for record in narrowed],
|
||||
)
|
||||
self.assertEqual((), conflict)
|
||||
self.assertEqual((), ballot_only)
|
||||
|
||||
def test_erasure_is_retain_only(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
records=records,
|
||||
)
|
||||
self.assertTrue(actions)
|
||||
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
actions=actions,
|
||||
request_id="dsar-voting-1",
|
||||
)
|
||||
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||
self.assertEqual(3, self.session.query(VotingCastRecord).count())
|
||||
|
||||
def test_manifest_and_core_workflow_discover_provider(self) -> None:
|
||||
self.assertIn(VOTING_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-VOTING-1",
|
||||
request_kind="access",
|
||||
subject=self._subject(),
|
||||
purpose="Voting participation access request",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="operator-1",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=row.resource_revision,
|
||||
)
|
||||
self.assertEqual("searched", row.status)
|
||||
self.assertEqual(5, row.search_result["record_count"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,14 @@ from govoplan_voting.backend.manifest import manifest
|
||||
|
||||
|
||||
class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
@@ -21,9 +29,13 @@ class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
reference = topics["voting.reference.fields-and-consequences"]
|
||||
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||
self.assertIn("voting.field.assurance-profile", reference.metadata["help_contexts"])
|
||||
self.assertEqual("workflow", guide.metadata["kind"])
|
||||
self.assertIn(
|
||||
"voting.field.assurance-profile", reference.metadata["help_contexts"]
|
||||
)
|
||||
self.assertIn("cast", reference.metadata["consequence_classes"])
|
||||
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
||||
self.assertEqual("reference", reference.metadata["kind"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/voting-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/voting.css": "./src/styles/voting.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
import { FormGrid,
|
||||
Button,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
@@ -118,6 +118,7 @@ export default function VotingBallotDialog({
|
||||
onClose={requestClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
size="wide"
|
||||
className="voting-ballot-dialog"
|
||||
footer={
|
||||
<>
|
||||
@@ -128,7 +129,7 @@ export default function VotingBallotDialog({
|
||||
<div className="voting-ballot-editor">
|
||||
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="voting-editor-grid">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="workspace">
|
||||
<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"] })}>
|
||||
@@ -175,7 +176,7 @@ export default function VotingBallotDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</>}
|
||||
</div>
|
||||
</FormGrid>
|
||||
|
||||
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
|
||||
<div className="voting-editor-list">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, Pencil, Plus, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
import { ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
@@ -10,8 +9,16 @@ import {
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
PageScrollViewport,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceLayout,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
@@ -144,28 +151,44 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
|
||||
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} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
||||
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
||||
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
||||
</div>
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="default"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
surface="contained"
|
||||
primaryClassName="voting-catalogue"
|
||||
contentClassName="voting-workspace"
|
||||
primaryLabel="Ballots"
|
||||
contentLabel="Ballot details"
|
||||
interfaceId="voting.workspace"
|
||||
helpContextId="voting.workspace"
|
||||
helpModuleId="voting"
|
||||
primary={<>
|
||||
<WorkspaceActionBar
|
||||
scope="collection-pane"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void loadList(), loading: loading || busy, label: "Refresh ballots" }}
|
||||
className="voting-toolbar"
|
||||
createAction={<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />}
|
||||
/>
|
||||
<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>
|
||||
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
|
||||
<SelectionList variant="navigation" label="Ballots">
|
||||
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
|
||||
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
|
||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||
</button>)}
|
||||
</div>
|
||||
</SelectionListItem>)}
|
||||
</SelectionList>
|
||||
</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 && !loading && <StatePanel size="fill" title="Ballots" description="Select a ballot." />}
|
||||
{selected && <PageScrollViewport className="voting-detail-viewport">
|
||||
<div className="voting-detail-heading">
|
||||
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
||||
@@ -179,12 +202,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} 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>
|
||||
<MetricGrid columns={4} spacing="block">
|
||||
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
|
||||
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
||||
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
|
||||
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
|
||||
</MetricGrid>
|
||||
{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>
|
||||
@@ -215,12 +238,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
</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>
|
||||
<MetricGrid columns={4} spacing="block">
|
||||
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
||||
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
||||
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
||||
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
||||
</MetricGrid>
|
||||
<Hash label="Result hash" value={selected.result.result_sha256} />
|
||||
</section>}
|
||||
<section className="voting-section voting-assurance">
|
||||
@@ -235,8 +258,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
</div>
|
||||
</section>
|
||||
</PageScrollViewport>}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceLayout>
|
||||
{editing && <VotingBallotDialog
|
||||
open
|
||||
settings={settings}
|
||||
@@ -299,10 +321,6 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -4,22 +4,12 @@
|
||||
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 {
|
||||
@@ -28,54 +18,20 @@
|
||||
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);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.voting-workspace {
|
||||
@@ -96,7 +52,7 @@
|
||||
.voting-detail-heading {
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voting-detail-heading h2,
|
||||
@@ -109,7 +65,7 @@
|
||||
}
|
||||
|
||||
.voting-detail-heading > div:first-child span {
|
||||
color: var(--text-muted, #65717e);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -118,25 +74,8 @@
|
||||
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);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -149,7 +88,7 @@
|
||||
.voting-cast-panel {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-color, #d8dde3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voting-options,
|
||||
@@ -170,7 +109,7 @@
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 7px 9px;
|
||||
background: var(--surface-muted, rgba(127, 137, 147, 0.08));
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.voting-options label > span {
|
||||
@@ -183,7 +122,7 @@
|
||||
}
|
||||
|
||||
.voting-history time {
|
||||
color: var(--text-muted, #65717e);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -200,13 +139,7 @@
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -223,12 +156,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.voting-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voting-editor-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -262,19 +189,7 @@
|
||||
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,
|
||||
@media (max-width: 760px) {
|
||||
.voting-option-row,
|
||||
.voting-elector-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user