Integrate committee ballots with Voting

This commit is contained in:
2026-08-01 20:57:25 +02:00
parent 758b59ca03
commit 133e55987e
12 changed files with 565 additions and 104 deletions
+10 -8
View File
@@ -19,12 +19,13 @@ protected local Decision projection so the outcome remains reconstructable.
The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes
workspace using the same immutable revisions and lifecycle guards as the API.
`committee.ballot_finalizer` delegates provider-bound external or secret
ballots to `committee.ballot_adapter.<provider>` capabilities. Committee
validates the exact choices, eligible/cast totals, tenant evidence, receipt,
and result hash, then stores only the verified aggregate. Individual ballots
never enter Committee persistence, and a provider-bound vote cannot be closed
through the generic workspace endpoint.
When Voting is installed, Committee creates and follows a governed
`voting.ballots` record while retaining only its meeting/agendum reference and
verified aggregate outcome. Voting owns the frozen electorate, casting,
tallying, certification, challenge, and annulment lifecycle. The older
`committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
remains a 0.1 compatibility boundary for existing external integrations;
individual provider ballots never enter Committee persistence.
## Initial Ownership
@@ -57,6 +58,7 @@ Expected optional integrations:
- tasks
- workflow
- approvals
- voting
## Development Install
@@ -83,8 +85,8 @@ use a separate read permission. Database restore is the semantic recovery unit;
linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain
their own recovery responsibility.
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the
separate effect boundary for an installed ballot adapter. It requires the
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the 0.1
compatibility effect boundary for an installed ballot adapter. It requires the
`committee:ballot:finalize` permission and preserves provider receipt/hash and
evidence without exposing or persisting individual votes.
+6 -1
View File
@@ -50,6 +50,8 @@ The current repository state is intentionally bounded:
vote results, and minutes
- a provider-neutral ballot-finalization contract for external and secret
ballots that retains aggregate evidence rather than individual ballots
- an optional primary integration with Voting, which owns frozen electorates,
vote casting/replacement, tally, certification, challenge, and annulment
- Gitea issue workflow templates
- manifest and decision reconstruction contract tests
@@ -69,7 +71,10 @@ Decision reference, and meetings cannot close while agenda items remain
unfinished. Accepted or corrected minutes require a Records reference,
Approval, and evidence.
A provider-bound vote is finalized only through
A governed ballot is delegated to `voting.ballots` when Voting is installed;
Committee retains the meeting/agendum linkage and verified aggregate result.
The following direct provider path remains a 0.1 compatibility contract only.
A provider-bound vote on that path is finalized through
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
choices, eligible count, external ballot reference, request time, and
idempotency key. Its result must cover exactly the configured choices, sum to
+120 -6
View File
@@ -12,6 +12,10 @@ from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalReference,
)
from govoplan_core.core.voting import (
CAPABILITY_VOTING_BALLOTS,
VotingBallotProvider,
)
from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError,
CommitteeWorkspaceRecord,
@@ -54,9 +58,7 @@ class BallotFinalizationRequest:
"Committee provider ballot reference is required."
)
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
raise CommitteeWorkspaceError(
"Committee ballot choices must be unique."
)
raise CommitteeWorkspaceError("Committee ballot choices must be unique.")
if self.eligible_count < 0:
raise CommitteeWorkspaceError(
"Committee ballot eligible count cannot be negative."
@@ -95,9 +97,7 @@ class BallotFinalizationResult:
if self.cast_count < 0 or any(
not isinstance(value, int) or value < 0 for value in self.counts.values()
):
raise CommitteeWorkspaceError(
"Committee ballot counts cannot be negative."
)
raise CommitteeWorkspaceError("Committee ballot counts cannot be negative.")
if not self.evidence:
raise CommitteeWorkspaceError(
"Committee ballot finalization requires provider evidence."
@@ -209,6 +209,120 @@ class CommitteeBallotFinalizer:
_provider_finalization=True,
)
def finalize_voting_ballot(
self,
session: Session,
principal: object,
*,
vote_id: str,
voting_ballot_id: str,
voting_expected_revision: int,
approval_ref: InstitutionalReference,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CommitteeWorkspaceRecord:
"""Close a Voting-owned ballot and project its aggregate into Committee."""
current = get_workspace_object(
session,
principal,
object_kind="vote",
object_id=vote_id,
)
if current is None:
raise LookupError("Committee vote not found.")
if current.state != "open":
raise CommitteeWorkspaceError(
"Only an open Committee vote can be finalized."
)
if current.revision != expected_revision:
raise CommitteeWorkspaceError(
"Committee revision conflict: the expected revision is stale."
)
configured_id = str(current.attributes.get("voting_ballot_id") or "").strip()
if not configured_id or configured_id != voting_ballot_id:
raise CommitteeWorkspaceError(
"Committee vote is not bound to the requested Voting ballot."
)
provider = _capability(self._registry, CAPABILITY_VOTING_BALLOTS)
if not isinstance(provider, VotingBallotProvider):
raise CommitteeWorkspaceError("Voting ballot capability is unavailable.")
ballot = provider.get_ballot(
session,
principal,
ballot_id=voting_ballot_id,
)
if ballot is None:
raise CommitteeWorkspaceError("Referenced Voting ballot was not found.")
context = ballot.get("context")
if isinstance(context, Mapping):
context_module = str(context.get("module") or "").strip()
context_id = str(context.get("resource_id") or "").strip()
if context_module and context_module != "committee":
raise CommitteeWorkspaceError(
"Referenced Voting ballot belongs to another module context."
)
if context_id and context_id != vote_id:
raise CommitteeWorkspaceError(
"Referenced Voting ballot belongs to another Committee vote."
)
result = provider.close_ballot(
session,
principal,
ballot_id=voting_ballot_id,
expected_revision=voting_expected_revision,
idempotency_key=f"committee:{idempotency_key}",
)
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
if set(result.counts) != set(choices):
raise CommitteeWorkspaceError(
"Voting result options do not match the Committee vote choices."
)
evidence = EvidenceReference(
kind="snapshot",
owner_module="voting",
evidence_id=f"result-{result.result_sha256}",
tenant_id=current.tenant_id,
version=str(result.revision),
checksum=result.result_sha256,
source_ref=f"voting:{voting_ballot_id}",
captured_at=recorded_at,
)
next_record = replace(
current,
revision=current.revision + 1,
state="closed",
recorded_at=recorded_at,
change_reason=change_reason,
attributes={
**dict(current.attributes),
"voting_ballot_id": voting_ballot_id,
"voting_ballot_revision": result.revision,
"voting_result_sha256": result.result_sha256,
"counts": {key: int(value) for key, value in result.counts.items()},
"weighted_counts": {
key: int(value) for key, value in result.weighted_counts.items()
},
"cast_count": result.cast_count,
"cast_weight": result.cast_weight,
"quorum_met": result.quorum_met,
"threshold_met": result.threshold_met,
"winning_options": list(result.winning_options),
"approval_ref": approval_ref.to_dict(),
},
evidence=_merge_evidence(current.evidence, (evidence,)),
)
return record_workspace_object(
session,
principal,
record=next_record,
expected_revision=expected_revision,
idempotency_key=idempotency_key,
_provider_finalization=True,
)
def _validate_result(
result: BallotFinalizationResult,
+31 -5
View File
@@ -30,6 +30,7 @@ from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
)
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
@@ -65,6 +66,7 @@ OPTIONAL_DEPENDENCIES = (
"mandates",
"decisions",
"tasks",
"voting",
"workflow_engine",
"approvals",
)
@@ -87,7 +89,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
),
known_limits=(
"The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.",
"External and secret ballots are delegated to provider adapters. Committee stores only verified aggregates, provider receipts, hashes, and evidence rather than individual ballots.",
"Governed ballot execution is delegated to Voting when installed. The Committee-owned provider adapter remains a 0.1 compatibility path; Committee stores only aggregate result evidence.",
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
),
owned_concepts=(
@@ -140,7 +142,9 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
).count(),
"committee_meetings": current.filter(
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
committee_models.CommitteeWorkspaceRevision.state.in_(("scheduled", "open")),
committee_models.CommitteeWorkspaceRevision.state.in_(
("scheduled", "open")
),
).count(),
}
@@ -261,6 +265,7 @@ manifest = ModuleManifest(
optional_capabilities=(
CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_VOTING_BALLOTS,
),
route_factory=_router,
nav_items=(
@@ -324,9 +329,30 @@ manifest = ModuleManifest(
),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="decisions.reconstruction", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(
name="mandates.resolution",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.formal_outcome",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.reconstruction",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_VOTING_BALLOTS,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
capability_factories={
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
+43 -2
View File
@@ -20,6 +20,7 @@ from govoplan_committee.backend.manifest import (
)
from govoplan_committee.backend.schemas import (
CommitteeBallotFinalizeRequest,
CommitteeVotingFinalizeRequest,
CommitteeWorkspaceHistoryResponse,
CommitteeWorkspaceListResponse,
CommitteeWorkspaceWriteRequest,
@@ -52,7 +53,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
code = 409 if any(word in lowered for word in ("conflict", "stale", "already")) else 400
code = (
409
if any(word in lowered for word in ("conflict", "stale", "already"))
else 400
)
return HTTPException(status_code=code, detail=message)
@@ -71,7 +76,9 @@ def api_get_local_decision(
revision=revision,
)
if item is None:
raise HTTPException(status_code=404, detail="Committee Decision projection not found")
raise HTTPException(
status_code=404, detail="Committee Decision projection not found"
)
return item.to_dict(include_protected=True)
@@ -175,6 +182,40 @@ def api_finalize_provider_vote(
return item.to_dict()
@router.post(
"/workspace/vote/{vote_id}/finalize-voting",
response_model=dict[str, Any],
)
def api_finalize_voting_vote(
vote_id: str,
payload: CommitteeVotingFinalizeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, BALLOT_SCOPE)
try:
item = CommitteeBallotFinalizer(_registry).finalize_voting_ballot(
session,
principal,
vote_id=vote_id,
voting_ballot_id=payload.voting_ballot_id,
voting_expected_revision=payload.voting_expected_revision,
approval_ref=InstitutionalReference.from_mapping(payload.approval_ref),
expected_revision=payload.expected_revision,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any])
def api_get_workspace(
object_kind: str,
+13
View File
@@ -37,9 +37,22 @@ class CommitteeBallotFinalizeRequest(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=255)
class CommitteeVotingFinalizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
voting_ballot_id: str = Field(min_length=1, max_length=255)
voting_expected_revision: int = Field(ge=1)
approval_ref: dict[str, Any]
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
__all__ = [
"CommitteeWorkspaceHistoryResponse",
"CommitteeWorkspaceListResponse",
"CommitteeWorkspaceWriteRequest",
"CommitteeBallotFinalizeRequest",
"CommitteeVotingFinalizeRequest",
]
+120 -54
View File
@@ -45,7 +45,9 @@ _PARENT_KIND: dict[str, str | None] = {
_STATES: dict[str, frozenset[str]] = {
"body": frozenset({"draft", "active", "suspended", "retired"}),
"meeting": frozenset({"draft", "scheduled", "open", "closed", "cancelled"}),
"agenda_item": frozenset({"draft", "scheduled", "deliberating", "decided", "withdrawn"}),
"agenda_item": frozenset(
{"draft", "scheduled", "deliberating", "decided", "withdrawn"}
),
"vote": frozenset({"draft", "open", "closed", "cancelled"}),
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
}
@@ -111,9 +113,7 @@ class CommitteeWorkspaceRecord:
_identifier(self.tenant_id, "Committee tenant id", maximum=36)
_identifier(self.object_id, "Committee object id", maximum=255)
if self.revision < 1:
raise CommitteeWorkspaceError(
"Committee object revision must be positive."
)
raise CommitteeWorkspaceError("Committee object revision must be positive.")
if self.state not in _STATES[self.object_kind]:
raise CommitteeWorkspaceError(
f"Unsupported {self.object_kind} state: {self.state!r}."
@@ -137,9 +137,7 @@ class CommitteeWorkspaceRecord:
"Committee institutional context belongs to another tenant."
)
if any(item.tenant_id != self.tenant_id for item in self.evidence):
raise CommitteeWorkspaceError(
"Committee evidence cannot cross tenants."
)
raise CommitteeWorkspaceError("Committee evidence cannot cross tenants.")
if any(
item.tenant_id != self.tenant_id or item.kind != "record"
for item in self.record_refs
@@ -171,19 +169,23 @@ class CommitteeWorkspaceRecord:
context = value.get("context")
attributes = value.get("attributes") or {}
if not isinstance(attributes, Mapping):
raise CommitteeWorkspaceError(
"Committee attributes must be an object."
)
raise CommitteeWorkspaceError("Committee attributes must be an object.")
return cls(
tenant_id=_required_text(value.get("tenant_id"), "Committee tenant id", maximum=36),
tenant_id=_required_text(
value.get("tenant_id"), "Committee tenant id", maximum=36
),
object_kind=_object_kind(value.get("object_kind")),
object_id=_required_text(value.get("object_id"), "Committee object id", maximum=255),
object_id=_required_text(
value.get("object_id"), "Committee object id", maximum=255
),
revision=_positive_int(value.get("revision"), "Committee revision"),
state=_required_text(value.get("state"), "Committee state", maximum=30),
title=_required_text(value.get("title"), "Committee title", maximum=500),
parent_id=_optional_text(value.get("parent_id"), maximum=255),
recorded_at=_datetime(value.get("recorded_at"), "Committee recorded_at"),
change_reason=_required_text(value.get("change_reason"), "Committee change reason", maximum=1_000),
change_reason=_required_text(
value.get("change_reason"), "Committee change reason", maximum=1_000
),
attributes=dict(attributes),
context=(
GovernedContextEnvelope.from_mapping(context)
@@ -196,7 +198,9 @@ class CommitteeWorkspaceRecord:
),
record_refs=tuple(
InstitutionalReference.from_mapping(item)
for item in _mapping_items(value.get("record_refs"), "Committee record references")
for item in _mapping_items(
value.get("record_refs"), "Committee record references"
)
),
)
@@ -264,22 +268,29 @@ def record_workspace_object(
f"Committee {record.object_kind} transition {current.state!r} to "
f"{record.state!r} is not allowed."
)
current_payload = current.payload if isinstance(current.payload, Mapping) else {}
current_payload = (
current.payload if isinstance(current.payload, Mapping) else {}
)
current_attributes = current_payload.get("attributes")
provider_id = (
str(current_attributes.get("provider_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
voting_ballot_id = (
str(current_attributes.get("voting_ballot_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
if (
record.object_kind == "vote"
and current.state == "open"
and record.state == "closed"
and provider_id
and (provider_id or voting_ballot_id)
and not _provider_finalization
):
raise CommitteeWorkspaceError(
"A provider-bound Committee vote must be finalized through its ballot adapter."
"A managed Committee vote must be finalized through Voting or its compatibility ballot adapter."
)
current.superseded_at = record.recorded_at
_validate_parent(session, record)
@@ -389,9 +400,7 @@ def list_workspace_objects(
CommitteeWorkspaceRevision.superseded_at.is_(None),
)
if parent_id is not None:
statement = statement.filter(
CommitteeWorkspaceRevision.parent_id == parent_id
)
statement = statement.filter(CommitteeWorkspaceRevision.parent_id == parent_id)
if states:
statement = statement.filter(
CommitteeWorkspaceRevision.state.in_(tuple(states))
@@ -441,11 +450,39 @@ def workspace_history(
class SqlCommitteeWorkspace:
def record(self, session: object, principal: object, *, record: CommitteeWorkspaceRecord, idempotency_key: str, expected_revision: int | None = None) -> CommitteeWorkspaceRecord:
return record_workspace_object(_session(session), principal, record=record, idempotency_key=idempotency_key, expected_revision=expected_revision)
def record(
self,
session: object,
principal: object,
*,
record: CommitteeWorkspaceRecord,
idempotency_key: str,
expected_revision: int | None = None,
) -> CommitteeWorkspaceRecord:
return record_workspace_object(
_session(session),
principal,
record=record,
idempotency_key=idempotency_key,
expected_revision=expected_revision,
)
def get(self, session: object, principal: object, *, object_kind: str, object_id: str, revision: int | None = None) -> CommitteeWorkspaceRecord | None:
return get_workspace_object(_session(session), principal, object_kind=object_kind, object_id=object_id, revision=revision)
def get(
self,
session: object,
principal: object,
*,
object_kind: str,
object_id: str,
revision: int | None = None,
) -> CommitteeWorkspaceRecord | None:
return get_workspace_object(
_session(session),
principal,
object_kind=object_kind,
object_id=object_id,
revision=revision,
)
def record_local_decision(
self,
@@ -466,8 +503,17 @@ class SqlCommitteeWorkspace:
expected_revision=expected_revision,
)
def get_local_decision(self, session: object, principal: object, *, decision_id: str, revision: str | None = None) -> FormalDecision | None:
return get_local_decision(_session(session), principal, decision_id=decision_id, revision=revision)
def get_local_decision(
self,
session: object,
principal: object,
*,
decision_id: str,
revision: str | None = None,
) -> FormalDecision | None:
return get_local_decision(
_session(session), principal, decision_id=decision_id, revision=revision
)
def record_local_decision(
@@ -489,12 +535,15 @@ def record_local_decision(
"Committee Decision projections cannot cross tenants."
)
for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)):
if get_workspace_object(
session,
principal,
object_kind=kind,
object_id=object_id,
) is None:
if (
get_workspace_object(
session,
principal,
object_kind=kind,
object_id=object_id,
)
is None
):
raise CommitteeWorkspaceError(
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
)
@@ -715,10 +764,11 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
if record.state == "closed":
counts = attributes.get("counts")
if not isinstance(counts, Mapping):
raise CommitteeWorkspaceError(
"A closed vote requires result counts."
)
clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()}
raise CommitteeWorkspaceError("A closed vote requires result counts.")
clean_counts = {
str(key): _non_negative_int(value, "Vote count")
for key, value in counts.items()
}
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
raise CommitteeWorkspaceError(
"Closed vote counts must match the configured choices and cast_count."
@@ -734,9 +784,7 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
required=True,
)
if not record.evidence:
raise CommitteeWorkspaceError(
"A closed vote requires result evidence."
)
raise CommitteeWorkspaceError("A closed vote requires result evidence.")
elif record.object_kind == "minute":
_reference(
attributes.get("content_ref"),
@@ -757,27 +805,39 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
)
def _replay(session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str) -> CommitteeWorkspaceRecord | None:
row = session.query(CommitteeWorkspaceEvent).filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
).one_or_none()
def _replay(
session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str
) -> CommitteeWorkspaceRecord | None:
row = (
session.query(CommitteeWorkspaceEvent)
.filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
)
.one_or_none()
)
if row is None:
return None
if row.request_sha256 != request_sha256:
raise CommitteeWorkspaceError(
"Committee idempotency conflict: the key was used for another request."
)
revision = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision,
).one()
revision = (
session.query(CommitteeWorkspaceRevision)
.filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision,
)
.one()
)
return _workspace_from_row(revision)
def _current_row(session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool) -> CommitteeWorkspaceRevision | None:
def _current_row(
session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool
) -> CommitteeWorkspaceRevision | None:
query = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == object_kind,
@@ -804,7 +864,9 @@ def _object_kind(value: object) -> CommitteeObjectKind:
return result # type: ignore[return-value]
def _reference(value: object, kind: str | None, tenant_id: str, *, required: bool) -> InstitutionalReference | None:
def _reference(
value: object, kind: str | None, tenant_id: str, *, required: bool
) -> InstitutionalReference | None:
if value is None:
if required:
raise CommitteeWorkspaceError(
@@ -821,7 +883,9 @@ def _reference(value: object, kind: str | None, tenant_id: str, *, required: boo
return result
def _references(value: object, kind: str | None, tenant_id: str) -> tuple[InstitutionalReference, ...]:
def _references(
value: object, kind: str | None, tenant_id: str
) -> tuple[InstitutionalReference, ...]:
return tuple(
item
for item in (
@@ -910,7 +974,9 @@ def _optional_text(value: object, *, maximum: int) -> str | None:
def _sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
json.dumps(
_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
).hexdigest()
+159 -16
View File
@@ -18,6 +18,7 @@ from govoplan_core.core.institutional import (
MandateResolution,
TemporalRevision,
)
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS, VotingResult
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
@@ -80,6 +81,61 @@ class BallotRegistry:
return self.adapter
class VotingBallots:
def create_ballot(self, *args, **kwargs):
raise NotImplementedError
def get_ballot(self, session, principal, *, ballot_id):
return {
"id": ballot_id,
"revision": 2,
"state": "open",
"context": {"module": "committee", "resource_id": "vote-voting"},
}
def open_ballot(self, *args, **kwargs):
raise NotImplementedError
def cast_ballot(self, *args, **kwargs):
raise NotImplementedError
def close_ballot(
self, session, principal, *, ballot_id, expected_revision, idempotency_key
):
if expected_revision != 2:
raise AssertionError("unexpected Voting revision")
return VotingResult(
ballot_id=ballot_id,
revision=3,
counts={"yes": 2, "no": 1},
weighted_counts={"yes": 3, "no": 1},
cast_count=3,
cast_weight=4,
eligible_count=4,
eligible_weight=5,
quorum_met=True,
threshold_met=True,
winning_options=("yes",),
result_sha256="c" * 64,
)
def certify_ballot(self, *args, **kwargs):
raise NotImplementedError
class VotingRegistry:
def __init__(self) -> None:
self.provider = VotingBallots()
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_VOTING_BALLOTS
def capability(self, name: str) -> object:
if name != CAPABILITY_VOTING_BALLOTS:
raise KeyError(name)
return self.provider
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
return InstitutionalReference(
kind=kind, # type: ignore[arg-type]
@@ -320,15 +376,19 @@ class CommitteeWorkspaceTests(unittest.TestCase):
idempotency_key="vote-close",
)
decision = CommitteeDecisionPath().decide(
self.session,
self.principal,
proposal=proposal(),
mandate_resolution=MandateResolution(
competent=True,
mandates=(mandate(),),
),
).decision
decision = (
CommitteeDecisionPath()
.decide(
self.session,
self.principal,
proposal=proposal(),
mandate_resolution=MandateResolution(
competent=True,
mandates=(mandate(),),
),
)
.decision
)
workspace = SqlCommitteeWorkspace()
projected = workspace.record_local_decision(
self.session,
@@ -387,7 +447,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
parent_id="meeting-1",
attributes={
"content_ref": ref("record", "minutes-1", "records").to_dict(),
"approval_ref": ref("approval", "minutes-ok", "approvals").to_dict(),
"approval_ref": ref(
"approval", "minutes-ok", "approvals"
).to_dict(),
},
evidence_refs=(evidence("minutes-signature"),),
)
@@ -400,11 +462,14 @@ class CommitteeWorkspaceTests(unittest.TestCase):
self.assertEqual([], events)
self.session.commit()
self.assertEqual("decision-1", get_local_decision(
self.session,
self.principal,
decision_id="decision-1",
).reference.object_id)
self.assertEqual(
"decision-1",
get_local_decision(
self.session,
self.principal,
decision_id="decision-1",
).reference.object_id,
)
agenda_history = workspace_history(
self.session,
self.principal,
@@ -488,7 +553,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
idempotency_key="cross-tenant",
)
def test_provider_ballot_finalization_persists_only_aggregate_evidence(self) -> None:
def test_provider_ballot_finalization_persists_only_aggregate_evidence(
self,
) -> None:
records = (
workspace_record(
"body",
@@ -592,6 +659,82 @@ class CommitteeWorkspaceTests(unittest.TestCase):
self.assertNotIn("ballots", closed.attributes)
self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id)
def test_voting_ballot_finalization_projects_only_aggregate_result(self) -> None:
records = (
workspace_record(
"body",
"body-voting",
state="active",
attributes={
"organization_unit_ref": ref(
"organization_unit", "board-1", "organizations"
).to_dict(),
"function_refs": [],
},
),
workspace_record(
"meeting",
"meeting-voting",
state="open",
parent_id="body-voting",
attributes={
"starts_at": NOW.isoformat(),
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
},
),
workspace_record(
"agenda_item",
"agenda-voting",
state="deliberating",
parent_id="meeting-voting",
attributes={
"position": 1,
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
},
),
workspace_record(
"vote",
"vote-voting",
state="open",
parent_id="agenda-voting",
attributes={
"method": "recorded",
"voting_ballot_id": "ballot-1",
"choices": ["yes", "no"],
"eligible_count": 4,
"cast_count": 0,
},
),
)
for index, item in enumerate(records):
record_workspace_object(
self.session,
self.principal,
record=item,
idempotency_key=f"voting-setup-{index}",
)
self.session.commit()
closed = CommitteeBallotFinalizer(VotingRegistry()).finalize_voting_ballot(
self.session,
self.principal,
vote_id="vote-voting",
voting_ballot_id="ballot-1",
voting_expected_revision=2,
approval_ref=ref("approval", "vote-approval", "approvals"),
expected_revision=1,
recorded_at=NOW + timedelta(minutes=5),
change_reason="Closed the governed Voting ballot.",
idempotency_key="voting-finalize-1",
)
self.session.commit()
self.assertEqual("closed", closed.state)
self.assertEqual({"yes": 2, "no": 1}, closed.attributes["counts"])
self.assertEqual("c" * 64, closed.attributes["voting_result_sha256"])
self.assertEqual("voting", closed.evidence[0].owner_module)
self.assertNotIn("selections", closed.attributes)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -113,3 +113,40 @@ export function finalizeProviderBallot(
}
);
}
export async function finalizeVotingBallot(
settings: ApiSettings,
record: CommitteeRecord,
input: {
approvalId: string;
changeReason: string;
}
): Promise<CommitteeRecord> {
const ballotId = String(record.attributes.voting_ballot_id ?? "").trim();
const ballot = await apiFetch<{ revision: number }>(
settings,
`/api/v1/voting/${encodeURIComponent(ballotId)}`
);
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-voting`,
{
method: "POST",
body: JSON.stringify({
voting_ballot_id: ballotId,
voting_expected_revision: ballot.revision,
approval_ref: {
kind: "approval",
owner_module: "approvals",
object_id: input.approvalId.trim(),
tenant_id: record.tenant_id,
version: "1"
},
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: input.changeReason.trim(),
idempotency_key: crypto.randomUUID()
})
}
);
}
@@ -8,6 +8,7 @@ import {
} from "@govoplan/core-webui";
import {
finalizeProviderBallot,
finalizeVotingBallot,
type CommitteeRecord
} from "../../api/committee";
@@ -43,11 +44,13 @@ export default function CommitteeBallotDialog({
setBusy(true);
setError("");
try {
const saved = await finalizeProviderBallot(settings, record, {
providerBallotRef,
approvalId,
changeReason
});
const saved = votingBallotId
? await finalizeVotingBallot(settings, record, { approvalId, changeReason })
: await finalizeProviderBallot(settings, record, {
providerBallotRef,
approvalId,
changeReason
});
onSaved(saved);
onClose();
} catch (reason) {
@@ -58,10 +61,11 @@ export default function CommitteeBallotDialog({
}
const providerId = String(record.attributes.provider_id ?? "");
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
return (
<Dialog
open={open}
title="Finalize provider ballot"
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
onClose={onClose}
closeDisabled={busy}
portal
@@ -71,7 +75,7 @@ export default function CommitteeBallotDialog({
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()}
disabled={busy || (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim()}
onClick={() => void finalize()}
>
{busy ? "Importing" : "Finalize"}
@@ -82,12 +86,13 @@ export default function CommitteeBallotDialog({
<div className="committee-record-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="committee-dialog-note">
Provider <strong>{providerId}</strong> returns only the verified aggregate result,
receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN.
{votingBallotId
? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
</p>
<FormField label="Provider ballot reference">
{!votingBallotId ? <FormField label="Provider ballot reference">
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
</FormField>
</FormField> : null}
<FormField label="Approval ID">
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
</FormField>
@@ -377,7 +377,10 @@ function voteSummary(record: CommitteeRecord): string {
}
function isProviderBallotReady(record: CommitteeRecord): boolean {
return record.state === "open" && Boolean(String(record.attributes.provider_id ?? "").trim());
return record.state === "open" && Boolean(
String(record.attributes.voting_ballot_id ?? "").trim()
|| String(record.attributes.provider_id ?? "").trim()
);
}
function statusTone(state: string): "active" | "inactive" | "warning" {
@@ -32,6 +32,7 @@ type Draft = {
approvalId: string;
evidenceId: string;
providerId: string;
votingBallotId: string;
contentRecordId: string;
decisionId: string;
};
@@ -206,6 +207,9 @@ export default function CommitteeRecordDialog({
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField>
</div>
<FormField label="Voting ballot ID (optional)">
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
</FormField>
<FormField label="Choices (comma separated)">
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
</FormField>
@@ -310,6 +314,7 @@ function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | n
approvalId: text(approval.object_id),
evidenceId: text(firstMapping(record?.evidence).evidence_id),
providerId: text(attributes.provider_id),
votingBallotId: text(attributes.voting_ballot_id),
contentRecordId: text(content.object_id),
decisionId: text(decision.object_id)
};
@@ -373,6 +378,7 @@ function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft:
eligible_count: Number(draft.eligibleCount),
cast_count: Number(draft.castCount),
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
...(draft.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
...(draft.state === "closed" ? {
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
quorum_met: draft.quorumMet,