Integrate committee ballots with Voting
This commit is contained in:
@@ -19,12 +19,13 @@ protected local Decision projection so the outcome remains reconstructable.
|
|||||||
The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes
|
The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes
|
||||||
workspace using the same immutable revisions and lifecycle guards as the API.
|
workspace using the same immutable revisions and lifecycle guards as the API.
|
||||||
|
|
||||||
`committee.ballot_finalizer` delegates provider-bound external or secret
|
When Voting is installed, Committee creates and follows a governed
|
||||||
ballots to `committee.ballot_adapter.<provider>` capabilities. Committee
|
`voting.ballots` record while retaining only its meeting/agendum reference and
|
||||||
validates the exact choices, eligible/cast totals, tenant evidence, receipt,
|
verified aggregate outcome. Voting owns the frozen electorate, casting,
|
||||||
and result hash, then stores only the verified aggregate. Individual ballots
|
tallying, certification, challenge, and annulment lifecycle. The older
|
||||||
never enter Committee persistence, and a provider-bound vote cannot be closed
|
`committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
|
||||||
through the generic workspace endpoint.
|
remains a 0.1 compatibility boundary for existing external integrations;
|
||||||
|
individual provider ballots never enter Committee persistence.
|
||||||
|
|
||||||
## Initial Ownership
|
## Initial Ownership
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ Expected optional integrations:
|
|||||||
- tasks
|
- tasks
|
||||||
- workflow
|
- workflow
|
||||||
- approvals
|
- approvals
|
||||||
|
- voting
|
||||||
|
|
||||||
## Development Install
|
## 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
|
linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain
|
||||||
their own recovery responsibility.
|
their own recovery responsibility.
|
||||||
|
|
||||||
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the
|
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the 0.1
|
||||||
separate effect boundary for an installed ballot adapter. It requires the
|
compatibility effect boundary for an installed ballot adapter. It requires the
|
||||||
`committee:ballot:finalize` permission and preserves provider receipt/hash and
|
`committee:ballot:finalize` permission and preserves provider receipt/hash and
|
||||||
evidence without exposing or persisting individual votes.
|
evidence without exposing or persisting individual votes.
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ The current repository state is intentionally bounded:
|
|||||||
vote results, and minutes
|
vote results, and minutes
|
||||||
- a provider-neutral ballot-finalization contract for external and secret
|
- a provider-neutral ballot-finalization contract for external and secret
|
||||||
ballots that retains aggregate evidence rather than individual ballots
|
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
|
- Gitea issue workflow templates
|
||||||
- manifest and decision reconstruction contract tests
|
- 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,
|
unfinished. Accepted or corrected minutes require a Records reference,
|
||||||
Approval, and evidence.
|
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,
|
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
|
||||||
choices, eligible count, external ballot reference, request time, and
|
choices, eligible count, external ballot reference, request time, and
|
||||||
idempotency key. Its result must cover exactly the configured choices, sum to
|
idempotency key. Its result must cover exactly the configured choices, sum to
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from govoplan_core.core.institutional import (
|
|||||||
EvidenceReference,
|
EvidenceReference,
|
||||||
InstitutionalReference,
|
InstitutionalReference,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.voting import (
|
||||||
|
CAPABILITY_VOTING_BALLOTS,
|
||||||
|
VotingBallotProvider,
|
||||||
|
)
|
||||||
from govoplan_committee.backend.workspace import (
|
from govoplan_committee.backend.workspace import (
|
||||||
CommitteeWorkspaceError,
|
CommitteeWorkspaceError,
|
||||||
CommitteeWorkspaceRecord,
|
CommitteeWorkspaceRecord,
|
||||||
@@ -54,9 +58,7 @@ class BallotFinalizationRequest:
|
|||||||
"Committee provider ballot reference is required."
|
"Committee provider ballot reference is required."
|
||||||
)
|
)
|
||||||
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
|
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("Committee ballot choices must be unique.")
|
||||||
"Committee ballot choices must be unique."
|
|
||||||
)
|
|
||||||
if self.eligible_count < 0:
|
if self.eligible_count < 0:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
"Committee ballot eligible count cannot be negative."
|
"Committee ballot eligible count cannot be negative."
|
||||||
@@ -95,9 +97,7 @@ class BallotFinalizationResult:
|
|||||||
if self.cast_count < 0 or any(
|
if self.cast_count < 0 or any(
|
||||||
not isinstance(value, int) or value < 0 for value in self.counts.values()
|
not isinstance(value, int) or value < 0 for value in self.counts.values()
|
||||||
):
|
):
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("Committee ballot counts cannot be negative.")
|
||||||
"Committee ballot counts cannot be negative."
|
|
||||||
)
|
|
||||||
if not self.evidence:
|
if not self.evidence:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
"Committee ballot finalization requires provider evidence."
|
"Committee ballot finalization requires provider evidence."
|
||||||
@@ -209,6 +209,120 @@ class CommitteeBallotFinalizer:
|
|||||||
_provider_finalization=True,
|
_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(
|
def _validate_result(
|
||||||
result: BallotFinalizationResult,
|
result: BallotFinalizationResult,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from govoplan_core.core.institutional import (
|
|||||||
CAPABILITY_DECISION_REGISTRY,
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
CAPABILITY_MANDATE_RESOLVER,
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
ModuleArchitectureDeclaration,
|
ModuleArchitectureDeclaration,
|
||||||
ModuleArchitectureDocumentation,
|
ModuleArchitectureDocumentation,
|
||||||
@@ -65,6 +66,7 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"mandates",
|
"mandates",
|
||||||
"decisions",
|
"decisions",
|
||||||
"tasks",
|
"tasks",
|
||||||
|
"voting",
|
||||||
"workflow_engine",
|
"workflow_engine",
|
||||||
"approvals",
|
"approvals",
|
||||||
)
|
)
|
||||||
@@ -87,7 +89,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
|
|||||||
),
|
),
|
||||||
known_limits=(
|
known_limits=(
|
||||||
"The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.",
|
"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.",
|
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
|
||||||
),
|
),
|
||||||
owned_concepts=(
|
owned_concepts=(
|
||||||
@@ -140,7 +142,9 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
).count(),
|
).count(),
|
||||||
"committee_meetings": current.filter(
|
"committee_meetings": current.filter(
|
||||||
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
|
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
|
||||||
committee_models.CommitteeWorkspaceRevision.state.in_(("scheduled", "open")),
|
committee_models.CommitteeWorkspaceRevision.state.in_(
|
||||||
|
("scheduled", "open")
|
||||||
|
),
|
||||||
).count(),
|
).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +265,7 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_MANDATE_RESOLVER,
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
CAPABILITY_DECISION_REGISTRY,
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_VOTING_BALLOTS,
|
||||||
),
|
),
|
||||||
route_factory=_router,
|
route_factory=_router,
|
||||||
nav_items=(
|
nav_items=(
|
||||||
@@ -324,9 +329,30 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(
|
||||||
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
name="mandates.resolution",
|
||||||
ModuleInterfaceRequirement(name="decisions.reconstruction", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
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_factories={
|
||||||
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
|
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from govoplan_committee.backend.manifest import (
|
|||||||
)
|
)
|
||||||
from govoplan_committee.backend.schemas import (
|
from govoplan_committee.backend.schemas import (
|
||||||
CommitteeBallotFinalizeRequest,
|
CommitteeBallotFinalizeRequest,
|
||||||
|
CommitteeVotingFinalizeRequest,
|
||||||
CommitteeWorkspaceHistoryResponse,
|
CommitteeWorkspaceHistoryResponse,
|
||||||
CommitteeWorkspaceListResponse,
|
CommitteeWorkspaceListResponse,
|
||||||
CommitteeWorkspaceWriteRequest,
|
CommitteeWorkspaceWriteRequest,
|
||||||
@@ -52,7 +53,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
|||||||
def _error(exc: Exception) -> HTTPException:
|
def _error(exc: Exception) -> HTTPException:
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
lowered = message.casefold()
|
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)
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +76,9 @@ def api_get_local_decision(
|
|||||||
revision=revision,
|
revision=revision,
|
||||||
)
|
)
|
||||||
if item is None:
|
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)
|
return item.to_dict(include_protected=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +182,40 @@ def api_finalize_provider_vote(
|
|||||||
return item.to_dict()
|
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])
|
@router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any])
|
||||||
def api_get_workspace(
|
def api_get_workspace(
|
||||||
object_kind: str,
|
object_kind: str,
|
||||||
|
|||||||
@@ -37,9 +37,22 @@ class CommitteeBallotFinalizeRequest(BaseModel):
|
|||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
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__ = [
|
__all__ = [
|
||||||
"CommitteeWorkspaceHistoryResponse",
|
"CommitteeWorkspaceHistoryResponse",
|
||||||
"CommitteeWorkspaceListResponse",
|
"CommitteeWorkspaceListResponse",
|
||||||
"CommitteeWorkspaceWriteRequest",
|
"CommitteeWorkspaceWriteRequest",
|
||||||
"CommitteeBallotFinalizeRequest",
|
"CommitteeBallotFinalizeRequest",
|
||||||
|
"CommitteeVotingFinalizeRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ _PARENT_KIND: dict[str, str | None] = {
|
|||||||
_STATES: dict[str, frozenset[str]] = {
|
_STATES: dict[str, frozenset[str]] = {
|
||||||
"body": frozenset({"draft", "active", "suspended", "retired"}),
|
"body": frozenset({"draft", "active", "suspended", "retired"}),
|
||||||
"meeting": frozenset({"draft", "scheduled", "open", "closed", "cancelled"}),
|
"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"}),
|
"vote": frozenset({"draft", "open", "closed", "cancelled"}),
|
||||||
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
|
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
|
||||||
}
|
}
|
||||||
@@ -111,9 +113,7 @@ class CommitteeWorkspaceRecord:
|
|||||||
_identifier(self.tenant_id, "Committee tenant id", maximum=36)
|
_identifier(self.tenant_id, "Committee tenant id", maximum=36)
|
||||||
_identifier(self.object_id, "Committee object id", maximum=255)
|
_identifier(self.object_id, "Committee object id", maximum=255)
|
||||||
if self.revision < 1:
|
if self.revision < 1:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("Committee object revision must be positive.")
|
||||||
"Committee object revision must be positive."
|
|
||||||
)
|
|
||||||
if self.state not in _STATES[self.object_kind]:
|
if self.state not in _STATES[self.object_kind]:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
f"Unsupported {self.object_kind} state: {self.state!r}."
|
f"Unsupported {self.object_kind} state: {self.state!r}."
|
||||||
@@ -137,9 +137,7 @@ class CommitteeWorkspaceRecord:
|
|||||||
"Committee institutional context belongs to another tenant."
|
"Committee institutional context belongs to another tenant."
|
||||||
)
|
)
|
||||||
if any(item.tenant_id != self.tenant_id for item in self.evidence):
|
if any(item.tenant_id != self.tenant_id for item in self.evidence):
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("Committee evidence cannot cross tenants.")
|
||||||
"Committee evidence cannot cross tenants."
|
|
||||||
)
|
|
||||||
if any(
|
if any(
|
||||||
item.tenant_id != self.tenant_id or item.kind != "record"
|
item.tenant_id != self.tenant_id or item.kind != "record"
|
||||||
for item in self.record_refs
|
for item in self.record_refs
|
||||||
@@ -171,19 +169,23 @@ class CommitteeWorkspaceRecord:
|
|||||||
context = value.get("context")
|
context = value.get("context")
|
||||||
attributes = value.get("attributes") or {}
|
attributes = value.get("attributes") or {}
|
||||||
if not isinstance(attributes, Mapping):
|
if not isinstance(attributes, Mapping):
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("Committee attributes must be an object.")
|
||||||
"Committee attributes must be an object."
|
|
||||||
)
|
|
||||||
return cls(
|
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_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"),
|
revision=_positive_int(value.get("revision"), "Committee revision"),
|
||||||
state=_required_text(value.get("state"), "Committee state", maximum=30),
|
state=_required_text(value.get("state"), "Committee state", maximum=30),
|
||||||
title=_required_text(value.get("title"), "Committee title", maximum=500),
|
title=_required_text(value.get("title"), "Committee title", maximum=500),
|
||||||
parent_id=_optional_text(value.get("parent_id"), maximum=255),
|
parent_id=_optional_text(value.get("parent_id"), maximum=255),
|
||||||
recorded_at=_datetime(value.get("recorded_at"), "Committee recorded_at"),
|
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),
|
attributes=dict(attributes),
|
||||||
context=(
|
context=(
|
||||||
GovernedContextEnvelope.from_mapping(context)
|
GovernedContextEnvelope.from_mapping(context)
|
||||||
@@ -196,7 +198,9 @@ class CommitteeWorkspaceRecord:
|
|||||||
),
|
),
|
||||||
record_refs=tuple(
|
record_refs=tuple(
|
||||||
InstitutionalReference.from_mapping(item)
|
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"Committee {record.object_kind} transition {current.state!r} to "
|
||||||
f"{record.state!r} is not allowed."
|
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")
|
current_attributes = current_payload.get("attributes")
|
||||||
provider_id = (
|
provider_id = (
|
||||||
str(current_attributes.get("provider_id") or "").strip()
|
str(current_attributes.get("provider_id") or "").strip()
|
||||||
if isinstance(current_attributes, Mapping)
|
if isinstance(current_attributes, Mapping)
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
voting_ballot_id = (
|
||||||
|
str(current_attributes.get("voting_ballot_id") or "").strip()
|
||||||
|
if isinstance(current_attributes, Mapping)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
record.object_kind == "vote"
|
record.object_kind == "vote"
|
||||||
and current.state == "open"
|
and current.state == "open"
|
||||||
and record.state == "closed"
|
and record.state == "closed"
|
||||||
and provider_id
|
and (provider_id or voting_ballot_id)
|
||||||
and not _provider_finalization
|
and not _provider_finalization
|
||||||
):
|
):
|
||||||
raise CommitteeWorkspaceError(
|
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
|
current.superseded_at = record.recorded_at
|
||||||
_validate_parent(session, record)
|
_validate_parent(session, record)
|
||||||
@@ -389,9 +400,7 @@ def list_workspace_objects(
|
|||||||
CommitteeWorkspaceRevision.superseded_at.is_(None),
|
CommitteeWorkspaceRevision.superseded_at.is_(None),
|
||||||
)
|
)
|
||||||
if parent_id is not None:
|
if parent_id is not None:
|
||||||
statement = statement.filter(
|
statement = statement.filter(CommitteeWorkspaceRevision.parent_id == parent_id)
|
||||||
CommitteeWorkspaceRevision.parent_id == parent_id
|
|
||||||
)
|
|
||||||
if states:
|
if states:
|
||||||
statement = statement.filter(
|
statement = statement.filter(
|
||||||
CommitteeWorkspaceRevision.state.in_(tuple(states))
|
CommitteeWorkspaceRevision.state.in_(tuple(states))
|
||||||
@@ -441,11 +450,39 @@ def workspace_history(
|
|||||||
|
|
||||||
|
|
||||||
class SqlCommitteeWorkspace:
|
class SqlCommitteeWorkspace:
|
||||||
def record(self, session: object, principal: object, *, record: CommitteeWorkspaceRecord, idempotency_key: str, expected_revision: int | None = None) -> CommitteeWorkspaceRecord:
|
def record(
|
||||||
return record_workspace_object(_session(session), principal, record=record, idempotency_key=idempotency_key, expected_revision=expected_revision)
|
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:
|
def get(
|
||||||
return get_workspace_object(_session(session), principal, object_kind=object_kind, object_id=object_id, revision=revision)
|
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(
|
def record_local_decision(
|
||||||
self,
|
self,
|
||||||
@@ -466,8 +503,17 @@ class SqlCommitteeWorkspace:
|
|||||||
expected_revision=expected_revision,
|
expected_revision=expected_revision,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_local_decision(self, session: object, principal: object, *, decision_id: str, revision: str | None = None) -> FormalDecision | None:
|
def get_local_decision(
|
||||||
return get_local_decision(_session(session), principal, decision_id=decision_id, revision=revision)
|
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(
|
def record_local_decision(
|
||||||
@@ -489,12 +535,15 @@ def record_local_decision(
|
|||||||
"Committee Decision projections cannot cross tenants."
|
"Committee Decision projections cannot cross tenants."
|
||||||
)
|
)
|
||||||
for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)):
|
for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)):
|
||||||
if get_workspace_object(
|
if (
|
||||||
|
get_workspace_object(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
object_kind=kind,
|
object_kind=kind,
|
||||||
object_id=object_id,
|
object_id=object_id,
|
||||||
) is None:
|
)
|
||||||
|
is None
|
||||||
|
):
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
|
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
|
||||||
)
|
)
|
||||||
@@ -715,10 +764,11 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
|
|||||||
if record.state == "closed":
|
if record.state == "closed":
|
||||||
counts = attributes.get("counts")
|
counts = attributes.get("counts")
|
||||||
if not isinstance(counts, Mapping):
|
if not isinstance(counts, Mapping):
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("A closed vote requires result counts.")
|
||||||
"A closed vote requires result counts."
|
clean_counts = {
|
||||||
)
|
str(key): _non_negative_int(value, "Vote count")
|
||||||
clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()}
|
for key, value in counts.items()
|
||||||
|
}
|
||||||
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
|
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
"Closed vote counts must match the configured choices and cast_count."
|
"Closed vote counts must match the configured choices and cast_count."
|
||||||
@@ -734,9 +784,7 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
|
|||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
if not record.evidence:
|
if not record.evidence:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError("A closed vote requires result evidence.")
|
||||||
"A closed vote requires result evidence."
|
|
||||||
)
|
|
||||||
elif record.object_kind == "minute":
|
elif record.object_kind == "minute":
|
||||||
_reference(
|
_reference(
|
||||||
attributes.get("content_ref"),
|
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:
|
def _replay(
|
||||||
row = session.query(CommitteeWorkspaceEvent).filter(
|
session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str
|
||||||
|
) -> CommitteeWorkspaceRecord | None:
|
||||||
|
row = (
|
||||||
|
session.query(CommitteeWorkspaceEvent)
|
||||||
|
.filter(
|
||||||
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
||||||
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
|
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
|
||||||
).one_or_none()
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
if row.request_sha256 != request_sha256:
|
if row.request_sha256 != request_sha256:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
"Committee idempotency conflict: the key was used for another request."
|
"Committee idempotency conflict: the key was used for another request."
|
||||||
)
|
)
|
||||||
revision = session.query(CommitteeWorkspaceRevision).filter(
|
revision = (
|
||||||
|
session.query(CommitteeWorkspaceRevision)
|
||||||
|
.filter(
|
||||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||||
CommitteeWorkspaceRevision.object_kind == row.object_kind,
|
CommitteeWorkspaceRevision.object_kind == row.object_kind,
|
||||||
CommitteeWorkspaceRevision.object_id == row.object_id,
|
CommitteeWorkspaceRevision.object_id == row.object_id,
|
||||||
CommitteeWorkspaceRevision.revision == row.object_revision,
|
CommitteeWorkspaceRevision.revision == row.object_revision,
|
||||||
).one()
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
return _workspace_from_row(revision)
|
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(
|
query = session.query(CommitteeWorkspaceRevision).filter(
|
||||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||||
CommitteeWorkspaceRevision.object_kind == object_kind,
|
CommitteeWorkspaceRevision.object_kind == object_kind,
|
||||||
@@ -804,7 +864,9 @@ def _object_kind(value: object) -> CommitteeObjectKind:
|
|||||||
return result # type: ignore[return-value]
|
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 value is None:
|
||||||
if required:
|
if required:
|
||||||
raise CommitteeWorkspaceError(
|
raise CommitteeWorkspaceError(
|
||||||
@@ -821,7 +883,9 @@ def _reference(value: object, kind: str | None, tenant_id: str, *, required: boo
|
|||||||
return result
|
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(
|
return tuple(
|
||||||
item
|
item
|
||||||
for item in (
|
for item in (
|
||||||
@@ -910,7 +974,9 @@ def _optional_text(value: object, *, maximum: int) -> str | None:
|
|||||||
|
|
||||||
def _sha256(value: object) -> str:
|
def _sha256(value: object) -> str:
|
||||||
return hashlib.sha256(
|
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()
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+149
-6
@@ -18,6 +18,7 @@ from govoplan_core.core.institutional import (
|
|||||||
MandateResolution,
|
MandateResolution,
|
||||||
TemporalRevision,
|
TemporalRevision,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS, VotingResult
|
||||||
from govoplan_committee.backend.db.models import (
|
from govoplan_committee.backend.db.models import (
|
||||||
CommitteeDecisionProjection,
|
CommitteeDecisionProjection,
|
||||||
CommitteeWorkspaceEvent,
|
CommitteeWorkspaceEvent,
|
||||||
@@ -80,6 +81,61 @@ class BallotRegistry:
|
|||||||
return self.adapter
|
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:
|
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||||
return InstitutionalReference(
|
return InstitutionalReference(
|
||||||
kind=kind, # type: ignore[arg-type]
|
kind=kind, # type: ignore[arg-type]
|
||||||
@@ -320,7 +376,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
|||||||
idempotency_key="vote-close",
|
idempotency_key="vote-close",
|
||||||
)
|
)
|
||||||
|
|
||||||
decision = CommitteeDecisionPath().decide(
|
decision = (
|
||||||
|
CommitteeDecisionPath()
|
||||||
|
.decide(
|
||||||
self.session,
|
self.session,
|
||||||
self.principal,
|
self.principal,
|
||||||
proposal=proposal(),
|
proposal=proposal(),
|
||||||
@@ -328,7 +386,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
|||||||
competent=True,
|
competent=True,
|
||||||
mandates=(mandate(),),
|
mandates=(mandate(),),
|
||||||
),
|
),
|
||||||
).decision
|
)
|
||||||
|
.decision
|
||||||
|
)
|
||||||
workspace = SqlCommitteeWorkspace()
|
workspace = SqlCommitteeWorkspace()
|
||||||
projected = workspace.record_local_decision(
|
projected = workspace.record_local_decision(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -387,7 +447,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
|||||||
parent_id="meeting-1",
|
parent_id="meeting-1",
|
||||||
attributes={
|
attributes={
|
||||||
"content_ref": ref("record", "minutes-1", "records").to_dict(),
|
"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"),),
|
evidence_refs=(evidence("minutes-signature"),),
|
||||||
)
|
)
|
||||||
@@ -400,11 +462,14 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
|||||||
self.assertEqual([], events)
|
self.assertEqual([], events)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
self.assertEqual("decision-1", get_local_decision(
|
self.assertEqual(
|
||||||
|
"decision-1",
|
||||||
|
get_local_decision(
|
||||||
self.session,
|
self.session,
|
||||||
self.principal,
|
self.principal,
|
||||||
decision_id="decision-1",
|
decision_id="decision-1",
|
||||||
).reference.object_id)
|
).reference.object_id,
|
||||||
|
)
|
||||||
agenda_history = workspace_history(
|
agenda_history = workspace_history(
|
||||||
self.session,
|
self.session,
|
||||||
self.principal,
|
self.principal,
|
||||||
@@ -488,7 +553,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
|||||||
idempotency_key="cross-tenant",
|
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 = (
|
records = (
|
||||||
workspace_record(
|
workspace_record(
|
||||||
"body",
|
"body",
|
||||||
@@ -592,6 +659,82 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
|||||||
self.assertNotIn("ballots", closed.attributes)
|
self.assertNotIn("ballots", closed.attributes)
|
||||||
self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
finalizeProviderBallot,
|
finalizeProviderBallot,
|
||||||
|
finalizeVotingBallot,
|
||||||
type CommitteeRecord
|
type CommitteeRecord
|
||||||
} from "../../api/committee";
|
} from "../../api/committee";
|
||||||
|
|
||||||
@@ -43,7 +44,9 @@ export default function CommitteeBallotDialog({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const saved = await finalizeProviderBallot(settings, record, {
|
const saved = votingBallotId
|
||||||
|
? await finalizeVotingBallot(settings, record, { approvalId, changeReason })
|
||||||
|
: await finalizeProviderBallot(settings, record, {
|
||||||
providerBallotRef,
|
providerBallotRef,
|
||||||
approvalId,
|
approvalId,
|
||||||
changeReason
|
changeReason
|
||||||
@@ -58,10 +61,11 @@ export default function CommitteeBallotDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const providerId = String(record.attributes.provider_id ?? "");
|
const providerId = String(record.attributes.provider_id ?? "");
|
||||||
|
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
title="Finalize provider ballot"
|
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
@@ -71,7 +75,7 @@ export default function CommitteeBallotDialog({
|
|||||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()}
|
disabled={busy || (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim()}
|
||||||
onClick={() => void finalize()}
|
onClick={() => void finalize()}
|
||||||
>
|
>
|
||||||
{busy ? "Importing" : "Finalize"}
|
{busy ? "Importing" : "Finalize"}
|
||||||
@@ -82,12 +86,13 @@ export default function CommitteeBallotDialog({
|
|||||||
<div className="committee-record-form">
|
<div className="committee-record-form">
|
||||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
<p className="committee-dialog-note">
|
<p className="committee-dialog-note">
|
||||||
Provider <strong>{providerId}</strong> returns only the verified aggregate result,
|
{votingBallotId
|
||||||
receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN.
|
? <>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>
|
</p>
|
||||||
<FormField label="Provider ballot reference">
|
{!votingBallotId ? <FormField label="Provider ballot reference">
|
||||||
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
||||||
</FormField>
|
</FormField> : null}
|
||||||
<FormField label="Approval ID">
|
<FormField label="Approval ID">
|
||||||
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -377,7 +377,10 @@ function voteSummary(record: CommitteeRecord): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isProviderBallotReady(record: CommitteeRecord): boolean {
|
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" {
|
function statusTone(state: string): "active" | "inactive" | "warning" {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Draft = {
|
|||||||
approvalId: string;
|
approvalId: string;
|
||||||
evidenceId: string;
|
evidenceId: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
|
votingBallotId: string;
|
||||||
contentRecordId: string;
|
contentRecordId: string;
|
||||||
decisionId: 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 })} />
|
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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)">
|
<FormField label="Choices (comma separated)">
|
||||||
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
|
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -310,6 +314,7 @@ function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | n
|
|||||||
approvalId: text(approval.object_id),
|
approvalId: text(approval.object_id),
|
||||||
evidenceId: text(firstMapping(record?.evidence).evidence_id),
|
evidenceId: text(firstMapping(record?.evidence).evidence_id),
|
||||||
providerId: text(attributes.provider_id),
|
providerId: text(attributes.provider_id),
|
||||||
|
votingBallotId: text(attributes.voting_ballot_id),
|
||||||
contentRecordId: text(content.object_id),
|
contentRecordId: text(content.object_id),
|
||||||
decisionId: text(decision.object_id)
|
decisionId: text(decision.object_id)
|
||||||
};
|
};
|
||||||
@@ -373,6 +378,7 @@ function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft:
|
|||||||
eligible_count: Number(draft.eligibleCount),
|
eligible_count: Number(draft.eligibleCount),
|
||||||
cast_count: Number(draft.castCount),
|
cast_count: Number(draft.castCount),
|
||||||
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
|
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
|
||||||
|
...(draft.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
|
||||||
...(draft.state === "closed" ? {
|
...(draft.state === "closed" ? {
|
||||||
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
|
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
|
||||||
quorum_met: draft.quorumMet,
|
quorum_met: draft.quorumMet,
|
||||||
|
|||||||
Reference in New Issue
Block a user