feat: implement committee decision workspace
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
ActorRepresentationReference,
|
||||
DecisionEffectReference,
|
||||
EvidenceReference,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_committee.backend.decision_path import (
|
||||
CommitteeDecisionPath,
|
||||
CommitteeDecisionProposal,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def reference(kind: str, object_id: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=(
|
||||
"committee"
|
||||
if kind in {"decision", "record"}
|
||||
else "organizations"
|
||||
if kind in {"organization_unit", "function", "jurisdiction"}
|
||||
else "approvals"
|
||||
if kind == "approval"
|
||||
else "cases"
|
||||
),
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def mandate() -> MandateDefinition:
|
||||
return MandateDefinition(
|
||||
reference=InstitutionalReference(
|
||||
kind="mandate",
|
||||
owner_module="committee",
|
||||
object_id="mandate-1",
|
||||
tenant_id="tenant-1",
|
||||
version="7",
|
||||
valid_at=NOW,
|
||||
),
|
||||
temporal=TemporalRevision(
|
||||
revision="7",
|
||||
valid_from=NOW - timedelta(days=30),
|
||||
valid_to=NOW + timedelta(days=30),
|
||||
recorded_at=NOW - timedelta(days=40),
|
||||
),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("committee.resolution",),
|
||||
organization_unit_refs=(reference("organization_unit", "board-1"),),
|
||||
function_refs=(reference("function", "chair"),),
|
||||
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
evidence=(evidence("mandate-evidence"),),
|
||||
)
|
||||
|
||||
|
||||
def legal_basis() -> LegalBasisReference:
|
||||
return LegalBasisReference(
|
||||
kind="statute",
|
||||
authority="Example council",
|
||||
reference="rules:committee:12",
|
||||
version="2026-01",
|
||||
effective_from=NOW - timedelta(days=100),
|
||||
)
|
||||
|
||||
|
||||
def evidence(evidence_id: str) -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind="record",
|
||||
owner_module="committee",
|
||||
evidence_id=evidence_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
checksum="sha256:example",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def proposal() -> CommitteeDecisionProposal:
|
||||
return CommitteeDecisionProposal(
|
||||
tenant_id="tenant-1",
|
||||
decision_id="decision-1",
|
||||
revision="1",
|
||||
effective_at=NOW,
|
||||
meeting_ref="meeting-4",
|
||||
agenda_item_ref="item-7",
|
||||
decision_type="committee.resolution",
|
||||
subject_refs=(reference("case", "case-1"),),
|
||||
organization_unit_ref=reference("organization_unit", "board-1"),
|
||||
function_ref=reference("function", "chair"),
|
||||
actor=ActorRepresentationReference(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
represented_function_ref=reference("function", "chair"),
|
||||
mandate_ref=mandate().reference,
|
||||
),
|
||||
approval_refs=(reference("approval", "vote-approval-1"),),
|
||||
fact_evidence=(evidence("minutes-4"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
operative_result="The proposal is accepted.",
|
||||
reasoning="The submitted evidence satisfies the applicable rule.",
|
||||
case_ref=reference("case", "case-1"),
|
||||
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
|
||||
record_refs=(reference("record", "minutes-4"),),
|
||||
requested_effects=(
|
||||
DecisionEffectReference(
|
||||
effect_key="postbox.notify_parties",
|
||||
state="requested",
|
||||
resource_refs=("postbox:case-1",),
|
||||
),
|
||||
),
|
||||
review_refs=("review:administrative-court",),
|
||||
information_governance=InformationGovernanceReference(
|
||||
classification="restricted",
|
||||
purposes=("formal_decision",),
|
||||
legal_basis_refs=("rules:committee:12@2026-01",),
|
||||
disclosure_state="partly_disclosable",
|
||||
),
|
||||
assurance_level="human_reviewed_automation",
|
||||
automation_preparation_refs=("dataflow:recommendation-1",),
|
||||
)
|
||||
|
||||
|
||||
class FakeDecisionRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.recorded = None
|
||||
|
||||
def get_decision(self, session, principal, *, reference):
|
||||
return self.recorded
|
||||
|
||||
def record_decision(self, session, principal, *, decision, expected_revision=None):
|
||||
self.recorded = decision
|
||||
return decision
|
||||
|
||||
|
||||
class FakeMandateResolver:
|
||||
def __init__(self, resolution: MandateResolution) -> None:
|
||||
self.resolution = resolution
|
||||
self.request = None
|
||||
|
||||
def resolve_mandate(self, session, principal, *, request):
|
||||
self.request = request
|
||||
return self.resolution
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self, capabilities: dict[str, object]) -> None:
|
||||
self.capabilities = capabilities
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class CommitteeDecisionPathTests(unittest.TestCase):
|
||||
def test_decision_reconstructs_authority_approval_evidence_and_effects(self) -> None:
|
||||
registry = FakeDecisionRegistry()
|
||||
resolution = MandateResolution(
|
||||
competent=True,
|
||||
mandates=(mandate(),),
|
||||
explanation="Chair is competent for this agenda item.",
|
||||
)
|
||||
mandate_resolver = FakeMandateResolver(resolution)
|
||||
path = CommitteeDecisionPath(
|
||||
FakeRegistry(
|
||||
{
|
||||
CAPABILITY_MANDATE_RESOLVER: mandate_resolver,
|
||||
CAPABILITY_DECISION_REGISTRY: registry,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = path.decide(None, None, proposal=proposal())
|
||||
payload = result.reconstruction_payload()
|
||||
|
||||
self.assertTrue(result.persisted_by_decision_registry)
|
||||
self.assertIs(registry.recorded, result.decision)
|
||||
self.assertEqual("mandate-1", payload["mandate_ref"]["object_id"])
|
||||
self.assertEqual(
|
||||
"vote-approval-1",
|
||||
payload["decision"]["authority_context"]["approval_refs"][0]["object_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"postbox.notify_parties",
|
||||
payload["decision"]["requested_effects"][0]["effect_key"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"The submitted evidence satisfies the applicable rule.",
|
||||
payload["decision"]["reasoning"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"city-1", mandate_resolver.request.jurisdiction_refs[0].object_id
|
||||
)
|
||||
self.assertEqual(
|
||||
"human_reviewed_automation",
|
||||
payload["decision"]["assurance_level"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["dataflow:recommendation-1"],
|
||||
payload["decision"]["automation_preparation_refs"],
|
||||
)
|
||||
|
||||
def test_decision_rejects_ambiguous_or_ineffective_mandate(self) -> None:
|
||||
accepted = mandate()
|
||||
with self.assertRaisesRegex(
|
||||
InstitutionalContextError,
|
||||
"exactly one effective active mandate",
|
||||
):
|
||||
CommitteeDecisionPath().decide(
|
||||
None,
|
||||
None,
|
||||
proposal=proposal(),
|
||||
mandate_resolution=MandateResolution(
|
||||
competent=True,
|
||||
mandates=(accepted, accepted),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+22
-5
@@ -2,7 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_committee.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
||||
from govoplan_committee.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
BALLOT_SCOPE,
|
||||
PROTECTED_READ_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
get_manifest,
|
||||
)
|
||||
|
||||
|
||||
class ManifestSeedTests(unittest.TestCase):
|
||||
@@ -12,12 +19,22 @@ class ManifestSeedTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, "committee")
|
||||
self.assertEqual(manifest.name, "Committee")
|
||||
self.assertEqual(manifest.dependencies, ("access",))
|
||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
||||
self.assertEqual(
|
||||
{permission.scope for permission in manifest.permissions},
|
||||
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE},
|
||||
)
|
||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"committee_manager", "committee_viewer"})
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertEqual("@govoplan/committee-webui", manifest.frontend.package_name)
|
||||
self.assertEqual("/committee", manifest.frontend.routes[0].path)
|
||||
self.assertEqual(
|
||||
{"committee.navigation", "committee.workspace"},
|
||||
{surface.id for surface in manifest.frontend.view_surfaces},
|
||||
)
|
||||
self.assertIn("committee.workspace", manifest.capability_factories)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_committee.backend.manifest import get_manifest
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
|
||||
|
||||
class CommitteeMigrationTests(unittest.TestCase):
|
||||
def test_fresh_migration_creates_committee_workspace_and_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-committee-migration-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'committee.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("committee",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
self.assertTrue(
|
||||
{
|
||||
"committee_decision_projections",
|
||||
"committee_workspace_events",
|
||||
"committee_workspace_revisions",
|
||||
}.issubset(inspect(engine).get_table_names())
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"d8b9f0a1c2e3",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,597 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.core.institutional import (
|
||||
ActorRepresentationReference,
|
||||
EvidenceReference,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
from govoplan_committee.backend.ballots import (
|
||||
BallotFinalizationResult,
|
||||
CommitteeBallotFinalizer,
|
||||
ballot_adapter_capability,
|
||||
)
|
||||
from govoplan_committee.backend.decision_path import (
|
||||
CommitteeDecisionPath,
|
||||
CommitteeDecisionProposal,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CommitteeWorkspaceError,
|
||||
CommitteeWorkspaceRecord,
|
||||
SqlCommitteeWorkspace,
|
||||
get_local_decision,
|
||||
list_workspace_objects,
|
||||
record_workspace_object,
|
||||
workspace_history,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 13, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
|
||||
|
||||
class BallotAdapter:
|
||||
def finalize_ballot(self, session, principal, *, request):
|
||||
return BallotFinalizationResult(
|
||||
provider_id=request.provider_id,
|
||||
provider_ballot_ref=request.provider_ballot_ref,
|
||||
counts={"yes": 3, "no": 1},
|
||||
cast_count=4,
|
||||
quorum_met=True,
|
||||
receipt_ref="receipt:secret-ballot-1",
|
||||
result_sha256="a" * 64,
|
||||
evidence=(evidence("secret-ballot-result"),),
|
||||
)
|
||||
|
||||
|
||||
class BallotRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.name = ballot_adapter_capability("secure-vote")
|
||||
self.adapter = BallotAdapter()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == self.name
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if name != self.name:
|
||||
raise KeyError(name)
|
||||
return self.adapter
|
||||
|
||||
|
||||
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def evidence(evidence_id: str) -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind="record",
|
||||
owner_module="records",
|
||||
evidence_id=evidence_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def workspace_record(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
*,
|
||||
state: str,
|
||||
attributes: dict[str, object],
|
||||
parent_id: str | None = None,
|
||||
revision: int = 1,
|
||||
evidence_refs: tuple[EvidenceReference, ...] = (),
|
||||
) -> CommitteeWorkspaceRecord:
|
||||
return CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind=kind, # type: ignore[arg-type]
|
||||
object_id=object_id,
|
||||
revision=revision,
|
||||
state=state,
|
||||
title=f"{kind.replace('_', ' ').title()} {object_id}",
|
||||
parent_id=parent_id,
|
||||
recorded_at=NOW + timedelta(minutes=revision - 1),
|
||||
change_reason="Governed Committee update.",
|
||||
attributes=attributes,
|
||||
evidence=evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
def mandate() -> MandateDefinition:
|
||||
return MandateDefinition(
|
||||
reference=ref("mandate", "mandate-1", "mandates"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("committee.resolution",),
|
||||
organization_unit_refs=(ref("organization_unit", "board-1", "organizations"),),
|
||||
function_refs=(ref("function", "chair", "organizations"),),
|
||||
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
evidence=(evidence("mandate-proof"),),
|
||||
)
|
||||
|
||||
|
||||
def legal_basis() -> LegalBasisReference:
|
||||
return LegalBasisReference(
|
||||
kind="statute",
|
||||
authority="Example council",
|
||||
reference="committee-rules:12",
|
||||
version="2026-01",
|
||||
)
|
||||
|
||||
|
||||
def proposal() -> CommitteeDecisionProposal:
|
||||
function_ref = ref("function", "chair", "organizations")
|
||||
return CommitteeDecisionProposal(
|
||||
tenant_id="tenant-1",
|
||||
decision_id="decision-1",
|
||||
revision="1",
|
||||
effective_at=NOW,
|
||||
meeting_ref="meeting-1",
|
||||
agenda_item_ref="agenda-1",
|
||||
decision_type="committee.resolution",
|
||||
subject_refs=(ref("case", "case-1", "cases"),),
|
||||
organization_unit_ref=ref("organization_unit", "board-1", "organizations"),
|
||||
function_ref=function_ref,
|
||||
actor=ActorRepresentationReference(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
represented_function_ref=function_ref,
|
||||
mandate_ref=mandate().reference,
|
||||
),
|
||||
approval_refs=(ref("approval", "vote-1", "approvals"),),
|
||||
fact_evidence=(evidence("vote-result-1"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
operative_result="The application is approved.",
|
||||
reasoning="The evidence and vote meet the applicable rules.",
|
||||
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||
information_governance=InformationGovernanceReference(
|
||||
classification="restricted",
|
||||
purposes=("formal_decision",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CommitteeWorkspaceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CommitteeWorkspaceRevision.__table__,
|
||||
CommitteeWorkspaceEvent.__table__,
|
||||
CommitteeDecisionProjection.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.principal = Principal()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_full_workspace_lifecycle_and_local_decision_projection(self) -> None:
|
||||
bus = EventBus()
|
||||
events = []
|
||||
bus.subscribe("*", events.append)
|
||||
with event_bus_context(bus):
|
||||
body = workspace_record(
|
||||
"body",
|
||||
"body-1",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit",
|
||||
"board-1",
|
||||
"organizations",
|
||||
).to_dict(),
|
||||
"function_refs": [
|
||||
ref("function", "chair", "organizations").to_dict()
|
||||
],
|
||||
"quorum": {"minimum_count": 3},
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=body,
|
||||
idempotency_key="body-create",
|
||||
)
|
||||
meeting = workspace_record(
|
||||
"meeting",
|
||||
"meeting-1",
|
||||
state="scheduled",
|
||||
parent_id="body-1",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
|
||||
"location": "Council chamber",
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=meeting,
|
||||
idempotency_key="meeting-create",
|
||||
)
|
||||
agenda = workspace_record(
|
||||
"agenda_item",
|
||||
"agenda-1",
|
||||
state="scheduled",
|
||||
parent_id="meeting-1",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=agenda,
|
||||
idempotency_key="agenda-create",
|
||||
)
|
||||
deliberating = replace(
|
||||
agenda,
|
||||
revision=2,
|
||||
state="deliberating",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=deliberating,
|
||||
expected_revision=1,
|
||||
idempotency_key="agenda-deliberating",
|
||||
)
|
||||
vote = workspace_record(
|
||||
"vote",
|
||||
"vote-1",
|
||||
state="open",
|
||||
parent_id="agenda-1",
|
||||
attributes={
|
||||
"method": "recorded",
|
||||
"choices": ["yes", "no", "abstain"],
|
||||
"eligible_count": 5,
|
||||
"cast_count": 0,
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=vote,
|
||||
idempotency_key="vote-open",
|
||||
)
|
||||
closed_vote = replace(
|
||||
vote,
|
||||
revision=2,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
attributes={
|
||||
**dict(vote.attributes),
|
||||
"cast_count": 5,
|
||||
"counts": {"yes": 4, "no": 1, "abstain": 0},
|
||||
"quorum_met": True,
|
||||
"approval_ref": ref(
|
||||
"approval",
|
||||
"vote-1",
|
||||
"approvals",
|
||||
).to_dict(),
|
||||
},
|
||||
evidence=(evidence("vote-result-1"),),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=closed_vote,
|
||||
expected_revision=1,
|
||||
idempotency_key="vote-close",
|
||||
)
|
||||
|
||||
decision = CommitteeDecisionPath().decide(
|
||||
self.session,
|
||||
self.principal,
|
||||
proposal=proposal(),
|
||||
mandate_resolution=MandateResolution(
|
||||
competent=True,
|
||||
mandates=(mandate(),),
|
||||
),
|
||||
).decision
|
||||
workspace = SqlCommitteeWorkspace()
|
||||
projected = workspace.record_local_decision(
|
||||
self.session,
|
||||
self.principal,
|
||||
decision=decision,
|
||||
meeting_id="meeting-1",
|
||||
agenda_item_id="agenda-1",
|
||||
)
|
||||
decided = replace(
|
||||
deliberating,
|
||||
revision=3,
|
||||
state="decided",
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
attributes={
|
||||
**dict(deliberating.attributes),
|
||||
"decision_ref": projected.reference.to_dict(),
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=decided,
|
||||
expected_revision=2,
|
||||
idempotency_key="agenda-decide",
|
||||
)
|
||||
open_meeting = replace(
|
||||
meeting,
|
||||
revision=2,
|
||||
state="open",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=open_meeting,
|
||||
expected_revision=1,
|
||||
idempotency_key="meeting-open",
|
||||
)
|
||||
closed_meeting = replace(
|
||||
open_meeting,
|
||||
revision=3,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(hours=2),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=closed_meeting,
|
||||
expected_revision=2,
|
||||
idempotency_key="meeting-close",
|
||||
)
|
||||
minute = workspace_record(
|
||||
"minute",
|
||||
"minute-1",
|
||||
state="accepted",
|
||||
parent_id="meeting-1",
|
||||
attributes={
|
||||
"content_ref": ref("record", "minutes-1", "records").to_dict(),
|
||||
"approval_ref": ref("approval", "minutes-ok", "approvals").to_dict(),
|
||||
},
|
||||
evidence_refs=(evidence("minutes-signature"),),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=minute,
|
||||
idempotency_key="minute-accept",
|
||||
)
|
||||
self.assertEqual([], events)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual("decision-1", get_local_decision(
|
||||
self.session,
|
||||
self.principal,
|
||||
decision_id="decision-1",
|
||||
).reference.object_id)
|
||||
agenda_history = workspace_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
object_kind="agenda_item",
|
||||
object_id="agenda-1",
|
||||
)
|
||||
self.assertEqual([3, 2, 1], [item.revision for item in agenda_history])
|
||||
meetings, total = list_workspace_objects(
|
||||
self.session,
|
||||
self.principal,
|
||||
object_kind="meeting",
|
||||
states=("closed",),
|
||||
)
|
||||
self.assertEqual(1, total)
|
||||
self.assertEqual("meeting-1", meetings[0].object_id)
|
||||
self.assertIn("committee.decision.projected", [item.type for item in events])
|
||||
|
||||
def test_parent_state_occ_replay_and_tenant_boundaries_fail_closed(self) -> None:
|
||||
body = workspace_record(
|
||||
"body",
|
||||
"body-1",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit",
|
||||
"board-1",
|
||||
"organizations",
|
||||
).to_dict(),
|
||||
},
|
||||
)
|
||||
first = record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=body,
|
||||
idempotency_key="body-1",
|
||||
)
|
||||
replay = record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=body,
|
||||
idempotency_key="body-1",
|
||||
)
|
||||
self.assertEqual(first, replay)
|
||||
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "idempotency conflict"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=replace(body, title="Changed"),
|
||||
idempotency_key="body-1",
|
||||
)
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "existing body"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=workspace_record(
|
||||
"meeting",
|
||||
"meeting-orphan",
|
||||
state="scheduled",
|
||||
parent_id="missing",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=1)).isoformat(),
|
||||
},
|
||||
),
|
||||
idempotency_key="meeting-orphan",
|
||||
)
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "stale"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=replace(body, revision=2),
|
||||
expected_revision=99,
|
||||
idempotency_key="body-stale",
|
||||
)
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "cross tenants"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
Principal(tenant_id="tenant-2"),
|
||||
record=body,
|
||||
idempotency_key="cross-tenant",
|
||||
)
|
||||
|
||||
def test_provider_ballot_finalization_persists_only_aggregate_evidence(self) -> None:
|
||||
records = (
|
||||
workspace_record(
|
||||
"body",
|
||||
"body-ballot",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit",
|
||||
"board-1",
|
||||
"organizations",
|
||||
).to_dict(),
|
||||
"function_refs": [],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"meeting",
|
||||
"meeting-ballot",
|
||||
state="open",
|
||||
parent_id="body-ballot",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"agenda_item",
|
||||
"agenda-ballot",
|
||||
state="deliberating",
|
||||
parent_id="meeting-ballot",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"vote",
|
||||
"vote-provider",
|
||||
state="open",
|
||||
parent_id="agenda-ballot",
|
||||
attributes={
|
||||
"method": "secret",
|
||||
"provider_id": "secure-vote",
|
||||
"choices": ["yes", "no"],
|
||||
"eligible_count": 5,
|
||||
"cast_count": 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
for index, item in enumerate(records):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=item,
|
||||
idempotency_key=f"ballot-setup-{index}",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "ballot adapter"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=replace(
|
||||
records[-1],
|
||||
revision=2,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(minutes=4),
|
||||
evidence=(evidence("manual-result"),),
|
||||
attributes={
|
||||
**records[-1].attributes,
|
||||
"counts": {"yes": 3, "no": 1},
|
||||
"cast_count": 4,
|
||||
"quorum_met": True,
|
||||
"provider_receipt_ref": "forged-receipt",
|
||||
"provider_result_sha256": "b" * 64,
|
||||
"approval_ref": ref(
|
||||
"approval", "vote-approval", "approvals"
|
||||
).to_dict(),
|
||||
},
|
||||
),
|
||||
expected_revision=1,
|
||||
idempotency_key="ballot-manual-close",
|
||||
)
|
||||
|
||||
closed = CommitteeBallotFinalizer(BallotRegistry()).finalize(
|
||||
self.session,
|
||||
self.principal,
|
||||
vote_id="vote-provider",
|
||||
provider_id="secure-vote",
|
||||
provider_ballot_ref="external-ballot-7",
|
||||
approval_ref=ref("approval", "vote-approval", "approvals"),
|
||||
expected_revision=1,
|
||||
recorded_at=NOW + timedelta(minutes=5),
|
||||
change_reason="Imported verified secret ballot aggregate.",
|
||||
idempotency_key="ballot-finalize-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual("closed", closed.state)
|
||||
self.assertEqual({"yes": 3, "no": 1}, closed.attributes["counts"])
|
||||
self.assertEqual("a" * 64, closed.attributes["provider_result_sha256"])
|
||||
self.assertNotIn("ballots", closed.attributes)
|
||||
self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user