Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432f8bbd02 | ||
|
|
049ea80385 | ||
|
|
3259741765 | ||
|
|
be411e4ca8 | ||
|
|
75c9488f66 | ||
|
|
0e03f6a70c | ||
|
|
1351338590 | ||
|
|
21052accee | ||
|
|
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
|
# 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
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN Committee platform module seed.
|
This repository owns the GovOPlaN Committee platform module seed.
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
# GovOPlaN Committee
|
# 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
|
## Initial Ownership
|
||||||
|
|
||||||
- committee bodies
|
- committee bodies
|
||||||
- meeting agendas
|
- meeting agendas
|
||||||
- minutes
|
- minutes
|
||||||
- decision records
|
- deliberation/vote context and formal decision references
|
||||||
- votes
|
- votes
|
||||||
- follow-up assignments
|
- follow-up assignments
|
||||||
|
|
||||||
@@ -20,6 +43,8 @@ This module does not own:
|
|||||||
- generic task execution
|
- generic task execution
|
||||||
- document storage
|
- document storage
|
||||||
- calendar event 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).
|
Detailed boundary notes are in [docs/COMMITTEE_DOMAIN_BOUNDARY.md](docs/COMMITTEE_DOMAIN_BOUNDARY.md).
|
||||||
|
|
||||||
@@ -33,6 +58,7 @@ Expected optional integrations:
|
|||||||
- tasks
|
- tasks
|
||||||
- workflow
|
- workflow
|
||||||
- approvals
|
- approvals
|
||||||
|
- voting
|
||||||
|
|
||||||
## Development Install
|
## 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
|
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
|
## 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`.
|
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
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
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
|
- committee bodies
|
||||||
- meeting agendas
|
- meeting agendas
|
||||||
- minutes
|
- minutes
|
||||||
- decision records
|
- deliberation and vote context plus references to formal decision records
|
||||||
- votes
|
- votes
|
||||||
- follow-up assignments
|
- follow-up assignments
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
|||||||
- generic task execution
|
- generic task execution
|
||||||
- document storage
|
- document storage
|
||||||
- calendar event 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
|
## Integration Candidates
|
||||||
|
|
||||||
@@ -28,19 +31,76 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
|||||||
- workflow
|
- workflow
|
||||||
- approvals
|
- 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
|
- module manifest and entry point
|
||||||
- tenant-level permission definitions
|
- tenant-level permission definitions
|
||||||
- manager and viewer role templates
|
- 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
|
- 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",
|
"name": "@govoplan/committee",
|
||||||
"version": "0.1.7",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GovOPlaN Committee platform module seed.",
|
"description": "GovOPlaN Committee platform module seed.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-committee"
|
name = "govoplan-committee"
|
||||||
version = "0.1.7"
|
version = "0.1.19"
|
||||||
description = "GovOPlaN Committee platform module seed."
|
description = "GovOPlaN committee governance and formal-decision integration module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.7",
|
"govoplan-core>=0.1.18",
|
||||||
"govoplan-access>=0.1.7",
|
"govoplan-access>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_committee.backend.db.models import (
|
||||||
|
CommitteeDecisionProjection,
|
||||||
|
CommitteeWorkspaceEvent,
|
||||||
|
CommitteeWorkspaceRevision,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
_CONFLICT = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
actor_ids: tuple[str, ...]
|
||||||
|
object_kind: str | None
|
||||||
|
object_id: str | None
|
||||||
|
decision_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CommitteeDsarProvider:
|
||||||
|
provider_id = "committee"
|
||||||
|
module_id = "committee"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None:
|
||||||
|
return ()
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
revisions = db.query(CommitteeWorkspaceRevision).filter(
|
||||||
|
CommitteeWorkspaceRevision.tenant_id == tenant_id,
|
||||||
|
CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
events = db.query(CommitteeWorkspaceEvent).filter(
|
||||||
|
CommitteeWorkspaceEvent.tenant_id == tenant_id,
|
||||||
|
CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
decisions = db.query(CommitteeDecisionProjection).filter(
|
||||||
|
CommitteeDecisionProjection.tenant_id == tenant_id,
|
||||||
|
CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids),
|
||||||
|
)
|
||||||
|
if selectors.object_kind:
|
||||||
|
revisions = revisions.filter(
|
||||||
|
CommitteeWorkspaceRevision.object_kind == selectors.object_kind
|
||||||
|
)
|
||||||
|
events = events.filter(
|
||||||
|
CommitteeWorkspaceEvent.object_kind == selectors.object_kind
|
||||||
|
)
|
||||||
|
if selectors.object_id:
|
||||||
|
revisions = revisions.filter(
|
||||||
|
CommitteeWorkspaceRevision.object_id == selectors.object_id
|
||||||
|
)
|
||||||
|
events = events.filter(
|
||||||
|
CommitteeWorkspaceEvent.object_id == selectors.object_id
|
||||||
|
)
|
||||||
|
if selectors.object_kind == "meeting":
|
||||||
|
decisions = decisions.filter(
|
||||||
|
CommitteeDecisionProjection.meeting_id == selectors.object_id
|
||||||
|
)
|
||||||
|
elif selectors.object_kind in {"agenda", "agenda_item"}:
|
||||||
|
decisions = decisions.filter(
|
||||||
|
CommitteeDecisionProjection.agenda_item_id == selectors.object_id
|
||||||
|
)
|
||||||
|
elif selectors.object_kind == "decision":
|
||||||
|
decisions = decisions.filter(
|
||||||
|
CommitteeDecisionProjection.decision_id == selectors.object_id
|
||||||
|
)
|
||||||
|
if selectors.decision_id:
|
||||||
|
decisions = decisions.filter(
|
||||||
|
CommitteeDecisionProjection.decision_id == selectors.decision_id
|
||||||
|
)
|
||||||
|
|
||||||
|
records.extend(
|
||||||
|
_revision_attribution(row)
|
||||||
|
for row in _limited(
|
||||||
|
revisions,
|
||||||
|
CommitteeWorkspaceRevision.recorded_at,
|
||||||
|
CommitteeWorkspaceRevision.id,
|
||||||
|
label="workspace revision attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_event_attribution(row)
|
||||||
|
for row in _limited(
|
||||||
|
events,
|
||||||
|
CommitteeWorkspaceEvent.occurred_at,
|
||||||
|
CommitteeWorkspaceEvent.id,
|
||||||
|
label="workspace event attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
records.extend(
|
||||||
|
_decision_attribution(row)
|
||||||
|
for row in _limited(
|
||||||
|
decisions,
|
||||||
|
CommitteeDecisionProjection.recorded_at,
|
||||||
|
CommitteeDecisionProjection.id,
|
||||||
|
label="Decision projection attribution",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(records) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Committee DSAR combined result limit exceeded; narrow the selectors."
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Committee DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"committee:retain:{record.resource_type}:{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=record.retention_reason
|
||||||
|
or "Committee attribution is immutable evidence.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del tenant_id
|
||||||
|
_session(session)
|
||||||
|
if _subject_selectors(subject) is None:
|
||||||
|
raise ValueError("Committee DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable or action.kind != "retain":
|
||||||
|
raise ValueError("Committee DSAR publishes retain-only actions.")
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Committee workspace, event, and Decision attribution remains "
|
||||||
|
"immutable institutional evidence."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
references = subject.external_references
|
||||||
|
values = {
|
||||||
|
"account_id": _coalesce(
|
||||||
|
subject.account_id,
|
||||||
|
references.get("committee.account"),
|
||||||
|
references.get("access.account"),
|
||||||
|
),
|
||||||
|
"membership_id": _coalesce(
|
||||||
|
subject.membership_id,
|
||||||
|
references.get("committee.membership"),
|
||||||
|
references.get("tenancy.membership"),
|
||||||
|
),
|
||||||
|
"identity_id": _coalesce(
|
||||||
|
subject.identity_id,
|
||||||
|
references.get("committee.identity"),
|
||||||
|
references.get("identity.id"),
|
||||||
|
),
|
||||||
|
"actor_id": _coalesce(
|
||||||
|
references.get("committee.actor"),
|
||||||
|
references.get("committee.changed_by"),
|
||||||
|
),
|
||||||
|
"object_kind": _coalesce(
|
||||||
|
references.get("committee.object_kind"),
|
||||||
|
references.get("committee.kind"),
|
||||||
|
),
|
||||||
|
"object_id": _coalesce(
|
||||||
|
references.get("committee.object"),
|
||||||
|
references.get("committee.object_id"),
|
||||||
|
),
|
||||||
|
"decision_id": _coalesce(
|
||||||
|
references.get("committee.decision"),
|
||||||
|
references.get("committee.decision_id"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(value is _CONFLICT for value in values.values()):
|
||||||
|
return None
|
||||||
|
actor_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
_optional_string(values["account_id"]),
|
||||||
|
_prefixed("account", values["account_id"]),
|
||||||
|
_optional_string(values["membership_id"]),
|
||||||
|
_prefixed("membership", values["membership_id"]),
|
||||||
|
_optional_string(values["identity_id"]),
|
||||||
|
_prefixed("identity", values["identity_id"]),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
direct_actor = _optional_string(values["actor_id"])
|
||||||
|
if direct_actor:
|
||||||
|
if actor_ids and direct_actor not in actor_ids:
|
||||||
|
return None
|
||||||
|
if not actor_ids:
|
||||||
|
actor_ids = (direct_actor,)
|
||||||
|
if not actor_ids:
|
||||||
|
return None
|
||||||
|
return _SubjectSelectors(
|
||||||
|
actor_ids=actor_ids,
|
||||||
|
object_kind=_optional_string(values["object_kind"]),
|
||||||
|
object_id=_optional_string(values["object_id"]),
|
||||||
|
decision_id=_optional_string(values["decision_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="committee",
|
||||||
|
module_id="committee",
|
||||||
|
resource_type="committee_workspace_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="committee_workspace_accountability",
|
||||||
|
title="Committee workspace revision attribution",
|
||||||
|
data={
|
||||||
|
"object_kind": row.object_kind,
|
||||||
|
"object_id": row.object_id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"parent_kind": row.parent_kind,
|
||||||
|
"parent_id": row.parent_id,
|
||||||
|
"state": row.state,
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
"activity": "recorded_workspace_revision",
|
||||||
|
},
|
||||||
|
observed_at=_aware(row.recorded_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason="Committee workspace attribution is immutable evidence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="committee",
|
||||||
|
module_id="committee",
|
||||||
|
resource_type="committee_event_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="committee_workspace_accountability",
|
||||||
|
title="Committee lifecycle event attribution",
|
||||||
|
data={
|
||||||
|
"object_kind": row.object_kind,
|
||||||
|
"object_id": row.object_id,
|
||||||
|
"object_revision": row.object_revision,
|
||||||
|
"event_id": row.event_id,
|
||||||
|
"event_type": row.event_type,
|
||||||
|
"occurred_at": _iso(row.occurred_at),
|
||||||
|
},
|
||||||
|
observed_at=_aware(row.occurred_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason="Committee event attribution is immutable evidence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="committee",
|
||||||
|
module_id="committee",
|
||||||
|
resource_type="committee_decision_actor_attribution",
|
||||||
|
resource_id=row.id,
|
||||||
|
category="committee_decision_accountability",
|
||||||
|
title="Committee fallback Decision attribution",
|
||||||
|
data={
|
||||||
|
"decision_id": row.decision_id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"meeting_id": row.meeting_id,
|
||||||
|
"agenda_item_id": row.agenda_item_id,
|
||||||
|
"state": row.state,
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
"activity": "recorded_decision_projection",
|
||||||
|
},
|
||||||
|
observed_at=_aware(row.recorded_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Committee fallback Decision attribution is immutable evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _limited(query, first, second, *, label: str):
|
||||||
|
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _coalesce(*values: str | None) -> str | None | object:
|
||||||
|
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||||
|
if len(normalized) > 1:
|
||||||
|
return _CONFLICT
|
||||||
|
return next(iter(normalized), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _prefixed(prefix: str, value: object) -> str | None:
|
||||||
|
normalized = _optional_string(value)
|
||||||
|
return f"{prefix}:{normalized}" if normalized else None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Committee DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_RESOURCE_TYPES = {
|
||||||
|
"committee_workspace_actor_attribution",
|
||||||
|
"committee_event_actor_attribution",
|
||||||
|
"committee_decision_actor_attribution",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "committee" or record.module_id != "committee":
|
||||||
|
raise ValueError("Committee DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||||
|
raise ValueError("Committee DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "committee" or action.module_id != "committee":
|
||||||
|
raise ValueError("Committee DSAR cannot execute a foreign provider action.")
|
||||||
|
if not action.action_id.startswith("committee:retain:"):
|
||||||
|
raise ValueError("Committee DSAR action identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"]
|
||||||
@@ -1,23 +1,163 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from pathlib import Path
|
||||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
|
||||||
|
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,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
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.dsar_provider import (
|
||||||
|
COMMITTEE_DSAR_CAPABILITY,
|
||||||
|
CommitteeDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_committee.backend.workspace import (
|
||||||
|
CAPABILITY_COMMITTEE_WORKSPACE,
|
||||||
|
SqlCommitteeWorkspace,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
MODULE_ID = "committee"
|
MODULE_ID = "committee"
|
||||||
MODULE_NAME = "Committee"
|
MODULE_NAME = "Committee"
|
||||||
MODULE_VERSION = "0.1.7"
|
MODULE_VERSION = "0.1.19"
|
||||||
READ_SCOPE = "committee:workspace:read"
|
READ_SCOPE = "committee:workspace:read"
|
||||||
WRITE_SCOPE = "committee:workspace:write"
|
WRITE_SCOPE = "committee:workspace:write"
|
||||||
|
BALLOT_SCOPE = "committee:ballot:finalize"
|
||||||
ADMIN_SCOPE = "committee:workspace:admin"
|
ADMIN_SCOPE = "committee:workspace:admin"
|
||||||
|
PROTECTED_READ_SCOPE = "committee:decision:protected_read"
|
||||||
OPTIONAL_DEPENDENCIES = (
|
OPTIONAL_DEPENDENCIES = (
|
||||||
"calendar",
|
"calendar",
|
||||||
"docs",
|
"docs",
|
||||||
"files",
|
"files",
|
||||||
|
"mandates",
|
||||||
|
"decisions",
|
||||||
"tasks",
|
"tasks",
|
||||||
"workflow",
|
"voting",
|
||||||
|
"workflow_engine",
|
||||||
"approvals",
|
"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 _dsar_provider(_context: ModuleContext) -> CommitteeDsarProvider:
|
||||||
|
return CommitteeDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
@@ -34,9 +174,31 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View committee workspace", "Read committee records, configuration, and workflow context."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage committee workspace", "Create and update committee records and workflow state."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Administer committee workspace", "Configure committee policies, templates, and tenant-level administration."),
|
"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 = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -44,7 +206,7 @@ ROLE_TEMPLATES = (
|
|||||||
slug="committee_manager",
|
slug="committee_manager",
|
||||||
name="Committee manager",
|
name="Committee manager",
|
||||||
description="Manage committee records and workflow state.",
|
description="Manage committee records and workflow state.",
|
||||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
permissions=(READ_SCOPE, WRITE_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="committee_viewer",
|
slug="committee_viewer",
|
||||||
@@ -58,15 +220,23 @@ DOCUMENTATION = (
|
|||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{MODULE_ID}.module-boundary",
|
id=f"{MODULE_ID}.module-boundary",
|
||||||
title=f"{MODULE_NAME} 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=(
|
body=(
|
||||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
"The persistent workspace keeps immutable bodies, meetings, agenda items, governed "
|
||||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
"vote results, minutes, and lifecycle events. The decision path constructs a "
|
||||||
"database models, migrations, and WebUI routes are introduced."
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
order=100,
|
order=100,
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
links=(
|
links=(
|
||||||
@@ -77,9 +247,139 @@ DOCUMENTATION = (
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"domain_objects": ['committee bodies', 'meeting agendas', 'minutes', 'decision records', 'votes', 'follow-up assignments'],
|
"help_contexts": [
|
||||||
"first_slice": "Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.",
|
"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"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Abgrenzung des Committee-Moduls",
|
||||||
|
"summary": (
|
||||||
|
"Arbeitsabläufe für Ausschüsse, Vorstände, Räte und Senate mit Sitzungen, Tagesordnungen, Protokollen, Beratung, Abstimmung, formellen Entscheidungsverweisen und Folgeaufgaben."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der dauerhafte Arbeitsbereich bewahrt unveränderliche Gremien, Sitzungen, Tagesordnungspunkte, gesteuerte "
|
||||||
|
"Abstimmungsergebnisse, Protokolle und Lebenszyklusereignisse. Der Entscheidungspfad konstruiert daraus, aus "
|
||||||
|
"einer wirksamen Mandatsauflösung, Genehmigung, versionierten Rechtsgrundlagen, Nachweisen, Begründung und "
|
||||||
|
"beantragten oder beobachteten Wirkungen eine rekonstruierbare formelle Entscheidung. Committee führt keine "
|
||||||
|
"generische Mandats- oder Entscheidungspersistenz; optionale Anbieter lösen diese Objekte auf und speichern "
|
||||||
|
"sie, während eine geschützte lokale Projektion den begrenzten Rückfall bewahrt. An einen Anbieter gebundene "
|
||||||
|
"Stimmabgaben werden über Adapterfähigkeiten abgeschlossen, ohne einzelne Stimmen aufzubewahren. Von Voting "
|
||||||
|
"gelieferte Ergebnisse bewahren bereinigte Anbieterzusicherungen und stufen den nicht zertifizierten lokalen "
|
||||||
|
"vertraulichen Referenzanbieter niemals zu einem zertifizierten Profil hoch."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"privacy_notes": [
|
||||||
|
"Committee-Listen zeigen nur Datensätze, die im aktiven Mandanten- und Berechtigungskontext autorisiert sind.",
|
||||||
|
"Anbietergestützte Abstimmungen speichern aggregierte Ergebnisnachweise, keine einzelnen vertraulichen Stimmen.",
|
||||||
|
"Geschützte Entscheidungsbegründungen erfordern die besondere Berechtigung zum geschützten Lesen.",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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={
|
||||||
|
"kind": "reference",
|
||||||
|
"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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Committee-Felder und Folgen",
|
||||||
|
"summary": (
|
||||||
|
"Feldherkunft, Lebenszyklusbeschränkungen, optionale Anbieterverweise und Nachweisfolgen für Committee-Datensätze."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Organisationseinheitsverweise eines Gremiums bestimmen den institutionellen Kontext, gewähren aber selbst "
|
||||||
|
"keinen Zugriff. Sitzungszeiten begründen den Tagesordnungskontext. Gegenstands-, Decision-, Approval-, "
|
||||||
|
"Records- und Abstimmungskennungen sind stabile modulübergreifende Verweise und bleiben nur optional, soweit "
|
||||||
|
"der aktuelle Lebenszyklus dies erlaubt. Zustandsübergänge erzeugen neue unveränderliche Revisionen; endgültige "
|
||||||
|
"Datensätze können nicht an Ort und Stelle bearbeitet werden. Der Abschluss einer Abstimmung oder die Annahme "
|
||||||
|
"eines Protokolls erfordert den zugehörigen gesteuerten Nachweis. Die anbietergebundene Finalisierung importiert "
|
||||||
|
"nur ein aggregiertes Ergebnis und Zusicherungsnachweise. Änderungsgründe bleiben mit jeder Revision erhalten. "
|
||||||
|
"Abbruch, Rücknahme oder Außerbetriebnahme stoppt künftige Arbeit, löscht aber keine bestehenden Revisionen, "
|
||||||
|
"Entscheidungen, Protokolle oder Prüfnachweise."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"save_revision": "Erzeugt eine neue unveränderliche Committee-Revision mit ihrem Änderungsgrund.",
|
||||||
|
"change_lifecycle": "Ändert die künftig möglichen Aktionen; endgültige Zustände sind unveränderlich.",
|
||||||
|
"finalize_ballot": "Importiert ein gesteuertes aggregiertes Ergebnis und Nachweise, ohne einzelne Stimmen aufzubewahren.",
|
||||||
|
"cancel_or_retire": "Stoppt die künftige Nutzung und bewahrt institutionelle sowie Prüfnachweise.",
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -90,10 +390,239 @@ manifest = ModuleManifest(
|
|||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="meetings-decisions",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.meetings_decisions",
|
||||||
|
icon="calendar",
|
||||||
|
description="i18n:govoplan-core.product_area.meetings_decisions_description",
|
||||||
|
surface_ids=("committee.nav.committee", "committee.route.committee"),
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(name=COMMITTEE_DSAR_CAPABILITY, 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,
|
||||||
|
COMMITTEE_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
COMMITTEE_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Committee data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports minimized workspace, event, and fallback-Decision actor "
|
||||||
|
"attribution without deliberation or minute payloads."
|
||||||
|
),
|
||||||
|
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,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
documentation=DOCUMENTATION,
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="committee.data-subject-requests",
|
||||||
|
title="Committee data-subject requests",
|
||||||
|
summary=(
|
||||||
|
"Export minimized actor attribution from immutable Committee records "
|
||||||
|
"without disclosing deliberation or ballot content."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Committee correlates exact account, membership, identity, or explicit "
|
||||||
|
"actor identifiers inside the active tenant. Optional object-kind, "
|
||||||
|
"object, or Decision identifiers only narrow an already verified actor "
|
||||||
|
"search. Workspace results contain stable object, parent, revision, "
|
||||||
|
"state, and timing context. Event and fallback-Decision results contain "
|
||||||
|
"only lifecycle attribution and stable meeting or agenda references. "
|
||||||
|
"Search text, minutes, deliberation payloads, individual ballots, "
|
||||||
|
"request hashes, idempotency values, and referenced provider records "
|
||||||
|
"are excluded and never traversed. All returned attribution is retained "
|
||||||
|
"as immutable institutional evidence."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("core", "decisions", "voting", "records"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"committee.workspace",
|
||||||
|
"privacy.data-subject-requests",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_actor_attribution": (
|
||||||
|
"Returns stable lifecycle context without institutional payloads."
|
||||||
|
),
|
||||||
|
"retain_committee_history": (
|
||||||
|
"Preserves immutable meeting and Decision accountability evidence."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzanfragen zu Committee",
|
||||||
|
"summary": (
|
||||||
|
"Minimierte Akteurszuordnungen aus unveränderlichen Committee-Datensätzen ausgeben, ohne Beratungs- oder Abstimmungsinhalte offenzulegen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Committee gleicht innerhalb des aktiven Mandanten exakte Konto-, Mitgliedschafts-, Identitäts- oder "
|
||||||
|
"Akteurskennungen ab. Optionale Objektart-, Objekt- oder Decision-Kennungen schränken nur eine bereits "
|
||||||
|
"verifizierte Akteurssuche ein. Arbeitsbereichsergebnisse enthalten stabilen Objekt-, Eltern-, Revisions-, "
|
||||||
|
"Zustands- und Zeitkontext. Ereignis- und Rückfall-Decision-Ergebnisse enthalten ausschließlich "
|
||||||
|
"Lebenszykluszuordnungen sowie stabile Sitzungs- oder Tagesordnungsverweise. Suchtext, Protokolle, "
|
||||||
|
"Beratungsinhalte, einzelne Stimmen, Anfrageprüfsummen, Idempotenzwerte und referenzierte Anbieterdatensätze "
|
||||||
|
"werden ausgeschlossen und nie traversiert. Alle zurückgegebenen Zuordnungen bleiben unveränderliche "
|
||||||
|
"institutionelle Nachweise."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"export_actor_attribution": "Gibt stabilen Lebenszykluskontext ohne institutionelle Inhalte zurück.",
|
||||||
|
"retain_committee_history": "Bewahrt unveränderliche Verantwortungsnachweise zu Sitzungen und Decisions.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
*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,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
localizable_documentation_metadata_keys,
|
||||||
|
)
|
||||||
|
from govoplan_committee.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class CommitteeDocumentationTests(unittest.TestCase):
|
||||||
|
def test_german_reference_documentation_is_complete(self) -> None:
|
||||||
|
topics = manifest.documentation
|
||||||
|
self.assertEqual(3, len(topics))
|
||||||
|
for topic in topics:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(translation.get("title"), topic.id)
|
||||||
|
self.assertTrue(translation.get("summary"), topic.id)
|
||||||
|
self.assertTrue(translation.get("body"), topic.id)
|
||||||
|
if localizable_documentation_metadata_keys(topic):
|
||||||
|
self.assertEqual("1", topic.structured_translation_version, topic.id)
|
||||||
|
self.assertIn("de", topic.structured_translations, topic.id)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in topics}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
|
||||||
|
self.assertTrue(workflow.conditions)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_committee.backend.db.models import (
|
||||||
|
CommitteeDecisionProjection,
|
||||||
|
CommitteeWorkspaceEvent,
|
||||||
|
CommitteeWorkspaceRevision,
|
||||||
|
)
|
||||||
|
from govoplan_committee.backend.dsar_provider import (
|
||||||
|
COMMITTEE_DSAR_CAPABILITY,
|
||||||
|
CommitteeDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_committee.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 17, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: CommitteeDsarProvider) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (COMMITTEE_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
if name != COMMITTEE_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
return "committee"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type("State", (), {"effective_modules": ("committee",)})()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
if name != COMMITTEE_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "committee"})(),)
|
||||||
|
|
||||||
|
|
||||||
|
class CommitteeDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = CommitteeDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
self._seed()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
CommitteeWorkspaceRevision(
|
||||||
|
id="workspace-revision-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
object_kind="meeting",
|
||||||
|
object_id="meeting-1",
|
||||||
|
revision=1,
|
||||||
|
parent_kind="body",
|
||||||
|
parent_id="body-1",
|
||||||
|
state="scheduled",
|
||||||
|
title="Private meeting title do not export",
|
||||||
|
search_text="private search text do not export",
|
||||||
|
recorded_at=NOW,
|
||||||
|
payload={"minutes": "private minutes do not export"},
|
||||||
|
changed_by="account-1",
|
||||||
|
),
|
||||||
|
CommitteeWorkspaceRevision(
|
||||||
|
id="workspace-revision-other",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
object_kind="meeting",
|
||||||
|
object_id="meeting-other",
|
||||||
|
revision=1,
|
||||||
|
state="scheduled",
|
||||||
|
title="Other meeting",
|
||||||
|
search_text="other search text",
|
||||||
|
recorded_at=NOW,
|
||||||
|
payload={"private": "other payload"},
|
||||||
|
changed_by="account-other",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
CommitteeWorkspaceEvent(
|
||||||
|
id="event-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
object_kind="meeting",
|
||||||
|
object_id="meeting-1",
|
||||||
|
object_revision=1,
|
||||||
|
event_id="event-external-1",
|
||||||
|
event_type="meeting.scheduled",
|
||||||
|
occurred_at=NOW,
|
||||||
|
actor_id="account-1",
|
||||||
|
idempotency_key="event-idempotency-do-not-export",
|
||||||
|
request_sha256="event-request-hash-do-not-export",
|
||||||
|
payload={"secret": "event-payload-do-not-export"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.add(
|
||||||
|
CommitteeDecisionProjection(
|
||||||
|
id="decision-projection-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
decision_id="decision-1",
|
||||||
|
revision="1",
|
||||||
|
meeting_id="meeting-1",
|
||||||
|
agenda_item_id="agenda-1",
|
||||||
|
state="effective",
|
||||||
|
recorded_at=NOW,
|
||||||
|
payload={
|
||||||
|
"reasoning": "decision-reasoning-do-not-export",
|
||||||
|
"ballot": "individual-ballot-do-not-export",
|
||||||
|
},
|
||||||
|
changed_by="account-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subject() -> DsarSubjectRef:
|
||||||
|
return DsarSubjectRef(account_id="account-1")
|
||||||
|
|
||||||
|
def test_search_exports_minimized_committee_attribution(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"committee_workspace_actor_attribution",
|
||||||
|
"committee_event_actor_attribution",
|
||||||
|
"committee_decision_actor_attribution",
|
||||||
|
},
|
||||||
|
{record.resource_type for record in records},
|
||||||
|
)
|
||||||
|
exported = json.dumps([record.to_dict() for record in records])
|
||||||
|
self.assertIn("meeting-1", exported)
|
||||||
|
self.assertIn("agenda-1", exported)
|
||||||
|
for excluded in (
|
||||||
|
"Private meeting title do not export",
|
||||||
|
"private search text do not export",
|
||||||
|
"private minutes do not export",
|
||||||
|
"event-idempotency-do-not-export",
|
||||||
|
"event-request-hash-do-not-export",
|
||||||
|
"event-payload-do-not-export",
|
||||||
|
"decision-reasoning-do-not-export",
|
||||||
|
"individual-ballot-do-not-export",
|
||||||
|
"meeting-other",
|
||||||
|
):
|
||||||
|
self.assertNotIn(excluded, exported)
|
||||||
|
|
||||||
|
def test_object_narrowing_and_actor_conflicts_fail_closed(self) -> None:
|
||||||
|
narrowed = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={
|
||||||
|
"committee.object_kind": "meeting",
|
||||||
|
"committee.object": "meeting-1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"committee.actor": "account-other"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
object_only = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
external_references={"committee.object": "meeting-1"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"committee_workspace_actor_attribution",
|
||||||
|
"committee_event_actor_attribution",
|
||||||
|
"committee_decision_actor_attribution",
|
||||||
|
},
|
||||||
|
{record.resource_type for record in narrowed},
|
||||||
|
)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
self.assertEqual((), object_only)
|
||||||
|
|
||||||
|
def test_erasure_is_retain_only(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self._subject(),
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self._subject(),
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-committee-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||||
|
self.assertEqual(2, self.session.query(CommitteeWorkspaceRevision).count())
|
||||||
|
|
||||||
|
def test_manifest_and_core_workflow_discover_provider(self) -> None:
|
||||||
|
self.assertIn(COMMITTEE_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-COMMITTEE-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self._subject(),
|
||||||
|
purpose="Committee attribution access request",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="operator-1",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=row.resource_revision,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", row.status)
|
||||||
|
self.assertEqual(3, row.search_result["record_count"])
|
||||||
|
|
||||||
|
|
||||||
|
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
|
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):
|
class ManifestSeedTests(unittest.TestCase):
|
||||||
@@ -12,12 +19,22 @@ class ManifestSeedTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest.id, "committee")
|
self.assertEqual(manifest.id, "committee")
|
||||||
self.assertEqual(manifest.name, "Committee")
|
self.assertEqual(manifest.name, "Committee")
|
||||||
self.assertEqual(manifest.dependencies, ("access",))
|
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.assertEqual({role.slug for role in manifest.role_templates}, {"committee_manager", "committee_viewer"})
|
||||||
self.assertTrue(manifest.documentation)
|
self.assertTrue(manifest.documentation)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIsNone(manifest.frontend)
|
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__":
|
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.19",
|
||||||
|
"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.18",
|
||||||
|
"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,498 @@
|
|||||||
|
import {
|
||||||
|
CalendarPlus,
|
||||||
|
FilePlus2,
|
||||||
|
ListPlus,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
Vote
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { ActionBlockerHint,
|
||||||
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FilterBar,
|
||||||
|
IconButton,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatePanel,
|
||||||
|
StatusBadge,
|
||||||
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
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">
|
||||||
|
<WorkspaceFrame className="committee-shell" label="Committee workspace" interfaceId="committee.workspace" helpContextId="committee.page.workspace" helpModuleId="committee">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void refresh(), loading }}
|
||||||
|
className="committee-toolbar"
|
||||||
|
contextActions={<FilterBar as="form" surface="control" wrap="never" width="compact" 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" />
|
||||||
|
</FilterBar>}
|
||||||
|
createAction={<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canWrite || loading}
|
||||||
|
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
|
||||||
|
onClick={() => setEditor({ kind: "body" })}
|
||||||
|
>
|
||||||
|
<Plus size={16} aria-hidden="true" />
|
||||||
|
New body
|
||||||
|
</Button>}
|
||||||
|
helpAction={<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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 ? (
|
||||||
|
<StatePanel size="fill" title="Meetings" description="Select or create a meeting." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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 ? <StatePanel size="inline" description="No agenda items." /> : 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>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
|
||||||
|
{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 <StatePanel size="inline" description="No records." />;
|
||||||
|
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 <StatePanel size="inline" description="No records." />;
|
||||||
|
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 { FormGrid,
|
||||||
|
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}
|
||||||
|
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||||
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
|
||||||
|
{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" ? (
|
||||||
|
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||||
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{kind === "agenda_item" ? (
|
||||||
|
<>
|
||||||
|
<FormGrid columns={3} gap="compact" collapseAt="narrow">
|
||||||
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
{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" ? (
|
||||||
|
<>
|
||||||
|
<FormGrid columns={3} gap="compact" collapseAt="narrow">
|
||||||
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
<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">
|
||||||
|
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||||
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
<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 (
|
||||||
|
<FormGrid columns={2} gap="compact" collapseAt="narrow">
|
||||||
|
<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>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,280 @@
|
|||||||
|
.committee-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.committee-search {
|
||||||
|
flex: 1 1 460px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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-count-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 1100px) {
|
||||||
|
.committee-workspace {
|
||||||
|
grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user