Integrate committee ballots with Voting

This commit is contained in:
2026-08-01 20:57:25 +02:00
parent 758b59ca03
commit 133e55987e
12 changed files with 565 additions and 104 deletions
+120 -54
View File
@@ -45,7 +45,9 @@ _PARENT_KIND: dict[str, str | None] = {
_STATES: dict[str, frozenset[str]] = {
"body": frozenset({"draft", "active", "suspended", "retired"}),
"meeting": frozenset({"draft", "scheduled", "open", "closed", "cancelled"}),
"agenda_item": frozenset({"draft", "scheduled", "deliberating", "decided", "withdrawn"}),
"agenda_item": frozenset(
{"draft", "scheduled", "deliberating", "decided", "withdrawn"}
),
"vote": frozenset({"draft", "open", "closed", "cancelled"}),
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
}
@@ -111,9 +113,7 @@ class CommitteeWorkspaceRecord:
_identifier(self.tenant_id, "Committee tenant id", maximum=36)
_identifier(self.object_id, "Committee object id", maximum=255)
if self.revision < 1:
raise CommitteeWorkspaceError(
"Committee object revision must be positive."
)
raise CommitteeWorkspaceError("Committee object revision must be positive.")
if self.state not in _STATES[self.object_kind]:
raise CommitteeWorkspaceError(
f"Unsupported {self.object_kind} state: {self.state!r}."
@@ -137,9 +137,7 @@ class CommitteeWorkspaceRecord:
"Committee institutional context belongs to another tenant."
)
if any(item.tenant_id != self.tenant_id for item in self.evidence):
raise CommitteeWorkspaceError(
"Committee evidence cannot cross tenants."
)
raise CommitteeWorkspaceError("Committee evidence cannot cross tenants.")
if any(
item.tenant_id != self.tenant_id or item.kind != "record"
for item in self.record_refs
@@ -171,19 +169,23 @@ class CommitteeWorkspaceRecord:
context = value.get("context")
attributes = value.get("attributes") or {}
if not isinstance(attributes, Mapping):
raise CommitteeWorkspaceError(
"Committee attributes must be an object."
)
raise CommitteeWorkspaceError("Committee attributes must be an object.")
return cls(
tenant_id=_required_text(value.get("tenant_id"), "Committee tenant id", maximum=36),
tenant_id=_required_text(
value.get("tenant_id"), "Committee tenant id", maximum=36
),
object_kind=_object_kind(value.get("object_kind")),
object_id=_required_text(value.get("object_id"), "Committee object id", maximum=255),
object_id=_required_text(
value.get("object_id"), "Committee object id", maximum=255
),
revision=_positive_int(value.get("revision"), "Committee revision"),
state=_required_text(value.get("state"), "Committee state", maximum=30),
title=_required_text(value.get("title"), "Committee title", maximum=500),
parent_id=_optional_text(value.get("parent_id"), maximum=255),
recorded_at=_datetime(value.get("recorded_at"), "Committee recorded_at"),
change_reason=_required_text(value.get("change_reason"), "Committee change reason", maximum=1_000),
change_reason=_required_text(
value.get("change_reason"), "Committee change reason", maximum=1_000
),
attributes=dict(attributes),
context=(
GovernedContextEnvelope.from_mapping(context)
@@ -196,7 +198,9 @@ class CommitteeWorkspaceRecord:
),
record_refs=tuple(
InstitutionalReference.from_mapping(item)
for item in _mapping_items(value.get("record_refs"), "Committee record references")
for item in _mapping_items(
value.get("record_refs"), "Committee record references"
)
),
)
@@ -264,22 +268,29 @@ def record_workspace_object(
f"Committee {record.object_kind} transition {current.state!r} to "
f"{record.state!r} is not allowed."
)
current_payload = current.payload if isinstance(current.payload, Mapping) else {}
current_payload = (
current.payload if isinstance(current.payload, Mapping) else {}
)
current_attributes = current_payload.get("attributes")
provider_id = (
str(current_attributes.get("provider_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
voting_ballot_id = (
str(current_attributes.get("voting_ballot_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
if (
record.object_kind == "vote"
and current.state == "open"
and record.state == "closed"
and provider_id
and (provider_id or voting_ballot_id)
and not _provider_finalization
):
raise CommitteeWorkspaceError(
"A provider-bound Committee vote must be finalized through its ballot adapter."
"A managed Committee vote must be finalized through Voting or its compatibility ballot adapter."
)
current.superseded_at = record.recorded_at
_validate_parent(session, record)
@@ -389,9 +400,7 @@ def list_workspace_objects(
CommitteeWorkspaceRevision.superseded_at.is_(None),
)
if parent_id is not None:
statement = statement.filter(
CommitteeWorkspaceRevision.parent_id == parent_id
)
statement = statement.filter(CommitteeWorkspaceRevision.parent_id == parent_id)
if states:
statement = statement.filter(
CommitteeWorkspaceRevision.state.in_(tuple(states))
@@ -441,11 +450,39 @@ def workspace_history(
class SqlCommitteeWorkspace:
def record(self, session: object, principal: object, *, record: CommitteeWorkspaceRecord, idempotency_key: str, expected_revision: int | None = None) -> CommitteeWorkspaceRecord:
return record_workspace_object(_session(session), principal, record=record, idempotency_key=idempotency_key, expected_revision=expected_revision)
def record(
self,
session: object,
principal: object,
*,
record: CommitteeWorkspaceRecord,
idempotency_key: str,
expected_revision: int | None = None,
) -> CommitteeWorkspaceRecord:
return record_workspace_object(
_session(session),
principal,
record=record,
idempotency_key=idempotency_key,
expected_revision=expected_revision,
)
def get(self, session: object, principal: object, *, object_kind: str, object_id: str, revision: int | None = None) -> CommitteeWorkspaceRecord | None:
return get_workspace_object(_session(session), principal, object_kind=object_kind, object_id=object_id, revision=revision)
def get(
self,
session: object,
principal: object,
*,
object_kind: str,
object_id: str,
revision: int | None = None,
) -> CommitteeWorkspaceRecord | None:
return get_workspace_object(
_session(session),
principal,
object_kind=object_kind,
object_id=object_id,
revision=revision,
)
def record_local_decision(
self,
@@ -466,8 +503,17 @@ class SqlCommitteeWorkspace:
expected_revision=expected_revision,
)
def get_local_decision(self, session: object, principal: object, *, decision_id: str, revision: str | None = None) -> FormalDecision | None:
return get_local_decision(_session(session), principal, decision_id=decision_id, revision=revision)
def get_local_decision(
self,
session: object,
principal: object,
*,
decision_id: str,
revision: str | None = None,
) -> FormalDecision | None:
return get_local_decision(
_session(session), principal, decision_id=decision_id, revision=revision
)
def record_local_decision(
@@ -489,12 +535,15 @@ def record_local_decision(
"Committee Decision projections cannot cross tenants."
)
for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)):
if get_workspace_object(
session,
principal,
object_kind=kind,
object_id=object_id,
) is None:
if (
get_workspace_object(
session,
principal,
object_kind=kind,
object_id=object_id,
)
is None
):
raise CommitteeWorkspaceError(
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
)
@@ -715,10 +764,11 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
if record.state == "closed":
counts = attributes.get("counts")
if not isinstance(counts, Mapping):
raise CommitteeWorkspaceError(
"A closed vote requires result counts."
)
clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()}
raise CommitteeWorkspaceError("A closed vote requires result counts.")
clean_counts = {
str(key): _non_negative_int(value, "Vote count")
for key, value in counts.items()
}
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
raise CommitteeWorkspaceError(
"Closed vote counts must match the configured choices and cast_count."
@@ -734,9 +784,7 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
required=True,
)
if not record.evidence:
raise CommitteeWorkspaceError(
"A closed vote requires result evidence."
)
raise CommitteeWorkspaceError("A closed vote requires result evidence.")
elif record.object_kind == "minute":
_reference(
attributes.get("content_ref"),
@@ -757,27 +805,39 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
)
def _replay(session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str) -> CommitteeWorkspaceRecord | None:
row = session.query(CommitteeWorkspaceEvent).filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
).one_or_none()
def _replay(
session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str
) -> CommitteeWorkspaceRecord | None:
row = (
session.query(CommitteeWorkspaceEvent)
.filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
)
.one_or_none()
)
if row is None:
return None
if row.request_sha256 != request_sha256:
raise CommitteeWorkspaceError(
"Committee idempotency conflict: the key was used for another request."
)
revision = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision,
).one()
revision = (
session.query(CommitteeWorkspaceRevision)
.filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision,
)
.one()
)
return _workspace_from_row(revision)
def _current_row(session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool) -> CommitteeWorkspaceRevision | None:
def _current_row(
session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool
) -> CommitteeWorkspaceRevision | None:
query = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == object_kind,
@@ -804,7 +864,9 @@ def _object_kind(value: object) -> CommitteeObjectKind:
return result # type: ignore[return-value]
def _reference(value: object, kind: str | None, tenant_id: str, *, required: bool) -> InstitutionalReference | None:
def _reference(
value: object, kind: str | None, tenant_id: str, *, required: bool
) -> InstitutionalReference | None:
if value is None:
if required:
raise CommitteeWorkspaceError(
@@ -821,7 +883,9 @@ def _reference(value: object, kind: str | None, tenant_id: str, *, required: boo
return result
def _references(value: object, kind: str | None, tenant_id: str) -> tuple[InstitutionalReference, ...]:
def _references(
value: object, kind: str | None, tenant_id: str
) -> tuple[InstitutionalReference, ...]:
return tuple(
item
for item in (
@@ -910,7 +974,9 @@ def _optional_text(value: object, *, maximum: int) -> str | None:
def _sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
json.dumps(
_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
).hexdigest()