Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae25ed350 | ||
|
|
bbddc8b52a | ||
|
|
01ebd44d93 | ||
|
|
c06de41bdc | ||
|
|
bcf7dc53b4 | ||
|
|
e64af30534 | ||
|
|
11b946ea78 | ||
|
|
133e55987e | ||
|
|
758b59ca03 | ||
|
|
232329ac52 | ||
|
|
aedc245cc2 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -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.
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
# GovOPlaN Committee
|
||||
|
||||
`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-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
When Voting is installed, Committee creates and follows a governed
|
||||
`voting.ballots` record while retaining only its meeting/agendum reference and
|
||||
verified aggregate outcome. Voting owns the frozen electorate, casting,
|
||||
tallying, certification, challenge, and annulment lifecycle. The older
|
||||
`committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
|
||||
remains a 0.1 compatibility boundary for existing external integrations;
|
||||
individual provider ballots never enter Committee persistence.
|
||||
|
||||
## Initial Ownership
|
||||
|
||||
- committee bodies
|
||||
- meeting agendas
|
||||
- minutes
|
||||
- decision records
|
||||
- deliberation/vote context and formal decision references
|
||||
- votes
|
||||
- follow-up assignments
|
||||
|
||||
@@ -20,6 +43,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).
|
||||
|
||||
@@ -33,6 +58,7 @@ Expected optional integrations:
|
||||
- tasks
|
||||
- workflow
|
||||
- approvals
|
||||
- voting
|
||||
|
||||
## Development Install
|
||||
|
||||
@@ -50,6 +76,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 0.1
|
||||
compatibility effect boundary for an installed ballot adapter. It requires the
|
||||
`committee:ballot:finalize` permission and preserves provider receipt/hash and
|
||||
evidence without exposing or persisting individual votes.
|
||||
|
||||
## 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`.
|
||||
@@ -58,5 +98,5 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
|
||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
|
||||
```
|
||||
|
||||
@@ -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,76 @@ 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
|
||||
- an optional primary integration with Voting, which owns frozen electorates,
|
||||
vote casting/replacement, tally, certification, challenge, and annulment
|
||||
- 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 governed ballot is delegated to `voting.ballots` when Voting is installed;
|
||||
Committee retains the meeting/agendum linkage and verified aggregate result.
|
||||
For provider-backed Voting ballots, Committee also snapshots the assurance
|
||||
profile, provider and ballot references, and sanitized provider evidence. A
|
||||
reference `local_confidential` result remains explicitly uncertified; its
|
||||
server-encrypted casts do not satisfy secret-ballot, anonymity,
|
||||
coercion-resistance, or legal-certification requirements.
|
||||
The following direct provider path remains a 0.1 compatibility contract only.
|
||||
A provider-bound vote on that path is finalized through
|
||||
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
|
||||
choices, eligible count, external ballot reference, request time, and
|
||||
idempotency key. Its result must cover exactly the configured choices, sum to
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Committee Interface Pattern Migration
|
||||
|
||||
This document records the bounded migration of Committee-owned WebUI surfaces
|
||||
to the GovOPlaN interface pattern language. Core owns shared controls, help,
|
||||
blocking explanations, draft guards, confirmations, and host shells. Committee
|
||||
owns bodies, meetings, agenda context, vote context, minutes, and the bounded
|
||||
projection of their institutional evidence.
|
||||
|
||||
## Surface Inventory
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `/committee` toolbar and body list | Searchable workspace directory | Change query projection or create body | Shared search/button/help controls, explicit loading/error/permission states |
|
||||
| Body and meeting selectors | Master-detail selection | Change active context | Stable keyboard buttons, selected/hover state, bounded independent scrolling |
|
||||
| Meeting detail | Governed record workspace | Create or revise agenda, vote, and minute records | Shared sections, statuses, action explanations, locale-aware dates |
|
||||
| Committee record dialog | Consequential record editor | Save immutable revision or change lifecycle | Contextual field help, guarded draft, required change reason, lifecycle confirmation |
|
||||
| Ballot finalization dialog | Governed provider action | Import aggregate result and evidence | Permission explanation, guarded draft, explicit confirmation, no individual ballots |
|
||||
|
||||
## Consequence And Availability Rules
|
||||
|
||||
- Every save creates a new Committee revision and retains the change reason.
|
||||
- Terminal lifecycle states are immutable; disabled edit actions explain that
|
||||
constraint rather than disappearing.
|
||||
- Cancelling, withdrawing, or retiring stops future work but never removes
|
||||
existing revisions, minutes, decisions, or evidence.
|
||||
- Provider and Voting integrations remain optional capability boundaries.
|
||||
Committee stores only the aggregate result and assurance evidence.
|
||||
- Missing write or ballot-finalization permission identifies the required
|
||||
action, responsible administrator, and Access destination.
|
||||
- Unsaved editors intercept route, browser, backdrop, and explicit close
|
||||
attempts through the shared draft guard.
|
||||
|
||||
## State And Accessibility Evidence
|
||||
|
||||
The module uses shared buttons, icon buttons, dialogs, confirmations, alerts,
|
||||
status badges, loading indicators, action blockers, field help, and draft
|
||||
guards. Native buttons preserve keyboard order and shared dialogs retain focus.
|
||||
The existing three-region desktop layout and two-region compact layout keep
|
||||
lists and details independently scrollable. English and German catalogues cover
|
||||
module metadata and owned UI copy, while dates follow the selected platform
|
||||
locale. Manifest topics expose stable route, field, blocker, privacy, and
|
||||
consequence references without importing optional sibling modules.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/committee",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"description": "GovOPlaN Committee platform module seed.",
|
||||
"type": "module",
|
||||
|
||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-committee"
|
||||
version = "0.1.7"
|
||||
description = "GovOPlaN Committee platform module seed."
|
||||
version = "0.1.16"
|
||||
description = "GovOPlaN committee governance and formal-decision integration module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.7",
|
||||
"govoplan-access>=0.1.7",
|
||||
"govoplan-core>=0.1.16",
|
||||
"govoplan-access>=0.1.16",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
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_core.core.voting import (
|
||||
CAPABILITY_VOTING_BALLOTS,
|
||||
VotingBallotProvider,
|
||||
)
|
||||
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 finalize_voting_ballot(
|
||||
self,
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
vote_id: str,
|
||||
voting_ballot_id: str,
|
||||
voting_expected_revision: int,
|
||||
approval_ref: InstitutionalReference,
|
||||
expected_revision: int,
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> CommitteeWorkspaceRecord:
|
||||
"""Close a Voting-owned ballot and project its aggregate into Committee."""
|
||||
|
||||
current = get_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind="vote",
|
||||
object_id=vote_id,
|
||||
)
|
||||
if current is None:
|
||||
raise LookupError("Committee vote not found.")
|
||||
if current.state != "open":
|
||||
raise CommitteeWorkspaceError(
|
||||
"Only an open Committee vote can be finalized."
|
||||
)
|
||||
if current.revision != expected_revision:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee revision conflict: the expected revision is stale."
|
||||
)
|
||||
configured_id = str(current.attributes.get("voting_ballot_id") or "").strip()
|
||||
if not configured_id or configured_id != voting_ballot_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Committee vote is not bound to the requested Voting ballot."
|
||||
)
|
||||
provider = _capability(self._registry, CAPABILITY_VOTING_BALLOTS)
|
||||
if not isinstance(provider, VotingBallotProvider):
|
||||
raise CommitteeWorkspaceError("Voting ballot capability is unavailable.")
|
||||
ballot = provider.get_ballot(
|
||||
session,
|
||||
principal,
|
||||
ballot_id=voting_ballot_id,
|
||||
)
|
||||
if ballot is None:
|
||||
raise CommitteeWorkspaceError("Referenced Voting ballot was not found.")
|
||||
context = ballot.get("context")
|
||||
if isinstance(context, Mapping):
|
||||
context_module = str(context.get("module") or "").strip()
|
||||
context_id = str(context.get("resource_id") or "").strip()
|
||||
if context_module and context_module != "committee":
|
||||
raise CommitteeWorkspaceError(
|
||||
"Referenced Voting ballot belongs to another module context."
|
||||
)
|
||||
if context_id and context_id != vote_id:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Referenced Voting ballot belongs to another Committee vote."
|
||||
)
|
||||
result = provider.close_ballot(
|
||||
session,
|
||||
principal,
|
||||
ballot_id=voting_ballot_id,
|
||||
expected_revision=voting_expected_revision,
|
||||
idempotency_key=f"committee:{idempotency_key}",
|
||||
)
|
||||
assurance_profile = str(
|
||||
ballot.get("assurance_profile") or "recorded"
|
||||
).strip()
|
||||
if assurance_profile != "recorded" and not result.evidence:
|
||||
raise CommitteeWorkspaceError(
|
||||
"Provider-backed Voting results require sanitized provider evidence."
|
||||
)
|
||||
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
|
||||
if set(result.counts) != set(choices):
|
||||
raise CommitteeWorkspaceError(
|
||||
"Voting result options do not match the Committee vote choices."
|
||||
)
|
||||
evidence = EvidenceReference(
|
||||
kind="snapshot",
|
||||
owner_module="voting",
|
||||
evidence_id=f"result-{result.result_sha256}",
|
||||
tenant_id=current.tenant_id,
|
||||
version=str(result.revision),
|
||||
checksum=result.result_sha256,
|
||||
source_ref=f"voting:{voting_ballot_id}",
|
||||
captured_at=recorded_at,
|
||||
)
|
||||
next_record = replace(
|
||||
current,
|
||||
revision=current.revision + 1,
|
||||
state="closed",
|
||||
recorded_at=recorded_at,
|
||||
change_reason=change_reason,
|
||||
attributes={
|
||||
**dict(current.attributes),
|
||||
"voting_ballot_id": voting_ballot_id,
|
||||
"voting_ballot_revision": result.revision,
|
||||
"voting_result_sha256": result.result_sha256,
|
||||
"voting_assurance_profile": assurance_profile,
|
||||
"voting_provider_id": ballot.get("provider_id"),
|
||||
"voting_provider_ballot_ref": ballot.get("provider_ballot_ref"),
|
||||
"voting_provider_evidence": [
|
||||
dict(item) for item in result.evidence
|
||||
],
|
||||
"counts": {key: int(value) for key, value in result.counts.items()},
|
||||
"weighted_counts": {
|
||||
key: int(value) for key, value in result.weighted_counts.items()
|
||||
},
|
||||
"cast_count": result.cast_count,
|
||||
"cast_weight": result.cast_weight,
|
||||
"quorum_met": result.quorum_met,
|
||||
"threshold_met": result.threshold_met,
|
||||
"winning_options": list(result.winning_options),
|
||||
"approval_ref": approval_ref.to_dict(),
|
||||
},
|
||||
evidence=_merge_evidence(current.evidence, (evidence,)),
|
||||
)
|
||||
return record_workspace_object(
|
||||
session,
|
||||
principal,
|
||||
record=next_record,
|
||||
expected_revision=expected_revision,
|
||||
idempotency_key=idempotency_key,
|
||||
_provider_finalization=True,
|
||||
)
|
||||
|
||||
|
||||
def _validate_result(
|
||||
result: BallotFinalizationResult,
|
||||
*,
|
||||
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,153 @@
|
||||
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.voting import CAPABILITY_VOTING_BALLOTS
|
||||
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.7"
|
||||
MODULE_VERSION = "0.1.16"
|
||||
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",
|
||||
"voting",
|
||||
"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.",
|
||||
"Governed ballot execution is delegated to Voting when installed. The Committee-owned provider adapter remains a 0.1 compatibility path; Committee stores only aggregate result evidence.",
|
||||
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"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 +164,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 +196,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 +210,22 @@ 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. Voting-backed "
|
||||
"results preserve sanitized provider assurance evidence and never promote the "
|
||||
"uncertified local confidential reference provider to a certified profile."
|
||||
),
|
||||
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 +237,77 @@ 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.",
|
||||
"help_contexts": [
|
||||
"committee.navigation",
|
||||
"committee.workspace",
|
||||
"committee.state.read-only",
|
||||
"committee.state.permission-blocked",
|
||||
],
|
||||
"privacy_notes": [
|
||||
"Committee lists expose only records authorized by the active tenant and permission context.",
|
||||
"Provider-backed voting stores aggregate result evidence, not individual confidential ballots.",
|
||||
"Protected decision reasoning requires the dedicated protected-read permission.",
|
||||
],
|
||||
"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"
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.reference.fields-and-consequences",
|
||||
title="Committee fields and consequences",
|
||||
summary="Field provenance, lifecycle restrictions, optional provider references, and evidence consequences for Committee records.",
|
||||
body=(
|
||||
"Body organization-unit references determine institutional context but do not grant access by themselves. "
|
||||
"Meeting times establish agenda context. Subject, Decision, Approval, record, and ballot identifiers are stable "
|
||||
"cross-module references and remain optional only where the current lifecycle allows it. State transitions create "
|
||||
"new immutable revisions; terminal records cannot be edited in place. Closing a vote or accepting minutes requires "
|
||||
"the corresponding governed evidence. Provider ballot finalization imports only an aggregate result and assurance "
|
||||
"evidence. Change reasons are retained with every revision. Cancelling, withdrawing, or retiring a record stops future "
|
||||
"work but does not erase existing revisions, decisions, minutes, or audit evidence."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
order=110,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Committee domain boundary",
|
||||
href="govoplan-committee/docs/COMMITTEE_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"committee.field.state",
|
||||
"committee.field.organization-unit-reference",
|
||||
"committee.field.subject-reference",
|
||||
"committee.field.decision-reference",
|
||||
"committee.field.approval-reference",
|
||||
"committee.field.evidence-reference",
|
||||
"committee.field.ballot-provider-reference",
|
||||
"committee.field.change-reason",
|
||||
"committee.action.change-state",
|
||||
"committee.action.finalize-ballot",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"save_revision": "Creates a new immutable Committee record revision with its change reason.",
|
||||
"change_lifecycle": "Changes which future actions remain possible; terminal states are immutable.",
|
||||
"finalize_ballot": "Imports a governed aggregate result and evidence without retaining individual ballots.",
|
||||
"cancel_or_retire": "Stops future use while retaining institutional and audit evidence.",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -90,10 +318,150 @@ 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,
|
||||
CAPABILITY_VOTING_BALLOTS,
|
||||
),
|
||||
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,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_VOTING_BALLOTS,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
|
||||
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,270 @@
|
||||
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,
|
||||
CommitteeVotingFinalizeRequest,
|
||||
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.post(
|
||||
"/workspace/vote/{vote_id}/finalize-voting",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_finalize_voting_vote(
|
||||
vote_id: str,
|
||||
payload: CommitteeVotingFinalizeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
_require(principal, BALLOT_SCOPE)
|
||||
try:
|
||||
item = CommitteeBallotFinalizer(_registry).finalize_voting_ballot(
|
||||
session,
|
||||
principal,
|
||||
vote_id=vote_id,
|
||||
voting_ballot_id=payload.voting_ballot_id,
|
||||
voting_expected_revision=payload.voting_expected_revision,
|
||||
approval_ref=InstitutionalReference.from_mapping(payload.approval_ref),
|
||||
expected_revision=payload.expected_revision,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except LookupError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any])
|
||||
def api_get_workspace(
|
||||
object_kind: str,
|
||||
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,58 @@
|
||||
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)
|
||||
|
||||
|
||||
class CommitteeVotingFinalizeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
voting_ballot_id: str = Field(min_length=1, max_length=255)
|
||||
voting_expected_revision: int = Field(ge=1)
|
||||
approval_ref: dict[str, Any]
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommitteeWorkspaceHistoryResponse",
|
||||
"CommitteeWorkspaceListResponse",
|
||||
"CommitteeWorkspaceWriteRequest",
|
||||
"CommitteeBallotFinalizeRequest",
|
||||
"CommitteeVotingFinalizeRequest",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_committee.backend.manifest import manifest
|
||||
|
||||
|
||||
class CommitteeInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
self.assertEqual(
|
||||
{"/committee"},
|
||||
{route.path for route in frontend.routes}, # type: ignore[union-attr]
|
||||
)
|
||||
self.assertEqual(
|
||||
{"committee.navigation", "committee.workspace"},
|
||||
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||
)
|
||||
|
||||
def test_topics_publish_help_privacy_and_consequence_metadata(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
self.assertIn("committee.module-boundary", topics)
|
||||
self.assertIn("committee.reference.fields-and-consequences", topics)
|
||||
|
||||
guide = topics["committee.module-boundary"]
|
||||
self.assertIn("committee.workspace", guide.metadata["help_contexts"])
|
||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||
|
||||
reference = topics["committee.reference.fields-and-consequences"]
|
||||
self.assertIn("committee.field.state", reference.metadata["help_contexts"])
|
||||
self.assertIn("committee.action.finalize-ballot", reference.metadata["help_contexts"])
|
||||
self.assertIn("change_lifecycle", reference.metadata["consequence_classes"])
|
||||
self.assertIn("finalize_ballot", reference.metadata["consequence_classes"])
|
||||
|
||||
|
||||
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,755 @@
|
||||
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_core.core.voting import CAPABILITY_VOTING_BALLOTS, VotingResult
|
||||
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
|
||||
|
||||
|
||||
class VotingBallots:
|
||||
def create_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_ballot(self, session, principal, *, ballot_id):
|
||||
return {
|
||||
"id": ballot_id,
|
||||
"revision": 2,
|
||||
"state": "open",
|
||||
"assurance_profile": "confidential",
|
||||
"provider_id": "local_confidential",
|
||||
"provider_ballot_ref": "local-confidential:ballot-1",
|
||||
"context": {"module": "committee", "resource_id": "vote-voting"},
|
||||
}
|
||||
|
||||
def open_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def cast_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def close_ballot(
|
||||
self, session, principal, *, ballot_id, expected_revision, idempotency_key
|
||||
):
|
||||
if expected_revision != 2:
|
||||
raise AssertionError("unexpected Voting revision")
|
||||
return VotingResult(
|
||||
ballot_id=ballot_id,
|
||||
revision=3,
|
||||
counts={"yes": 2, "no": 1},
|
||||
weighted_counts={"yes": 3, "no": 1},
|
||||
cast_count=3,
|
||||
cast_weight=4,
|
||||
eligible_count=4,
|
||||
eligible_weight=5,
|
||||
quorum_met=True,
|
||||
threshold_met=True,
|
||||
winning_options=("yes",),
|
||||
result_sha256="c" * 64,
|
||||
evidence=(
|
||||
{
|
||||
"kind": "reference_provider_result",
|
||||
"provider_id": "local_confidential",
|
||||
"certified": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def certify_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class VotingRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.provider = VotingBallots()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_VOTING_BALLOTS
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if name != CAPABILITY_VOTING_BALLOTS:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
|
||||
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
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)
|
||||
|
||||
def test_voting_ballot_finalization_projects_only_aggregate_result(self) -> None:
|
||||
records = (
|
||||
workspace_record(
|
||||
"body",
|
||||
"body-voting",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit", "board-1", "organizations"
|
||||
).to_dict(),
|
||||
"function_refs": [],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"meeting",
|
||||
"meeting-voting",
|
||||
state="open",
|
||||
parent_id="body-voting",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"agenda_item",
|
||||
"agenda-voting",
|
||||
state="deliberating",
|
||||
parent_id="meeting-voting",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"vote",
|
||||
"vote-voting",
|
||||
state="open",
|
||||
parent_id="agenda-voting",
|
||||
attributes={
|
||||
"method": "recorded",
|
||||
"voting_ballot_id": "ballot-1",
|
||||
"choices": ["yes", "no"],
|
||||
"eligible_count": 4,
|
||||
"cast_count": 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
for index, item in enumerate(records):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=item,
|
||||
idempotency_key=f"voting-setup-{index}",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
closed = CommitteeBallotFinalizer(VotingRegistry()).finalize_voting_ballot(
|
||||
self.session,
|
||||
self.principal,
|
||||
vote_id="vote-voting",
|
||||
voting_ballot_id="ballot-1",
|
||||
voting_expected_revision=2,
|
||||
approval_ref=ref("approval", "vote-approval", "approvals"),
|
||||
expected_revision=1,
|
||||
recorded_at=NOW + timedelta(minutes=5),
|
||||
change_reason="Closed the governed Voting ballot.",
|
||||
idempotency_key="voting-finalize-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual("closed", closed.state)
|
||||
self.assertEqual({"yes": 2, "no": 1}, closed.attributes["counts"])
|
||||
self.assertEqual("c" * 64, closed.attributes["voting_result_sha256"])
|
||||
self.assertEqual(
|
||||
"confidential",
|
||||
closed.attributes["voting_assurance_profile"],
|
||||
)
|
||||
self.assertFalse(closed.attributes["voting_provider_evidence"][0]["certified"])
|
||||
self.assertEqual("voting", closed.evidence[0].owner_module)
|
||||
self.assertNotIn("selections", closed.attributes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.16",
|
||||
"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.16",
|
||||
"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,152 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeVotingBallot(
|
||||
settings: ApiSettings,
|
||||
record: CommitteeRecord,
|
||||
input: {
|
||||
approvalId: string;
|
||||
changeReason: string;
|
||||
}
|
||||
): Promise<CommitteeRecord> {
|
||||
const ballotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
||||
const ballot = await apiFetch<{ revision: number }>(
|
||||
settings,
|
||||
`/api/v1/voting/${encodeURIComponent(ballotId)}`
|
||||
);
|
||||
return apiFetch<CommitteeRecord>(
|
||||
settings,
|
||||
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-voting`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
voting_ballot_id: ballotId,
|
||||
voting_expected_revision: ballot.revision,
|
||||
approval_ref: {
|
||||
kind: "approval",
|
||||
owner_module: "approvals",
|
||||
object_id: input.approvalId.trim(),
|
||||
tenant_id: record.tenant_id,
|
||||
version: "1"
|
||||
},
|
||||
expected_revision: record.revision,
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: input.changeReason.trim(),
|
||||
idempotency_key: crypto.randomUUID()
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
i18nMessage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
finalizeProviderBallot,
|
||||
finalizeVotingBallot,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
import {
|
||||
COMMITTEE_FIELD_DOCUMENTATION,
|
||||
COMMITTEE_INTERFACE_I18N
|
||||
} from "./interfacePatterns";
|
||||
|
||||
|
||||
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("");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProviderBallotRef("");
|
||||
setApprovalId("");
|
||||
setChangeReason("Imported verified ballot aggregate.");
|
||||
setError("");
|
||||
setConfirming(false);
|
||||
}, [open, record.object_id]);
|
||||
|
||||
const dirty = Boolean(providerBallotRef || approvalId || changeReason !== "Imported verified ballot aggregate.");
|
||||
|
||||
async function finalize(closeAfter = true): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = votingBallotId
|
||||
? await finalizeVotingBallot(settings, record, { approvalId, changeReason })
|
||||
: await finalizeProviderBallot(settings, record, {
|
||||
providerBallotRef,
|
||||
approvalId,
|
||||
changeReason
|
||||
});
|
||||
onSaved(saved);
|
||||
if (closeAfter) onClose();
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Ballot result could not be imported.");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: open && dirty,
|
||||
onSave: () => finalize(false),
|
||||
onDiscard: () => {
|
||||
setProviderBallotRef("");
|
||||
setApprovalId("");
|
||||
setChangeReason("Imported verified ballot aggregate.");
|
||||
},
|
||||
title: "i18n:govoplan-committee.unsaved_title",
|
||||
message: "i18n:govoplan-committee.unsaved_message"
|
||||
});
|
||||
|
||||
function requestClose() {
|
||||
if (busy) return;
|
||||
if (dirty) requestDiscard(onClose);
|
||||
else onClose();
|
||||
}
|
||||
|
||||
const providerId = String(record.attributes.provider_id ?? "");
|
||||
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
|
||||
const incomplete = (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim();
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
|
||||
onClose={requestClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="committee-ballot-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : undefined} onClick={requestClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || incomplete}
|
||||
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{busy ? "Importing" : "Finalize"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="committee-record-form">
|
||||
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<p className="committee-dialog-note">
|
||||
{votingBallotId
|
||||
? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
|
||||
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
|
||||
</p>
|
||||
{!votingBallotId ? <FormField label="Provider ballot reference" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
|
||||
</FormField> : null}
|
||||
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Change reason" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="i18n:govoplan-committee.finalize_title"
|
||||
message={i18nMessage("i18n:govoplan-committee.confirm_ballot_finalization", { title: record.title })}
|
||||
confirmLabel="Finalize"
|
||||
busy={busy}
|
||||
onConfirm={() => {
|
||||
setConfirming(false);
|
||||
void finalize();
|
||||
}}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
CalendarPlus,
|
||||
FilePlus2,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Vote
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listCommitteeRecords,
|
||||
type CommitteeObjectKind,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
import CommitteeBallotDialog from "./CommitteeBallotDialog";
|
||||
import CommitteeRecordDialog from "./CommitteeRecordDialog";
|
||||
import {
|
||||
COMMITTEE_DOCUMENTATION,
|
||||
COMMITTEE_INTERFACE_I18N,
|
||||
committeeDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
import { canReviseCommitteeRecord } from "./lifecycle";
|
||||
|
||||
|
||||
type EditorTarget = {
|
||||
kind: CommitteeObjectKind;
|
||||
parentId?: string | null;
|
||||
record?: CommitteeRecord | null;
|
||||
};
|
||||
|
||||
export default function CommitteePage({ settings, auth }: PlatformRouteContext) {
|
||||
const { language, translateText } = usePlatformLanguage();
|
||||
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, language)} - ${formatTime(selectedMeeting.attributes.ends_at, language)}`
|
||||
: "",
|
||||
[language, 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} disabledReason={loading ? COMMITTEE_INTERFACE_I18N.loading : undefined}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canWrite || loading}
|
||||
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
|
||||
onClick={() => setEditor({ kind: "body" })}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
New body
|
||||
</Button>
|
||||
<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />
|
||||
</div>
|
||||
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
|
||||
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
|
||||
{!canWrite ? (
|
||||
<ActionBlockerHint
|
||||
tone="info"
|
||||
reason={{
|
||||
summary: "No Committee management permission",
|
||||
details: COMMITTEE_INTERFACE_I18N.writeReason,
|
||||
requiredAction: COMMITTEE_INTERFACE_I18N.permissionAction,
|
||||
actor: COMMITTEE_INTERFACE_I18N.permissionActor,
|
||||
target: COMMITTEE_INTERFACE_I18N.permissionDestination
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: COMMITTEE_INTERFACE_I18N.requiredAction,
|
||||
actor: COMMITTEE_INTERFACE_I18N.actor,
|
||||
target: COMMITTEE_INTERFACE_I18N.destination
|
||||
}}
|
||||
documentation={COMMITTEE_DOCUMENTATION}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<div className="committee-panel-actions">
|
||||
<IconButton
|
||||
label="Edit body"
|
||||
icon={<Pencil size={16} />}
|
||||
disabled={!canWrite || !canReviseCommitteeRecord(selectedBody)}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedBody) })}
|
||||
onClick={() => setEditor({ kind: "body", record: selectedBody })}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canWrite}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||
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={(record) => meetingSecondary(record, language, translateText)} />
|
||||
</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={stateLabel(selectedMeeting.state)} />
|
||||
<IconButton
|
||||
label="Edit meeting"
|
||||
icon={<Pencil size={16} />}
|
||||
disabled={!canWrite || !canReviseCommitteeRecord(selectedMeeting)}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(selectedMeeting) })}
|
||||
onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })}
|
||||
/>
|
||||
</div>
|
||||
<PageScrollViewport className="committee-detail-scroll">
|
||||
<WorkspaceSection
|
||||
title="Agenda"
|
||||
action={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!canWrite}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||
onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}
|
||||
>
|
||||
<ListPlus size={16} aria-hidden="true" />
|
||||
Add item
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<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>{stateLabel(item.state)}</small></span>
|
||||
</button>
|
||||
<IconButton
|
||||
label="Edit agenda item"
|
||||
icon={<Pencil size={15} />}
|
||||
disabled={!canWrite || !canReviseCommitteeRecord(item)}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(item) })}
|
||||
onClick={() => setEditor({ kind: "agenda_item", record: item })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
|
||||
</div>
|
||||
</WorkspaceSection>
|
||||
|
||||
{selectedAgenda ? (
|
||||
<WorkspaceSection
|
||||
title={i18nMessage("i18n:govoplan-committee.votes_for", { title: selectedAgenda.title })}
|
||||
action={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!canWrite}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||
onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}
|
||||
>
|
||||
<Vote size={16} aria-hidden="true" />
|
||||
Add vote
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<RecordRows
|
||||
records={votes}
|
||||
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
|
||||
onEdit={(record) => setEditor({ kind: "vote", record })}
|
||||
detail={(record) => voteSummary(record, translateText)}
|
||||
secondaryAction={(record) => isProviderBallotReady(record) ? (
|
||||
<IconButton
|
||||
label={i18nMessage("i18n:govoplan-committee.finalize_provider_record", { title: record.title })}
|
||||
icon={<ShieldCheck size={15} />}
|
||||
disabled={!canFinalizeBallot}
|
||||
disabledReason={!canFinalizeBallot ? COMMITTEE_INTERFACE_I18N.finalizeReason : undefined}
|
||||
onClick={() => setBallotRecord(record)}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
</WorkspaceSection>
|
||||
) : null}
|
||||
|
||||
<WorkspaceSection
|
||||
title="Minutes"
|
||||
action={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!canWrite}
|
||||
disabledReason={committeeDisabledReason({ permitted: canWrite })}
|
||||
onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}
|
||||
>
|
||||
<FilePlus2 size={16} aria-hidden="true" />
|
||||
Add minutes
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<RecordRows
|
||||
records={minutes}
|
||||
editDisabledReason={(record) => committeeDisabledReason({ permitted: canWrite, lifecycleBlocked: !canReviseCommitteeRecord(record) })}
|
||||
onEdit={(record) => setEditor({ kind: "minute", record })}
|
||||
detail={(record) => i18nMessage("i18n:govoplan-committee.record_reference", { id: 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) ?? stateLabel(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, editDisabledReason, onEdit, detail, secondaryAction }: {
|
||||
records: CommitteeRecord[];
|
||||
editDisabledReason: (record: CommitteeRecord) => string | undefined;
|
||||
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={stateLabel(record.state)} />
|
||||
<span className="committee-row-actions">
|
||||
{secondaryAction?.(record)}
|
||||
<IconButton
|
||||
label={i18nMessage("i18n:govoplan-committee.edit_record", { title: record.title })}
|
||||
icon={<Pencil size={15} />}
|
||||
disabled={Boolean(editDisabledReason(record))}
|
||||
disabledReason={editDisabledReason(record)}
|
||||
onClick={() => onEdit(record)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}</div>;
|
||||
}
|
||||
|
||||
function meetingSecondary(record: CommitteeRecord, locale: string, translateText: (value: string) => string): string {
|
||||
return `${formatDateTime(record.attributes.starts_at, locale)} - ${translateText(stateLabel(record.state))}`;
|
||||
}
|
||||
|
||||
function voteSummary(record: CommitteeRecord, translateText: (value: string) => string): string {
|
||||
const cast = Number(record.attributes.cast_count ?? 0);
|
||||
const eligible = Number(record.attributes.eligible_count ?? 0);
|
||||
return i18nMessage("i18n:govoplan-committee.vote_summary", {
|
||||
method: translateText(domainLabel(String(record.attributes.method ?? "recorded"))),
|
||||
cast,
|
||||
eligible
|
||||
});
|
||||
}
|
||||
|
||||
function isProviderBallotReady(record: CommitteeRecord): boolean {
|
||||
return record.state === "open" && Boolean(
|
||||
String(record.attributes.voting_ballot_id ?? "").trim()
|
||||
|| String(record.attributes.provider_id ?? "").trim()
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(state: string): "active" | "inactive" | "warning" {
|
||||
if (["active", "open", "accepted", "decided", "closed"].includes(state)) return "active";
|
||||
if (["cancelled", "retired", "withdrawn"].includes(state)) return "inactive";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function formatDateTime(value: unknown, locale?: string): string {
|
||||
if (!value) return "Date not set";
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value)));
|
||||
}
|
||||
|
||||
function formatTime(value: unknown, locale?: string): string {
|
||||
if (!value) return "-";
|
||||
return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(new Date(String(value)));
|
||||
}
|
||||
|
||||
function stateLabel(value: string): string {
|
||||
return `i18n:govoplan-committee.state_${value}`;
|
||||
}
|
||||
|
||||
function domainLabel(value: string): string {
|
||||
return `i18n:govoplan-committee.domain_${value}`;
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
saveCommitteeRecord,
|
||||
type CommitteeObjectKind,
|
||||
type CommitteeRecord
|
||||
} from "../../api/committee";
|
||||
import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle";
|
||||
import {
|
||||
COMMITTEE_FIELD_DOCUMENTATION,
|
||||
COMMITTEE_INTERFACE_I18N,
|
||||
committeeDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
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;
|
||||
votingBallotId: 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 { translateText } = usePlatformLanguage();
|
||||
const initialDraft = useMemo(() => draftFromRecord(kind, record), [kind, record]);
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
const [baseline, setBaseline] = useState<Draft>(initialDraft);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmLifecycleChange, setConfirmLifecycleChange] = useState(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
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(initialDraft);
|
||||
setBaseline(initialDraft);
|
||||
setError("");
|
||||
setConfirmLifecycleChange(false);
|
||||
}, [initialDraft, open]);
|
||||
|
||||
const dirty = draftKey(draft) !== draftKey(baseline);
|
||||
const incomplete = !draft.title.trim() || !draft.changeReason.trim();
|
||||
|
||||
async function save(closeAfter = true): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = recordFromDraft({
|
||||
tenantId,
|
||||
kind,
|
||||
parentId,
|
||||
record,
|
||||
draft,
|
||||
choices
|
||||
});
|
||||
const saved = await saveCommitteeRecord(
|
||||
settings,
|
||||
payload,
|
||||
record?.revision
|
||||
);
|
||||
const nextDraft = draftFromRecord(kind, saved);
|
||||
setDraft(nextDraft);
|
||||
setBaseline(nextDraft);
|
||||
onSaved(saved);
|
||||
if (closeAfter) onClose();
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Committee record could not be saved.");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: open && dirty,
|
||||
onSave: () => save(false),
|
||||
onDiscard: () => setDraft(baseline),
|
||||
title: "i18n:govoplan-committee.unsaved_title",
|
||||
message: "i18n:govoplan-committee.unsaved_message"
|
||||
});
|
||||
|
||||
function requestClose() {
|
||||
if (busy) return;
|
||||
if (dirty) requestDiscard(onClose);
|
||||
else onClose();
|
||||
}
|
||||
|
||||
function requestSave() {
|
||||
if (record && draft.state !== record.state) {
|
||||
setConfirmLifecycleChange(true);
|
||||
return;
|
||||
}
|
||||
void save();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={i18nMessage("i18n:govoplan-committee.record_dialog_title", {
|
||||
action: translateText(record ? "Edit" : "New"),
|
||||
kind: translateText(kindLabel(kind))
|
||||
})}
|
||||
onClose={requestClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
className="committee-record-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} disabledReason={committeeDisabledReason({ busy })} onClick={requestClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || incomplete}
|
||||
disabledReason={busy ? COMMITTEE_INTERFACE_I18N.busy : incomplete ? COMMITTEE_INTERFACE_I18N.incomplete : undefined}
|
||||
onClick={requestSave}
|
||||
>
|
||||
{busy ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="committee-record-form">
|
||||
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
<div className="committee-form-grid">
|
||||
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.title}
|
||||
maxLength={500}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="State" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<select
|
||||
value={draft.state}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
|
||||
>
|
||||
{stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{kind === "body" ? (
|
||||
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<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" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<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)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Choices (comma separated)">
|
||||
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
|
||||
</FormField>
|
||||
{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" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<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" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input
|
||||
value={draft.changeReason}
|
||||
maxLength={1000}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({ ...draft, changeReason: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmLifecycleChange}
|
||||
title="i18n:govoplan-committee.save_state_title"
|
||||
message={record ? i18nMessage("i18n:govoplan-committee.confirm_state_change", {
|
||||
kind: translateText(kindLabel(kind)),
|
||||
from: translateText(stateLabel(record.state)),
|
||||
to: translateText(stateLabel(draft.state))
|
||||
}) : ""}
|
||||
confirmLabel="Save"
|
||||
tone={["cancelled", "retired", "withdrawn"].includes(draft.state) ? "danger" : "default"}
|
||||
busy={busy}
|
||||
onConfirm={() => {
|
||||
setConfirmLifecycleChange(false);
|
||||
void save();
|
||||
}}
|
||||
onCancel={() => setConfirmLifecycleChange(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
|
||||
return (
|
||||
<div className="committee-form-grid">
|
||||
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
|
||||
<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),
|
||||
votingBallotId: text(attributes.voting_ballot_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.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
|
||||
...(draft.state === "closed" ? {
|
||||
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
|
||||
quorum_met: draft.quorumMet,
|
||||
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 kindLabel(kind: CommitteeObjectKind): string {
|
||||
return `i18n:govoplan-committee.kind_${kind}`;
|
||||
}
|
||||
|
||||
function stateLabel(state: string): string {
|
||||
return `i18n:govoplan-committee.state_${state}`;
|
||||
}
|
||||
|
||||
function draftKey(draft: Draft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function humanize(value: string): string {
|
||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const COMMITTEE_DOCUMENTATION = {
|
||||
topicId: "committee.module-boundary",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const COMMITTEE_FIELD_DOCUMENTATION = {
|
||||
topicId: "committee.reference.fields-and-consequences",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const COMMITTEE_INTERFACE_I18N = {
|
||||
loading: "i18n:govoplan-committee.loading_reason",
|
||||
busy: "i18n:govoplan-committee.busy_reason",
|
||||
writeReason: "i18n:govoplan-committee.write_permission_reason",
|
||||
finalizeReason: "i18n:govoplan-committee.finalize_permission_reason",
|
||||
lifecycleReason: "i18n:govoplan-committee.lifecycle_reason",
|
||||
incomplete: "i18n:govoplan-committee.incomplete_reason",
|
||||
requiredAction: "i18n:govoplan-committee.required_action",
|
||||
actor: "i18n:govoplan-committee.responsible_actor",
|
||||
destination: "i18n:govoplan-committee.destination",
|
||||
permissionAction: "i18n:govoplan-committee.permission_action",
|
||||
permissionActor: "i18n:govoplan-committee.permission_actor",
|
||||
permissionDestination: "i18n:govoplan-committee.permission_destination"
|
||||
} as const;
|
||||
|
||||
export function committeeDisabledReason({
|
||||
loading = false,
|
||||
busy = false,
|
||||
permitted = true,
|
||||
lifecycleBlocked = false
|
||||
}: {
|
||||
loading?: boolean;
|
||||
busy?: boolean;
|
||||
permitted?: boolean;
|
||||
lifecycleBlocked?: boolean;
|
||||
}): string | undefined {
|
||||
if (loading) return COMMITTEE_INTERFACE_I18N.loading;
|
||||
if (busy) return COMMITTEE_INTERFACE_I18N.busy;
|
||||
if (!permitted) return COMMITTEE_INTERFACE_I18N.writeReason;
|
||||
if (lifecycleBlocked) return COMMITTEE_INTERFACE_I18N.lifecycleReason;
|
||||
return undefined;
|
||||
}
|
||||
@@ -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,223 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
const en = {
|
||||
"i18n:govoplan-committee.committee": "Committee",
|
||||
"i18n:govoplan-committee.loading_reason": "Committee data is still loading.",
|
||||
"i18n:govoplan-committee.busy_reason": "Another Committee action is still running.",
|
||||
"i18n:govoplan-committee.write_permission_reason": "Your account may not manage Committee records.",
|
||||
"i18n:govoplan-committee.finalize_permission_reason": "Your account may not finalize governed ballot results.",
|
||||
"i18n:govoplan-committee.lifecycle_reason": "This record is immutable in its current lifecycle state.",
|
||||
"i18n:govoplan-committee.incomplete_reason": "Complete the required title and change reason first.",
|
||||
"i18n:govoplan-committee.required_action": "Required action",
|
||||
"i18n:govoplan-committee.responsible_actor": "Responsible actor",
|
||||
"i18n:govoplan-committee.destination": "Destination",
|
||||
"i18n:govoplan-committee.permission_action": "Ask for the corresponding Committee management permission.",
|
||||
"i18n:govoplan-committee.permission_actor": "An Access or tenant administrator",
|
||||
"i18n:govoplan-committee.permission_destination": "Access role assignments",
|
||||
"i18n:govoplan-committee.confirm_state_change": "Save {kind} and change its state from {from} to {to}? The reason and revision are retained as evidence.",
|
||||
"i18n:govoplan-committee.confirm_ballot_finalization": "Finalize {title}? The aggregate result, approval and evidence become part of the governed Committee record.",
|
||||
"i18n:govoplan-committee.finalize_title": "Finalize ballot result",
|
||||
"i18n:govoplan-committee.save_state_title": "Confirm lifecycle change",
|
||||
"i18n:govoplan-committee.unsaved_title": "Unsaved Committee record",
|
||||
"i18n:govoplan-committee.unsaved_message": "Save or discard the Committee record before leaving this surface.",
|
||||
"i18n:govoplan-committee.votes_for": "Votes - {title}",
|
||||
"i18n:govoplan-committee.finalize_provider_record": "Finalize {title} from ballot provider",
|
||||
"i18n:govoplan-committee.record_reference": "Record {id}",
|
||||
"i18n:govoplan-committee.edit_record": "Edit {title}",
|
||||
"i18n:govoplan-committee.vote_summary": "{method}, {cast}/{eligible} cast",
|
||||
"i18n:govoplan-committee.record_dialog_title": "{action} {kind}",
|
||||
"i18n:govoplan-committee.kind_body": "body",
|
||||
"i18n:govoplan-committee.kind_meeting": "meeting",
|
||||
"i18n:govoplan-committee.kind_agenda_item": "agenda item",
|
||||
"i18n:govoplan-committee.kind_vote": "vote",
|
||||
"i18n:govoplan-committee.kind_minute": "minutes",
|
||||
"i18n:govoplan-committee.state_draft": "Draft",
|
||||
"i18n:govoplan-committee.state_active": "Active",
|
||||
"i18n:govoplan-committee.state_suspended": "Suspended",
|
||||
"i18n:govoplan-committee.state_retired": "Retired",
|
||||
"i18n:govoplan-committee.state_scheduled": "Scheduled",
|
||||
"i18n:govoplan-committee.state_open": "Open",
|
||||
"i18n:govoplan-committee.state_closed": "Closed",
|
||||
"i18n:govoplan-committee.state_cancelled": "Cancelled",
|
||||
"i18n:govoplan-committee.state_deliberating": "Deliberating",
|
||||
"i18n:govoplan-committee.state_decided": "Decided",
|
||||
"i18n:govoplan-committee.state_withdrawn": "Withdrawn",
|
||||
"i18n:govoplan-committee.state_proposed": "Proposed",
|
||||
"i18n:govoplan-committee.state_accepted": "Accepted",
|
||||
"i18n:govoplan-committee.state_corrected": "Corrected",
|
||||
"i18n:govoplan-committee.domain_recorded": "Recorded",
|
||||
"i18n:govoplan-committee.domain_public": "Public",
|
||||
"i18n:govoplan-committee.domain_secret": "Secret",
|
||||
"Committee": "Committee",
|
||||
"Search bodies": "Search bodies",
|
||||
"Search committee bodies": "Search committee bodies",
|
||||
"Refresh": "Refresh",
|
||||
"New body": "New body",
|
||||
"Loading committee workspace": "Loading committee workspace",
|
||||
"Committee bodies": "Committee bodies",
|
||||
"Bodies": "Bodies",
|
||||
"Edit body": "Edit body",
|
||||
"New meeting": "New meeting",
|
||||
"Meetings": "Meetings",
|
||||
"Meeting workspace": "Meeting workspace",
|
||||
"Select or create a meeting.": "Select or create a meeting.",
|
||||
"Edit meeting": "Edit meeting",
|
||||
"Agenda": "Agenda",
|
||||
"Add item": "Add item",
|
||||
"No agenda items.": "No agenda items.",
|
||||
"Add vote": "Add vote",
|
||||
"Minutes": "Minutes",
|
||||
"Add minutes": "Add minutes",
|
||||
"No records.": "No records.",
|
||||
"Cancel": "Cancel",
|
||||
"Save": "Save",
|
||||
"Saving": "Saving",
|
||||
"Title": "Title",
|
||||
"State": "State",
|
||||
"Responsible organization unit ID": "Responsible organization unit ID",
|
||||
"Starts": "Starts",
|
||||
"Ends": "Ends",
|
||||
"Position": "Position",
|
||||
"Subject type": "Subject type",
|
||||
"Subject ID": "Subject ID",
|
||||
"Formal Decision ID": "Formal Decision ID",
|
||||
"Method": "Method",
|
||||
"Eligible voters": "Eligible voters",
|
||||
"Ballot provider (optional)": "Ballot provider (optional)",
|
||||
"Voting ballot ID (optional)": "Voting ballot ID (optional)",
|
||||
"Choices (comma separated)": "Choices (comma separated)",
|
||||
"Votes cast": "Votes cast",
|
||||
"Quorum": "Quorum",
|
||||
"Met": "Met",
|
||||
"Not met": "Not met",
|
||||
"Approval ID": "Approval ID",
|
||||
"Evidence record ID": "Evidence record ID",
|
||||
"Minutes record ID": "Minutes record ID",
|
||||
"Change reason": "Change reason",
|
||||
"Provider ballot reference": "Provider ballot reference",
|
||||
"Importing": "Importing",
|
||||
"Finalize": "Finalize",
|
||||
"Date not set": "Date not set",
|
||||
"No Committee management permission": "No Committee management permission",
|
||||
"Edit": "Edit",
|
||||
"New": "New",
|
||||
"Recorded": "Recorded",
|
||||
"Public": "Public",
|
||||
"Secret": "Secret",
|
||||
"Case": "Case",
|
||||
"Service": "Service",
|
||||
"Work Item": "Work item",
|
||||
"Record": "Record"
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
"i18n:govoplan-committee.committee": "Gremien",
|
||||
"i18n:govoplan-committee.loading_reason": "Gremiendaten werden noch geladen.",
|
||||
"i18n:govoplan-committee.busy_reason": "Eine andere Gremienaktion läuft noch.",
|
||||
"i18n:govoplan-committee.write_permission_reason": "Ihr Konto darf Gremiendatensätze nicht verwalten.",
|
||||
"i18n:govoplan-committee.finalize_permission_reason": "Ihr Konto darf geregelte Abstimmungsergebnisse nicht abschließen.",
|
||||
"i18n:govoplan-committee.lifecycle_reason": "Dieser Datensatz ist in seinem aktuellen Lebenszyklus unveränderlich.",
|
||||
"i18n:govoplan-committee.incomplete_reason": "Füllen Sie zuerst Titel und Änderungsgrund aus.",
|
||||
"i18n:govoplan-committee.required_action": "Erforderliche Aktion",
|
||||
"i18n:govoplan-committee.responsible_actor": "Verantwortliche Stelle",
|
||||
"i18n:govoplan-committee.destination": "Ziel",
|
||||
"i18n:govoplan-committee.permission_action": "Fordern Sie die entsprechende Berechtigung zur Gremienverwaltung an.",
|
||||
"i18n:govoplan-committee.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
|
||||
"i18n:govoplan-committee.permission_destination": "Zugriff und Rollenzuweisungen",
|
||||
"i18n:govoplan-committee.confirm_state_change": "{kind} speichern und den Status von {from} auf {to} ändern? Grund und Revision werden als Nachweis aufbewahrt.",
|
||||
"i18n:govoplan-committee.confirm_ballot_finalization": "{title} abschließen? Gesamtergebnis, Genehmigung und Nachweis werden Bestandteil des geregelten Gremiendatensatzes.",
|
||||
"i18n:govoplan-committee.finalize_title": "Abstimmungsergebnis abschließen",
|
||||
"i18n:govoplan-committee.save_state_title": "Lebenszyklusänderung bestätigen",
|
||||
"i18n:govoplan-committee.unsaved_title": "Ungespeicherter Gremiendatensatz",
|
||||
"i18n:govoplan-committee.unsaved_message": "Speichern oder verwerfen Sie den Gremiendatensatz, bevor Sie diese Oberfläche verlassen.",
|
||||
"i18n:govoplan-committee.votes_for": "Abstimmungen - {title}",
|
||||
"i18n:govoplan-committee.finalize_provider_record": "{title} über Abstimmungsanbieter abschließen",
|
||||
"i18n:govoplan-committee.record_reference": "Datensatz {id}",
|
||||
"i18n:govoplan-committee.edit_record": "{title} bearbeiten",
|
||||
"i18n:govoplan-committee.vote_summary": "{method}, {cast}/{eligible} abgegeben",
|
||||
"i18n:govoplan-committee.record_dialog_title": "{kind}: {action}",
|
||||
"i18n:govoplan-committee.kind_body": "Gremium",
|
||||
"i18n:govoplan-committee.kind_meeting": "Sitzung",
|
||||
"i18n:govoplan-committee.kind_agenda_item": "Tagesordnungspunkt",
|
||||
"i18n:govoplan-committee.kind_vote": "Abstimmung",
|
||||
"i18n:govoplan-committee.kind_minute": "Protokoll",
|
||||
"i18n:govoplan-committee.state_draft": "Entwurf",
|
||||
"i18n:govoplan-committee.state_active": "Aktiv",
|
||||
"i18n:govoplan-committee.state_suspended": "Ausgesetzt",
|
||||
"i18n:govoplan-committee.state_retired": "Stillgelegt",
|
||||
"i18n:govoplan-committee.state_scheduled": "Geplant",
|
||||
"i18n:govoplan-committee.state_open": "Offen",
|
||||
"i18n:govoplan-committee.state_closed": "Geschlossen",
|
||||
"i18n:govoplan-committee.state_cancelled": "Abgesagt",
|
||||
"i18n:govoplan-committee.state_deliberating": "In Beratung",
|
||||
"i18n:govoplan-committee.state_decided": "Entschieden",
|
||||
"i18n:govoplan-committee.state_withdrawn": "Zurückgezogen",
|
||||
"i18n:govoplan-committee.state_proposed": "Vorgeschlagen",
|
||||
"i18n:govoplan-committee.state_accepted": "Angenommen",
|
||||
"i18n:govoplan-committee.state_corrected": "Berichtigt",
|
||||
"i18n:govoplan-committee.domain_recorded": "Namentlich",
|
||||
"i18n:govoplan-committee.domain_public": "Öffentlich",
|
||||
"i18n:govoplan-committee.domain_secret": "Geheim",
|
||||
"Committee": "Gremien",
|
||||
"Search bodies": "Gremien suchen",
|
||||
"Search committee bodies": "Gremien durchsuchen",
|
||||
"Refresh": "Aktualisieren",
|
||||
"New body": "Neues Gremium",
|
||||
"Loading committee workspace": "Gremienarbeitsbereich wird geladen",
|
||||
"Committee bodies": "Gremien",
|
||||
"Bodies": "Gremien",
|
||||
"Edit body": "Gremium bearbeiten",
|
||||
"New meeting": "Neue Sitzung",
|
||||
"Meetings": "Sitzungen",
|
||||
"Meeting workspace": "Sitzungsarbeitsbereich",
|
||||
"Select or create a meeting.": "Wählen oder erstellen Sie eine Sitzung.",
|
||||
"Edit meeting": "Sitzung bearbeiten",
|
||||
"Agenda": "Tagesordnung",
|
||||
"Add item": "Punkt hinzufügen",
|
||||
"No agenda items.": "Keine Tagesordnungspunkte.",
|
||||
"Add vote": "Abstimmung hinzufügen",
|
||||
"Minutes": "Protokolle",
|
||||
"Add minutes": "Protokoll hinzufügen",
|
||||
"No records.": "Keine Datensätze.",
|
||||
"Cancel": "Abbrechen",
|
||||
"Save": "Speichern",
|
||||
"Saving": "Speichern",
|
||||
"Title": "Titel",
|
||||
"State": "Status",
|
||||
"Responsible organization unit ID": "ID der zuständigen Organisationseinheit",
|
||||
"Starts": "Beginn",
|
||||
"Ends": "Ende",
|
||||
"Position": "Position",
|
||||
"Subject type": "Gegenstandsart",
|
||||
"Subject ID": "Gegenstands-ID",
|
||||
"Formal Decision ID": "Formelle Entscheidungs-ID",
|
||||
"Method": "Verfahren",
|
||||
"Eligible voters": "Stimmberechtigte",
|
||||
"Ballot provider (optional)": "Abstimmungsanbieter (optional)",
|
||||
"Voting ballot ID (optional)": "Abstimmungs-ID (optional)",
|
||||
"Choices (comma separated)": "Auswahlmöglichkeiten (kommagetrennt)",
|
||||
"Votes cast": "Abgegebene Stimmen",
|
||||
"Quorum": "Quorum",
|
||||
"Met": "Erfüllt",
|
||||
"Not met": "Nicht erfüllt",
|
||||
"Approval ID": "Genehmigungs-ID",
|
||||
"Evidence record ID": "Nachweisdatensatz-ID",
|
||||
"Minutes record ID": "Protokolldatensatz-ID",
|
||||
"Change reason": "Änderungsgrund",
|
||||
"Provider ballot reference": "Referenz des Abstimmungsanbieters",
|
||||
"Importing": "Importieren",
|
||||
"Finalize": "Abschließen",
|
||||
"Date not set": "Datum nicht festgelegt",
|
||||
"No Committee management permission": "Keine Berechtigung zur Gremienverwaltung",
|
||||
"Edit": "Bearbeiten",
|
||||
"New": "Neu",
|
||||
"Recorded": "Namentlich",
|
||||
"Public": "Öffentlich",
|
||||
"Secret": "Geheim",
|
||||
"Case": "Fall",
|
||||
"Service": "Leistung",
|
||||
"Work Item": "Arbeitsschritt",
|
||||
"Record": "Datensatz"
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, committeeModule } from "./module";
|
||||
export * from "./api/committee";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/committee.css";
|
||||
|
||||
|
||||
const CommitteePage = lazy(() => import("./features/committee/CommitteePage"));
|
||||
|
||||
export const committeeModule: PlatformWebModule = {
|
||||
id: "committee",
|
||||
label: "i18n:govoplan-committee.committee",
|
||||
version: "0.1.8",
|
||||
optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"],
|
||||
translations: generatedTranslations,
|
||||
navItems: [
|
||||
{
|
||||
to: "/committee",
|
||||
label: "i18n:govoplan-committee.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,335 @@
|
||||
.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-shell > .action-blocker-hint {
|
||||
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-dialog-help {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.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