Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8ab0f0e8d | ||
|
|
05c8c97044 | ||
|
|
00650aee81 | ||
|
|
64ff8da814 | ||
|
|
7e03fe62ac | ||
|
|
13bdeccdab | ||
|
|
89cc5f0189 | ||
|
|
dc9fdc9143 | ||
|
|
f0f0286866 | ||
|
|
e3c0db76c9 | ||
|
|
5608022b3f |
@@ -26,3 +26,21 @@ profile and integration prerequisites are documented in
|
|||||||
[the POLYAS provider profile](docs/POLYAS_PROVIDER_PROFILE.md). Development of
|
[the POLYAS provider profile](docs/POLYAS_PROVIDER_PROFILE.md). Development of
|
||||||
a native certifiable provider follows the staged, independently evaluated
|
a native certifiable provider follows the staged, independently evaluated
|
||||||
[certifiable Voting program](docs/CERTIFIABLE_VOTING_PROGRAM.md).
|
[certifiable Voting program](docs/CERTIFIABLE_VOTING_PROGRAM.md).
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/voting-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/voting-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/voting-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/voting.css": "./webui/src/styles/voting.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-voting"
|
name = "govoplan-voting"
|
||||||
version = "0.1.17"
|
version = "0.1.21"
|
||||||
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = ["govoplan-core>=0.1.17", "govoplan-access>=0.1.17"]
|
dependencies = ["govoplan-core>=0.1.45", "govoplan-access>=0.1.18"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Voting module."""
|
"""GovOPlaN Voting module."""
|
||||||
|
|
||||||
__version__ = "0.1.17"
|
__version__ = "0.1.21"
|
||||||
|
|||||||
@@ -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 __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 pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -16,6 +19,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -27,6 +31,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
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_core.db.base import Base
|
||||||
from govoplan_voting.backend.db import models as voting_models
|
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.service import SqlVotingBallots
|
||||||
from govoplan_voting.backend.local_confidential_provider import (
|
from govoplan_voting.backend.local_confidential_provider import (
|
||||||
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
@@ -46,7 +55,7 @@ from govoplan_voting.backend.local_confidential_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "voting"
|
MODULE_ID = "voting"
|
||||||
MODULE_NAME = "Voting"
|
MODULE_NAME = "Voting"
|
||||||
MODULE_VERSION = "0.1.17"
|
MODULE_VERSION = "0.1.21"
|
||||||
READ_SCOPE = "voting:ballot:read"
|
READ_SCOPE = "voting:ballot:read"
|
||||||
MANAGE_SCOPE = "voting:ballot:manage"
|
MANAGE_SCOPE = "voting:ballot:manage"
|
||||||
CAST_SCOPE = "voting:ballot:cast"
|
CAST_SCOPE = "voting:ballot:cast"
|
||||||
@@ -95,6 +104,10 @@ def _local_confidential_provider(
|
|||||||
return LocalConfidentialVotingProvider(context.registry)
|
return LocalConfidentialVotingProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> VotingDsarProvider:
|
||||||
|
return VotingDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
current = session.query(voting_models.VotingBallotRevision).filter(
|
current = session.query(voting_models.VotingBallotRevision).filter(
|
||||||
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
||||||
@@ -124,6 +137,7 @@ manifest = ModuleManifest(
|
|||||||
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(name=VOTING_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -222,6 +236,17 @@ manifest = ModuleManifest(
|
|||||||
order=39,
|
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=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="voting.navigation",
|
id="voting.navigation",
|
||||||
@@ -252,13 +277,22 @@ manifest = ModuleManifest(
|
|||||||
voting_provider_capability(
|
voting_provider_capability(
|
||||||
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
||||||
): _local_confidential_provider,
|
): _local_confidential_provider,
|
||||||
|
VOTING_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
||||||
label="Governed ballots",
|
label="Governed ballots",
|
||||||
summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.",
|
summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.",
|
||||||
contract_version="0.1.0",
|
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(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -289,6 +323,82 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="voting.workspace-layout",
|
||||||
|
title="Voting workspace layout",
|
||||||
|
summary="Find workspace actions and read consistently arranged content.",
|
||||||
|
body="Reload and New ballot remain in the workspace-wide top bar, even when a ballot is selected or the catalogue is empty. Reload sits immediately before New. Ballot actions such as opening, casting, certification, challenge, and annulment stay with the selected ballot. New ballot stays visible but disabled without management permission. The shared layout changes no electorate or assurance policy; administrators retain the existing separate management, casting, certification, and administrative grants.",
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=5,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Abstimmungen: Aufbau des Arbeitsbereichs",
|
||||||
|
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
|
||||||
|
"body": "Neu laden und Neue Abstimmung bleiben in der arbeitsbereichsweiten oberen Leiste, auch bei ausgewählter Abstimmung oder leerem Katalog. Neu laden steht unmittelbar vor Neu. Öffnen, Stimmabgabe, Zertifizierung, Anfechtung und Annullierung bleiben bei der ausgewählten Abstimmung. Neue Abstimmung bleibt ohne Verwaltungsrecht sichtbar, aber deaktiviert. Das gemeinsame Layout verändert weder Wählerschaft noch Zusicherung; die getrennten Verwaltungs-, Stimmabgabe-, Zertifizierungs- und Administrationsrechte bleiben bestehen.",
|
||||||
|
}},
|
||||||
|
),
|
||||||
|
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(
|
DocumentationTopic(
|
||||||
id="voting.assurance",
|
id="voting.assurance",
|
||||||
title="Voting assurance and certification",
|
title="Voting assurance and certification",
|
||||||
@@ -303,6 +413,18 @@ manifest = ModuleManifest(
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
|
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=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Voting domain and assurance boundary",
|
label="Voting domain and assurance boundary",
|
||||||
@@ -320,7 +442,26 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
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={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"voting.navigation",
|
"voting.navigation",
|
||||||
@@ -359,7 +500,27 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
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={
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"voting.field.assurance-profile",
|
"voting.field.assurance-profile",
|
||||||
@@ -424,5 +585,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
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):
|
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:
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
self.assertIsNotNone(frontend)
|
self.assertIsNotNone(frontend)
|
||||||
@@ -21,9 +29,13 @@ class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
reference = topics["voting.reference.fields-and-consequences"]
|
reference = topics["voting.reference.fields-and-consequences"]
|
||||||
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
||||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
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("cast", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
||||||
|
self.assertEqual("reference", reference.metadata["kind"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/voting-webui",
|
"name": "@govoplan/voting-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/voting.css": "./src/styles/voting.css"
|
"./styles/voting.css": "./src/styles/voting.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.17",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20"
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { FormGrid,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
@@ -118,6 +118,7 @@ export default function VotingBallotDialog({
|
|||||||
onClose={requestClose}
|
onClose={requestClose}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
|
size="wide"
|
||||||
className="voting-ballot-dialog"
|
className="voting-ballot-dialog"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -128,7 +129,7 @@ export default function VotingBallotDialog({
|
|||||||
<div className="voting-ballot-editor">
|
<div className="voting-ballot-editor">
|
||||||
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{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="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||||
<FormField label="Method">
|
<FormField label="Method">
|
||||||
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value as VotingBallotDraft["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>
|
</FormField>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</FormGrid>
|
||||||
|
|
||||||
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
|
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
|
||||||
<div className="voting-editor-list">
|
<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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { ActionBlockerHint,
|
||||||
ActionBlockerHint,
|
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -10,8 +9,17 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
|
MetricCard,
|
||||||
|
MetricGrid,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
@@ -143,29 +151,44 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="voting-page">
|
<WorkspaceFrame as="main" className="voting-page" label="Ballots" interfaceId="voting.workspace" helpContextId="voting.workspace" helpModuleId="voting">
|
||||||
<div className="voting-shell">
|
<WorkspaceActionBar
|
||||||
<aside className="voting-catalogue">
|
scope="workspace"
|
||||||
<div className="voting-toolbar">
|
variant="collection"
|
||||||
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
refreshable
|
||||||
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
reloadAction={{ onReload: () => void loadList().catch((reason) => setError(message(reason, "Ballots could not be loaded."))), loading: loading || busy, label: "Refresh ballots" }}
|
||||||
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
createAction={<Button variant="primary" disabled={!canManage || busy} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
|
||||||
</div>
|
helpAction={<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />}
|
||||||
|
/>
|
||||||
|
<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={<>
|
||||||
<PageScrollViewport className="voting-list-viewport">
|
<PageScrollViewport className="voting-list-viewport">
|
||||||
{loading && <LoadingIndicator label="Loading ballots" />}
|
{loading && <LoadingIndicator label="Loading ballots" />}
|
||||||
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
|
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
|
||||||
<div className="voting-list" role="list">
|
<SelectionList variant="navigation" label="Ballots">
|
||||||
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
|
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
|
||||||
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
|
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
|
||||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||||
</button>)}
|
</SelectionListItem>)}
|
||||||
</div>
|
</SelectionList>
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</aside>
|
</>}
|
||||||
<section className="voting-workspace">
|
>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</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">
|
{selected && <PageScrollViewport className="voting-detail-viewport">
|
||||||
<div className="voting-detail-heading">
|
<div className="voting-detail-heading">
|
||||||
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
<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>}
|
{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>
|
</div>
|
||||||
<div className="voting-metrics">
|
<MetricGrid columns={4} spacing="block">
|
||||||
<Metric label="Electors" value={selected.electorate.length} />
|
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
|
||||||
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
||||||
<Metric label="Quorum" value={selected.quorum_weight} />
|
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
|
||||||
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
|
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
{selected.description && <p className="voting-description">{selected.description}</p>}
|
{selected.description && <p className="voting-description">{selected.description}</p>}
|
||||||
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
||||||
<h3>Cast vote</h3>
|
<h3>Cast vote</h3>
|
||||||
@@ -215,12 +238,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</section>
|
</section>
|
||||||
{selected.result && <section className="voting-section">
|
{selected.result && <section className="voting-section">
|
||||||
<h3>Result</h3>
|
<h3>Result</h3>
|
||||||
<div className="voting-metrics">
|
<MetricGrid columns={4} spacing="block">
|
||||||
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
||||||
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
||||||
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
||||||
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<Hash label="Result hash" value={selected.result.result_sha256} />
|
<Hash label="Result hash" value={selected.result.result_sha256} />
|
||||||
</section>}
|
</section>}
|
||||||
<section className="voting-section voting-assurance">
|
<section className="voting-section voting-assurance">
|
||||||
@@ -235,8 +258,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</PageScrollViewport>}
|
</PageScrollViewport>}
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
{editing && <VotingBallotDialog
|
{editing && <VotingBallotDialog
|
||||||
open
|
open
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -295,14 +317,10 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
void cast();
|
void cast();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</WorkspaceFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
function Hash({ label, value }: { label: string; value?: string | null }) {
|
||||||
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,12 @@
|
|||||||
overflow: hidden;
|
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 {
|
.voting-catalogue {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-toolbar,
|
|
||||||
.voting-detail-heading,
|
.voting-detail-heading,
|
||||||
.voting-actions,
|
.voting-actions,
|
||||||
.voting-editor-heading {
|
.voting-editor-heading {
|
||||||
@@ -28,54 +18,20 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-toolbar {
|
|
||||||
min-height: 50px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-list-viewport,
|
.voting-list-viewport,
|
||||||
.voting-detail-viewport {
|
.voting-detail-viewport {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
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 {
|
.voting-option-results > div > span:first-child {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-list-row small,
|
|
||||||
.voting-option-results small {
|
.voting-option-results small {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-workspace {
|
.voting-workspace {
|
||||||
@@ -96,7 +52,7 @@
|
|||||||
.voting-detail-heading {
|
.voting-detail-heading {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-detail-heading h2,
|
.voting-detail-heading h2,
|
||||||
@@ -109,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voting-detail-heading > div:first-child span {
|
.voting-detail-heading > div:first-child span {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,25 +74,8 @@
|
|||||||
justify-content: flex-end;
|
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 {
|
.voting-hash span {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -149,7 +88,7 @@
|
|||||||
.voting-cast-panel {
|
.voting-cast-panel {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid var(--border-color, #d8dde3);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-options,
|
.voting-options,
|
||||||
@@ -170,7 +109,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
background: var(--surface-muted, rgba(127, 137, 147, 0.08));
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-options label > span {
|
.voting-options label > span {
|
||||||
@@ -183,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voting-history time {
|
.voting-history time {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +139,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-empty {
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--text-muted, #65717e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-ballot-dialog {
|
.voting-ballot-dialog {
|
||||||
width: min(1040px, calc(100vw - 32px));
|
|
||||||
height: min(820px, calc(100vh - 32px));
|
height: min(820px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,12 +156,6 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-editor-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-editor-wide {
|
.voting-editor-wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -262,19 +189,7 @@
|
|||||||
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
|
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 760px) {
|
||||||
.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-option-row,
|
||||||
.voting-elector-row {
|
.voting-elector-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user