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
|
||||
workspace using the same immutable revisions and lifecycle guards as the API.
|
||||
|
||||
`committee.ballot_finalizer` delegates provider-bound external or secret
|
||||
ballots to `committee.ballot_adapter.<provider>` capabilities. Committee
|
||||
validates the exact choices, eligible/cast totals, tenant evidence, receipt,
|
||||
and result hash, then stores only the verified aggregate. Individual ballots
|
||||
never enter Committee persistence, and a provider-bound vote cannot be closed
|
||||
through the generic workspace endpoint.
|
||||
When Voting is installed, Committee creates and follows a governed
|
||||
`voting.ballots` record while retaining only its meeting/agendum reference and
|
||||
verified aggregate outcome. Voting owns the frozen electorate, casting,
|
||||
tallying, certification, challenge, and annulment lifecycle. The older
|
||||
`committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
|
||||
remains a 0.1 compatibility boundary for existing external integrations;
|
||||
individual provider ballots never enter Committee persistence.
|
||||
|
||||
## Initial Ownership
|
||||
|
||||
@@ -57,6 +58,7 @@ Expected optional integrations:
|
||||
- tasks
|
||||
- workflow
|
||||
- approvals
|
||||
- voting
|
||||
|
||||
## Development Install
|
||||
|
||||
@@ -83,8 +85,8 @@ use a separate read permission. Database restore is the semantic recovery unit;
|
||||
linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain
|
||||
their own recovery responsibility.
|
||||
|
||||
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the
|
||||
separate effect boundary for an installed ballot adapter. It requires the
|
||||
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the 0.1
|
||||
compatibility effect boundary for an installed ballot adapter. It requires the
|
||||
`committee:ballot:finalize` permission and preserves provider receipt/hash and
|
||||
evidence without exposing or persisting individual votes.
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ The current repository state is intentionally bounded:
|
||||
vote results, and minutes
|
||||
- a provider-neutral ballot-finalization contract for external and secret
|
||||
ballots that retains aggregate evidence rather than individual ballots
|
||||
- an optional primary integration with Voting, which owns frozen electorates,
|
||||
vote casting/replacement, tally, certification, challenge, and annulment
|
||||
- Gitea issue workflow templates
|
||||
- manifest and decision reconstruction contract tests
|
||||
|
||||
@@ -69,7 +71,10 @@ Decision reference, and meetings cannot close while agenda items remain
|
||||
unfinished. Accepted or corrected minutes require a Records reference,
|
||||
Approval, and evidence.
|
||||
|
||||
A provider-bound vote is finalized only through
|
||||
A governed ballot is delegated to `voting.ballots` when Voting is installed;
|
||||
Committee retains the meeting/agendum linkage and verified aggregate result.
|
||||
The following direct provider path remains a 0.1 compatibility contract only.
|
||||
A provider-bound vote on that path is finalized through
|
||||
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
|
||||
choices, eligible count, external ballot reference, request time, and
|
||||
idempotency key. Its result must cover exactly the configured choices, sum to
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
if (
|
||||
get_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=kind,
|
||||
object_id=object_id,
|
||||
) is None:
|
||||
)
|
||||
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(
|
||||
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()
|
||||
)
|
||||
.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(
|
||||
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()
|
||||
)
|
||||
.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()
|
||||
|
||||
|
||||
|
||||
+149
-6
@@ -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,7 +376,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
||||
idempotency_key="vote-close",
|
||||
)
|
||||
|
||||
decision = CommitteeDecisionPath().decide(
|
||||
decision = (
|
||||
CommitteeDecisionPath()
|
||||
.decide(
|
||||
self.session,
|
||||
self.principal,
|
||||
proposal=proposal(),
|
||||
@@ -328,7 +386,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
|
||||
competent=True,
|
||||
mandates=(mandate(),),
|
||||
),
|
||||
).decision
|
||||
)
|
||||
.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.assertEqual(
|
||||
"decision-1",
|
||||
get_local_decision(
|
||||
self.session,
|
||||
self.principal,
|
||||
decision_id="decision-1",
|
||||
).reference.object_id)
|
||||
).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()
|
||||
|
||||
@@ -113,3 +113,40 @@ export function finalizeProviderBallot(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeVotingBallot(
|
||||
settings: ApiSettings,
|
||||
record: CommitteeRecord,
|
||||
input: {
|
||||
approvalId: string;
|
||||
changeReason: string;
|
||||
}
|
||||
): Promise<CommitteeRecord> {
|
||||
const ballotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
||||
const ballot = await apiFetch<{ revision: number }>(
|
||||
settings,
|
||||
`/api/v1/voting/${encodeURIComponent(ballotId)}`
|
||||
);
|
||||
return apiFetch<CommitteeRecord>(
|
||||
settings,
|
||||
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-voting`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
voting_ballot_id: ballotId,
|
||||
voting_expected_revision: ballot.revision,
|
||||
approval_ref: {
|
||||
kind: "approval",
|
||||
owner_module: "approvals",
|
||||
object_id: input.approvalId.trim(),
|
||||
tenant_id: record.tenant_id,
|
||||
version: "1"
|
||||
},
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: input.changeReason.trim(),
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
finalizeProviderBallot,
|
||||
finalizeVotingBallot,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
|
||||
@@ -43,7 +44,9 @@ export default function CommitteeBallotDialog({
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await finalizeProviderBallot(settings, record, {
|
||||
const saved = votingBallotId
|
||||
? await finalizeVotingBallot(settings, record, { approvalId, changeReason })
|
||||
: await finalizeProviderBallot(settings, record, {
|
||||
providerBallotRef,
|
||||
approvalId,
|
||||
changeReason
|
||||
@@ -58,10 +61,11 @@ export default function CommitteeBallotDialog({
|
||||
}
|
||||
|
||||
const providerId = String(record.attributes.provider_id ?? "");
|
||||
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Finalize provider ballot"
|
||||
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
@@ -71,7 +75,7 @@ export default function CommitteeBallotDialog({
|
||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()}
|
||||
disabled={busy || (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim()}
|
||||
onClick={() => void finalize()}
|
||||
>
|
||||
{busy ? "Importing" : "Finalize"}
|
||||
@@ -82,12 +86,13 @@ export default function CommitteeBallotDialog({
|
||||
<div className="committee-record-form">
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<p className="committee-dialog-note">
|
||||
Provider <strong>{providerId}</strong> returns only the verified aggregate result,
|
||||
receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN.
|
||||
{votingBallotId
|
||||
? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
|
||||
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
|
||||
</p>
|
||||
<FormField label="Provider ballot reference">
|
||||
{!votingBallotId ? <FormField label="Provider ballot reference">
|
||||
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
||||
</FormField>
|
||||
</FormField> : null}
|
||||
<FormField label="Approval ID">
|
||||
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
||||
</FormField>
|
||||
|
||||
@@ -377,7 +377,10 @@ function voteSummary(record: CommitteeRecord): string {
|
||||
}
|
||||
|
||||
function isProviderBallotReady(record: CommitteeRecord): boolean {
|
||||
return record.state === "open" && Boolean(String(record.attributes.provider_id ?? "").trim());
|
||||
return record.state === "open" && Boolean(
|
||||
String(record.attributes.voting_ballot_id ?? "").trim()
|
||||
|| String(record.attributes.provider_id ?? "").trim()
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(state: string): "active" | "inactive" | "warning" {
|
||||
|
||||
@@ -32,6 +32,7 @@ type Draft = {
|
||||
approvalId: string;
|
||||
evidenceId: string;
|
||||
providerId: string;
|
||||
votingBallotId: string;
|
||||
contentRecordId: string;
|
||||
decisionId: string;
|
||||
};
|
||||
@@ -206,6 +207,9 @@ export default function CommitteeRecordDialog({
|
||||
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Voting ballot ID (optional)">
|
||||
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Choices (comma separated)">
|
||||
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -310,6 +314,7 @@ function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | n
|
||||
approvalId: text(approval.object_id),
|
||||
evidenceId: text(firstMapping(record?.evidence).evidence_id),
|
||||
providerId: text(attributes.provider_id),
|
||||
votingBallotId: text(attributes.voting_ballot_id),
|
||||
contentRecordId: text(content.object_id),
|
||||
decisionId: text(decision.object_id)
|
||||
};
|
||||
@@ -373,6 +378,7 @@ function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft:
|
||||
eligible_count: Number(draft.eligibleCount),
|
||||
cast_count: Number(draft.castCount),
|
||||
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
|
||||
...(draft.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
|
||||
...(draft.state === "closed" ? {
|
||||
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
|
||||
quorum_met: draft.quorumMet,
|
||||
|
||||
Reference in New Issue
Block a user