diff --git a/README.md b/README.md index 0e9cbe8..fe1ee23 100644 --- a/README.md +++ b/README.md @@ -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.` 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.` 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. diff --git a/docs/COMMITTEE_DOMAIN_BOUNDARY.md b/docs/COMMITTEE_DOMAIN_BOUNDARY.md index 3479372..208a168 100644 --- a/docs/COMMITTEE_DOMAIN_BOUNDARY.md +++ b/docs/COMMITTEE_DOMAIN_BOUNDARY.md @@ -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.`. 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 diff --git a/src/govoplan_committee/backend/ballots.py b/src/govoplan_committee/backend/ballots.py index aeb45cd..a794af5 100644 --- a/src/govoplan_committee/backend/ballots.py +++ b/src/govoplan_committee/backend/ballots.py @@ -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, diff --git a/src/govoplan_committee/backend/manifest.py b/src/govoplan_committee/backend/manifest.py index aada86c..5fb389c 100644 --- a/src/govoplan_committee/backend/manifest.py +++ b/src/govoplan_committee/backend/manifest.py @@ -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, diff --git a/src/govoplan_committee/backend/router.py b/src/govoplan_committee/backend/router.py index 0db31bb..10b06c8 100644 --- a/src/govoplan_committee/backend/router.py +++ b/src/govoplan_committee/backend/router.py @@ -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, diff --git a/src/govoplan_committee/backend/schemas.py b/src/govoplan_committee/backend/schemas.py index 89d4cac..788b390 100644 --- a/src/govoplan_committee/backend/schemas.py +++ b/src/govoplan_committee/backend/schemas.py @@ -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", ] diff --git a/src/govoplan_committee/backend/workspace.py b/src/govoplan_committee/backend/workspace.py index a1157aa..0f8bfcb 100644 --- a/src/govoplan_committee/backend/workspace.py +++ b/src/govoplan_committee/backend/workspace.py @@ -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() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index e5aa6d3..be0d8c9 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -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() diff --git a/webui/src/api/committee.ts b/webui/src/api/committee.ts index 20d9c42..8ce3389 100644 --- a/webui/src/api/committee.ts +++ b/webui/src/api/committee.ts @@ -113,3 +113,40 @@ export function finalizeProviderBallot( } ); } + +export async function finalizeVotingBallot( + settings: ApiSettings, + record: CommitteeRecord, + input: { + approvalId: string; + changeReason: string; + } +): Promise { + const ballotId = String(record.attributes.voting_ballot_id ?? "").trim(); + const ballot = await apiFetch<{ revision: number }>( + settings, + `/api/v1/voting/${encodeURIComponent(ballotId)}` + ); + return apiFetch( + 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() + }) + } + ); +} diff --git a/webui/src/features/committee/CommitteeBallotDialog.tsx b/webui/src/features/committee/CommitteeBallotDialog.tsx index 416bbfd..8bc6d71 100644 --- a/webui/src/features/committee/CommitteeBallotDialog.tsx +++ b/webui/src/features/committee/CommitteeBallotDialog.tsx @@ -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 ( Cancel