Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ff8da814 | ||
|
|
7e03fe62ac | ||
|
|
13bdeccdab | ||
|
|
89cc5f0189 | ||
|
|
dc9fdc9143 | ||
|
|
f0f0286866 | ||
|
|
e3c0db76c9 | ||
|
|
5608022b3f | ||
|
|
d8224e4676 |
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-voting"
|
name = "govoplan-voting"
|
||||||
version = "0.1.16"
|
version = "0.1.19"
|
||||||
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.16", "govoplan-access>=0.1.16"]
|
dependencies = ["govoplan-core>=0.1.18", "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.16"
|
__version__ = "0.1.19"
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -16,6 +16,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 +28,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 +39,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 +52,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.16"
|
MODULE_VERSION = "0.1.19"
|
||||||
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 +101,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 +134,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 +233,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 +274,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 +320,67 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="voting.assurance",
|
id="voting.assurance",
|
||||||
title="Voting assurance and certification",
|
title="Voting assurance and certification",
|
||||||
@@ -303,6 +395,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 +424,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 +482,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",
|
||||||
|
|||||||
@@ -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.16",
|
"version": "0.1.19",
|
||||||
"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.16",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"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,16 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
|
MetricCard,
|
||||||
|
MetricGrid,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
@@ -144,28 +151,44 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="voting-page">
|
<main className="voting-page">
|
||||||
<div className="voting-shell">
|
<WorkspaceLayout
|
||||||
<aside className="voting-catalogue">
|
variant="split"
|
||||||
<div className="voting-toolbar">
|
primarySize="default"
|
||||||
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
primaryScrollable={false}
|
||||||
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
contentScrollable={false}
|
||||||
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
surface="contained"
|
||||||
</div>
|
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">
|
<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}
|
||||||
@@ -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 }) {
|
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