feat: implement committee decision workspace
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Committee Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Committee internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the GovOPlaN Committee platform module seed.
|
||||
|
||||
@@ -4,16 +4,34 @@
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-committee` is the GovOPlaN platform module seed for committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.
|
||||
`govoplan-committee` is the GovOPlaN module for committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.
|
||||
|
||||
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
|
||||
The backend now exposes versioned `committee.workspace` and
|
||||
`committee.decision_path` capabilities. The workspace persists immutable,
|
||||
OCC-guarded revisions for bodies, meetings, agenda items, governed vote
|
||||
results, and minutes, with replay-safe lifecycle events. The decision path
|
||||
combines that committee context with an effective Mandate,
|
||||
including organization, function, and jurisdiction coverage, with approval,
|
||||
legal bases, evidence, reasoning, and effects to create the shared
|
||||
formal Decision contract. Mandate resolution and Decision persistence remain
|
||||
optional providers. If Decisions is absent, Committee retains a bounded,
|
||||
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.
|
||||
|
||||
## Initial Ownership
|
||||
|
||||
- committee bodies
|
||||
- meeting agendas
|
||||
- minutes
|
||||
- decision records
|
||||
- deliberation/vote context and formal decision references
|
||||
- votes
|
||||
- follow-up assignments
|
||||
|
||||
@@ -24,6 +42,8 @@ This module does not own:
|
||||
- generic task execution
|
||||
- document storage
|
||||
- calendar event storage
|
||||
- the generic formal decision lifecycle; a future Decisions provider owns
|
||||
authority, facts/rules, reasoning, effects, review, correction, and revocation
|
||||
|
||||
Detailed boundary notes are in [docs/COMMITTEE_DOMAIN_BOUNDARY.md](docs/COMMITTEE_DOMAIN_BOUNDARY.md).
|
||||
|
||||
@@ -54,6 +74,20 @@ cd /mnt/DATA/git/govoplan-committee
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
## API And Recovery
|
||||
|
||||
`/api/v1/committee/workspace/{body|meeting|agenda_item|vote|minute}` supports
|
||||
bounded list/read/write and immutable history. Writes require an idempotency
|
||||
key and expected revision after creation. Protected local Decision projections
|
||||
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
|
||||
`committee:ballot:finalize` permission and preserves provider receipt/hash and
|
||||
evidence without exposing or persisting individual votes.
|
||||
|
||||
## Gitea Workflow
|
||||
|
||||
Issue templates are installed under `.gitea/`, and the shared label taxonomy is copied to `docs/gitea-labels.json` with the module label `module/committee`.
|
||||
|
||||
@@ -9,7 +9,7 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
||||
- committee bodies
|
||||
- meeting agendas
|
||||
- minutes
|
||||
- decision records
|
||||
- deliberation and vote context plus references to formal decision records
|
||||
- votes
|
||||
- follow-up assignments
|
||||
|
||||
@@ -18,6 +18,9 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
||||
- generic task execution
|
||||
- document storage
|
||||
- calendar event storage
|
||||
- generic approval gates
|
||||
- the cross-domain formal decision lifecycle, including authority, facts,
|
||||
applicable rules, reasoning, effects, review, correction, and revocation
|
||||
|
||||
## Integration Candidates
|
||||
|
||||
@@ -28,19 +31,66 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
||||
- workflow
|
||||
- approvals
|
||||
|
||||
## Seed State
|
||||
## Current Persistent Backend Slice
|
||||
|
||||
The current repository state is intentionally small:
|
||||
The current repository state is intentionally bounded:
|
||||
|
||||
- module manifest and entry point
|
||||
- tenant-level permission definitions
|
||||
- manager and viewer role templates
|
||||
- documentation topic describing the module boundary
|
||||
- documentation topic and architecture/evidence declaration
|
||||
- `committee.workspace` and `committee.decision_path` interfaces and capabilities
|
||||
- tenant-scoped body, meeting, agenda-item, vote-result, and minute persistence
|
||||
- immutable revisions, OCC, replay-safe lifecycle events, migrations, uninstall
|
||||
guards, API routes, and tenant summary counts
|
||||
- a governed assembler for one formal committee outcome
|
||||
- a protected local Decision projection when the optional Decisions provider is
|
||||
absent
|
||||
- a three-pane `/committee` workspace for bodies, meetings, agendas, governed
|
||||
vote results, and minutes
|
||||
- a provider-neutral ballot-finalization contract for external and secret
|
||||
ballots that retains aggregate evidence rather than individual ballots
|
||||
- Gitea issue workflow templates
|
||||
- manifest contract test
|
||||
- manifest and decision reconstruction contract tests
|
||||
|
||||
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
|
||||
The decision path accepts or resolves one effective
|
||||
Mandate covering the deciding unit, function, and jurisdiction; requires
|
||||
approval, fact evidence, versioned legal bases, operative
|
||||
result, and reasoning, and emits the shared formal Decision contract. If a
|
||||
Decision registry is installed it records there; otherwise the result is
|
||||
retained in the Committee-owned fallback projection and is available only
|
||||
through the protected-read permission.
|
||||
|
||||
## First Implementation Slice
|
||||
The workspace records the result of a governed vote rather than becoming a
|
||||
general remote-balloting system. Local closure requires unique choices,
|
||||
eligible/cast counts, matching result counts, an explicit quorum result, an
|
||||
Approval reference, and evidence. Decided agenda items require a formal
|
||||
Decision reference, and meetings cannot close while agenda items remain
|
||||
unfinished. Accepted or corrected minutes require a Records reference,
|
||||
Approval, and evidence.
|
||||
|
||||
Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.
|
||||
A provider-bound vote is finalized only 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
|
||||
the cast count, stay within eligibility, carry same-tenant evidence, and supply
|
||||
a lowercase SHA-256 result digest plus provider receipt. Committee persists
|
||||
that aggregate and does not persist voter choices or provider credentials.
|
||||
|
||||
Database restore is the module's semantic recovery unit. Calendar events,
|
||||
documents, records, tasks, approvals, and externally conducted votes remain
|
||||
recoverable through their owning providers and are linked by stable references.
|
||||
|
||||
## Decision Reconstruction Proof
|
||||
|
||||
`tests/test_decision_path.py` proves effective-time authority, organization,
|
||||
function, and jurisdiction coverage, approval, legal basis/evidence versions, requested effects,
|
||||
information governance, responsible actor/automation assurance, and a protected
|
||||
reconstruction payload. A vote remains
|
||||
an approval reference and is not made indistinguishable from the formal
|
||||
institutional outcome.
|
||||
|
||||
`tests/test_workspace.py` proves parent and lifecycle constraints, immutable
|
||||
history, replay and stale-write rejection, tenant isolation, committed-only
|
||||
events, vote/quorum evidence, adapter-only provider closure, aggregate-only
|
||||
secret-ballot persistence, minutes, and the local Decision projection.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "govoplan-committee"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN Committee platform module seed."
|
||||
description = "GovOPlaN committee governance and formal-decision integration module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
import re
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
EvidenceReference,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CommitteeWorkspaceError,
|
||||
CommitteeWorkspaceRecord,
|
||||
get_workspace_object,
|
||||
record_workspace_object,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_COMMITTEE_BALLOT_FINALIZER = "committee.ballot_finalizer"
|
||||
_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,79}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def ballot_adapter_capability(provider_id: str) -> str:
|
||||
value = str(provider_id or "").strip()
|
||||
if not _PROVIDER_RE.fullmatch(value):
|
||||
raise CommitteeWorkspaceError("Committee ballot provider id is invalid.")
|
||||
return f"committee.ballot_adapter.{value}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BallotFinalizationRequest:
|
||||
tenant_id: str
|
||||
vote_id: str
|
||||
provider_id: str
|
||||
provider_ballot_ref: str
|
||||
choices: tuple[str, ...]
|
||||
eligible_count: int
|
||||
requested_at: datetime
|
||||
idempotency_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
ballot_adapter_capability(self.provider_id)
|
||||
if not self.tenant_id.strip() or not self.vote_id.strip():
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot tenant and vote identifiers are required."
|
||||
)
|
||||
if not self.provider_ballot_ref.strip():
|
||||
raise CommitteeWorkspaceError(
|
||||
"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."
|
||||
)
|
||||
if self.eligible_count < 0:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot eligible count cannot be negative."
|
||||
)
|
||||
if self.requested_at.tzinfo is None:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot requested_at must include a timezone."
|
||||
)
|
||||
if not self.idempotency_key.strip():
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot idempotency key is required."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BallotFinalizationResult:
|
||||
provider_id: str
|
||||
provider_ballot_ref: str
|
||||
counts: Mapping[str, int]
|
||||
cast_count: int
|
||||
quorum_met: bool
|
||||
receipt_ref: str
|
||||
result_sha256: str
|
||||
evidence: tuple[EvidenceReference, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
ballot_adapter_capability(self.provider_id)
|
||||
if not self.provider_ballot_ref.strip() or not self.receipt_ref.strip():
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot provider and receipt references are required."
|
||||
)
|
||||
if not _SHA256_RE.fullmatch(self.result_sha256):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot result hash must be a lowercase SHA-256 digest."
|
||||
)
|
||||
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."
|
||||
)
|
||||
if not self.evidence:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot finalization requires provider evidence."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CommitteeBallotAdapter(Protocol):
|
||||
def finalize_ballot(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: BallotFinalizationRequest,
|
||||
) -> BallotFinalizationResult: ...
|
||||
|
||||
|
||||
class CommitteeBallotFinalizer:
|
||||
def __init__(self, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
vote_id: str,
|
||||
provider_id: str,
|
||||
provider_ballot_ref: str,
|
||||
approval_ref: InstitutionalReference,
|
||||
expected_revision: int,
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> CommitteeWorkspaceRecord:
|
||||
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 by a ballot provider."
|
||||
)
|
||||
if current.revision != expected_revision:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee revision conflict: the expected revision is stale."
|
||||
)
|
||||
configured_provider = str(current.attributes.get("provider_id") or "").strip()
|
||||
if configured_provider and configured_provider != provider_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee vote is bound to another ballot provider."
|
||||
)
|
||||
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
|
||||
eligible_count = int(current.attributes.get("eligible_count") or 0)
|
||||
request = BallotFinalizationRequest(
|
||||
tenant_id=current.tenant_id,
|
||||
vote_id=current.object_id,
|
||||
provider_id=provider_id,
|
||||
provider_ballot_ref=provider_ballot_ref,
|
||||
choices=choices,
|
||||
eligible_count=eligible_count,
|
||||
requested_at=recorded_at,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
adapter = _capability(
|
||||
self._registry,
|
||||
ballot_adapter_capability(provider_id),
|
||||
)
|
||||
if not isinstance(adapter, CommitteeBallotAdapter):
|
||||
raise CommitteeWorkspaceError(
|
||||
f"Committee ballot provider is unavailable: {provider_id}."
|
||||
)
|
||||
result = adapter.finalize_ballot(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
_validate_result(result, request=request)
|
||||
counts = {choice: int(result.counts.get(choice, 0)) for choice in choices}
|
||||
next_record = replace(
|
||||
current,
|
||||
revision=current.revision + 1,
|
||||
state="closed",
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
attributes={
|
||||
**dict(current.attributes),
|
||||
"provider_id": provider_id,
|
||||
"provider_ballot_ref": provider_ballot_ref,
|
||||
"provider_receipt_ref": result.receipt_ref,
|
||||
"provider_result_sha256": result.result_sha256,
|
||||
"counts": counts,
|
||||
"cast_count": result.cast_count,
|
||||
"quorum_met": result.quorum_met,
|
||||
"approval_ref": approval_ref.to_dict(),
|
||||
},
|
||||
evidence=_merge_evidence(current.evidence, result.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,
|
||||
*,
|
||||
request: BallotFinalizationRequest,
|
||||
) -> None:
|
||||
if (
|
||||
result.provider_id != request.provider_id
|
||||
or result.provider_ballot_ref != request.provider_ballot_ref
|
||||
):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot provider returned a result for another ballot."
|
||||
)
|
||||
if set(result.counts) != set(request.choices):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot provider result must cover exactly the configured choices."
|
||||
)
|
||||
if sum(result.counts.values()) != result.cast_count:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot provider counts do not match cast_count."
|
||||
)
|
||||
if result.cast_count > request.eligible_count:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot provider cast_count exceeds eligible_count."
|
||||
)
|
||||
if any(item.tenant_id != request.tenant_id for item in result.evidence):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee ballot provider evidence cannot cross tenants."
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _merge_evidence(
|
||||
existing: tuple[EvidenceReference, ...],
|
||||
incoming: tuple[EvidenceReference, ...],
|
||||
) -> tuple[EvidenceReference, ...]:
|
||||
merged: list[EvidenceReference] = []
|
||||
seen: set[tuple[str, str, str, str, str]] = set()
|
||||
for item in (*existing, *incoming):
|
||||
key = (
|
||||
item.kind,
|
||||
item.owner_module,
|
||||
item.evidence_id,
|
||||
item.tenant_id,
|
||||
item.version,
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(item)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_COMMITTEE_BALLOT_FINALIZER",
|
||||
"BallotFinalizationRequest",
|
||||
"BallotFinalizationResult",
|
||||
"CommitteeBallotAdapter",
|
||||
"CommitteeBallotFinalizer",
|
||||
"ballot_adapter_capability",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Committee database models."""
|
||||
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CommitteeDecisionProjection",
|
||||
"CommitteeWorkspaceEvent",
|
||||
"CommitteeWorkspaceRevision",
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class CommitteeWorkspaceRevision(Base, TimestampMixin):
|
||||
__tablename__ = "committee_workspace_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"revision",
|
||||
name="uq_committee_workspace_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_committee_workspace_current",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index(
|
||||
"ix_committee_workspace_parent",
|
||||
"tenant_id",
|
||||
"parent_kind",
|
||||
"parent_id",
|
||||
"object_kind",
|
||||
"state",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("committee_workspace_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
parent_kind: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
parent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
search_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
class CommitteeWorkspaceEvent(Base, TimestampMixin):
|
||||
__tablename__ = "committee_workspace_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "event_id", name="uq_committee_workspace_event"),
|
||||
UniqueConstraint("tenant_id", "idempotency_key", name="uq_committee_workspace_idempotency"),
|
||||
Index(
|
||||
"ix_committee_workspace_event_object",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"occurred_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
object_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class CommitteeDecisionProjection(Base, TimestampMixin):
|
||||
__tablename__ = "committee_decision_projections"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"decision_id",
|
||||
"revision",
|
||||
name="uq_committee_decision_projection_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_committee_decision_projection_current",
|
||||
"tenant_id",
|
||||
"decision_id",
|
||||
"superseded_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
decision_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("committee_decision_projections.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
meeting_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
agenda_item_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommitteeDecisionProjection",
|
||||
"CommitteeWorkspaceEvent",
|
||||
"CommitteeWorkspaceRevision",
|
||||
]
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
ActorRepresentationReference,
|
||||
DecisionAssuranceLevel,
|
||||
DecisionEffectReference,
|
||||
DecisionRegistry,
|
||||
EvidenceReference,
|
||||
FormalDecision,
|
||||
GovernedContextEnvelope,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
MandateResolutionRequest,
|
||||
MandateResolver,
|
||||
TemporalRevision,
|
||||
resolve_mandate_candidates,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import CAPABILITY_COMMITTEE_WORKSPACE
|
||||
|
||||
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH = "committee.decision_path"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommitteeDecisionProposal:
|
||||
tenant_id: str
|
||||
decision_id: str
|
||||
revision: str
|
||||
effective_at: datetime
|
||||
meeting_ref: str
|
||||
agenda_item_ref: str
|
||||
decision_type: str
|
||||
subject_refs: tuple[InstitutionalReference, ...]
|
||||
organization_unit_ref: InstitutionalReference
|
||||
function_ref: InstitutionalReference
|
||||
actor: ActorRepresentationReference
|
||||
approval_refs: tuple[InstitutionalReference, ...]
|
||||
fact_evidence: tuple[EvidenceReference, ...]
|
||||
legal_bases: tuple[LegalBasisReference, ...]
|
||||
operative_result: str
|
||||
reasoning: str
|
||||
case_ref: InstitutionalReference | None = None
|
||||
jurisdiction_refs: tuple[InstitutionalReference, ...] = ()
|
||||
party_refs: tuple[InstitutionalReference, ...] = ()
|
||||
record_refs: tuple[InstitutionalReference, ...] = ()
|
||||
conditions: tuple[str, ...] = ()
|
||||
requested_effects: tuple[DecisionEffectReference, ...] = ()
|
||||
remedy_refs: tuple[str, ...] = ()
|
||||
review_refs: tuple[str, ...] = ()
|
||||
information_governance: InformationGovernanceReference | None = None
|
||||
assurance_level: DecisionAssuranceLevel = "human"
|
||||
automation_preparation_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.effective_at.tzinfo is None or self.effective_at.utcoffset() is None:
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision effective_at must include a timezone."
|
||||
)
|
||||
if not self.meeting_ref.strip() or not self.agenda_item_ref.strip():
|
||||
raise InstitutionalContextError(
|
||||
"Committee meeting and agenda item references are required."
|
||||
)
|
||||
if not self.subject_refs:
|
||||
raise InstitutionalContextError(
|
||||
"Committee decisions require at least one subject."
|
||||
)
|
||||
if not self.approval_refs:
|
||||
raise InstitutionalContextError(
|
||||
"Committee decisions require an accepted approval reference."
|
||||
)
|
||||
if not self.fact_evidence or not self.legal_bases:
|
||||
raise InstitutionalContextError(
|
||||
"Committee decisions require fact evidence and legal basis versions."
|
||||
)
|
||||
if not self.operative_result.strip() or not self.reasoning.strip():
|
||||
raise InstitutionalContextError(
|
||||
"Committee decisions require an operative result and reasoning."
|
||||
)
|
||||
references = (
|
||||
*self.subject_refs,
|
||||
self.organization_unit_ref,
|
||||
self.function_ref,
|
||||
*self.approval_refs,
|
||||
self.case_ref,
|
||||
*self.jurisdiction_refs,
|
||||
*self.party_refs,
|
||||
*self.record_refs,
|
||||
)
|
||||
if any(
|
||||
item is not None and item.tenant_id != self.tenant_id
|
||||
for item in references
|
||||
):
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision references cannot cross tenants."
|
||||
)
|
||||
if self.organization_unit_ref.kind != "organization_unit":
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision organization reference must identify an organization unit."
|
||||
)
|
||||
if self.function_ref.kind != "function":
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision function reference must identify a function."
|
||||
)
|
||||
if any(item.kind != "approval" for item in self.approval_refs):
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision approval references must have approval kind."
|
||||
)
|
||||
if any(item.kind != "jurisdiction" for item in self.jurisdiction_refs):
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision jurisdiction references must have jurisdiction kind."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommitteeDecisionPathResult:
|
||||
decision: FormalDecision
|
||||
mandate: MandateDefinition
|
||||
persisted_by_decision_registry: bool
|
||||
persisted_by_committee_projection: bool = False
|
||||
|
||||
def reconstruction_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"decision": self.decision.to_dict(include_protected=True),
|
||||
"mandate_ref": self.mandate.reference.to_dict(),
|
||||
"mandate_revision": self.mandate.temporal.to_dict(),
|
||||
"persisted_by_decision_registry": self.persisted_by_decision_registry,
|
||||
"persisted_by_committee_projection": self.persisted_by_committee_projection,
|
||||
}
|
||||
|
||||
|
||||
class CommitteeDecisionPath:
|
||||
"""Build one reconstructable formal outcome without owning Decision storage."""
|
||||
|
||||
def __init__(self, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def decide(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
proposal: CommitteeDecisionProposal,
|
||||
mandate_resolution: MandateResolution | None = None,
|
||||
observed_effects: tuple[DecisionEffectReference, ...] = (),
|
||||
expected_revision: str | None = None,
|
||||
) -> CommitteeDecisionPathResult:
|
||||
resolution = mandate_resolution or self._resolve_mandate(
|
||||
session,
|
||||
principal,
|
||||
proposal=proposal,
|
||||
)
|
||||
mandate = _accepted_mandate(proposal, resolution)
|
||||
decision_registry = _capability(
|
||||
self._registry,
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
)
|
||||
decision_ref = InstitutionalReference(
|
||||
kind="decision",
|
||||
owner_module=(
|
||||
"decisions"
|
||||
if isinstance(decision_registry, DecisionRegistry)
|
||||
else "committee"
|
||||
),
|
||||
object_id=proposal.decision_id,
|
||||
tenant_id=proposal.tenant_id,
|
||||
version=proposal.revision,
|
||||
valid_at=proposal.effective_at,
|
||||
)
|
||||
temporal = TemporalRevision(
|
||||
revision=proposal.revision,
|
||||
valid_from=proposal.effective_at,
|
||||
recorded_at=proposal.effective_at,
|
||||
change_reason="Committee decision accepted",
|
||||
)
|
||||
evidence = tuple(
|
||||
dict.fromkeys((*proposal.fact_evidence, *mandate.evidence, *resolution.evidence))
|
||||
)
|
||||
legal_bases = tuple(
|
||||
dict.fromkeys((*proposal.legal_bases, *mandate.legal_bases))
|
||||
)
|
||||
authority_context = GovernedContextEnvelope(
|
||||
tenant_id=proposal.tenant_id,
|
||||
temporal=temporal,
|
||||
actor=proposal.actor,
|
||||
organization_unit_ref=proposal.organization_unit_ref,
|
||||
function_ref=proposal.function_ref,
|
||||
mandate_ref=mandate.reference,
|
||||
jurisdiction_refs=proposal.jurisdiction_refs,
|
||||
case_ref=proposal.case_ref,
|
||||
party_refs=proposal.party_refs,
|
||||
approval_refs=proposal.approval_refs,
|
||||
decision_ref=decision_ref,
|
||||
record_refs=proposal.record_refs,
|
||||
legal_bases=legal_bases,
|
||||
evidence=evidence,
|
||||
information_governance=proposal.information_governance,
|
||||
)
|
||||
decision = FormalDecision(
|
||||
reference=decision_ref,
|
||||
temporal=temporal,
|
||||
decision_type=proposal.decision_type,
|
||||
subject_refs=proposal.subject_refs,
|
||||
state="decided",
|
||||
authority_context=authority_context,
|
||||
fact_evidence=evidence,
|
||||
legal_bases=legal_bases,
|
||||
assurance_level=proposal.assurance_level,
|
||||
automation_preparation_refs=proposal.automation_preparation_refs,
|
||||
operative_result=proposal.operative_result,
|
||||
reasoning=proposal.reasoning,
|
||||
conditions=proposal.conditions,
|
||||
requested_effects=proposal.requested_effects,
|
||||
observed_effects=observed_effects,
|
||||
publication_refs=(
|
||||
f"committee-meeting:{proposal.meeting_ref}",
|
||||
f"committee-agenda-item:{proposal.agenda_item_ref}",
|
||||
),
|
||||
remedy_refs=proposal.remedy_refs,
|
||||
review_refs=proposal.review_refs,
|
||||
)
|
||||
persisted = False
|
||||
projected = False
|
||||
if isinstance(decision_registry, DecisionRegistry):
|
||||
decision = decision_registry.record_decision(
|
||||
session,
|
||||
principal,
|
||||
decision=decision,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
persisted = True
|
||||
else:
|
||||
workspace = _capability(self._registry, CAPABILITY_COMMITTEE_WORKSPACE)
|
||||
if workspace is not None and hasattr(workspace, "record_local_decision"):
|
||||
decision = workspace.record_local_decision(
|
||||
session,
|
||||
principal,
|
||||
decision=decision,
|
||||
meeting_id=proposal.meeting_ref,
|
||||
agenda_item_id=proposal.agenda_item_ref,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
projected = True
|
||||
return CommitteeDecisionPathResult(
|
||||
decision=decision,
|
||||
mandate=mandate,
|
||||
persisted_by_decision_registry=persisted,
|
||||
persisted_by_committee_projection=projected,
|
||||
)
|
||||
|
||||
def _resolve_mandate(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
proposal: CommitteeDecisionProposal,
|
||||
) -> MandateResolution:
|
||||
resolver = _capability(self._registry, CAPABILITY_MANDATE_RESOLVER)
|
||||
if not isinstance(resolver, MandateResolver):
|
||||
raise InstitutionalContextError(
|
||||
"Committee decision requires a Mandate resolver or an explicit governed resolution."
|
||||
)
|
||||
return resolver.resolve_mandate(
|
||||
session,
|
||||
principal,
|
||||
request=_mandate_request(proposal),
|
||||
)
|
||||
|
||||
|
||||
def _accepted_mandate(
|
||||
proposal: CommitteeDecisionProposal,
|
||||
resolution: MandateResolution,
|
||||
) -> MandateDefinition:
|
||||
if not resolution.competent:
|
||||
raise InstitutionalContextError(
|
||||
resolution.explanation or "The acting function is not competent to decide."
|
||||
)
|
||||
if resolution.conflict_refs:
|
||||
raise InstitutionalContextError(
|
||||
"Mandate resolution has unresolved conflicts: "
|
||||
+ ", ".join(resolution.conflict_refs)
|
||||
)
|
||||
verified = resolve_mandate_candidates(
|
||||
_mandate_request(proposal),
|
||||
resolution.mandates,
|
||||
)
|
||||
if not verified.competent:
|
||||
raise InstitutionalContextError(
|
||||
verified.explanation
|
||||
or "Committee decision requires exactly one effective active mandate."
|
||||
)
|
||||
return verified.mandates[0]
|
||||
|
||||
|
||||
def _mandate_request(
|
||||
proposal: CommitteeDecisionProposal,
|
||||
) -> MandateResolutionRequest:
|
||||
return MandateResolutionRequest(
|
||||
tenant_id=proposal.tenant_id,
|
||||
effective_at=proposal.effective_at,
|
||||
task_type="committee.formal_decision",
|
||||
authority_type=proposal.decision_type,
|
||||
organization_unit_ref=proposal.organization_unit_ref,
|
||||
function_ref=proposal.function_ref,
|
||||
jurisdiction_refs=proposal.jurisdiction_refs,
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_COMMITTEE_DECISION_PATH",
|
||||
"CommitteeDecisionPath",
|
||||
"CommitteeDecisionPathResult",
|
||||
"CommitteeDecisionProposal",
|
||||
]
|
||||
@@ -1,23 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_committee.backend.decision_path import (
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH,
|
||||
CommitteeDecisionPath,
|
||||
)
|
||||
from govoplan_committee.backend.ballots import (
|
||||
CAPABILITY_COMMITTEE_BALLOT_FINALIZER,
|
||||
CommitteeBallotFinalizer,
|
||||
)
|
||||
from govoplan_committee.backend.db import models as committee_models
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CAPABILITY_COMMITTEE_WORKSPACE,
|
||||
SqlCommitteeWorkspace,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
MODULE_ID = "committee"
|
||||
MODULE_NAME = "Committee"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
READ_SCOPE = "committee:workspace:read"
|
||||
WRITE_SCOPE = "committee:workspace:write"
|
||||
BALLOT_SCOPE = "committee:ballot:finalize"
|
||||
ADMIN_SCOPE = "committee:workspace:admin"
|
||||
PROTECTED_READ_SCOPE = "committee:decision:protected_read"
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"calendar",
|
||||
"docs",
|
||||
"files",
|
||||
"mandates",
|
||||
"decisions",
|
||||
"tasks",
|
||||
"workflow_engine",
|
||||
"approvals",
|
||||
)
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="communication_participation",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_decision_path.py",
|
||||
summary="Proves effective mandate, approval, evidence, effect, and reconstruction semantics for a committee decision.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/COMMITTEE_DOMAIN_BOUNDARY.md",
|
||||
summary="Defines the Committee/Decision/Mandate ownership boundary.",
|
||||
),
|
||||
),
|
||||
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.",
|
||||
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"committee bodies",
|
||||
"meeting and agenda context",
|
||||
"deliberation and vote context",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"formal decision lifecycle",
|
||||
"mandate and jurisdiction lifecycle",
|
||||
"generic approvals",
|
||||
),
|
||||
reference_packages=("product.service-to-decision",),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
security=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",),
|
||||
operations=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _decision_path(context: ModuleContext) -> CommitteeDecisionPath:
|
||||
return CommitteeDecisionPath(context.registry)
|
||||
|
||||
|
||||
def _workspace(context: ModuleContext) -> SqlCommitteeWorkspace:
|
||||
del context
|
||||
return SqlCommitteeWorkspace()
|
||||
|
||||
|
||||
def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer:
|
||||
return CommitteeBallotFinalizer(context.registry)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_committee.backend.router import configure_registry, router
|
||||
|
||||
configure_registry(context.registry)
|
||||
return router
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
current = session.query(committee_models.CommitteeWorkspaceRevision).filter(
|
||||
committee_models.CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
committee_models.CommitteeWorkspaceRevision.superseded_at.is_(None),
|
||||
)
|
||||
return {
|
||||
"committee_bodies": current.filter(
|
||||
committee_models.CommitteeWorkspaceRevision.object_kind == "body",
|
||||
committee_models.CommitteeWorkspaceRevision.state == "active",
|
||||
).count(),
|
||||
"committee_meetings": current.filter(
|
||||
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
|
||||
committee_models.CommitteeWorkspaceRevision.state.in_(("scheduled", "open")),
|
||||
).count(),
|
||||
}
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
@@ -34,9 +160,31 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View committee workspace", "Read committee records, configuration, and workflow context."),
|
||||
_permission(WRITE_SCOPE, "Manage committee workspace", "Create and update committee records and workflow state."),
|
||||
_permission(ADMIN_SCOPE, "Administer committee workspace", "Configure committee policies, templates, and tenant-level administration."),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View committee workspace",
|
||||
"Read committee records, configuration, and workflow context.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage committee workspace",
|
||||
"Create and update committee records and workflow state.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer committee workspace",
|
||||
"Configure committee policies, templates, and tenant-level administration.",
|
||||
),
|
||||
_permission(
|
||||
BALLOT_SCOPE,
|
||||
"Finalize provider ballots",
|
||||
"Import a verifiable aggregate result from an installed external or secret ballot provider.",
|
||||
),
|
||||
_permission(
|
||||
PROTECTED_READ_SCOPE,
|
||||
"Read protected committee decisions",
|
||||
"Read reasoning and protected institutional context from a Committee-owned fallback Decision projection.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -44,7 +192,7 @@ ROLE_TEMPLATES = (
|
||||
slug="committee_manager",
|
||||
name="Committee manager",
|
||||
description="Manage committee records and workflow state.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="committee_viewer",
|
||||
@@ -58,15 +206,20 @@ DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.module-boundary",
|
||||
title=f"{MODULE_NAME} module boundary",
|
||||
summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.",
|
||||
summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.",
|
||||
body=(
|
||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
||||
"database models, migrations, and WebUI routes are introduced."
|
||||
"The persistent workspace keeps immutable bodies, meetings, agenda items, governed "
|
||||
"vote results, minutes, and lifecycle events. The decision path constructs a "
|
||||
"reconstructable formal Decision from that context, an effective Mandate resolution, "
|
||||
"approval, versioned legal bases, evidence, reasoning, and requested or observed "
|
||||
"effects. Committee does not own generic Mandate or Decision persistence; optional "
|
||||
"providers resolve and record those objects when installed, while a protected local "
|
||||
"projection preserves the bounded fallback. Provider-bound ballots are finalized "
|
||||
"through adapter capabilities without retaining individual ballots."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
@@ -78,8 +231,19 @@ DOCUMENTATION = (
|
||||
),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"domain_objects": ['committee bodies', 'meeting agendas', 'minutes', 'decision records', 'votes', 'follow-up assignments'],
|
||||
"first_slice": "Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.",
|
||||
"domain_objects": [
|
||||
"committee bodies",
|
||||
"meeting agendas",
|
||||
"minutes",
|
||||
"deliberation and vote context",
|
||||
"formal decision references",
|
||||
"votes",
|
||||
"follow-up assignments",
|
||||
],
|
||||
"first_slice": "Persist committee body, meeting, agenda item, governed vote result, minute, and formal Decision references.",
|
||||
"does_not_own": [
|
||||
"generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle"
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -90,10 +254,128 @@ manifest = ModuleManifest(
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
),
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/committee",
|
||||
label="Committee",
|
||||
icon="gavel",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/committee-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/committee",
|
||||
component="CommitteePage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/committee",
|
||||
label="Committee",
|
||||
icon="gavel",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="committee.navigation",
|
||||
module_id=MODULE_ID,
|
||||
kind="navigation",
|
||||
label="Committee navigation",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="committee.workspace",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Committee workspace",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(
|
||||
name="committee.decision_path",
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="committee.workspace",
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER,
|
||||
version="0.1.0",
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
|
||||
CAPABILITY_COMMITTEE_WORKSPACE: _workspace,
|
||||
CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation(
|
||||
label="Committee decision path",
|
||||
summary="Builds a formal, evidence-backed Decision from governed committee context.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_COMMITTEE_WORKSPACE: CapabilityDocumentation(
|
||||
label="Committee workspace",
|
||||
summary="Persists versioned bodies, meetings, agenda items, vote results, minutes, and fallback Decision projections.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
CAPABILITY_COMMITTEE_BALLOT_FINALIZER: CapabilityDocumentation(
|
||||
label="Committee ballot finalizer",
|
||||
summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
committee_models.CommitteeDecisionProjection,
|
||||
committee_models.CommitteeWorkspaceEvent,
|
||||
committee_models.CommitteeWorkspaceRevision,
|
||||
label="Committee",
|
||||
),
|
||||
retirement_notes="Destructive retirement requires a database snapshot and removes Committee workspace revisions, lifecycle events, and local Decision projections.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
committee_models.CommitteeWorkspaceRevision,
|
||||
committee_models.CommitteeWorkspaceEvent,
|
||||
committee_models.CommitteeDecisionProjection,
|
||||
label="Committee",
|
||||
),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
documentation=DOCUMENTATION,
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Committee Alembic revisions."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Committee migration revisions."""
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
"""v0.1.8 Committee workspace baseline.
|
||||
|
||||
Revision ID: d8b9f0a1c2e3
|
||||
Revises: None
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8b9f0a1c2e3"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"committee_workspace_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("object_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("parent_kind", sa.String(length=30), nullable=True),
|
||||
sa.Column("parent_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("state", sa.String(length=30), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=False),
|
||||
sa.Column("search_text", sa.Text(), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["previous_revision_id"], ["committee_workspace_revisions.id"], name=op.f("fk_committee_workspace_revisions_previous_revision_id_committee_workspace_revisions"), ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_workspace_revisions")),
|
||||
sa.UniqueConstraint("tenant_id", "object_kind", "object_id", "revision", name="uq_committee_workspace_revision"),
|
||||
)
|
||||
for column in ("tenant_id", "object_kind", "object_id", "previous_revision_id", "parent_kind", "parent_id", "state", "recorded_at", "superseded_at", "changed_by"):
|
||||
op.create_index(op.f(f"ix_committee_workspace_revisions_{column}"), "committee_workspace_revisions", [column], unique=False)
|
||||
op.create_index("ix_committee_workspace_current", "committee_workspace_revisions", ["tenant_id", "object_kind", "object_id", "superseded_at"], unique=False)
|
||||
op.create_index("ix_committee_workspace_parent", "committee_workspace_revisions", ["tenant_id", "parent_kind", "parent_id", "object_kind", "state"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"committee_workspace_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("object_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("object_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_workspace_events")),
|
||||
sa.UniqueConstraint("tenant_id", "event_id", name="uq_committee_workspace_event"),
|
||||
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_committee_workspace_idempotency"),
|
||||
)
|
||||
for column in ("tenant_id", "object_kind", "object_id", "event_id", "event_type", "occurred_at", "actor_id"):
|
||||
op.create_index(op.f(f"ix_committee_workspace_events_{column}"), "committee_workspace_events", [column], unique=False)
|
||||
op.create_index("ix_committee_workspace_event_object", "committee_workspace_events", ["tenant_id", "object_kind", "object_id", "occurred_at"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"committee_decision_projections",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("decision_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.String(length=120), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("meeting_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("agenda_item_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("state", sa.String(length=30), nullable=False),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["previous_revision_id"], ["committee_decision_projections.id"], name=op.f("fk_committee_decision_projections_previous_revision_id_committee_decision_projections"), ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_decision_projections")),
|
||||
sa.UniqueConstraint("tenant_id", "decision_id", "revision", name="uq_committee_decision_projection_revision"),
|
||||
)
|
||||
for column in ("tenant_id", "decision_id", "previous_revision_id", "meeting_id", "agenda_item_id", "state", "recorded_at", "superseded_at", "changed_by"):
|
||||
op.create_index(op.f(f"ix_committee_decision_projections_{column}"), "committee_decision_projections", [column], unique=False)
|
||||
op.create_index("ix_committee_decision_projection_current", "committee_decision_projections", ["tenant_id", "decision_id", "superseded_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("committee_decision_projections")
|
||||
op.drop_table("committee_workspace_events")
|
||||
op.drop_table("committee_workspace_revisions")
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.institutional import (
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_committee.backend.ballots import CommitteeBallotFinalizer
|
||||
from govoplan_committee.backend.manifest import (
|
||||
BALLOT_SCOPE,
|
||||
PROTECTED_READ_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
)
|
||||
from govoplan_committee.backend.schemas import (
|
||||
CommitteeBallotFinalizeRequest,
|
||||
CommitteeWorkspaceHistoryResponse,
|
||||
CommitteeWorkspaceListResponse,
|
||||
CommitteeWorkspaceWriteRequest,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CommitteeWorkspaceError,
|
||||
CommitteeWorkspaceRecord,
|
||||
get_local_decision,
|
||||
get_workspace_object,
|
||||
list_workspace_objects,
|
||||
record_workspace_object,
|
||||
workspace_history,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/committee", tags=["committee"])
|
||||
_registry: object | None = None
|
||||
|
||||
|
||||
def configure_registry(registry: object | None) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
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
|
||||
return HTTPException(status_code=code, detail=message)
|
||||
|
||||
|
||||
@router.get("/decision-projections/{decision_id}", response_model=dict[str, Any])
|
||||
def api_get_local_decision(
|
||||
decision_id: str,
|
||||
revision: str | None = Query(default=None, max_length=120),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, PROTECTED_READ_SCOPE)
|
||||
item = get_local_decision(
|
||||
session,
|
||||
principal,
|
||||
decision_id=decision_id,
|
||||
revision=revision,
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Committee Decision projection not found")
|
||||
return item.to_dict(include_protected=True)
|
||||
|
||||
|
||||
@router.get("/workspace/{object_kind}", response_model=CommitteeWorkspaceListResponse)
|
||||
def api_list_workspace(
|
||||
object_kind: str,
|
||||
parent_id: str | None = Query(default=None, max_length=255),
|
||||
state: list[str] | None = Query(default=None),
|
||||
query: str = "",
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> CommitteeWorkspaceListResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
records, total = list_workspace_objects(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
parent_id=parent_id,
|
||||
states=state,
|
||||
query=query,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
except CommitteeWorkspaceError as exc:
|
||||
raise _error(exc) from exc
|
||||
return CommitteeWorkspaceListResponse(
|
||||
records=[item.to_dict() for item in records],
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspace/{object_kind}",
|
||||
response_model=dict[str, Any],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_record_workspace(
|
||||
object_kind: str,
|
||||
payload: CommitteeWorkspaceWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
raw = dict(payload.record)
|
||||
if str(raw.get("object_kind") or "") != object_kind:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Committee object kind in path and payload must match.",
|
||||
)
|
||||
try:
|
||||
item = record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=CommitteeWorkspaceRecord.from_mapping(raw),
|
||||
idempotency_key=payload.idempotency_key,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
session.commit()
|
||||
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspace/vote/{vote_id}/finalize-provider",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_finalize_provider_vote(
|
||||
vote_id: str,
|
||||
payload: CommitteeBallotFinalizeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, BALLOT_SCOPE)
|
||||
try:
|
||||
item = CommitteeBallotFinalizer(_registry).finalize(
|
||||
session,
|
||||
principal,
|
||||
vote_id=vote_id,
|
||||
provider_id=payload.provider_id,
|
||||
provider_ballot_ref=payload.provider_ballot_ref,
|
||||
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,
|
||||
object_id: str,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
item = get_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
revision=revision,
|
||||
)
|
||||
except CommitteeWorkspaceError as exc:
|
||||
raise _error(exc) from exc
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Committee object not found")
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/workspace/{object_kind}/{object_id}/history",
|
||||
response_model=CommitteeWorkspaceHistoryResponse,
|
||||
)
|
||||
def api_workspace_history(
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> CommitteeWorkspaceHistoryResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
revisions = workspace_history(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
limit=limit,
|
||||
)
|
||||
except CommitteeWorkspaceError as exc:
|
||||
raise _error(exc) from exc
|
||||
return CommitteeWorkspaceHistoryResponse(
|
||||
revisions=[item.to_dict() for item in revisions]
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["configure_registry", "router"]
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CommitteeWorkspaceWriteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
record: dict[str, Any]
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class CommitteeWorkspaceListResponse(BaseModel):
|
||||
records: list[dict[str, Any]]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
|
||||
|
||||
class CommitteeWorkspaceHistoryResponse(BaseModel):
|
||||
revisions: list[dict[str, Any]]
|
||||
|
||||
|
||||
class CommitteeBallotFinalizeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider_id: str = Field(min_length=1, max_length=80)
|
||||
provider_ballot_ref: str = Field(min_length=1, max_length=255)
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,980 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.institutional import (
|
||||
EvidenceReference,
|
||||
FormalDecision,
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
)
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_COMMITTEE_WORKSPACE = "committee.workspace"
|
||||
CommitteeObjectKind = Literal["body", "meeting", "agenda_item", "vote", "minute"]
|
||||
|
||||
_PARENT_KIND: dict[str, str | None] = {
|
||||
"body": None,
|
||||
"meeting": "body",
|
||||
"agenda_item": "meeting",
|
||||
"vote": "agenda_item",
|
||||
"minute": "meeting",
|
||||
}
|
||||
_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"}),
|
||||
"vote": frozenset({"draft", "open", "closed", "cancelled"}),
|
||||
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
|
||||
}
|
||||
_TRANSITIONS: dict[str, dict[str, frozenset[str]]] = {
|
||||
"body": {
|
||||
"draft": frozenset({"draft", "active", "retired"}),
|
||||
"active": frozenset({"active", "suspended", "retired"}),
|
||||
"suspended": frozenset({"active", "suspended", "retired"}),
|
||||
"retired": frozenset(),
|
||||
},
|
||||
"meeting": {
|
||||
"draft": frozenset({"draft", "scheduled", "cancelled"}),
|
||||
"scheduled": frozenset({"scheduled", "open", "cancelled"}),
|
||||
"open": frozenset({"open", "closed", "cancelled"}),
|
||||
"closed": frozenset(),
|
||||
"cancelled": frozenset(),
|
||||
},
|
||||
"agenda_item": {
|
||||
"draft": frozenset({"draft", "scheduled", "withdrawn"}),
|
||||
"scheduled": frozenset({"scheduled", "deliberating", "withdrawn"}),
|
||||
"deliberating": frozenset({"deliberating", "decided", "withdrawn"}),
|
||||
"decided": frozenset(),
|
||||
"withdrawn": frozenset(),
|
||||
},
|
||||
"vote": {
|
||||
"draft": frozenset({"draft", "open", "cancelled"}),
|
||||
"open": frozenset({"open", "closed", "cancelled"}),
|
||||
"closed": frozenset(),
|
||||
"cancelled": frozenset(),
|
||||
},
|
||||
"minute": {
|
||||
"draft": frozenset({"draft", "proposed"}),
|
||||
"proposed": frozenset({"proposed", "accepted"}),
|
||||
"accepted": frozenset({"corrected"}),
|
||||
"corrected": frozenset({"corrected"}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CommitteeWorkspaceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommitteeWorkspaceRecord:
|
||||
tenant_id: str
|
||||
object_kind: CommitteeObjectKind
|
||||
object_id: str
|
||||
revision: int
|
||||
state: str
|
||||
title: str
|
||||
recorded_at: datetime
|
||||
change_reason: str
|
||||
parent_id: str | None = None
|
||||
attributes: Mapping[str, Any] = field(default_factory=dict)
|
||||
context: GovernedContextEnvelope | None = None
|
||||
evidence: tuple[EvidenceReference, ...] = ()
|
||||
record_refs: tuple[InstitutionalReference, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.object_kind not in _PARENT_KIND:
|
||||
raise CommitteeWorkspaceError("Unsupported Committee object kind.")
|
||||
_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."
|
||||
)
|
||||
if self.state not in _STATES[self.object_kind]:
|
||||
raise CommitteeWorkspaceError(
|
||||
f"Unsupported {self.object_kind} state: {self.state!r}."
|
||||
)
|
||||
_required_text(self.title, "Committee object title", maximum=500)
|
||||
_required_text(self.change_reason, "Committee change reason", maximum=1_000)
|
||||
_require_aware(self.recorded_at, "Committee recorded_at")
|
||||
expected_parent = _PARENT_KIND[self.object_kind]
|
||||
if expected_parent is None and self.parent_id is not None:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee bodies cannot have a workspace parent."
|
||||
)
|
||||
if expected_parent is not None:
|
||||
_identifier(
|
||||
self.parent_id or "",
|
||||
f"Committee {expected_parent} parent id",
|
||||
maximum=255,
|
||||
)
|
||||
if self.context is not None and self.context.tenant_id != self.tenant_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"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."
|
||||
)
|
||||
if any(
|
||||
item.tenant_id != self.tenant_id or item.kind != "record"
|
||||
for item in self.record_refs
|
||||
):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee record references must be same-tenant record references."
|
||||
)
|
||||
_validate_attributes(self)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"object_kind": self.object_kind,
|
||||
"object_id": self.object_id,
|
||||
"revision": self.revision,
|
||||
"state": self.state,
|
||||
"title": self.title,
|
||||
"parent_id": self.parent_id,
|
||||
"recorded_at": self.recorded_at.isoformat(),
|
||||
"change_reason": self.change_reason,
|
||||
"attributes": _json_value(self.attributes),
|
||||
"context": self.context.to_dict() if self.context else None,
|
||||
"evidence": [item.to_dict() for item in self.evidence],
|
||||
"record_refs": [item.to_dict() for item in self.record_refs],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "CommitteeWorkspaceRecord":
|
||||
context = value.get("context")
|
||||
attributes = value.get("attributes") or {}
|
||||
if not isinstance(attributes, Mapping):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee attributes must be an object."
|
||||
)
|
||||
return cls(
|
||||
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),
|
||||
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),
|
||||
attributes=dict(attributes),
|
||||
context=(
|
||||
GovernedContextEnvelope.from_mapping(context)
|
||||
if isinstance(context, Mapping)
|
||||
else None
|
||||
),
|
||||
evidence=tuple(
|
||||
EvidenceReference.from_mapping(item)
|
||||
for item in _mapping_items(value.get("evidence"), "Committee evidence")
|
||||
),
|
||||
record_refs=tuple(
|
||||
InstitutionalReference.from_mapping(item)
|
||||
for item in _mapping_items(value.get("record_refs"), "Committee record references")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def record_workspace_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
record: CommitteeWorkspaceRecord,
|
||||
idempotency_key: str,
|
||||
expected_revision: int | None = None,
|
||||
_provider_finalization: bool = False,
|
||||
) -> CommitteeWorkspaceRecord:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
if record.tenant_id != tenant_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee workspace records cannot cross tenants."
|
||||
)
|
||||
clean_key = _required_text(
|
||||
idempotency_key,
|
||||
"Committee idempotency key",
|
||||
maximum=255,
|
||||
)
|
||||
request_sha256 = _sha256(
|
||||
{
|
||||
"record": record.to_dict(),
|
||||
"expected_revision": expected_revision,
|
||||
}
|
||||
)
|
||||
replay = _replay(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
idempotency_key=clean_key,
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
current = _current_row(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
lock=True,
|
||||
)
|
||||
if current is None:
|
||||
if expected_revision is not None or record.revision != 1:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A new Committee object must start at revision 1 without an expected revision."
|
||||
)
|
||||
else:
|
||||
if expected_revision != current.revision:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee revision conflict: the expected revision is stale."
|
||||
)
|
||||
if record.revision != current.revision + 1:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee object revisions must be consecutive."
|
||||
)
|
||||
if record.parent_id != current.parent_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A Committee object's parent cannot change across revisions."
|
||||
)
|
||||
if record.state not in _TRANSITIONS[record.object_kind][current.state]:
|
||||
raise CommitteeWorkspaceError(
|
||||
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_attributes = current_payload.get("attributes")
|
||||
provider_id = (
|
||||
str(current_attributes.get("provider_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 not _provider_finalization
|
||||
):
|
||||
raise CommitteeWorkspaceError(
|
||||
"A provider-bound Committee vote must be finalized through its ballot adapter."
|
||||
)
|
||||
current.superseded_at = record.recorded_at
|
||||
_validate_parent(session, record)
|
||||
_validate_related_state(session, record)
|
||||
row = CommitteeWorkspaceRevision(
|
||||
tenant_id=tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
revision=record.revision,
|
||||
previous_revision_id=current.id if current is not None else None,
|
||||
parent_kind=_PARENT_KIND[record.object_kind],
|
||||
parent_id=record.parent_id,
|
||||
state=record.state,
|
||||
title=record.title,
|
||||
search_text=f"{record.title} {record.object_id} {record.state}".casefold(),
|
||||
recorded_at=record.recorded_at,
|
||||
payload=record.to_dict(),
|
||||
changed_by=_principal_actor(principal),
|
||||
)
|
||||
event_id = str(uuid.uuid4())
|
||||
operation = "created" if current is None else "updated"
|
||||
event = CommitteeWorkspaceEvent(
|
||||
tenant_id=tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
object_revision=record.revision,
|
||||
event_id=event_id,
|
||||
event_type=f"committee.{record.object_kind}.{operation}",
|
||||
occurred_at=record.recorded_at,
|
||||
actor_id=_principal_actor(principal),
|
||||
idempotency_key=clean_key,
|
||||
request_sha256=request_sha256,
|
||||
payload={
|
||||
"state": record.state,
|
||||
"revision": record.revision,
|
||||
"parent_id": record.parent_id,
|
||||
"change_reason": record.change_reason,
|
||||
},
|
||||
)
|
||||
session.add_all((row, event))
|
||||
session.flush()
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
event_id=event_id,
|
||||
type=event.event_type,
|
||||
module_id="committee",
|
||||
payload=dict(event.payload),
|
||||
occurred_at=record.recorded_at,
|
||||
actor=EventActorRef(type="account", id=_principal_actor(principal)),
|
||||
tenant=EventTenantRef(id=tenant_id),
|
||||
resource=EventObjectRef(
|
||||
type=f"committee_{record.object_kind}",
|
||||
id=record.object_id,
|
||||
label=record.title,
|
||||
),
|
||||
classification="internal",
|
||||
institutional_context=record.context,
|
||||
),
|
||||
)
|
||||
return _workspace_from_row(row)
|
||||
|
||||
|
||||
def get_workspace_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
revision: int | None = None,
|
||||
) -> CommitteeWorkspaceRecord | None:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
kind = _object_kind(object_kind)
|
||||
query = session.query(CommitteeWorkspaceRevision).filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceRevision.object_kind == kind,
|
||||
CommitteeWorkspaceRevision.object_id == object_id,
|
||||
)
|
||||
if revision is None:
|
||||
query = query.filter(CommitteeWorkspaceRevision.superseded_at.is_(None))
|
||||
else:
|
||||
query = query.filter(CommitteeWorkspaceRevision.revision == revision)
|
||||
row = query.order_by(CommitteeWorkspaceRevision.revision.desc()).first()
|
||||
return _workspace_from_row(row) if row is not None else None
|
||||
|
||||
|
||||
def list_workspace_objects(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
parent_id: str | None = None,
|
||||
states: Sequence[str] | None = None,
|
||||
query: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[tuple[CommitteeWorkspaceRecord, ...], int]:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
kind = _object_kind(object_kind)
|
||||
if offset < 0 or not 1 <= limit <= 200:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee list offset must be non-negative and limit between 1 and 200."
|
||||
)
|
||||
statement = session.query(CommitteeWorkspaceRevision).filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||
CommitteeWorkspaceRevision.object_kind == kind,
|
||||
CommitteeWorkspaceRevision.superseded_at.is_(None),
|
||||
)
|
||||
if parent_id is not None:
|
||||
statement = statement.filter(
|
||||
CommitteeWorkspaceRevision.parent_id == parent_id
|
||||
)
|
||||
if states:
|
||||
statement = statement.filter(
|
||||
CommitteeWorkspaceRevision.state.in_(tuple(states))
|
||||
)
|
||||
clean_query = query.strip().casefold()
|
||||
if clean_query:
|
||||
statement = statement.filter(
|
||||
CommitteeWorkspaceRevision.search_text.contains(clean_query)
|
||||
)
|
||||
total = int(statement.with_entities(func.count()).scalar() or 0)
|
||||
rows = (
|
||||
statement.order_by(
|
||||
CommitteeWorkspaceRevision.recorded_at.desc(),
|
||||
CommitteeWorkspaceRevision.object_id.asc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return tuple(_workspace_from_row(row) for row in rows), total
|
||||
|
||||
|
||||
def workspace_history(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
limit: int = 100,
|
||||
) -> tuple[CommitteeWorkspaceRecord, ...]:
|
||||
if not 1 <= limit <= 200:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee history limit must be between 1 and 200."
|
||||
)
|
||||
rows = (
|
||||
session.query(CommitteeWorkspaceRevision)
|
||||
.filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == _principal_tenant(principal),
|
||||
CommitteeWorkspaceRevision.object_kind == _object_kind(object_kind),
|
||||
CommitteeWorkspaceRevision.object_id == object_id,
|
||||
)
|
||||
.order_by(CommitteeWorkspaceRevision.revision.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return tuple(_workspace_from_row(row) for row in rows)
|
||||
|
||||
|
||||
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 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,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
decision: FormalDecision,
|
||||
meeting_id: str,
|
||||
agenda_item_id: str,
|
||||
expected_revision: str | None = None,
|
||||
) -> FormalDecision:
|
||||
return record_local_decision(
|
||||
_session(session),
|
||||
principal,
|
||||
decision=decision,
|
||||
meeting_id=meeting_id,
|
||||
agenda_item_id=agenda_item_id,
|
||||
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 record_local_decision(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
decision: FormalDecision,
|
||||
meeting_id: str,
|
||||
agenda_item_id: str,
|
||||
expected_revision: str | None = None,
|
||||
) -> FormalDecision:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
if decision.reference.owner_module != "committee":
|
||||
raise CommitteeWorkspaceError(
|
||||
"Only a Committee-owned fallback Decision may use the local projection."
|
||||
)
|
||||
if decision.reference.tenant_id != tenant_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"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:
|
||||
raise CommitteeWorkspaceError(
|
||||
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
|
||||
)
|
||||
payload = decision.to_dict(include_protected=True)
|
||||
replay = (
|
||||
session.query(CommitteeDecisionProjection)
|
||||
.filter(
|
||||
CommitteeDecisionProjection.tenant_id == tenant_id,
|
||||
CommitteeDecisionProjection.decision_id == decision.reference.object_id,
|
||||
CommitteeDecisionProjection.revision == decision.temporal.revision,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if replay is not None:
|
||||
if replay.payload != payload:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A different Committee Decision already uses this revision."
|
||||
)
|
||||
return FormalDecision.from_mapping(replay.payload)
|
||||
current = (
|
||||
session.query(CommitteeDecisionProjection)
|
||||
.filter(
|
||||
CommitteeDecisionProjection.tenant_id == tenant_id,
|
||||
CommitteeDecisionProjection.decision_id == decision.reference.object_id,
|
||||
CommitteeDecisionProjection.superseded_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if current is None:
|
||||
if expected_revision is not None:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee Decision revision conflict: no current projection exists."
|
||||
)
|
||||
else:
|
||||
if expected_revision != current.revision:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee Decision revision conflict: the expected revision is stale."
|
||||
)
|
||||
current.superseded_at = decision.temporal.recorded_at
|
||||
if decision.temporal.recorded_at is None:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee Decision projection requires recorded_at."
|
||||
)
|
||||
row = CommitteeDecisionProjection(
|
||||
tenant_id=tenant_id,
|
||||
decision_id=decision.reference.object_id,
|
||||
revision=decision.temporal.revision,
|
||||
previous_revision_id=current.id if current is not None else None,
|
||||
meeting_id=meeting_id,
|
||||
agenda_item_id=agenda_item_id,
|
||||
state=decision.state,
|
||||
recorded_at=decision.temporal.recorded_at,
|
||||
payload=payload,
|
||||
changed_by=_principal_actor(principal),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type="committee.decision.projected",
|
||||
module_id="committee",
|
||||
payload={
|
||||
"revision": decision.temporal.revision,
|
||||
"state": decision.state,
|
||||
"meeting_id": meeting_id,
|
||||
"agenda_item_id": agenda_item_id,
|
||||
},
|
||||
occurred_at=decision.temporal.recorded_at,
|
||||
actor=EventActorRef(type="account", id=_principal_actor(principal)),
|
||||
tenant=EventTenantRef(id=tenant_id),
|
||||
resource=EventObjectRef(type="decision", id=decision.reference.object_id),
|
||||
classification="restricted",
|
||||
institutional_context=decision.authority_context,
|
||||
),
|
||||
)
|
||||
return FormalDecision.from_mapping(row.payload)
|
||||
|
||||
|
||||
def get_local_decision(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
decision_id: str,
|
||||
revision: str | None = None,
|
||||
) -> FormalDecision | None:
|
||||
query = session.query(CommitteeDecisionProjection).filter(
|
||||
CommitteeDecisionProjection.tenant_id == _principal_tenant(principal),
|
||||
CommitteeDecisionProjection.decision_id == decision_id,
|
||||
)
|
||||
if revision is None:
|
||||
query = query.filter(CommitteeDecisionProjection.superseded_at.is_(None))
|
||||
else:
|
||||
query = query.filter(CommitteeDecisionProjection.revision == revision)
|
||||
row = query.order_by(CommitteeDecisionProjection.recorded_at.desc()).first()
|
||||
return FormalDecision.from_mapping(row.payload) if row is not None else None
|
||||
|
||||
|
||||
def _validate_parent(session: Session, record: CommitteeWorkspaceRecord) -> None:
|
||||
parent_kind = _PARENT_KIND[record.object_kind]
|
||||
if parent_kind is None:
|
||||
return
|
||||
parent = _current_row(
|
||||
session,
|
||||
tenant_id=record.tenant_id,
|
||||
object_kind=parent_kind,
|
||||
object_id=record.parent_id or "",
|
||||
lock=False,
|
||||
)
|
||||
if parent is None:
|
||||
raise CommitteeWorkspaceError(
|
||||
f"Committee {record.object_kind} requires an existing {parent_kind.replace('_', ' ')}."
|
||||
)
|
||||
if parent.state in {"retired", "cancelled", "withdrawn"}:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee objects cannot be added below a terminal parent."
|
||||
)
|
||||
|
||||
|
||||
def _validate_related_state(session: Session, record: CommitteeWorkspaceRecord) -> None:
|
||||
if record.object_kind == "meeting" and record.state == "closed":
|
||||
unfinished = (
|
||||
session.query(CommitteeWorkspaceRevision.id)
|
||||
.filter(
|
||||
CommitteeWorkspaceRevision.tenant_id == record.tenant_id,
|
||||
CommitteeWorkspaceRevision.object_kind == "agenda_item",
|
||||
CommitteeWorkspaceRevision.parent_id == record.object_id,
|
||||
CommitteeWorkspaceRevision.superseded_at.is_(None),
|
||||
~CommitteeWorkspaceRevision.state.in_(("decided", "withdrawn")),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if unfinished is not None:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A meeting cannot close while agenda items remain unfinished."
|
||||
)
|
||||
if record.object_kind == "agenda_item" and record.state == "decided":
|
||||
decision_ref = _reference(
|
||||
record.attributes.get("decision_ref"),
|
||||
"decision",
|
||||
record.tenant_id,
|
||||
required=True,
|
||||
)
|
||||
if decision_ref is None:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A decided agenda item requires a formal Decision reference."
|
||||
)
|
||||
|
||||
|
||||
def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
|
||||
attributes = record.attributes
|
||||
if len(attributes) > 100:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee attributes are limited to 100 entries."
|
||||
)
|
||||
if record.object_kind == "body":
|
||||
_reference(
|
||||
attributes.get("organization_unit_ref"),
|
||||
"organization_unit",
|
||||
record.tenant_id,
|
||||
required=True,
|
||||
)
|
||||
_references(
|
||||
attributes.get("function_refs"),
|
||||
"function",
|
||||
record.tenant_id,
|
||||
)
|
||||
quorum = attributes.get("quorum")
|
||||
if quorum is not None and not isinstance(quorum, Mapping):
|
||||
raise CommitteeWorkspaceError("Committee body quorum must be an object.")
|
||||
elif record.object_kind == "meeting":
|
||||
starts_at = _datetime(attributes.get("starts_at"), "Meeting starts_at")
|
||||
ends_at = _datetime(attributes.get("ends_at"), "Meeting ends_at")
|
||||
if ends_at <= starts_at:
|
||||
raise CommitteeWorkspaceError("Meeting ends_at must follow starts_at.")
|
||||
elif record.object_kind == "agenda_item":
|
||||
if _positive_int(attributes.get("position"), "Agenda position") < 1:
|
||||
raise CommitteeWorkspaceError("Agenda position must be positive.")
|
||||
subjects = _references(
|
||||
attributes.get("subject_refs"),
|
||||
None,
|
||||
record.tenant_id,
|
||||
)
|
||||
if not subjects:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee agenda items require at least one subject reference."
|
||||
)
|
||||
if record.state == "decided":
|
||||
_reference(
|
||||
attributes.get("decision_ref"),
|
||||
"decision",
|
||||
record.tenant_id,
|
||||
required=True,
|
||||
)
|
||||
elif record.object_kind == "vote":
|
||||
method = str(attributes.get("method") or "recorded")
|
||||
if method not in {"recorded", "public", "secret"}:
|
||||
raise CommitteeWorkspaceError("Unsupported Committee vote method.")
|
||||
choices = tuple(
|
||||
str(item).strip()
|
||||
for item in _sequence(attributes.get("choices"), "Vote choices")
|
||||
if str(item).strip()
|
||||
)
|
||||
if len(choices) < 2 or len(choices) != len(set(choices)):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee votes require at least two unique choices."
|
||||
)
|
||||
eligible = _non_negative_int(
|
||||
attributes.get("eligible_count"),
|
||||
"Vote eligible_count",
|
||||
)
|
||||
cast = _non_negative_int(attributes.get("cast_count"), "Vote cast_count")
|
||||
if cast > eligible:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Vote cast_count cannot exceed eligible_count."
|
||||
)
|
||||
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()}
|
||||
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."
|
||||
)
|
||||
if not isinstance(attributes.get("quorum_met"), bool):
|
||||
raise CommitteeWorkspaceError(
|
||||
"A closed vote requires an explicit quorum_met result."
|
||||
)
|
||||
_reference(
|
||||
attributes.get("approval_ref"),
|
||||
"approval",
|
||||
record.tenant_id,
|
||||
required=True,
|
||||
)
|
||||
if not record.evidence:
|
||||
raise CommitteeWorkspaceError(
|
||||
"A closed vote requires result evidence."
|
||||
)
|
||||
elif record.object_kind == "minute":
|
||||
_reference(
|
||||
attributes.get("content_ref"),
|
||||
"record",
|
||||
record.tenant_id,
|
||||
required=True,
|
||||
)
|
||||
if record.state in {"accepted", "corrected"}:
|
||||
_reference(
|
||||
attributes.get("approval_ref"),
|
||||
"approval",
|
||||
record.tenant_id,
|
||||
required=True,
|
||||
)
|
||||
if not record.evidence:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Accepted or corrected minutes require evidence."
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
return _workspace_from_row(revision)
|
||||
|
||||
|
||||
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,
|
||||
CommitteeWorkspaceRevision.object_id == object_id,
|
||||
CommitteeWorkspaceRevision.superseded_at.is_(None),
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
return query.one_or_none()
|
||||
|
||||
|
||||
def _workspace_from_row(row: CommitteeWorkspaceRevision) -> CommitteeWorkspaceRecord:
|
||||
payload = dict(row.payload)
|
||||
payload["revision"] = row.revision
|
||||
payload["state"] = row.state
|
||||
payload["recorded_at"] = _datetime_text(row.recorded_at)
|
||||
return CommitteeWorkspaceRecord.from_mapping(payload)
|
||||
|
||||
|
||||
def _object_kind(value: object) -> CommitteeObjectKind:
|
||||
result = str(value or "").strip()
|
||||
if result not in _PARENT_KIND:
|
||||
raise CommitteeWorkspaceError("Unsupported Committee object kind.")
|
||||
return result # type: ignore[return-value]
|
||||
|
||||
|
||||
def _reference(value: object, kind: str | None, tenant_id: str, *, required: bool) -> InstitutionalReference | None:
|
||||
if value is None:
|
||||
if required:
|
||||
raise CommitteeWorkspaceError(
|
||||
f"Committee {kind or 'institutional'} reference is required."
|
||||
)
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise CommitteeWorkspaceError("Committee reference must be an object.")
|
||||
result = InstitutionalReference.from_mapping(value)
|
||||
if result.tenant_id != tenant_id or (kind is not None and result.kind != kind):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee reference has the wrong tenant or kind."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _references(value: object, kind: str | None, tenant_id: str) -> tuple[InstitutionalReference, ...]:
|
||||
return tuple(
|
||||
item
|
||||
for item in (
|
||||
_reference(candidate, kind, tenant_id, required=True)
|
||||
for candidate in _sequence(value, "Committee references")
|
||||
)
|
||||
if item is not None
|
||||
)
|
||||
|
||||
|
||||
def _mapping_items(value: object, label: str) -> tuple[Mapping[str, object], ...]:
|
||||
items = _sequence(value, label)
|
||||
if any(not isinstance(item, Mapping) for item in items):
|
||||
raise CommitteeWorkspaceError(f"{label} must contain objects.")
|
||||
return tuple(items) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _sequence(value: object, label: str) -> tuple[object, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise CommitteeWorkspaceError(f"{label} must be a list.")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _datetime(value: object, label: str) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
result = value
|
||||
else:
|
||||
try:
|
||||
result = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise CommitteeWorkspaceError(f"{label} is invalid.") from exc
|
||||
_require_aware(result, label)
|
||||
return result
|
||||
|
||||
|
||||
def _require_aware(value: datetime, label: str) -> None:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise CommitteeWorkspaceError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _positive_int(value: object, label: str) -> int:
|
||||
result = _non_negative_int(value, label)
|
||||
if result < 1:
|
||||
raise CommitteeWorkspaceError(f"{label} must be positive.")
|
||||
return result
|
||||
|
||||
|
||||
def _non_negative_int(value: object, label: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise CommitteeWorkspaceError(f"{label} must be an integer.")
|
||||
try:
|
||||
result = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CommitteeWorkspaceError(f"{label} must be an integer.") from exc
|
||||
if result < 0:
|
||||
raise CommitteeWorkspaceError(f"{label} cannot be negative.")
|
||||
return result
|
||||
|
||||
|
||||
def _identifier(value: object, label: str, *, maximum: int) -> str:
|
||||
result = _required_text(value, label, maximum=maximum)
|
||||
if any(character.isspace() for character in result):
|
||||
raise CommitteeWorkspaceError(f"{label} cannot contain whitespace.")
|
||||
return result
|
||||
|
||||
|
||||
def _required_text(value: object, label: str, *, maximum: int) -> str:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
raise CommitteeWorkspaceError(f"{label} is required.")
|
||||
if len(result) > maximum:
|
||||
raise CommitteeWorkspaceError(f"{label} is limited to {maximum} characters.")
|
||||
return result
|
||||
|
||||
|
||||
def _optional_text(value: object, *, maximum: int) -> str | None:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
return None
|
||||
if len(result) > maximum:
|
||||
raise CommitteeWorkspaceError(f"Text is limited to {maximum} characters.")
|
||||
return result
|
||||
|
||||
|
||||
def _sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "to_dict"):
|
||||
return _json_value(value.to_dict())
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _principal_tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise InstitutionalContextError(
|
||||
"Committee operations require a tenant-bound principal."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _principal_actor(principal: object) -> str | None:
|
||||
user = getattr(principal, "user", None)
|
||||
for value in (
|
||||
getattr(user, "id", None),
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
):
|
||||
candidate = str(value or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not hasattr(value, "query"):
|
||||
raise InstitutionalContextError(
|
||||
"Committee workspace requires a database session."
|
||||
)
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_COMMITTEE_WORKSPACE",
|
||||
"CommitteeObjectKind",
|
||||
"CommitteeWorkspaceError",
|
||||
"CommitteeWorkspaceRecord",
|
||||
"SqlCommitteeWorkspace",
|
||||
"get_local_decision",
|
||||
"get_workspace_object",
|
||||
"list_workspace_objects",
|
||||
"record_local_decision",
|
||||
"record_workspace_object",
|
||||
"workspace_history",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/committee.css": "./src/styles/committee.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
|
||||
export type CommitteeObjectKind = "body" | "meeting" | "agenda_item" | "vote" | "minute";
|
||||
|
||||
export type CommitteeRecord = {
|
||||
tenant_id: string;
|
||||
object_kind: CommitteeObjectKind;
|
||||
object_id: string;
|
||||
revision: number;
|
||||
state: string;
|
||||
title: string;
|
||||
parent_id?: string | null;
|
||||
recorded_at: string;
|
||||
change_reason: string;
|
||||
attributes: Record<string, unknown>;
|
||||
context?: Record<string, unknown> | null;
|
||||
evidence: Array<Record<string, unknown>>;
|
||||
record_refs: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type CommitteeRecordList = {
|
||||
records: CommitteeRecord[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export function listCommitteeRecords(
|
||||
settings: ApiSettings,
|
||||
kind: CommitteeObjectKind,
|
||||
options: {
|
||||
parentId?: string;
|
||||
query?: string;
|
||||
states?: string[];
|
||||
limit?: number;
|
||||
} = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<CommitteeRecordList> {
|
||||
return apiFetch<CommitteeRecordList>(
|
||||
settings,
|
||||
apiPath(`/api/v1/committee/workspace/${kind}`, {
|
||||
parent_id: options.parentId,
|
||||
query: options.query,
|
||||
state: options.states,
|
||||
limit: options.limit ?? 200
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function saveCommitteeRecord(
|
||||
settings: ApiSettings,
|
||||
record: CommitteeRecord,
|
||||
expectedRevision?: number
|
||||
): Promise<CommitteeRecord> {
|
||||
return apiFetch<CommitteeRecord>(
|
||||
settings,
|
||||
`/api/v1/committee/workspace/${record.object_kind}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
record,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
expected_revision: expectedRevision
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function committeeRecordHistory(
|
||||
settings: ApiSettings,
|
||||
record: CommitteeRecord,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ revisions: CommitteeRecord[] }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/committee/workspace/${record.object_kind}/${encodeURIComponent(record.object_id)}/history`,
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
export function finalizeProviderBallot(
|
||||
settings: ApiSettings,
|
||||
record: CommitteeRecord,
|
||||
input: {
|
||||
providerBallotRef: string;
|
||||
approvalId: string;
|
||||
changeReason: string;
|
||||
}
|
||||
): Promise<CommitteeRecord> {
|
||||
const providerId = String(record.attributes.provider_id ?? "").trim();
|
||||
return apiFetch<CommitteeRecord>(
|
||||
settings,
|
||||
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-provider`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
provider_id: providerId,
|
||||
provider_ballot_ref: input.providerBallotRef.trim(),
|
||||
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()
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
finalizeProviderBallot,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
|
||||
|
||||
export default function CommitteeBallotDialog({
|
||||
settings,
|
||||
record,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
record: CommitteeRecord;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (record: CommitteeRecord) => void;
|
||||
}) {
|
||||
const [providerBallotRef, setProviderBallotRef] = useState("");
|
||||
const [approvalId, setApprovalId] = useState("");
|
||||
const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate.");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProviderBallotRef("");
|
||||
setApprovalId("");
|
||||
setChangeReason("Imported verified ballot aggregate.");
|
||||
setError("");
|
||||
}, [open, record.object_id]);
|
||||
|
||||
async function finalize() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await finalizeProviderBallot(settings, record, {
|
||||
providerBallotRef,
|
||||
approvalId,
|
||||
changeReason
|
||||
});
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const providerId = String(record.attributes.provider_id ?? "");
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Finalize provider ballot"
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="committee-ballot-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()}
|
||||
onClick={() => void finalize()}
|
||||
>
|
||||
{busy ? "Importing" : "Finalize"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
<FormField label="Provider ballot reference">
|
||||
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Approval ID">
|
||||
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Change reason">
|
||||
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
CalendarPlus,
|
||||
FilePlus2,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Vote
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listCommitteeRecords,
|
||||
type CommitteeObjectKind,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
import CommitteeBallotDialog from "./CommitteeBallotDialog";
|
||||
import CommitteeRecordDialog from "./CommitteeRecordDialog";
|
||||
import { canReviseCommitteeRecord } from "./lifecycle";
|
||||
|
||||
|
||||
type EditorTarget = {
|
||||
kind: CommitteeObjectKind;
|
||||
parentId?: string | null;
|
||||
record?: CommitteeRecord | null;
|
||||
};
|
||||
|
||||
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
|
||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||
const canWrite = hasScope(auth, "committee:workspace:write");
|
||||
const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize");
|
||||
const [query, setQuery] = useState("");
|
||||
const [bodies, setBodies] = useState<CommitteeRecord[]>([]);
|
||||
const [meetings, setMeetings] = useState<CommitteeRecord[]>([]);
|
||||
const [agendaItems, setAgendaItems] = useState<CommitteeRecord[]>([]);
|
||||
const [votes, setVotes] = useState<CommitteeRecord[]>([]);
|
||||
const [minutes, setMinutes] = useState<CommitteeRecord[]>([]);
|
||||
const [bodyId, setBodyId] = useState("");
|
||||
const [meetingId, setMeetingId] = useState("");
|
||||
const [agendaId, setAgendaId] = useState("");
|
||||
const [editor, setEditor] = useState<EditorTarget | null>(null);
|
||||
const [ballotRecord, setBallotRecord] = useState<CommitteeRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const selectedBody = bodies.find((item) => item.object_id === bodyId) ?? null;
|
||||
const selectedMeeting = meetings.find((item) => item.object_id === meetingId) ?? null;
|
||||
const selectedAgenda = agendaItems.find((item) => item.object_id === agendaId) ?? null;
|
||||
|
||||
const loadBodies = useCallback(async (signal?: AbortSignal) => {
|
||||
const response = await listCommitteeRecords(
|
||||
settings,
|
||||
"body",
|
||||
{ query: query.trim(), limit: 200 },
|
||||
signal
|
||||
);
|
||||
setBodies(response.records);
|
||||
setBodyId((current) => response.records.some((item) => item.object_id === current)
|
||||
? current
|
||||
: response.records[0]?.object_id ?? "");
|
||||
}, [query, settings]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await loadBodies();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Committee workspace could not be loaded.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBodies]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
loadBodies(controller.signal).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Committee bodies could not be loaded.");
|
||||
}
|
||||
}).
|
||||
finally(() => setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [loadBodies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyId) {
|
||||
setMeetings([]);
|
||||
setMeetingId("");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
listCommitteeRecords(settings, "meeting", { parentId: bodyId }, controller.signal).
|
||||
then((response) => {
|
||||
setMeetings(response.records);
|
||||
setMeetingId((current) => response.records.some((item) => item.object_id === current)
|
||||
? current
|
||||
: response.records[0]?.object_id ?? "");
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Committee meetings could not be loaded.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [bodyId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!meetingId) {
|
||||
setAgendaItems([]);
|
||||
setMinutes([]);
|
||||
setAgendaId("");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
Promise.all([
|
||||
listCommitteeRecords(settings, "agenda_item", { parentId: meetingId }, controller.signal),
|
||||
listCommitteeRecords(settings, "minute", { parentId: meetingId }, controller.signal)
|
||||
]).
|
||||
then(([agenda, nextMinutes]) => {
|
||||
const ordered = [...agenda.records].sort(
|
||||
(left, right) => Number(left.attributes.position ?? 0) - Number(right.attributes.position ?? 0)
|
||||
);
|
||||
setAgendaItems(ordered);
|
||||
setMinutes(nextMinutes.records);
|
||||
setAgendaId((current) => ordered.some((item) => item.object_id === current)
|
||||
? current
|
||||
: ordered[0]?.object_id ?? "");
|
||||
}).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Meeting details could not be loaded.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [meetingId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agendaId) {
|
||||
setVotes([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
listCommitteeRecords(settings, "vote", { parentId: agendaId }, controller.signal).
|
||||
then((response) => setVotes(response.records)).
|
||||
catch((reason) => {
|
||||
if ((reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "Votes could not be loaded.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [agendaId, settings]);
|
||||
|
||||
const meetingTime = useMemo(
|
||||
() => selectedMeeting
|
||||
? `${formatDateTime(selectedMeeting.attributes.starts_at)} - ${formatTime(selectedMeeting.attributes.ends_at)}`
|
||||
: "",
|
||||
[selectedMeeting]
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="committee-page">
|
||||
<div className="committee-shell">
|
||||
<div className="committee-toolbar">
|
||||
<form onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
|
||||
</form>
|
||||
<Button variant="ghost" onClick={() => void refresh()} disabled={loading}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button variant="primary" onClick={() => setEditor({ kind: "body" })}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
New body
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
|
||||
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
|
||||
|
||||
<div className="committee-workspace">
|
||||
<section className="committee-panel committee-body-panel" aria-label="Committee bodies">
|
||||
<PanelHeading title="Bodies" count={bodies.length} />
|
||||
<PageScrollViewport className="committee-panel-scroll">
|
||||
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
|
||||
</PageScrollViewport>
|
||||
{selectedBody && canWrite ? (
|
||||
<div className="committee-panel-actions">
|
||||
{canReviseCommitteeRecord(selectedBody) ? <IconButton label="Edit body" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "body", record: selectedBody })} /> : null}
|
||||
<Button variant="primary" onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}>
|
||||
<CalendarPlus size={16} aria-hidden="true" />
|
||||
New meeting
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
|
||||
<PanelHeading title="Meetings" count={meetings.length} />
|
||||
<PageScrollViewport className="committee-panel-scroll">
|
||||
<RecordList records={meetings} selectedId={meetingId} onSelect={setMeetingId} secondary={meetingSecondary} />
|
||||
</PageScrollViewport>
|
||||
</section>
|
||||
|
||||
<section className="committee-detail" aria-label="Meeting workspace">
|
||||
{!selectedMeeting ? (
|
||||
<div className="committee-empty">Select or create a meeting.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="committee-detail-heading">
|
||||
<div>
|
||||
<span>{meetingTime}</span>
|
||||
<h1>{selectedMeeting.title}</h1>
|
||||
</div>
|
||||
<StatusBadge status={statusTone(selectedMeeting.state)} label={humanize(selectedMeeting.state)} />
|
||||
{canWrite && canReviseCommitteeRecord(selectedMeeting) ? <IconButton label="Edit meeting" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} /> : null}
|
||||
</div>
|
||||
<PageScrollViewport className="committee-detail-scroll">
|
||||
<WorkspaceSection
|
||||
title="Agenda"
|
||||
action={canWrite ? (
|
||||
<Button variant="ghost" onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}>
|
||||
<ListPlus size={16} aria-hidden="true" />
|
||||
Add item
|
||||
</Button>
|
||||
) : null}
|
||||
>
|
||||
<div className="committee-agenda-list">
|
||||
{agendaItems.map((item) => (
|
||||
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
|
||||
<button type="button" onClick={() => setAgendaId(item.object_id)}>
|
||||
<span className="committee-agenda-position">{String(item.attributes.position ?? "-")}</span>
|
||||
<span><strong>{item.title}</strong><small>{humanize(item.state)}</small></span>
|
||||
</button>
|
||||
{canWrite && canReviseCommitteeRecord(item) ? <IconButton label="Edit agenda item" icon={<Pencil size={15} />} onClick={() => setEditor({ kind: "agenda_item", record: item })} /> : null}
|
||||
</div>
|
||||
))}
|
||||
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
|
||||
</div>
|
||||
</WorkspaceSection>
|
||||
|
||||
{selectedAgenda ? (
|
||||
<WorkspaceSection
|
||||
title={`Votes - ${selectedAgenda.title}`}
|
||||
action={canWrite ? (
|
||||
<Button variant="ghost" onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}>
|
||||
<Vote size={16} aria-hidden="true" />
|
||||
Add vote
|
||||
</Button>
|
||||
) : null}
|
||||
>
|
||||
<RecordRows
|
||||
records={votes}
|
||||
canEdit={(record) => canWrite && canReviseCommitteeRecord(record)}
|
||||
onEdit={(record) => setEditor({ kind: "vote", record })}
|
||||
detail={voteSummary}
|
||||
secondaryAction={(record) => canFinalizeBallot && isProviderBallotReady(record) ? (
|
||||
<IconButton
|
||||
label={`Finalize ${record.title} from ballot provider`}
|
||||
icon={<ShieldCheck size={15} />}
|
||||
onClick={() => setBallotRecord(record)}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
</WorkspaceSection>
|
||||
) : null}
|
||||
|
||||
<WorkspaceSection
|
||||
title="Minutes"
|
||||
action={canWrite ? (
|
||||
<Button variant="ghost" onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}>
|
||||
<FilePlus2 size={16} aria-hidden="true" />
|
||||
Add minutes
|
||||
</Button>
|
||||
) : null}
|
||||
>
|
||||
<RecordRows records={minutes} canEdit={(record) => canWrite && canReviseCommitteeRecord(record)} onEdit={(record) => setEditor({ kind: "minute", record })} detail={(record) => `Record ${String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-")}`} />
|
||||
</WorkspaceSection>
|
||||
</PageScrollViewport>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editor ? (
|
||||
<CommitteeRecordDialog
|
||||
settings={settings}
|
||||
tenantId={tenantId}
|
||||
kind={editor.kind}
|
||||
parentId={editor.parentId}
|
||||
record={editor.record}
|
||||
open
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
) : null}
|
||||
{ballotRecord ? (
|
||||
<CommitteeBallotDialog
|
||||
settings={settings}
|
||||
record={ballotRecord}
|
||||
open
|
||||
onClose={() => setBallotRecord(null)}
|
||||
onSaved={() => void refresh()}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeading({ title, count }: { title: string; count: number }) {
|
||||
return <div className="committee-panel-heading"><h2>{title}</h2><span>{count}</span></div>;
|
||||
}
|
||||
|
||||
function RecordList({ records, selectedId, onSelect, secondary }: {
|
||||
records: CommitteeRecord[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
secondary?: (record: CommitteeRecord) => string;
|
||||
}) {
|
||||
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
|
||||
return <div className="committee-record-list">{records.map((record) => (
|
||||
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
|
||||
<strong>{record.title}</strong>
|
||||
<span>{secondary?.(record) ?? humanize(record.state)}</span>
|
||||
</button>
|
||||
))}</div>;
|
||||
}
|
||||
|
||||
function WorkspaceSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
|
||||
return <section className="committee-workspace-section"><div><h2>{title}</h2>{action}</div>{children}</section>;
|
||||
}
|
||||
|
||||
function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: {
|
||||
records: CommitteeRecord[];
|
||||
canEdit: (record: CommitteeRecord) => boolean;
|
||||
onEdit: (record: CommitteeRecord) => void;
|
||||
detail: (record: CommitteeRecord) => string;
|
||||
secondaryAction?: (record: CommitteeRecord) => ReactNode;
|
||||
}) {
|
||||
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
|
||||
return <div className="committee-record-rows">{records.map((record) => (
|
||||
<div key={record.object_id}>
|
||||
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
|
||||
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
|
||||
<span className="committee-row-actions">
|
||||
{secondaryAction?.(record)}
|
||||
{canEdit(record) ? <IconButton label={`Edit ${record.title}`} icon={<Pencil size={15} />} onClick={() => onEdit(record)} /> : null}
|
||||
</span>
|
||||
</div>
|
||||
))}</div>;
|
||||
}
|
||||
|
||||
function meetingSecondary(record: CommitteeRecord): string {
|
||||
return `${formatDateTime(record.attributes.starts_at)} - ${humanize(record.state)}`;
|
||||
}
|
||||
|
||||
function voteSummary(record: CommitteeRecord): string {
|
||||
const cast = Number(record.attributes.cast_count ?? 0);
|
||||
const eligible = Number(record.attributes.eligible_count ?? 0);
|
||||
return `${humanize(String(record.attributes.method ?? "recorded"))}, ${cast}/${eligible} cast`;
|
||||
}
|
||||
|
||||
function isProviderBallotReady(record: CommitteeRecord): boolean {
|
||||
return record.state === "open" && Boolean(String(record.attributes.provider_id ?? "").trim());
|
||||
}
|
||||
|
||||
function statusTone(state: string): "active" | "inactive" | "warning" {
|
||||
if (["active", "open", "accepted", "decided", "closed"].includes(state)) return "active";
|
||||
if (["cancelled", "retired", "withdrawn"].includes(state)) return "inactive";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function formatDateTime(value: unknown): string {
|
||||
if (!value) return "Date not set";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value)));
|
||||
}
|
||||
|
||||
function formatTime(value: unknown): string {
|
||||
if (!value) return "-";
|
||||
return new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(new Date(String(value)));
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
saveCommitteeRecord,
|
||||
type CommitteeObjectKind,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
|
||||
|
||||
type Draft = {
|
||||
title: string;
|
||||
state: string;
|
||||
changeReason: string;
|
||||
organizationUnitId: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
position: string;
|
||||
subjectKind: string;
|
||||
subjectId: string;
|
||||
choices: string;
|
||||
method: string;
|
||||
eligibleCount: string;
|
||||
castCount: string;
|
||||
counts: Record<string, string>;
|
||||
quorumMet: boolean;
|
||||
approvalId: string;
|
||||
evidenceId: string;
|
||||
providerId: string;
|
||||
contentRecordId: string;
|
||||
decisionId: string;
|
||||
};
|
||||
|
||||
export default function CommitteeRecordDialog({
|
||||
settings,
|
||||
tenantId,
|
||||
kind,
|
||||
parentId,
|
||||
record,
|
||||
open,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
tenantId: string;
|
||||
kind: CommitteeObjectKind;
|
||||
parentId?: string | null;
|
||||
record?: CommitteeRecord | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (record: CommitteeRecord) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFromRecord(kind, record));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const choices = useMemo(
|
||||
() => draft.choices.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
[draft.choices]
|
||||
);
|
||||
const stateOptions = useMemo(
|
||||
() => committeeStateOptions(kind, record),
|
||||
[kind, record]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(draftFromRecord(kind, record));
|
||||
setError("");
|
||||
}, [kind, open, record]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = recordFromDraft({
|
||||
tenantId,
|
||||
kind,
|
||||
parentId,
|
||||
record,
|
||||
draft,
|
||||
choices
|
||||
});
|
||||
const saved = await saveCommitteeRecord(
|
||||
settings,
|
||||
payload,
|
||||
record?.revision
|
||||
);
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`${record ? "Edit" : "New"} ${labelForKind(kind)}`}
|
||||
onClose={onClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="committee-record-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !draft.title.trim() || !draft.changeReason.trim()}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{busy ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="committee-record-form">
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="committee-form-grid">
|
||||
<FormField label="Title">
|
||||
<input
|
||||
value={draft.title}
|
||||
maxLength={500}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="State">
|
||||
<select
|
||||
value={draft.state}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
|
||||
>
|
||||
{stateOptions.map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{kind === "body" ? (
|
||||
<FormField label="Responsible organization unit ID">
|
||||
<input
|
||||
value={draft.organizationUnitId}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, organizationUnitId: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
|
||||
{kind === "meeting" ? (
|
||||
<div className="committee-form-grid">
|
||||
<FormField label="Starts">
|
||||
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ends">
|
||||
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{kind === "agenda_item" ? (
|
||||
<>
|
||||
<div className="committee-form-grid committee-form-grid-three">
|
||||
<FormField label="Position">
|
||||
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Subject type">
|
||||
<select value={draft.subjectKind} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectKind: event.target.value })}>
|
||||
<option value="case">Case</option>
|
||||
<option value="service">Service</option>
|
||||
<option value="work_item">Work item</option>
|
||||
<option value="record">Record</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Subject ID">
|
||||
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
{draft.state === "decided" ? (
|
||||
<FormField label="Formal Decision ID">
|
||||
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
|
||||
</FormField>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{kind === "vote" ? (
|
||||
<>
|
||||
<div className="committee-form-grid committee-form-grid-three">
|
||||
<FormField label="Method">
|
||||
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
|
||||
<option value="recorded">Recorded</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="secret">Secret</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Eligible voters">
|
||||
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ballot provider (optional)">
|
||||
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Choices (comma separated)">
|
||||
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
|
||||
</FormField>
|
||||
{draft.state === "closed" ? (
|
||||
<div className="committee-vote-result-fields">
|
||||
<div className="committee-form-grid">
|
||||
<FormField label="Votes cast">
|
||||
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Quorum">
|
||||
<select value={draft.quorumMet ? "met" : "not-met"} disabled={busy} onChange={(event) => setDraft({ ...draft, quorumMet: event.target.value === "met" })}>
|
||||
<option value="met">Met</option>
|
||||
<option value="not-met">Not met</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="committee-count-grid">
|
||||
{choices.map((choice) => (
|
||||
<FormField key={choice} label={`${choice} votes`}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={draft.counts[choice] ?? "0"}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
counts: { ...draft.counts, [choice]: event.target.value }
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{kind === "minute" ? (
|
||||
<>
|
||||
<FormField label="Minutes record ID">
|
||||
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
|
||||
</FormField>
|
||||
{draft.state === "accepted" || draft.state === "corrected" ? (
|
||||
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<FormField label="Change reason">
|
||||
<input
|
||||
value={draft.changeReason}
|
||||
maxLength={1000}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, changeReason: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
|
||||
return (
|
||||
<div className="committee-form-grid">
|
||||
<FormField label="Approval ID">
|
||||
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Evidence record ID">
|
||||
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | null): Draft {
|
||||
const attributes = record?.attributes ?? {};
|
||||
const startsAt = localDateTime(attributes.starts_at, 60);
|
||||
const endsAt = localDateTime(attributes.ends_at, 120);
|
||||
const subject = firstMapping(attributes.subject_refs);
|
||||
const approval = mapping(attributes.approval_ref);
|
||||
const content = mapping(attributes.content_ref);
|
||||
const decision = mapping(attributes.decision_ref);
|
||||
const organization = mapping(attributes.organization_unit_ref);
|
||||
const counts = mapping(attributes.counts);
|
||||
return {
|
||||
title: record?.title ?? "",
|
||||
state: record?.state ?? COMMITTEE_STATES[kind][0],
|
||||
changeReason: record ? "" : `Created ${labelForKind(kind)}.`,
|
||||
organizationUnitId: text(organization.object_id),
|
||||
startsAt,
|
||||
endsAt,
|
||||
position: String(attributes.position ?? 1),
|
||||
subjectKind: text(subject.kind) || "case",
|
||||
subjectId: text(subject.object_id),
|
||||
choices: array(attributes.choices).join(", ") || "yes, no, abstain",
|
||||
method: text(attributes.method) || "recorded",
|
||||
eligibleCount: String(attributes.eligible_count ?? 0),
|
||||
castCount: String(attributes.cast_count ?? 0),
|
||||
counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, String(value)])),
|
||||
quorumMet: attributes.quorum_met !== false,
|
||||
approvalId: text(approval.object_id),
|
||||
evidenceId: text(firstMapping(record?.evidence).evidence_id),
|
||||
providerId: text(attributes.provider_id),
|
||||
contentRecordId: text(content.object_id),
|
||||
decisionId: text(decision.object_id)
|
||||
};
|
||||
}
|
||||
|
||||
function recordFromDraft({ tenantId, kind, parentId, record, draft, choices }: {
|
||||
tenantId: string;
|
||||
kind: CommitteeObjectKind;
|
||||
parentId?: string | null;
|
||||
record?: CommitteeRecord | null;
|
||||
draft: Draft;
|
||||
choices: string[];
|
||||
}): CommitteeRecord {
|
||||
const attributes = attributesFromDraft(tenantId, kind, draft, choices);
|
||||
const evidence = needsEvidence(kind, draft.state) && draft.evidenceId.trim()
|
||||
? [{
|
||||
kind: "record",
|
||||
owner_module: "records",
|
||||
evidence_id: draft.evidenceId.trim(),
|
||||
tenant_id: tenantId,
|
||||
version: "1",
|
||||
captured_at: new Date().toISOString()
|
||||
}]
|
||||
: record?.evidence ?? [];
|
||||
return {
|
||||
tenant_id: tenantId,
|
||||
object_kind: kind,
|
||||
object_id: record?.object_id ?? crypto.randomUUID(),
|
||||
revision: (record?.revision ?? 0) + 1,
|
||||
state: draft.state,
|
||||
title: draft.title.trim(),
|
||||
parent_id: record?.parent_id ?? parentId ?? null,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: draft.changeReason.trim(),
|
||||
attributes,
|
||||
context: record?.context ?? null,
|
||||
evidence,
|
||||
record_refs: record?.record_refs ?? []
|
||||
};
|
||||
}
|
||||
|
||||
function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft: Draft, choices: string[]): Record<string, unknown> {
|
||||
if (kind === "body") return {
|
||||
organization_unit_ref: reference("organization_unit", "organizations", draft.organizationUnitId, tenantId),
|
||||
function_refs: []
|
||||
};
|
||||
if (kind === "meeting") return {
|
||||
starts_at: new Date(draft.startsAt).toISOString(),
|
||||
ends_at: new Date(draft.endsAt).toISOString()
|
||||
};
|
||||
if (kind === "agenda_item") return {
|
||||
position: Number(draft.position),
|
||||
subject_refs: [reference(draft.subjectKind, ownerForKind(draft.subjectKind), draft.subjectId, tenantId)],
|
||||
...(draft.state === "decided" ? {
|
||||
decision_ref: reference("decision", "decisions", draft.decisionId, tenantId)
|
||||
} : {})
|
||||
};
|
||||
if (kind === "vote") return {
|
||||
method: draft.method,
|
||||
choices,
|
||||
eligible_count: Number(draft.eligibleCount),
|
||||
cast_count: Number(draft.castCount),
|
||||
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
|
||||
...(draft.state === "closed" ? {
|
||||
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
|
||||
quorum_met: draft.quorumMet,
|
||||
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
|
||||
} : {})
|
||||
};
|
||||
return {
|
||||
content_ref: reference("record", "records", draft.contentRecordId, tenantId),
|
||||
...(draft.state === "accepted" || draft.state === "corrected" ? {
|
||||
approval_ref: reference("approval", "approvals", draft.approvalId, tenantId)
|
||||
} : {})
|
||||
};
|
||||
}
|
||||
|
||||
function reference(kind: string, owner: string, objectId: string, tenantId: string) {
|
||||
return { kind, owner_module: owner, object_id: objectId.trim(), tenant_id: tenantId, version: "1" };
|
||||
}
|
||||
|
||||
function ownerForKind(kind: string): string {
|
||||
return { case: "cases", service: "services", work_item: "workflow_engine", record: "records" }[kind] ?? kind;
|
||||
}
|
||||
|
||||
function needsEvidence(kind: CommitteeObjectKind, state: string): boolean {
|
||||
return (kind === "vote" && state === "closed")
|
||||
|| (kind === "minute" && ["accepted", "corrected"].includes(state));
|
||||
}
|
||||
|
||||
function localDateTime(value: unknown, offsetMinutes: number): string {
|
||||
const date = value ? new Date(String(value)) : new Date(Date.now() + offsetMinutes * 60_000);
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function mapping(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function firstMapping(value: unknown): Record<string, unknown> {
|
||||
return mapping(Array.isArray(value) ? value[0] : undefined);
|
||||
}
|
||||
|
||||
function array(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function labelForKind(kind: CommitteeObjectKind): string {
|
||||
return humanize(kind);
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CommitteeObjectKind, CommitteeRecord } from "../../api/committee";
|
||||
|
||||
|
||||
export const COMMITTEE_STATES: Record<CommitteeObjectKind, string[]> = {
|
||||
body: ["draft", "active", "suspended", "retired"],
|
||||
meeting: ["draft", "scheduled", "open", "closed", "cancelled"],
|
||||
agenda_item: ["draft", "scheduled", "deliberating", "decided", "withdrawn"],
|
||||
vote: ["draft", "open", "closed", "cancelled"],
|
||||
minute: ["draft", "proposed", "accepted", "corrected"]
|
||||
};
|
||||
|
||||
const TRANSITIONS: Record<CommitteeObjectKind, Record<string, string[]>> = {
|
||||
body: {
|
||||
draft: ["draft", "active", "retired"],
|
||||
active: ["active", "suspended", "retired"],
|
||||
suspended: ["active", "suspended", "retired"],
|
||||
retired: []
|
||||
},
|
||||
meeting: {
|
||||
draft: ["draft", "scheduled", "cancelled"],
|
||||
scheduled: ["scheduled", "open", "cancelled"],
|
||||
open: ["open", "closed", "cancelled"],
|
||||
closed: [],
|
||||
cancelled: []
|
||||
},
|
||||
agenda_item: {
|
||||
draft: ["draft", "scheduled", "withdrawn"],
|
||||
scheduled: ["scheduled", "deliberating", "withdrawn"],
|
||||
deliberating: ["deliberating", "decided", "withdrawn"],
|
||||
decided: [],
|
||||
withdrawn: []
|
||||
},
|
||||
vote: {
|
||||
draft: ["draft", "open", "cancelled"],
|
||||
open: ["open", "closed", "cancelled"],
|
||||
closed: [],
|
||||
cancelled: []
|
||||
},
|
||||
minute: {
|
||||
draft: ["draft", "proposed"],
|
||||
proposed: ["proposed", "accepted"],
|
||||
accepted: ["corrected"],
|
||||
corrected: ["corrected"]
|
||||
}
|
||||
};
|
||||
|
||||
export function committeeStateOptions(
|
||||
kind: CommitteeObjectKind,
|
||||
record?: CommitteeRecord | null
|
||||
): string[] {
|
||||
if (!record) return COMMITTEE_STATES[kind];
|
||||
const options = TRANSITIONS[kind][record.state] ?? [];
|
||||
const providerBound = kind === "vote" && Boolean(String(record.attributes.provider_id ?? "").trim());
|
||||
const allowed = providerBound ? options.filter((state) => state !== "closed") : options;
|
||||
return allowed.length > 0 ? allowed : [record.state];
|
||||
}
|
||||
|
||||
export function canReviseCommitteeRecord(record: CommitteeRecord): boolean {
|
||||
return (TRANSITIONS[record.object_kind][record.state] ?? []).length > 0;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, committeeModule } from "./module";
|
||||
export * from "./api/committee";
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/committee.css";
|
||||
|
||||
|
||||
const CommitteePage = lazy(() => import("./features/committee/CommitteePage"));
|
||||
|
||||
export const committeeModule: PlatformWebModule = {
|
||||
id: "committee",
|
||||
name: "Committee",
|
||||
version: "0.1.8",
|
||||
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
|
||||
navItems: [
|
||||
{
|
||||
to: "/committee",
|
||||
label: "Committee",
|
||||
iconName: "gavel",
|
||||
anyOf: ["committee:workspace:read"],
|
||||
order: 38
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "/committee",
|
||||
anyOf: ["committee:workspace:read"],
|
||||
order: 38,
|
||||
render: (context) => createElement(CommitteePage, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default committeeModule;
|
||||
@@ -0,0 +1,326 @@
|
||||
.committee-page,
|
||||
.committee-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.committee-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.committee-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.committee-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(460px, 100%);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.committee-search input {
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.committee-alert {
|
||||
margin: 10px 16px 0;
|
||||
}
|
||||
|
||||
.committee-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.committee-panel,
|
||||
.committee-detail {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-detail {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.committee-panel-heading,
|
||||
.committee-detail-heading,
|
||||
.committee-workspace-section > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.committee-panel-heading {
|
||||
min-height: 48px;
|
||||
padding: 8px 13px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-panel-heading h2,
|
||||
.committee-workspace-section h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.committee-panel-heading span {
|
||||
margin-left: auto;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.committee-panel-scroll,
|
||||
.committee-detail-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.committee-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.committee-record-list > button {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 9px 13px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.committee-record-list > button:hover,
|
||||
.committee-record-list > button.is-selected,
|
||||
.committee-agenda-list > div:hover,
|
||||
.committee-agenda-list > div.is-selected {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.committee-record-list span,
|
||||
.committee-agenda-list small,
|
||||
.committee-record-rows small,
|
||||
.committee-detail-heading span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.committee-panel-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-detail-heading {
|
||||
min-height: 70px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-detail-heading > div:first-child {
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.committee-detail-heading h1 {
|
||||
overflow: hidden;
|
||||
margin: 3px 0 0;
|
||||
font-size: 1.08rem;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.committee-workspace-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-workspace-section > div:first-child {
|
||||
min-height: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.committee-workspace-section > div:first-child .btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.committee-agenda-list,
|
||||
.committee-record-rows {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-agenda-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-agenda-list > div > button:first-child {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 51px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.committee-agenda-list > div > button:first-child > span:nth-child(2),
|
||||
.committee-record-rows > div > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.committee-agenda-position {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.committee-record-rows > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 52px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-row-actions {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.committee-empty {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
place-items: center;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.committee-empty.compact {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.committee-record-dialog {
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
max-height: min(820px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
.committee-ballot-dialog {
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.committee-dialog-note {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.committee-record-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.committee-form-grid,
|
||||
.committee-count-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.committee-form-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.committee-count-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.committee-vote-result-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.committee-workspace {
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.committee-toolbar {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.committee-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.committee-workspace {
|
||||
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr);
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.committee-body-panel {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.committee-meeting-panel {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.committee-detail {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.committee-form-grid,
|
||||
.committee-form-grid-three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user