Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c2ec4ec17 | ||
|
|
e84fb631f0 | ||
|
|
46ba69fbfe | ||
|
|
432f8bbd02 | ||
|
|
049ea80385 | ||
|
|
3259741765 | ||
|
|
be411e4ca8 | ||
|
|
75c9488f66 | ||
|
|
0e03f6a70c | ||
|
|
1351338590 | ||
|
|
21052accee | ||
|
|
0ae25ed350 | ||
|
|
bbddc8b52a | ||
|
|
01ebd44d93 | ||
|
|
c06de41bdc | ||
|
|
bcf7dc53b4 | ||
|
|
e64af30534 | ||
|
|
11b946ea78 | ||
|
|
133e55987e | ||
|
|
758b59ca03 | ||
|
|
232329ac52 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Committee Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Committee internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the GovOPlaN Committee platform module seed.
|
||||
|
||||
@@ -4,16 +4,35 @@
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-committee` is the GovOPlaN platform module seed for committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.
|
||||
`govoplan-committee` is the GovOPlaN module for committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.
|
||||
|
||||
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
|
||||
The backend now exposes versioned `committee.workspace` and
|
||||
`committee.decision_path` capabilities. The workspace persists immutable,
|
||||
OCC-guarded revisions for bodies, meetings, agenda items, governed vote
|
||||
results, and minutes, with replay-safe lifecycle events. The decision path
|
||||
combines that committee context with an effective Mandate,
|
||||
including organization, function, and jurisdiction coverage, with approval,
|
||||
legal bases, evidence, reasoning, and effects to create the shared
|
||||
formal Decision contract. Mandate resolution and Decision persistence remain
|
||||
optional providers. If Decisions is absent, Committee retains a bounded,
|
||||
protected local Decision projection so the outcome remains reconstructable.
|
||||
The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes
|
||||
workspace using the same immutable revisions and lifecycle guards as the API.
|
||||
|
||||
When Voting is installed, Committee creates and follows a governed
|
||||
`voting.ballots` record while retaining only its meeting/agendum reference and
|
||||
verified aggregate outcome. Voting owns the frozen electorate, casting,
|
||||
tallying, certification, challenge, and annulment lifecycle. The older
|
||||
`committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
|
||||
remains a 0.1 compatibility boundary for existing external integrations;
|
||||
individual provider ballots never enter Committee persistence.
|
||||
|
||||
## Initial Ownership
|
||||
|
||||
- committee bodies
|
||||
- meeting agendas
|
||||
- minutes
|
||||
- decision records
|
||||
- deliberation/vote context and formal decision references
|
||||
- votes
|
||||
- follow-up assignments
|
||||
|
||||
@@ -24,6 +43,8 @@ This module does not own:
|
||||
- generic task execution
|
||||
- document storage
|
||||
- calendar event storage
|
||||
- the generic formal decision lifecycle; a future Decisions provider owns
|
||||
authority, facts/rules, reasoning, effects, review, correction, and revocation
|
||||
|
||||
Detailed boundary notes are in [docs/COMMITTEE_DOMAIN_BOUNDARY.md](docs/COMMITTEE_DOMAIN_BOUNDARY.md).
|
||||
|
||||
@@ -37,6 +58,7 @@ Expected optional integrations:
|
||||
- tasks
|
||||
- workflow
|
||||
- approvals
|
||||
- voting
|
||||
|
||||
## Development Install
|
||||
|
||||
@@ -54,6 +76,20 @@ cd /mnt/DATA/git/govoplan-committee
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
## API And Recovery
|
||||
|
||||
`/api/v1/committee/workspace/{body|meeting|agenda_item|vote|minute}` supports
|
||||
bounded list/read/write and immutable history. Writes require an idempotency
|
||||
key and expected revision after creation. Protected local Decision projections
|
||||
use a separate read permission. Database restore is the semantic recovery unit;
|
||||
linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain
|
||||
their own recovery responsibility.
|
||||
|
||||
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the 0.1
|
||||
compatibility effect boundary for an installed ballot adapter. It requires the
|
||||
`committee:ballot:finalize` permission and preserves provider receipt/hash and
|
||||
evidence without exposing or persisting individual votes.
|
||||
|
||||
## Gitea Workflow
|
||||
|
||||
Issue templates are installed under `.gitea/`, and the shared label taxonomy is copied to `docs/gitea-labels.json` with the module label `module/committee`.
|
||||
@@ -64,3 +100,21 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
|
||||
```
|
||||
|
||||
## Git-source WebUI package
|
||||
|
||||
The repository root exposes `@govoplan/committee-webui` for Git-tagged release
|
||||
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||
development or install scripts. The source archive contains `webui/src`, this
|
||||
README and any repository license file. Run module development checks from `webui/`; Python
|
||||
installation remains governed by `pyproject.toml`.
|
||||
|
||||
Das Repository stellt `@govoplan/committee-webui` am Wurzelpfad für versionierte
|
||||
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||
`pyproject.toml` definiert.
|
||||
|
||||
@@ -9,7 +9,7 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
||||
- committee bodies
|
||||
- meeting agendas
|
||||
- minutes
|
||||
- decision records
|
||||
- deliberation and vote context plus references to formal decision records
|
||||
- votes
|
||||
- follow-up assignments
|
||||
|
||||
@@ -18,6 +18,9 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
||||
- generic task execution
|
||||
- document storage
|
||||
- calendar event storage
|
||||
- generic approval gates
|
||||
- the cross-domain formal decision lifecycle, including authority, facts,
|
||||
applicable rules, reasoning, effects, review, correction, and revocation
|
||||
|
||||
## Integration Candidates
|
||||
|
||||
@@ -28,19 +31,76 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes,
|
||||
- workflow
|
||||
- approvals
|
||||
|
||||
## Seed State
|
||||
## Current Persistent Backend Slice
|
||||
|
||||
The current repository state is intentionally small:
|
||||
The current repository state is intentionally bounded:
|
||||
|
||||
- module manifest and entry point
|
||||
- tenant-level permission definitions
|
||||
- manager and viewer role templates
|
||||
- documentation topic describing the module boundary
|
||||
- documentation topic and architecture/evidence declaration
|
||||
- `committee.workspace` and `committee.decision_path` interfaces and capabilities
|
||||
- tenant-scoped body, meeting, agenda-item, vote-result, and minute persistence
|
||||
- immutable revisions, OCC, replay-safe lifecycle events, migrations, uninstall
|
||||
guards, API routes, and tenant summary counts
|
||||
- a governed assembler for one formal committee outcome
|
||||
- a protected local Decision projection when the optional Decisions provider is
|
||||
absent
|
||||
- a three-pane `/committee` workspace for bodies, meetings, agendas, governed
|
||||
vote results, and minutes
|
||||
- a provider-neutral ballot-finalization contract for external and secret
|
||||
ballots that retains aggregate evidence rather than individual ballots
|
||||
- an optional primary integration with Voting, which owns frozen electorates,
|
||||
vote casting/replacement, tally, certification, challenge, and annulment
|
||||
- Gitea issue workflow templates
|
||||
- manifest contract test
|
||||
- manifest and decision reconstruction contract tests
|
||||
|
||||
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
|
||||
The decision path accepts or resolves one effective
|
||||
Mandate covering the deciding unit, function, and jurisdiction; requires
|
||||
approval, fact evidence, versioned legal bases, operative
|
||||
result, and reasoning, and emits the shared formal Decision contract. If a
|
||||
Decision registry is installed it records there; otherwise the result is
|
||||
retained in the Committee-owned fallback projection and is available only
|
||||
through the protected-read permission.
|
||||
|
||||
## First Implementation Slice
|
||||
The workspace records the result of a governed vote rather than becoming a
|
||||
general remote-balloting system. Local closure requires unique choices,
|
||||
eligible/cast counts, matching result counts, an explicit quorum result, an
|
||||
Approval reference, and evidence. Decided agenda items require a formal
|
||||
Decision reference, and meetings cannot close while agenda items remain
|
||||
unfinished. Accepted or corrected minutes require a Records reference,
|
||||
Approval, and evidence.
|
||||
|
||||
Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.
|
||||
A governed ballot is delegated to `voting.ballots` when Voting is installed;
|
||||
Committee retains the meeting/agendum linkage and verified aggregate result.
|
||||
For provider-backed Voting ballots, Committee also snapshots the assurance
|
||||
profile, provider and ballot references, and sanitized provider evidence. A
|
||||
reference `local_confidential` result remains explicitly uncertified; its
|
||||
server-encrypted casts do not satisfy secret-ballot, anonymity,
|
||||
coercion-resistance, or legal-certification requirements.
|
||||
The following direct provider path remains a 0.1 compatibility contract only.
|
||||
A provider-bound vote on that path is finalized through
|
||||
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
|
||||
choices, eligible count, external ballot reference, request time, and
|
||||
idempotency key. Its result must cover exactly the configured choices, sum to
|
||||
the cast count, stay within eligibility, carry same-tenant evidence, and supply
|
||||
a lowercase SHA-256 result digest plus provider receipt. Committee persists
|
||||
that aggregate and does not persist voter choices or provider credentials.
|
||||
|
||||
Database restore is the module's semantic recovery unit. Calendar events,
|
||||
documents, records, tasks, approvals, and externally conducted votes remain
|
||||
recoverable through their owning providers and are linked by stable references.
|
||||
|
||||
## Decision Reconstruction Proof
|
||||
|
||||
`tests/test_decision_path.py` proves effective-time authority, organization,
|
||||
function, and jurisdiction coverage, approval, legal basis/evidence versions, requested effects,
|
||||
information governance, responsible actor/automation assurance, and a protected
|
||||
reconstruction payload. A vote remains
|
||||
an approval reference and is not made indistinguishable from the formal
|
||||
institutional outcome.
|
||||
|
||||
`tests/test_workspace.py` proves parent and lifecycle constraints, immutable
|
||||
history, replay and stale-write rejection, tenant isolation, committed-only
|
||||
events, vote/quorum evidence, adapter-only provider closure, aggregate-only
|
||||
secret-ballot persistence, minutes, and the local Decision projection.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Committee Interface Pattern Migration
|
||||
|
||||
This document records the bounded migration of Committee-owned WebUI surfaces
|
||||
to the GovOPlaN interface pattern language. Core owns shared controls, help,
|
||||
blocking explanations, draft guards, confirmations, and host shells. Committee
|
||||
owns bodies, meetings, agenda context, vote context, minutes, and the bounded
|
||||
projection of their institutional evidence.
|
||||
|
||||
## Surface Inventory
|
||||
|
||||
| Surface | Archetype | Consequence class | Contract |
|
||||
| --- | --- | --- | --- |
|
||||
| `/committee` toolbar and body list | Searchable workspace directory | Change query projection or create body | Shared search/button/help controls, explicit loading/error/permission states |
|
||||
| Body and meeting selectors | Master-detail selection | Change active context | Stable keyboard buttons, selected/hover state, bounded independent scrolling |
|
||||
| Meeting detail | Governed record workspace | Create or revise agenda, vote, and minute records | Shared sections, statuses, action explanations, locale-aware dates |
|
||||
| Committee record dialog | Consequential record editor | Save immutable revision or change lifecycle | Contextual field help, guarded draft, required change reason, lifecycle confirmation |
|
||||
| Ballot finalization dialog | Governed provider action | Import aggregate result and evidence | Permission explanation, guarded draft, explicit confirmation, no individual ballots |
|
||||
|
||||
## Consequence And Availability Rules
|
||||
|
||||
- Every save creates a new Committee revision and retains the change reason.
|
||||
- Terminal lifecycle states are immutable; disabled edit actions explain that
|
||||
constraint rather than disappearing.
|
||||
- Cancelling, withdrawing, or retiring stops future work but never removes
|
||||
existing revisions, minutes, decisions, or evidence.
|
||||
- Provider and Voting integrations remain optional capability boundaries.
|
||||
Committee stores only the aggregate result and assurance evidence.
|
||||
- Missing write or ballot-finalization permission identifies the required
|
||||
action, responsible administrator, and Access destination.
|
||||
- Unsaved editors intercept route, browser, backdrop, and explicit close
|
||||
attempts through the shared draft guard.
|
||||
|
||||
## State And Accessibility Evidence
|
||||
|
||||
The module uses shared buttons, icon buttons, dialogs, confirmations, alerts,
|
||||
status badges, loading indicators, action blockers, field help, and draft
|
||||
guards. Native buttons preserve keyboard order and shared dialogs retain focus.
|
||||
The existing three-region desktop layout and two-region compact layout keep
|
||||
lists and details independently scrollable. English and German catalogues cover
|
||||
module metadata and owned UI copy, while dates follow the selected platform
|
||||
locale. Manifest topics expose stable route, field, blocker, privacy, and
|
||||
consequence references without importing optional sibling modules.
|
||||
+29
-3
@@ -1,8 +1,34 @@
|
||||
{
|
||||
"name": "@govoplan/committee",
|
||||
"version": "0.1.8",
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"description": "GovOPlaN Committee platform module seed.",
|
||||
"type": "module",
|
||||
"peerDependencies": {}
|
||||
"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"
|
||||
},
|
||||
"main": "webui/src/index.ts",
|
||||
"module": "webui/src/index.ts",
|
||||
"types": "webui/src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./webui/src/index.ts",
|
||||
"import": "./webui/src/index.ts"
|
||||
},
|
||||
"./styles/committee.css": "./webui/src/styles/committee.css"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
|
||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-committee"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN Committee platform module seed."
|
||||
version = "0.1.21"
|
||||
description = "GovOPlaN committee governance and formal-decision integration module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[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 govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
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_NAME = "Committee"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
MODULE_VERSION = "0.1.21"
|
||||
READ_SCOPE = "committee:workspace:read"
|
||||
WRITE_SCOPE = "committee:workspace:write"
|
||||
BALLOT_SCOPE = "committee:ballot:finalize"
|
||||
ADMIN_SCOPE = "committee:workspace:admin"
|
||||
PROTECTED_READ_SCOPE = "committee:decision:protected_read"
|
||||
OPTIONAL_DEPENDENCIES = (
|
||||
"calendar",
|
||||
"docs",
|
||||
"files",
|
||||
"mandates",
|
||||
"decisions",
|
||||
"tasks",
|
||||
"workflow",
|
||||
"voting",
|
||||
"workflow_engine",
|
||||
"approvals",
|
||||
)
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="communication_participation",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_decision_path.py",
|
||||
summary="Proves effective mandate, approval, evidence, effect, and reconstruction semantics for a committee decision.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/COMMITTEE_DOMAIN_BOUNDARY.md",
|
||||
summary="Defines the Committee/Decision/Mandate ownership boundary.",
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.",
|
||||
"Governed ballot execution is delegated to Voting when installed. The Committee-owned provider adapter remains a 0.1 compatibility path; Committee stores only aggregate result evidence.",
|
||||
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
|
||||
),
|
||||
owned_concepts=(
|
||||
"committee bodies",
|
||||
"meeting and agenda context",
|
||||
"deliberation and vote context",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"formal decision lifecycle",
|
||||
"mandate and jurisdiction lifecycle",
|
||||
"generic approvals",
|
||||
),
|
||||
reference_packages=("product.service-to-decision",),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
security=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",),
|
||||
operations=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _decision_path(context: ModuleContext) -> CommitteeDecisionPath:
|
||||
return CommitteeDecisionPath(context.registry)
|
||||
|
||||
|
||||
def _workspace(context: ModuleContext) -> SqlCommitteeWorkspace:
|
||||
del context
|
||||
return SqlCommitteeWorkspace()
|
||||
|
||||
|
||||
def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer:
|
||||
return CommitteeBallotFinalizer(context.registry)
|
||||
|
||||
|
||||
def _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:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
@@ -34,9 +174,31 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View committee workspace", "Read committee records, configuration, and workflow context."),
|
||||
_permission(WRITE_SCOPE, "Manage committee workspace", "Create and update committee records and workflow state."),
|
||||
_permission(ADMIN_SCOPE, "Administer committee workspace", "Configure committee policies, templates, and tenant-level administration."),
|
||||
_permission(
|
||||
READ_SCOPE,
|
||||
"View committee workspace",
|
||||
"Read committee records, configuration, and workflow context.",
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE,
|
||||
"Manage committee workspace",
|
||||
"Create and update committee records and workflow state.",
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE,
|
||||
"Administer committee workspace",
|
||||
"Configure committee policies, templates, and tenant-level administration.",
|
||||
),
|
||||
_permission(
|
||||
BALLOT_SCOPE,
|
||||
"Finalize provider ballots",
|
||||
"Import a verifiable aggregate result from an installed external or secret ballot provider.",
|
||||
),
|
||||
_permission(
|
||||
PROTECTED_READ_SCOPE,
|
||||
"Read protected committee decisions",
|
||||
"Read reasoning and protected institutional context from a Committee-owned fallback Decision projection.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -44,7 +206,7 @@ ROLE_TEMPLATES = (
|
||||
slug="committee_manager",
|
||||
name="Committee manager",
|
||||
description="Manage committee records and workflow state.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE),
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="committee_viewer",
|
||||
@@ -58,15 +220,23 @@ DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id=f"{MODULE_ID}.module-boundary",
|
||||
title=f"{MODULE_NAME} module boundary",
|
||||
summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.",
|
||||
summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.",
|
||||
body=(
|
||||
"This repository is currently a platform module seed. It registers the domain boundary, "
|
||||
"permission surface, role templates, and documentation metadata before runtime APIs, "
|
||||
"database models, migrations, and WebUI routes are introduced."
|
||||
"The persistent workspace keeps immutable bodies, meetings, agenda items, governed "
|
||||
"vote results, minutes, and lifecycle events. The decision path constructs a "
|
||||
"reconstructable formal Decision from that context, an effective Mandate resolution, "
|
||||
"approval, versioned legal bases, evidence, reasoning, and requested or observed "
|
||||
"effects. Committee does not own generic Mandate or Decision persistence; optional "
|
||||
"providers resolve and record those objects when installed, while a protected local "
|
||||
"projection preserves the bounded fallback. Provider-bound ballots are finalized "
|
||||
"through adapter capabilities without retaining individual ballots. Voting-backed "
|
||||
"results preserve sanitized provider assurance evidence and never promote the "
|
||||
"uncertified local confidential reference provider to a certified profile."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||
order=100,
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
@@ -77,9 +247,139 @@ DOCUMENTATION = (
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"seed": True,
|
||||
"domain_objects": ['committee bodies', 'meeting agendas', 'minutes', 'decision records', 'votes', 'follow-up assignments'],
|
||||
"first_slice": "Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.",
|
||||
"help_contexts": [
|
||||
"committee.navigation",
|
||||
"committee.workspace",
|
||||
"committee.state.read-only",
|
||||
"committee.state.permission-blocked",
|
||||
],
|
||||
"privacy_notes": [
|
||||
"Committee lists expose only records authorized by the active tenant and permission context.",
|
||||
"Provider-backed voting stores aggregate result evidence, not individual confidential ballots.",
|
||||
"Protected decision reasoning requires the dedicated protected-read permission.",
|
||||
],
|
||||
"domain_objects": [
|
||||
"committee bodies",
|
||||
"meeting agendas",
|
||||
"minutes",
|
||||
"deliberation and vote context",
|
||||
"formal decision references",
|
||||
"votes",
|
||||
"follow-up assignments",
|
||||
],
|
||||
"first_slice": "Persist committee body, meeting, agenda item, governed vote result, minute, and formal Decision references.",
|
||||
"does_not_own": [
|
||||
"generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle"
|
||||
],
|
||||
},
|
||||
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,254 @@ manifest = ModuleManifest(
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=OPTIONAL_DEPENDENCIES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_MANDATE_RESOLVER,
|
||||
CAPABILITY_DECISION_REGISTRY,
|
||||
CAPABILITY_VOTING_BALLOTS,
|
||||
),
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/committee",
|
||||
label="Committee",
|
||||
icon="gavel",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/committee-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/committee",
|
||||
component="CommitteePage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/committee",
|
||||
label="Committee",
|
||||
icon="gavel",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=38,
|
||||
),
|
||||
),
|
||||
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,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
documentation=DOCUMENTATION,
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="committee.workspace-layout",
|
||||
title="Committee workspace layout",
|
||||
summary="Find workspace actions and read consistently arranged content.",
|
||||
body="Reload and New body share the workspace-wide action bar at the upper right; Reload sits directly before New. Searching and selecting a body, meeting, or agenda item leaves those workspace actions in place. Item editing, ballots, and finalization remain scoped to the selected record. Disabled controls explain missing authority; administrators grant the existing management permissions rather than changing layouts.",
|
||||
layer="static",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("user", "module_admin", "operator"),
|
||||
order=5,
|
||||
translations={"de": {
|
||||
"title": "Gremien: Aufbau des Arbeitsbereichs",
|
||||
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
|
||||
"body": "Neu laden und Neues Gremium stehen oben rechts in der gemeinsamen Arbeitsbereichsleiste; Neu laden steht unmittelbar vor Neu. Suche und Auswahl eines Gremiums, einer Sitzung oder eines Tagesordnungspunkts verändern diese Positionen nicht. Bearbeitung, Abstimmungen und Abschluss bleiben dem ausgewählten Datensatz zugeordnet. Deaktivierte Aktionen erklären fehlende Berechtigungen; Administratoren vergeben die bestehenden Verwaltungsrechte, statt das Layout zu ändern.",
|
||||
}},
|
||||
),
|
||||
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,48 @@
|
||||
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
|
||||
by_id = {topic.id: topic for topic in topics}
|
||||
self.assertEqual(len(topics), len(by_id), "Documentation topic IDs must be unique")
|
||||
self.assertLessEqual(
|
||||
{
|
||||
"committee.module-boundary",
|
||||
"committee.reference.fields-and-consequences",
|
||||
"committee.data-subject-requests",
|
||||
"committee.workspace-layout",
|
||||
},
|
||||
set(by_id),
|
||||
)
|
||||
self.assertLessEqual(
|
||||
{"user", "admin"},
|
||||
set(by_id["committee.workspace-layout"].documentation_types),
|
||||
)
|
||||
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
|
||||
|
||||
from govoplan_committee.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
|
||||
from govoplan_committee.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
BALLOT_SCOPE,
|
||||
PROTECTED_READ_SCOPE,
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
get_manifest,
|
||||
)
|
||||
|
||||
|
||||
class ManifestSeedTests(unittest.TestCase):
|
||||
@@ -12,12 +19,22 @@ class ManifestSeedTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, "committee")
|
||||
self.assertEqual(manifest.name, "Committee")
|
||||
self.assertEqual(manifest.dependencies, ("access",))
|
||||
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
|
||||
self.assertEqual(
|
||||
{permission.scope for permission in manifest.permissions},
|
||||
{READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE},
|
||||
)
|
||||
self.assertEqual({role.slug for role in manifest.role_templates}, {"committee_manager", "committee_viewer"})
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.migration_spec)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertEqual("@govoplan/committee-webui", manifest.frontend.package_name)
|
||||
self.assertEqual("/committee", manifest.frontend.routes[0].path)
|
||||
self.assertEqual(
|
||||
{"committee.navigation", "committee.workspace"},
|
||||
{surface.id for surface in manifest.frontend.view_surfaces},
|
||||
)
|
||||
self.assertIn("committee.workspace", manifest.capability_factories)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_committee.backend.manifest import get_manifest
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
|
||||
|
||||
class CommitteeMigrationTests(unittest.TestCase):
|
||||
def test_fresh_migration_creates_committee_workspace_and_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-committee-migration-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'committee.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("committee",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
self.assertTrue(
|
||||
{
|
||||
"committee_decision_projections",
|
||||
"committee_workspace_events",
|
||||
"committee_workspace_revisions",
|
||||
}.issubset(inspect(engine).get_table_names())
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"d8b9f0a1c2e3",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,755 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.core.institutional import (
|
||||
ActorRepresentationReference,
|
||||
EvidenceReference,
|
||||
InformationGovernanceReference,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
MandateDefinition,
|
||||
MandateResolution,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS, VotingResult
|
||||
from govoplan_committee.backend.db.models import (
|
||||
CommitteeDecisionProjection,
|
||||
CommitteeWorkspaceEvent,
|
||||
CommitteeWorkspaceRevision,
|
||||
)
|
||||
from govoplan_committee.backend.ballots import (
|
||||
BallotFinalizationResult,
|
||||
CommitteeBallotFinalizer,
|
||||
ballot_adapter_capability,
|
||||
)
|
||||
from govoplan_committee.backend.decision_path import (
|
||||
CommitteeDecisionPath,
|
||||
CommitteeDecisionProposal,
|
||||
)
|
||||
from govoplan_committee.backend.workspace import (
|
||||
CommitteeWorkspaceError,
|
||||
CommitteeWorkspaceRecord,
|
||||
SqlCommitteeWorkspace,
|
||||
get_local_decision,
|
||||
list_workspace_objects,
|
||||
record_workspace_object,
|
||||
workspace_history,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 13, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
|
||||
|
||||
class BallotAdapter:
|
||||
def finalize_ballot(self, session, principal, *, request):
|
||||
return BallotFinalizationResult(
|
||||
provider_id=request.provider_id,
|
||||
provider_ballot_ref=request.provider_ballot_ref,
|
||||
counts={"yes": 3, "no": 1},
|
||||
cast_count=4,
|
||||
quorum_met=True,
|
||||
receipt_ref="receipt:secret-ballot-1",
|
||||
result_sha256="a" * 64,
|
||||
evidence=(evidence("secret-ballot-result"),),
|
||||
)
|
||||
|
||||
|
||||
class BallotRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.name = ballot_adapter_capability("secure-vote")
|
||||
self.adapter = BallotAdapter()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == self.name
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if name != self.name:
|
||||
raise KeyError(name)
|
||||
return self.adapter
|
||||
|
||||
|
||||
class VotingBallots:
|
||||
def create_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_ballot(self, session, principal, *, ballot_id):
|
||||
return {
|
||||
"id": ballot_id,
|
||||
"revision": 2,
|
||||
"state": "open",
|
||||
"assurance_profile": "confidential",
|
||||
"provider_id": "local_confidential",
|
||||
"provider_ballot_ref": "local-confidential:ballot-1",
|
||||
"context": {"module": "committee", "resource_id": "vote-voting"},
|
||||
}
|
||||
|
||||
def open_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def cast_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def close_ballot(
|
||||
self, session, principal, *, ballot_id, expected_revision, idempotency_key
|
||||
):
|
||||
if expected_revision != 2:
|
||||
raise AssertionError("unexpected Voting revision")
|
||||
return VotingResult(
|
||||
ballot_id=ballot_id,
|
||||
revision=3,
|
||||
counts={"yes": 2, "no": 1},
|
||||
weighted_counts={"yes": 3, "no": 1},
|
||||
cast_count=3,
|
||||
cast_weight=4,
|
||||
eligible_count=4,
|
||||
eligible_weight=5,
|
||||
quorum_met=True,
|
||||
threshold_met=True,
|
||||
winning_options=("yes",),
|
||||
result_sha256="c" * 64,
|
||||
evidence=(
|
||||
{
|
||||
"kind": "reference_provider_result",
|
||||
"provider_id": "local_confidential",
|
||||
"certified": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def certify_ballot(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class VotingRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.provider = VotingBallots()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_VOTING_BALLOTS
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if name != CAPABILITY_VOTING_BALLOTS:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
|
||||
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def evidence(evidence_id: str) -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind="record",
|
||||
owner_module="records",
|
||||
evidence_id=evidence_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def workspace_record(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
*,
|
||||
state: str,
|
||||
attributes: dict[str, object],
|
||||
parent_id: str | None = None,
|
||||
revision: int = 1,
|
||||
evidence_refs: tuple[EvidenceReference, ...] = (),
|
||||
) -> CommitteeWorkspaceRecord:
|
||||
return CommitteeWorkspaceRecord(
|
||||
tenant_id="tenant-1",
|
||||
object_kind=kind, # type: ignore[arg-type]
|
||||
object_id=object_id,
|
||||
revision=revision,
|
||||
state=state,
|
||||
title=f"{kind.replace('_', ' ').title()} {object_id}",
|
||||
parent_id=parent_id,
|
||||
recorded_at=NOW + timedelta(minutes=revision - 1),
|
||||
change_reason="Governed Committee update.",
|
||||
attributes=attributes,
|
||||
evidence=evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
def mandate() -> MandateDefinition:
|
||||
return MandateDefinition(
|
||||
reference=ref("mandate", "mandate-1", "mandates"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
task_types=("committee.formal_decision",),
|
||||
authority_types=("committee.resolution",),
|
||||
organization_unit_refs=(ref("organization_unit", "board-1", "organizations"),),
|
||||
function_refs=(ref("function", "chair", "organizations"),),
|
||||
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
evidence=(evidence("mandate-proof"),),
|
||||
)
|
||||
|
||||
|
||||
def legal_basis() -> LegalBasisReference:
|
||||
return LegalBasisReference(
|
||||
kind="statute",
|
||||
authority="Example council",
|
||||
reference="committee-rules:12",
|
||||
version="2026-01",
|
||||
)
|
||||
|
||||
|
||||
def proposal() -> CommitteeDecisionProposal:
|
||||
function_ref = ref("function", "chair", "organizations")
|
||||
return CommitteeDecisionProposal(
|
||||
tenant_id="tenant-1",
|
||||
decision_id="decision-1",
|
||||
revision="1",
|
||||
effective_at=NOW,
|
||||
meeting_ref="meeting-1",
|
||||
agenda_item_ref="agenda-1",
|
||||
decision_type="committee.resolution",
|
||||
subject_refs=(ref("case", "case-1", "cases"),),
|
||||
organization_unit_ref=ref("organization_unit", "board-1", "organizations"),
|
||||
function_ref=function_ref,
|
||||
actor=ActorRepresentationReference(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
represented_function_ref=function_ref,
|
||||
mandate_ref=mandate().reference,
|
||||
),
|
||||
approval_refs=(ref("approval", "vote-1", "approvals"),),
|
||||
fact_evidence=(evidence("vote-result-1"),),
|
||||
legal_bases=(legal_basis(),),
|
||||
operative_result="The application is approved.",
|
||||
reasoning="The evidence and vote meet the applicable rules.",
|
||||
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||
information_governance=InformationGovernanceReference(
|
||||
classification="restricted",
|
||||
purposes=("formal_decision",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CommitteeWorkspaceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CommitteeWorkspaceRevision.__table__,
|
||||
CommitteeWorkspaceEvent.__table__,
|
||||
CommitteeDecisionProjection.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.principal = Principal()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_full_workspace_lifecycle_and_local_decision_projection(self) -> None:
|
||||
bus = EventBus()
|
||||
events = []
|
||||
bus.subscribe("*", events.append)
|
||||
with event_bus_context(bus):
|
||||
body = workspace_record(
|
||||
"body",
|
||||
"body-1",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit",
|
||||
"board-1",
|
||||
"organizations",
|
||||
).to_dict(),
|
||||
"function_refs": [
|
||||
ref("function", "chair", "organizations").to_dict()
|
||||
],
|
||||
"quorum": {"minimum_count": 3},
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=body,
|
||||
idempotency_key="body-create",
|
||||
)
|
||||
meeting = workspace_record(
|
||||
"meeting",
|
||||
"meeting-1",
|
||||
state="scheduled",
|
||||
parent_id="body-1",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
|
||||
"location": "Council chamber",
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=meeting,
|
||||
idempotency_key="meeting-create",
|
||||
)
|
||||
agenda = workspace_record(
|
||||
"agenda_item",
|
||||
"agenda-1",
|
||||
state="scheduled",
|
||||
parent_id="meeting-1",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=agenda,
|
||||
idempotency_key="agenda-create",
|
||||
)
|
||||
deliberating = replace(
|
||||
agenda,
|
||||
revision=2,
|
||||
state="deliberating",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=deliberating,
|
||||
expected_revision=1,
|
||||
idempotency_key="agenda-deliberating",
|
||||
)
|
||||
vote = workspace_record(
|
||||
"vote",
|
||||
"vote-1",
|
||||
state="open",
|
||||
parent_id="agenda-1",
|
||||
attributes={
|
||||
"method": "recorded",
|
||||
"choices": ["yes", "no", "abstain"],
|
||||
"eligible_count": 5,
|
||||
"cast_count": 0,
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=vote,
|
||||
idempotency_key="vote-open",
|
||||
)
|
||||
closed_vote = replace(
|
||||
vote,
|
||||
revision=2,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
attributes={
|
||||
**dict(vote.attributes),
|
||||
"cast_count": 5,
|
||||
"counts": {"yes": 4, "no": 1, "abstain": 0},
|
||||
"quorum_met": True,
|
||||
"approval_ref": ref(
|
||||
"approval",
|
||||
"vote-1",
|
||||
"approvals",
|
||||
).to_dict(),
|
||||
},
|
||||
evidence=(evidence("vote-result-1"),),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=closed_vote,
|
||||
expected_revision=1,
|
||||
idempotency_key="vote-close",
|
||||
)
|
||||
|
||||
decision = (
|
||||
CommitteeDecisionPath()
|
||||
.decide(
|
||||
self.session,
|
||||
self.principal,
|
||||
proposal=proposal(),
|
||||
mandate_resolution=MandateResolution(
|
||||
competent=True,
|
||||
mandates=(mandate(),),
|
||||
),
|
||||
)
|
||||
.decision
|
||||
)
|
||||
workspace = SqlCommitteeWorkspace()
|
||||
projected = workspace.record_local_decision(
|
||||
self.session,
|
||||
self.principal,
|
||||
decision=decision,
|
||||
meeting_id="meeting-1",
|
||||
agenda_item_id="agenda-1",
|
||||
)
|
||||
decided = replace(
|
||||
deliberating,
|
||||
revision=3,
|
||||
state="decided",
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
attributes={
|
||||
**dict(deliberating.attributes),
|
||||
"decision_ref": projected.reference.to_dict(),
|
||||
},
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=decided,
|
||||
expected_revision=2,
|
||||
idempotency_key="agenda-decide",
|
||||
)
|
||||
open_meeting = replace(
|
||||
meeting,
|
||||
revision=2,
|
||||
state="open",
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=open_meeting,
|
||||
expected_revision=1,
|
||||
idempotency_key="meeting-open",
|
||||
)
|
||||
closed_meeting = replace(
|
||||
open_meeting,
|
||||
revision=3,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(hours=2),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=closed_meeting,
|
||||
expected_revision=2,
|
||||
idempotency_key="meeting-close",
|
||||
)
|
||||
minute = workspace_record(
|
||||
"minute",
|
||||
"minute-1",
|
||||
state="accepted",
|
||||
parent_id="meeting-1",
|
||||
attributes={
|
||||
"content_ref": ref("record", "minutes-1", "records").to_dict(),
|
||||
"approval_ref": ref(
|
||||
"approval", "minutes-ok", "approvals"
|
||||
).to_dict(),
|
||||
},
|
||||
evidence_refs=(evidence("minutes-signature"),),
|
||||
)
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=minute,
|
||||
idempotency_key="minute-accept",
|
||||
)
|
||||
self.assertEqual([], events)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(
|
||||
"decision-1",
|
||||
get_local_decision(
|
||||
self.session,
|
||||
self.principal,
|
||||
decision_id="decision-1",
|
||||
).reference.object_id,
|
||||
)
|
||||
agenda_history = workspace_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
object_kind="agenda_item",
|
||||
object_id="agenda-1",
|
||||
)
|
||||
self.assertEqual([3, 2, 1], [item.revision for item in agenda_history])
|
||||
meetings, total = list_workspace_objects(
|
||||
self.session,
|
||||
self.principal,
|
||||
object_kind="meeting",
|
||||
states=("closed",),
|
||||
)
|
||||
self.assertEqual(1, total)
|
||||
self.assertEqual("meeting-1", meetings[0].object_id)
|
||||
self.assertIn("committee.decision.projected", [item.type for item in events])
|
||||
|
||||
def test_parent_state_occ_replay_and_tenant_boundaries_fail_closed(self) -> None:
|
||||
body = workspace_record(
|
||||
"body",
|
||||
"body-1",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit",
|
||||
"board-1",
|
||||
"organizations",
|
||||
).to_dict(),
|
||||
},
|
||||
)
|
||||
first = record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=body,
|
||||
idempotency_key="body-1",
|
||||
)
|
||||
replay = record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=body,
|
||||
idempotency_key="body-1",
|
||||
)
|
||||
self.assertEqual(first, replay)
|
||||
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "idempotency conflict"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=replace(body, title="Changed"),
|
||||
idempotency_key="body-1",
|
||||
)
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "existing body"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=workspace_record(
|
||||
"meeting",
|
||||
"meeting-orphan",
|
||||
state="scheduled",
|
||||
parent_id="missing",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=1)).isoformat(),
|
||||
},
|
||||
),
|
||||
idempotency_key="meeting-orphan",
|
||||
)
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "stale"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=replace(body, revision=2),
|
||||
expected_revision=99,
|
||||
idempotency_key="body-stale",
|
||||
)
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "cross tenants"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
Principal(tenant_id="tenant-2"),
|
||||
record=body,
|
||||
idempotency_key="cross-tenant",
|
||||
)
|
||||
|
||||
def test_provider_ballot_finalization_persists_only_aggregate_evidence(
|
||||
self,
|
||||
) -> None:
|
||||
records = (
|
||||
workspace_record(
|
||||
"body",
|
||||
"body-ballot",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit",
|
||||
"board-1",
|
||||
"organizations",
|
||||
).to_dict(),
|
||||
"function_refs": [],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"meeting",
|
||||
"meeting-ballot",
|
||||
state="open",
|
||||
parent_id="body-ballot",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"agenda_item",
|
||||
"agenda-ballot",
|
||||
state="deliberating",
|
||||
parent_id="meeting-ballot",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"vote",
|
||||
"vote-provider",
|
||||
state="open",
|
||||
parent_id="agenda-ballot",
|
||||
attributes={
|
||||
"method": "secret",
|
||||
"provider_id": "secure-vote",
|
||||
"choices": ["yes", "no"],
|
||||
"eligible_count": 5,
|
||||
"cast_count": 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
for index, item in enumerate(records):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=item,
|
||||
idempotency_key=f"ballot-setup-{index}",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(CommitteeWorkspaceError, "ballot adapter"):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=replace(
|
||||
records[-1],
|
||||
revision=2,
|
||||
state="closed",
|
||||
recorded_at=NOW + timedelta(minutes=4),
|
||||
evidence=(evidence("manual-result"),),
|
||||
attributes={
|
||||
**records[-1].attributes,
|
||||
"counts": {"yes": 3, "no": 1},
|
||||
"cast_count": 4,
|
||||
"quorum_met": True,
|
||||
"provider_receipt_ref": "forged-receipt",
|
||||
"provider_result_sha256": "b" * 64,
|
||||
"approval_ref": ref(
|
||||
"approval", "vote-approval", "approvals"
|
||||
).to_dict(),
|
||||
},
|
||||
),
|
||||
expected_revision=1,
|
||||
idempotency_key="ballot-manual-close",
|
||||
)
|
||||
|
||||
closed = CommitteeBallotFinalizer(BallotRegistry()).finalize(
|
||||
self.session,
|
||||
self.principal,
|
||||
vote_id="vote-provider",
|
||||
provider_id="secure-vote",
|
||||
provider_ballot_ref="external-ballot-7",
|
||||
approval_ref=ref("approval", "vote-approval", "approvals"),
|
||||
expected_revision=1,
|
||||
recorded_at=NOW + timedelta(minutes=5),
|
||||
change_reason="Imported verified secret ballot aggregate.",
|
||||
idempotency_key="ballot-finalize-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual("closed", closed.state)
|
||||
self.assertEqual({"yes": 3, "no": 1}, closed.attributes["counts"])
|
||||
self.assertEqual("a" * 64, closed.attributes["provider_result_sha256"])
|
||||
self.assertNotIn("ballots", closed.attributes)
|
||||
self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id)
|
||||
|
||||
def test_voting_ballot_finalization_projects_only_aggregate_result(self) -> None:
|
||||
records = (
|
||||
workspace_record(
|
||||
"body",
|
||||
"body-voting",
|
||||
state="active",
|
||||
attributes={
|
||||
"organization_unit_ref": ref(
|
||||
"organization_unit", "board-1", "organizations"
|
||||
).to_dict(),
|
||||
"function_refs": [],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"meeting",
|
||||
"meeting-voting",
|
||||
state="open",
|
||||
parent_id="body-voting",
|
||||
attributes={
|
||||
"starts_at": NOW.isoformat(),
|
||||
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"agenda_item",
|
||||
"agenda-voting",
|
||||
state="deliberating",
|
||||
parent_id="meeting-voting",
|
||||
attributes={
|
||||
"position": 1,
|
||||
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
|
||||
},
|
||||
),
|
||||
workspace_record(
|
||||
"vote",
|
||||
"vote-voting",
|
||||
state="open",
|
||||
parent_id="agenda-voting",
|
||||
attributes={
|
||||
"method": "recorded",
|
||||
"voting_ballot_id": "ballot-1",
|
||||
"choices": ["yes", "no"],
|
||||
"eligible_count": 4,
|
||||
"cast_count": 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
for index, item in enumerate(records):
|
||||
record_workspace_object(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=item,
|
||||
idempotency_key=f"voting-setup-{index}",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
closed = CommitteeBallotFinalizer(VotingRegistry()).finalize_voting_ballot(
|
||||
self.session,
|
||||
self.principal,
|
||||
vote_id="vote-voting",
|
||||
voting_ballot_id="ballot-1",
|
||||
voting_expected_revision=2,
|
||||
approval_ref=ref("approval", "vote-approval", "approvals"),
|
||||
expected_revision=1,
|
||||
recorded_at=NOW + timedelta(minutes=5),
|
||||
change_reason="Closed the governed Voting ballot.",
|
||||
idempotency_key="voting-finalize-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual("closed", closed.state)
|
||||
self.assertEqual({"yes": 2, "no": 1}, closed.attributes["counts"])
|
||||
self.assertEqual("c" * 64, closed.attributes["voting_result_sha256"])
|
||||
self.assertEqual(
|
||||
"confidential",
|
||||
closed.attributes["voting_assurance_profile"],
|
||||
)
|
||||
self.assertFalse(closed.attributes["voting_provider_evidence"][0]["certified"])
|
||||
self.assertEqual("voting", closed.evidence[0].owner_module)
|
||||
self.assertNotIn("selections", closed.attributes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/committee-webui",
|
||||
"version": "0.1.21",
|
||||
"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 data-help-risk-reviewed="standard" 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