Integrate committee ballots with Voting
This commit is contained in:
@@ -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(
|
||||
session,
|
||||
principal,
|
||||
object_kind=kind,
|
||||
object_id=object_id,
|
||||
) is None:
|
||||
if (
|
||||
get_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=kind,
|
||||
object_id=object_id,
|
||||
)
|
||||
is None
|
||||
):
|
||||
raise CommitteeWorkspaceError(
|
||||
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
|
||||
)
|
||||
@@ -715,10 +764,11 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
|
||||
if record.state == "closed":
|
||||
counts = attributes.get("counts")
|
||||
if not isinstance(counts, Mapping):
|
||||
raise CommitteeWorkspaceError(
|
||||
"A closed vote requires result counts."
|
||||
)
|
||||
clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()}
|
||||
raise CommitteeWorkspaceError("A closed vote requires result counts.")
|
||||
clean_counts = {
|
||||
str(key): _non_negative_int(value, "Vote count")
|
||||
for key, value in counts.items()
|
||||
}
|
||||
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Closed vote counts must match the configured choices and cast_count."
|
||||
@@ -734,9 +784,7 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
|
||||
required=True,
|
||||
)
|
||||
if not record.evidence:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A closed vote requires result evidence."
|
||||
)
|
||||
raise CommitteeWorkspaceError("A closed vote requires result evidence.")
|
||||
elif record.object_kind == "minute":
|
||||
_reference(
|
||||
attributes.get("content_ref"),
|
||||
@@ -757,27 +805,39 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _replay(session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str) -> CommitteeWorkspaceRecord | None:
|
||||
row = session.query(CommitteeWorkspaceEvent).filter(
|
||||
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
|
||||
).one_or_none()
|
||||
def _replay(
|
||||
session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str
|
||||
) -> CommitteeWorkspaceRecord | None:
|
||||
row = (
|
||||
session.query(CommitteeWorkspaceEvent)
|
||||
.filter(
|
||||
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
if row.request_sha256 != request_sha256:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee idempotency conflict: the key was used for another request."
|
||||
)
|
||||
revision = session.query(CommitteeWorkspaceRevision).filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceRevision.object_kind == row.object_kind,
|
||||
CommitteeWorkspaceRevision.object_id == row.object_id,
|
||||
CommitteeWorkspaceRevision.revision == row.object_revision,
|
||||
).one()
|
||||
revision = (
|
||||
session.query(CommitteeWorkspaceRevision)
|
||||
.filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceRevision.object_kind == row.object_kind,
|
||||
CommitteeWorkspaceRevision.object_id == row.object_id,
|
||||
CommitteeWorkspaceRevision.revision == row.object_revision,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return _workspace_from_row(revision)
|
||||
|
||||
|
||||
def _current_row(session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool) -> CommitteeWorkspaceRevision | None:
|
||||
def _current_row(
|
||||
session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool
|
||||
) -> CommitteeWorkspaceRevision | None:
|
||||
query = session.query(CommitteeWorkspaceRevision).filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceRevision.object_kind == object_kind,
|
||||
@@ -804,7 +864,9 @@ def _object_kind(value: object) -> CommitteeObjectKind:
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
|
||||
def _reference(value: object, kind: str | None, tenant_id: str, *, required: bool) -> InstitutionalReference | None:
|
||||
def _reference(
|
||||
value: object, kind: str | None, tenant_id: str, *, required: bool
|
||||
) -> InstitutionalReference | None:
|
||||
if value is None:
|
||||
if required:
|
||||
raise CommitteeWorkspaceError(
|
||||
@@ -821,7 +883,9 @@ def _reference(value: object, kind: str | None, tenant_id: str, *, required: boo
|
||||
return result
|
||||
|
||||
|
||||
def _references(value: object, kind: str | None, tenant_id: str) -> tuple[InstitutionalReference, ...]:
|
||||
def _references(
|
||||
value: object, kind: str | None, tenant_id: str
|
||||
) -> tuple[InstitutionalReference, ...]:
|
||||
return tuple(
|
||||
item
|
||||
for item in (
|
||||
@@ -910,7 +974,9 @@ def _optional_text(value: object, *, maximum: int) -> str | None:
|
||||
|
||||
def _sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
json.dumps(
|
||||
_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user