Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f236034e88 | ||
|
|
eab2f4b3b2 | ||
|
|
fd7f05ffc9 | ||
|
|
2efe574f18 | ||
|
|
83e69565fe | ||
|
|
86878ef7a7 | ||
|
|
23f5091a2b | ||
|
|
2f030e56a9 | ||
|
|
cfe2389e5a | ||
|
|
0265e135f5 | ||
|
|
ee01662b07 | ||
|
|
e7b719a04e | ||
|
|
6c980c1865 | ||
|
|
6cf83fc3b3 | ||
|
|
816e55e29b | ||
|
|
43b4cc8b86 | ||
|
|
c7821a5cb0 | ||
|
|
122159de16 | ||
|
|
11979005bc | ||
|
|
150a90771d |
@@ -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
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
.component-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
|
||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# ---> VisualStudioCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# govoplan-cases
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
`govoplan-cases` owns formal administrative case context for GovOPlaN.
|
||||||
|
|
||||||
|
The module should stay focused on case identity, lifecycle state, assignments,
|
||||||
|
deadlines, participants, evidence links, and case-level audit context. It should
|
||||||
|
not own forms, files, workflow execution, task queues, templates, payments, or
|
||||||
|
mail delivery; those are integrated through GovOPlaN capabilities, events,
|
||||||
|
commands, and DTOs.
|
||||||
|
|
||||||
|
The executable backend slice now exposes:
|
||||||
|
|
||||||
|
- `cases.service_intake`, which converts one published, effective Service
|
||||||
|
definition into a case intake plan while retaining the exact service,
|
||||||
|
mandate, jurisdiction, legal-basis, form, workflow, result, evidence, and
|
||||||
|
deadline references; and
|
||||||
|
- `cases.party_context`, which consumes an optional procedure-party provider
|
||||||
|
or a bounded Cases-only compatibility projection and emits policy-filtered,
|
||||||
|
frozen contact snapshot references for downstream delivery; and
|
||||||
|
- `cases.registry` plus `/api/v1/cases`, which persist tenant-local case/status
|
||||||
|
catalogs, stable case identities, immutable OCC-guarded revisions, stable
|
||||||
|
assignment/evidence/Decision/record references, and replay-safe lifecycle
|
||||||
|
events; and
|
||||||
|
- `cases.service_launcher`, which opens exactly one deterministic case from an
|
||||||
|
exact published Service revision and safely replays the same Portal launch.
|
||||||
|
|
||||||
|
Cases does not own institutional Service, Party, representation, identity,
|
||||||
|
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
|
||||||
|
workspace now supplies list/detail, status/title revision, history, and timeline
|
||||||
|
surfaces. A case can remain tenant-visible or become restricted to its creator,
|
||||||
|
case administrators, explicit user/group grants, and assignment-derived
|
||||||
|
function, function-assignment, or organization-unit grants. The detail surface
|
||||||
|
uses the shared reference selector to manage those grants; list, detail,
|
||||||
|
history, timeline, and update paths all apply the same fail-closed ACL.
|
||||||
|
|
||||||
|
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
|
||||||
|
|
||||||
|
## Data-subject requests
|
||||||
|
|
||||||
|
Cases contributes `privacy.dsar.cases`. It reports exact-tenant access grants
|
||||||
|
and operator attribution, plus minimized case lifecycle data when an explicit
|
||||||
|
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
|
||||||
|
text, free-text reasons, event payloads, evidence identifiers, request digests,
|
||||||
|
idempotency keys, audit internals, and unrelated access subjects are excluded.
|
||||||
|
Historical case evidence is retained; current open case and active access facts
|
||||||
|
require authorized manual review through the existing lifecycle. Applicant
|
||||||
|
identity correlation remains a Parties responsibility and is never guessed
|
||||||
|
from a case's party references.
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
# govoplan-cases Concept
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`govoplan-cases` is the formal administrative case module. It creates a durable
|
||||||
|
container for a procedure such as a permit application, inspection, complaint,
|
||||||
|
grant request, or internal administrative matter.
|
||||||
|
|
||||||
|
The case is not the workflow engine and not the file store. It is the stable
|
||||||
|
record that ties together participants, status, assignments, deadlines,
|
||||||
|
evidence, decisions, communications, audit events, and retention references.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
The module owns:
|
||||||
|
|
||||||
|
- case identifiers, references, titles, types, and status
|
||||||
|
- case-local party references and role labels such as applicant, respondent,
|
||||||
|
beneficiary, and representative, using a shared Parties contract when present
|
||||||
|
- case metadata and tags
|
||||||
|
- due dates, service-level targets, and milestone dates
|
||||||
|
- links to evidence provided by other modules
|
||||||
|
- case comments and internal notes once the collaboration boundary is decided
|
||||||
|
- case-level access checks and resource ACL contributions
|
||||||
|
- case event emission for lifecycle changes
|
||||||
|
- case summary APIs and WebUI route contributions
|
||||||
|
|
||||||
|
The module does not own:
|
||||||
|
|
||||||
|
- form submissions and validation, owned by forms/forms-runtime
|
||||||
|
- binary file storage, owned by files or DMS
|
||||||
|
- workflow state machines and transition execution, owned by workflow
|
||||||
|
- work queues and task assignment semantics, owned by tasks
|
||||||
|
- generated documents, owned by templates/DMS
|
||||||
|
- appointments, mail, notifications, postbox, payments, or ledger postings
|
||||||
|
- identity/contact/organization masters, representation powers, formal
|
||||||
|
institutional decision semantics, mandates, or jurisdiction
|
||||||
|
|
||||||
|
## Core Contracts
|
||||||
|
|
||||||
|
The module should integrate through:
|
||||||
|
|
||||||
|
- module manifest metadata, route factories, permissions, and migrations
|
||||||
|
- a `cases.access` or similar case access capability for resource checks
|
||||||
|
- a `cases.summary` capability for dashboards and cross-module previews
|
||||||
|
- shared party, mandate, service, and decision reference DTOs when those
|
||||||
|
providers are available
|
||||||
|
- events such as `case.created`, `case.updated`, `case.status_changed`,
|
||||||
|
`case.assigned`, and `case.closed`
|
||||||
|
- commands such as `cases.open`, `cases.update_status`, `cases.link_evidence`,
|
||||||
|
and `cases.assign`
|
||||||
|
- DTOs with stable IDs and labels, not ORM objects
|
||||||
|
|
||||||
|
Other modules should link to a case by stable references:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"case_id": "case-uuid",
|
||||||
|
"tenant_id": "tenant-uuid",
|
||||||
|
"relation": "submission"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Journey
|
||||||
|
|
||||||
|
Permit-to-payment MVP:
|
||||||
|
|
||||||
|
1. Portal/forms runtime receives a public application.
|
||||||
|
2. Workflow asks cases to create a case record.
|
||||||
|
3. Files/DMS links uploaded evidence to the case.
|
||||||
|
4. Tasks assigns an internal review task.
|
||||||
|
5. Workflow moves the case from intake to review to appointment to decision.
|
||||||
|
6. Templates generates the permit or decision document and links it to the case.
|
||||||
|
7. Payments links payment evidence.
|
||||||
|
8. Audit and records retain the case history.
|
||||||
|
|
||||||
|
## Implemented MVP Backend
|
||||||
|
|
||||||
|
The persistent backend provides:
|
||||||
|
|
||||||
|
- tenant-local case type and status catalogs with guarded revisions
|
||||||
|
- create/list/read/update/history/timeline APIs
|
||||||
|
- stable case identities and immutable OCC-guarded record revisions
|
||||||
|
- parties stored as stable procedure-party references with a compatibility
|
||||||
|
path for direct identity/organization references; assignments remain
|
||||||
|
responsibility references
|
||||||
|
- evidence links as module/resource references
|
||||||
|
- replay-safe case timeline events carrying the institutional context and a
|
||||||
|
stable event/audit reference
|
||||||
|
- tenant-level resource ACL and tenant summary providers
|
||||||
|
- tenant-wide or restricted object access with explicit, revisioned grants
|
||||||
|
- deterministic, replay-safe case launch from an exact Service binding
|
||||||
|
|
||||||
|
The `/cases` list and `/cases/:caseId` detail workspace provide server-side
|
||||||
|
search/status filtering, case facts, typed references, history, timeline, and
|
||||||
|
OCC-guarded title/status/access revisions with an explicit change reason.
|
||||||
|
Restricted cases are visible to their creator, case administrators, explicit
|
||||||
|
user/group grants, and matching function, function-assignment, or
|
||||||
|
organization-unit assignments. The same decision filters list, detail,
|
||||||
|
history, timeline, and update operations so an inaccessible identifier does
|
||||||
|
not disclose case existence. API reads and writes are tenant-bound, and
|
||||||
|
create/update/assign/close/share/catalog operations have separate permissions.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Implemented scopes:
|
||||||
|
|
||||||
|
- `cases:case:read`
|
||||||
|
- `cases:case:create`
|
||||||
|
- `cases:case:update`
|
||||||
|
- `cases:case:assign`
|
||||||
|
- `cases:case:close`
|
||||||
|
- `cases:case:share`
|
||||||
|
- `cases:case:admin`
|
||||||
|
|
||||||
|
Access decisions combine tenant permissions, creator/administrative authority,
|
||||||
|
case assignments, and explicit case shares. Restricted access is versioned
|
||||||
|
with the case record so its history remains reconstructable.
|
||||||
|
|
||||||
|
## Data Model Sketch
|
||||||
|
|
||||||
|
Candidate tables:
|
||||||
|
|
||||||
|
- `cases`
|
||||||
|
- `case_parties`
|
||||||
|
- `case_assignments`
|
||||||
|
- `case_evidence_links`
|
||||||
|
- `case_timeline_entries`
|
||||||
|
- `case_type_definitions`
|
||||||
|
- `case_status_definitions`
|
||||||
|
- `case_access_grants`
|
||||||
|
|
||||||
|
`case_parties` is a compatibility seed for a Cases-only composition. It must
|
||||||
|
not grow a second identity/contact master or duplicate effective-dated powers
|
||||||
|
of representation. Once a shared Parties provider exists, Cases should retain
|
||||||
|
the provider reference plus the minimal immutable role/evidence snapshot needed
|
||||||
|
to reconstruct the case.
|
||||||
|
|
||||||
|
The implemented headless compatibility path therefore accepts only a stable
|
||||||
|
subject reference, case-local role, effective interval, permitted/preferred
|
||||||
|
channels, delivery flag, evidence, and immutable contact snapshot references.
|
||||||
|
It cannot model representation powers. When `parties.resolver` is available,
|
||||||
|
Cases uses its effective, tenant-scoped party and representation records and
|
||||||
|
rejects mismatched procedures or conflicting active role assignments.
|
||||||
|
Shared Core revision helpers keep corrections, expiry, supersession, and
|
||||||
|
revocation immutable and OCC-guarded. Representation revocation has its own
|
||||||
|
effective timestamp so a historical delivery decision can be reconstructed.
|
||||||
|
|
||||||
|
`cases.service_intake` consumes a published, effective shared Service
|
||||||
|
definition. It requires one case binding and preserves the exact Service
|
||||||
|
version plus its responsible unit/function, Mandate, jurisdictions, legal
|
||||||
|
bases, required evidence, deadlines, forms, workflows, and result bindings in
|
||||||
|
a governed intake plan. It does not copy or persist the Service definition.
|
||||||
|
|
||||||
|
`cases.service_launcher` is the effect boundary used by Portal. It derives a
|
||||||
|
stable case ID from tenant, exact Service revision, and idempotency key, creates
|
||||||
|
the case through the normal registry, and returns that exact case and Service
|
||||||
|
reference. Retrying the same launch returns the existing case; a changed request
|
||||||
|
cannot reuse the key. Portal therefore cannot create duplicate cases after an
|
||||||
|
ambiguous network response and does not gain access to Cases tables.
|
||||||
|
|
||||||
|
Cases links formal Decision records and may retain a current outcome/status
|
||||||
|
projection. It does not own decision authority, rule versions, reasoning,
|
||||||
|
correction, revocation, or remedy semantics.
|
||||||
|
|
||||||
|
The Case detail decision path is a guarded composition boundary. It resolves an
|
||||||
|
exact Case revision, rechecks Case update access, verifies the current acting
|
||||||
|
assignment through Access, and requires exactly one effective Mandate for
|
||||||
|
`cases.formal_decision` and the selected Decision type. Only then does it call
|
||||||
|
the optional Decisions registry and append the exact Decision reference to a
|
||||||
|
new Case revision in the same transaction. Deterministic identifiers make an
|
||||||
|
unchanged retry safe, while changed replay payloads and stale Case revisions
|
||||||
|
fail closed. Cases still does not store the protected result or reasoning.
|
||||||
|
|
||||||
|
Evidence links should store only stable module/resource references and display
|
||||||
|
metadata snapshots. The owning module remains responsible for the real object.
|
||||||
|
|
||||||
|
When Records is enabled, `records.source.cases` resolves one exact immutable
|
||||||
|
case revision for eAkte filing. Cases rechecks current tenant, scope, and
|
||||||
|
object-level access, then returns the case number, lifecycle state, represented
|
||||||
|
valid interval, recorded time, canonical snapshot digest, and launch link.
|
||||||
|
Records owns the filing decision and chronology; Cases remains authoritative
|
||||||
|
for the case and its revision history.
|
||||||
|
|
||||||
|
When Search is enabled, `cases.cases` indexes current Case titles, numbers,
|
||||||
|
types, states, and the existing bounded search projection. It includes no
|
||||||
|
provider-owned evidence content or protected Decision reasoning. Restricted
|
||||||
|
Case candidates carry bounded ACL tokens and every result is checked again
|
||||||
|
against the current Case object-access decision before disclosure.
|
||||||
|
|
||||||
|
## WebUI
|
||||||
|
|
||||||
|
Initial route contributions:
|
||||||
|
|
||||||
|
- `/cases`
|
||||||
|
- `/cases/:caseId`
|
||||||
|
|
||||||
|
The detail view renders the module-owned record, stable references, history,
|
||||||
|
timeline, and provider-resolved formal Decisions when currently authorized. It
|
||||||
|
can record a direct officer Decision when Access, Mandates, and Decisions are
|
||||||
|
enabled and can launch exact Case or Decision filing when Records is enabled.
|
||||||
|
Future linked forms, files, tasks, workflow state,
|
||||||
|
appointments, documents, communication, payment evidence, and richer audit
|
||||||
|
panels must arrive through declarative extension points, without direct UI
|
||||||
|
imports from sibling modules.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
The focused suite covers:
|
||||||
|
|
||||||
|
- provider-neutral Service intake and Party resolution
|
||||||
|
- committed-only lifecycle events with institutional context
|
||||||
|
- replay conflict detection, stale revision rejection, and immutable history
|
||||||
|
- tenant isolation, terminal status handling, and server-side filtering
|
||||||
|
- stable typed assignment/evidence/Decision/record references
|
||||||
|
- migration, uninstall-guard, ACL, summary, and capability registration
|
||||||
|
- explicit and assignment-derived case access, including non-disclosure
|
||||||
|
- exact Service launch, deterministic replay, and conflict behavior
|
||||||
|
|
||||||
|
## Data-subject requests
|
||||||
|
|
||||||
|
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
|
||||||
|
membership selectors cover Cases-owned operator attribution; account and
|
||||||
|
identity selectors also cover explicit case-access grants. Exact
|
||||||
|
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
|
||||||
|
references select lifecycle data. When a canonical and direct selector are
|
||||||
|
combined, a Cases-owned relationship must corroborate them and all supplied
|
||||||
|
direct references must identify the same case.
|
||||||
|
|
||||||
|
Direct case results are typed projections rather than stored snapshots. They
|
||||||
|
exclude opaque metadata, search text, free-text change reasons, timeline
|
||||||
|
payloads and summaries, evidence identifiers, request hashes, idempotency
|
||||||
|
keys, audit identifiers, and unrelated access subjects. Applicant-to-identity
|
||||||
|
correlation is owned by Parties; Cases does not infer it from opaque party
|
||||||
|
identifiers. Immutable case identities, revisions, timelines, and operator
|
||||||
|
attribution receive retention actions. Current open case facts and active
|
||||||
|
access grants receive non-executable manual-review actions and can only be
|
||||||
|
changed through the authorized case/access lifecycle.
|
||||||
|
|
||||||
|
## Open Decisions
|
||||||
|
|
||||||
|
- Whether comments belong in cases, tasks, or a collaboration module.
|
||||||
|
- Whether case type/status catalogs are fully configurable in MVP or seeded.
|
||||||
|
- How records/legal-hold integration should own retention of closed cases.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Cases Interface Pattern Migration
|
||||||
|
|
||||||
|
This migration applies the GovOPlaN interface pattern language to the case
|
||||||
|
directory, governed case detail, lifecycle editor, institutional references,
|
||||||
|
immutable evidence, and object-level access dialog.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/cases` | Governed directory | Search and select a readable case | Stable loading, empty, error, filter, count, and contextual-help states |
|
||||||
|
| Case summary | Governed object detail | Inspect current revision and lifecycle state | Privacy-safe title, type, status, dates, revision, and reason rendering |
|
||||||
|
| Lifecycle editor | Consequential record editor | Append revision or close case | Guarded draft, OCC revision, stable idempotency key, explicit reason, permission explanation, and save/discard |
|
||||||
|
| Institutional references | Provider-owned reference list | Inspect linked objects | Stable owner/object/version references without copying sibling-module state |
|
||||||
|
| Timeline and history | Immutable evidence view | Inspect recorded lifecycle evidence | Actor-safe summaries, revision order, timestamps, and no historical mutation controls |
|
||||||
|
| Access dialog | Governed object-access editor | Change visibility or explicit grants | Searchable account/group references, guarded nested draft, confirmation, OCC revision, reason, and permission boundary |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Every accepted title, status, visibility, or grant change appends an immutable
|
||||||
|
revision and timeline event. Existing history is never rewritten.
|
||||||
|
- Updates require `cases:case:update`; terminal statuses additionally require
|
||||||
|
`cases:case:close`; access changes require `cases:case:share`.
|
||||||
|
- A missing permission leaves the readable case available and explains the
|
||||||
|
actor, required action, and administrative destination instead of hiding the
|
||||||
|
entire object.
|
||||||
|
- Restricted visibility is evaluated by the Cases ACL provider. The WebUI
|
||||||
|
selector discovers accounts and groups through shared reference providers;
|
||||||
|
it does not import Access internals.
|
||||||
|
- Main and access-dialog drafts are guarded. A direct access save requires a
|
||||||
|
separate confirmation that identifies the case, visibility, and grant count.
|
||||||
|
- Service, party, assignment, Decision, and record links remain references to
|
||||||
|
provider-owned objects. Optional modules may enrich those objects without
|
||||||
|
becoming runtime dependencies of Cases.
|
||||||
|
|
||||||
|
Backend and WebUI manifests publish the same navigation, route, section, and
|
||||||
|
action surface identifiers. English and German catalogues cover module-owned
|
||||||
|
navigation, blocker, guard, and confirmation vocabulary. Contextual help links
|
||||||
|
resolve to the module-owned manifest documentation.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-cases"
|
||||||
|
version = "0.1.19"
|
||||||
|
description = "GovOPlaN administrative case context module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = ["govoplan-core>=0.1.18"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_cases = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
"cases" = "govoplan_cases.backend.manifest:get_manifest"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""GovOPlaN Cases module."""
|
||||||
|
|
||||||
|
from govoplan_cases.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
__all__ = ["get_manifest"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cases backend contracts."""
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.auth import has_scope
|
||||||
|
from govoplan_core.core.modules import AccessDecision
|
||||||
|
|
||||||
|
|
||||||
|
class CaseAclProvider:
|
||||||
|
"""Expose the tenant-level Case permission boundary to generic consumers."""
|
||||||
|
|
||||||
|
resource_type = "case"
|
||||||
|
|
||||||
|
def can_read(self, principal: object, resource_id: str) -> bool:
|
||||||
|
del resource_id
|
||||||
|
return has_scope(principal, "cases:case:read")
|
||||||
|
|
||||||
|
def can_write(self, principal: object, resource_id: str) -> bool:
|
||||||
|
del resource_id
|
||||||
|
return has_scope(principal, "cases:case:update")
|
||||||
|
|
||||||
|
def explain(self, principal: object, resource_id: str) -> AccessDecision:
|
||||||
|
del resource_id
|
||||||
|
allowed = self.can_read(principal, "")
|
||||||
|
return AccessDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason=None if allowed else "Missing scope: cases:case:read",
|
||||||
|
requirements=("cases:case:read",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CaseAclProvider"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Cases database models."""
|
||||||
|
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CaseAccessGrant",
|
||||||
|
"CaseIdentity",
|
||||||
|
"CaseRecordRevision",
|
||||||
|
"CaseStatusDefinition",
|
||||||
|
"CaseTimelineEntry",
|
||||||
|
"CaseTypeDefinition",
|
||||||
|
]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
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 CaseTypeDefinition(Base, TimestampMixin):
|
||||||
|
__tablename__ = "case_type_definitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
|
||||||
|
Index("ix_case_type_catalog", "tenant_id", "active", "label"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
type_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
initial_status_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
allowed_status_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseStatusDefinition(Base, TimestampMixin):
|
||||||
|
__tablename__ = "case_status_definitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
|
||||||
|
Index("ix_case_status_catalog", "tenant_id", "active", "sort_order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
status_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
category: Mapped[str] = mapped_column(String(30), nullable=False, default="open")
|
||||||
|
terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseIdentity(Base, TimestampMixin):
|
||||||
|
__tablename__ = "case_identities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
|
||||||
|
UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
|
||||||
|
Index("ix_case_identity_catalog", "tenant_id", "case_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
case_number: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseRecordRevision(Base, TimestampMixin):
|
||||||
|
__tablename__ = "case_record_revisions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
|
||||||
|
Index("ix_case_record_current", "tenant_id", "case_id", "superseded_at"),
|
||||||
|
Index("ix_case_record_list", "tenant_id", "status_key", "case_type_key", "recorded_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)
|
||||||
|
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
identity_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("case_identities.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("case_record_revisions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
case_type_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
status_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
access_mode: Mapped[str] = mapped_column(
|
||||||
|
String(30), nullable=False, default="tenant", index=True
|
||||||
|
)
|
||||||
|
search_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, 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)
|
||||||
|
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseAccessGrant(Base, TimestampMixin):
|
||||||
|
__tablename__ = "case_access_grants"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"case_id",
|
||||||
|
"subject_kind",
|
||||||
|
"subject_id",
|
||||||
|
"source",
|
||||||
|
name="uq_case_access_grant_subject",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_case_access_grant_lookup",
|
||||||
|
"tenant_id",
|
||||||
|
"case_id",
|
||||||
|
"active",
|
||||||
|
"subject_kind",
|
||||||
|
"subject_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseTimelineEntry(Base, TimestampMixin):
|
||||||
|
__tablename__ = "case_timeline_entries"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
|
||||||
|
UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
|
||||||
|
Index("ix_case_timeline_case", "tenant_id", "case_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)
|
||||||
|
case_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||||
|
case_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
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)
|
||||||
|
audit_event_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CaseAccessGrant",
|
||||||
|
"CaseIdentity",
|
||||||
|
"CaseRecordRevision",
|
||||||
|
"CaseStatusDefinition",
|
||||||
|
"CaseTimelineEntry",
|
||||||
|
"CaseTypeDefinition",
|
||||||
|
]
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
ActorRepresentationReference,
|
||||||
|
DecisionRegistry,
|
||||||
|
FormalDecision,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
MandateDefinition,
|
||||||
|
MandateResolution,
|
||||||
|
MandateResolutionRequest,
|
||||||
|
MandateResolver,
|
||||||
|
TemporalRevision,
|
||||||
|
resolve_mandate_candidates,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
can_access_case,
|
||||||
|
get_case,
|
||||||
|
update_case,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DECISION_READ_SCOPE = "decisions:decision:read"
|
||||||
|
DECISION_SENSITIVE_READ_SCOPE = "decisions:decision:read_sensitive"
|
||||||
|
DECISION_WRITE_SCOPE = "decisions:decision:write"
|
||||||
|
_DECISION_NAMESPACE = uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_URL,
|
||||||
|
"https://govoplan.add-ideas.de/contracts/cases/formal-decision/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionUnavailable(CaseDecisionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseDecisionCommand:
|
||||||
|
expected_case_revision: int
|
||||||
|
effective_at: datetime
|
||||||
|
decision_type: str
|
||||||
|
operative_result: str
|
||||||
|
reasoning: str
|
||||||
|
change_reason: str
|
||||||
|
idempotency_key: str
|
||||||
|
conditions: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseDecisionResult:
|
||||||
|
case: CaseRecord
|
||||||
|
decision: FormalDecision
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"case": self.case.to_dict(),
|
||||||
|
"decision": self.decision.to_dict(include_protected=True),
|
||||||
|
"replayed": self.replayed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionPath:
|
||||||
|
"""Record a formal outcome and its exact Case reference atomically."""
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
command: CaseDecisionCommand,
|
||||||
|
) -> CaseDecisionResult:
|
||||||
|
_validate_command(command)
|
||||||
|
source_case = get_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
revision=command.expected_case_revision,
|
||||||
|
)
|
||||||
|
current = get_case(session, principal, case_id=case_id)
|
||||||
|
if source_case is None or current is None:
|
||||||
|
raise LookupError("Case not found.")
|
||||||
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="update",
|
||||||
|
):
|
||||||
|
raise PermissionError("Case update access is denied.")
|
||||||
|
|
||||||
|
decision_id = _decision_id(source_case, command.idempotency_key)
|
||||||
|
existing_ref = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in current.decision_refs
|
||||||
|
if item.owner_module == "decisions" and item.object_id == decision_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing_ref is None and current.revision != command.expected_case_revision:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"Case revision conflict: reload the Case before recording a Decision."
|
||||||
|
)
|
||||||
|
|
||||||
|
decision_registry = _decision_registry(self._registry)
|
||||||
|
mandate = _resolve_mandate(
|
||||||
|
self._registry,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case=source_case,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
actor = _resolve_actor(
|
||||||
|
self._registry,
|
||||||
|
principal,
|
||||||
|
case=source_case,
|
||||||
|
mandate=mandate,
|
||||||
|
effective_at=command.effective_at,
|
||||||
|
)
|
||||||
|
decision = _decision(
|
||||||
|
source_case,
|
||||||
|
command=command,
|
||||||
|
decision_id=decision_id,
|
||||||
|
mandate=mandate,
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
persisted = decision_registry.record_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
decision=decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_ref is not None:
|
||||||
|
return CaseDecisionResult(
|
||||||
|
case=current,
|
||||||
|
decision=persisted,
|
||||||
|
replayed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
linked = update_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
changes={"decision_refs": (*current.decision_refs, persisted.reference)},
|
||||||
|
recorded_at=command.effective_at,
|
||||||
|
change_reason=command.change_reason,
|
||||||
|
idempotency_key=f"formal-decision:{command.idempotency_key}",
|
||||||
|
)
|
||||||
|
return CaseDecisionResult(case=linked, decision=persisted)
|
||||||
|
|
||||||
|
def linked(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
) -> tuple[FormalDecision, ...]:
|
||||||
|
case = get_case(session, principal, case_id=case_id)
|
||||||
|
if case is None:
|
||||||
|
raise LookupError("Case not found.")
|
||||||
|
registry = _decision_registry(self._registry)
|
||||||
|
resolved: list[FormalDecision] = []
|
||||||
|
for reference in case.decision_refs:
|
||||||
|
if reference.owner_module != "decisions":
|
||||||
|
continue
|
||||||
|
item = registry.get_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
if item is not None:
|
||||||
|
resolved.append(item)
|
||||||
|
return tuple(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
case: CaseRecord,
|
||||||
|
*,
|
||||||
|
command: CaseDecisionCommand,
|
||||||
|
decision_id: str,
|
||||||
|
mandate: MandateDefinition,
|
||||||
|
actor: ActorRepresentationReference,
|
||||||
|
) -> FormalDecision:
|
||||||
|
evidence = tuple(
|
||||||
|
dict.fromkeys((*case.evidence_refs, *case.context.evidence, *mandate.evidence))
|
||||||
|
)
|
||||||
|
legal_bases = tuple(
|
||||||
|
dict.fromkeys((*case.context.legal_bases, *mandate.legal_bases))
|
||||||
|
)
|
||||||
|
if not evidence:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"A formal Decision requires evidence linked to the exact Case revision."
|
||||||
|
)
|
||||||
|
if not legal_bases:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"A formal Decision requires an effective legal-basis version."
|
||||||
|
)
|
||||||
|
decision_ref = InstitutionalReference(
|
||||||
|
kind="decision",
|
||||||
|
owner_module="decisions",
|
||||||
|
object_id=decision_id,
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
version="1",
|
||||||
|
valid_at=command.effective_at,
|
||||||
|
)
|
||||||
|
temporal = TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=command.effective_at,
|
||||||
|
recorded_at=command.effective_at,
|
||||||
|
change_reason=command.change_reason,
|
||||||
|
)
|
||||||
|
authority_context: GovernedContextEnvelope = replace(
|
||||||
|
case.context,
|
||||||
|
temporal=temporal,
|
||||||
|
actor=actor,
|
||||||
|
mandate_ref=mandate.reference,
|
||||||
|
case_ref=case.reference,
|
||||||
|
party_refs=case.party_refs or case.context.party_refs,
|
||||||
|
decision_ref=decision_ref,
|
||||||
|
record_refs=case.record_refs,
|
||||||
|
legal_bases=legal_bases,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
return FormalDecision(
|
||||||
|
reference=decision_ref,
|
||||||
|
temporal=temporal,
|
||||||
|
decision_type=command.decision_type.strip(),
|
||||||
|
subject_refs=case.party_refs or (case.reference,),
|
||||||
|
state="decided",
|
||||||
|
authority_context=authority_context,
|
||||||
|
fact_evidence=evidence,
|
||||||
|
legal_bases=legal_bases,
|
||||||
|
operative_result=command.operative_result.strip(),
|
||||||
|
reasoning=command.reasoning.strip(),
|
||||||
|
conditions=tuple(item.strip() for item in command.conditions if item.strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mandate(
|
||||||
|
registry: object | None,
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case: CaseRecord,
|
||||||
|
command: CaseDecisionCommand,
|
||||||
|
) -> MandateDefinition:
|
||||||
|
resolver = _capability(registry, CAPABILITY_MANDATE_RESOLVER)
|
||||||
|
if not isinstance(resolver, MandateResolver):
|
||||||
|
raise CaseDecisionUnavailable(
|
||||||
|
"Formal Decisions require an enabled Mandates resolver."
|
||||||
|
)
|
||||||
|
request = MandateResolutionRequest(
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
effective_at=command.effective_at,
|
||||||
|
task_type="cases.formal_decision",
|
||||||
|
authority_type=command.decision_type.strip(),
|
||||||
|
organization_unit_ref=case.context.organization_unit_ref,
|
||||||
|
function_ref=case.context.function_ref,
|
||||||
|
jurisdiction_refs=case.context.jurisdiction_refs,
|
||||||
|
)
|
||||||
|
resolution: MandateResolution = resolver.resolve_mandate(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if not resolution.competent:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
resolution.explanation or "The acting function is not competent to decide."
|
||||||
|
)
|
||||||
|
if resolution.conflict_refs:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"Mandate resolution has unresolved conflicts: "
|
||||||
|
+ ", ".join(resolution.conflict_refs)
|
||||||
|
)
|
||||||
|
verified = resolve_mandate_candidates(request, resolution.mandates)
|
||||||
|
if not verified.competent or len(verified.mandates) != 1:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
verified.explanation
|
||||||
|
or "A formal Decision requires exactly one effective active Mandate."
|
||||||
|
)
|
||||||
|
return verified.mandates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_actor(
|
||||||
|
registry: object | None,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case: CaseRecord,
|
||||||
|
mandate: MandateDefinition,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> ActorRepresentationReference:
|
||||||
|
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||||
|
lookup = getattr(directory, "get_function_assignment", None)
|
||||||
|
if not callable(lookup):
|
||||||
|
raise CaseDecisionUnavailable(
|
||||||
|
"Formal Decisions require the Access semantic directory to verify the acting assignment."
|
||||||
|
)
|
||||||
|
acting_id = _text(getattr(principal, "acting_assignment_id", None))
|
||||||
|
granted_ids = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
str(item).strip()
|
||||||
|
for item in (getattr(principal, "function_assignment_ids", ()) or ())
|
||||||
|
if str(item).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if acting_id and acting_id not in granted_ids:
|
||||||
|
raise PermissionError("The selected acting assignment is not active for this account.")
|
||||||
|
candidate_ids = (acting_id,) if acting_id else granted_ids
|
||||||
|
matching = []
|
||||||
|
for assignment_id in candidate_ids:
|
||||||
|
assignment = lookup(assignment_id)
|
||||||
|
if assignment is None or not _assignment_matches(
|
||||||
|
assignment,
|
||||||
|
principal,
|
||||||
|
case=case,
|
||||||
|
effective_at=effective_at,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
matching.append(assignment)
|
||||||
|
if not matching:
|
||||||
|
raise PermissionError(
|
||||||
|
"No active acting assignment matches the Case organization and function."
|
||||||
|
)
|
||||||
|
if len(matching) > 1:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"Select one acting assignment in the title bar before recording the Decision."
|
||||||
|
)
|
||||||
|
assignment = matching[0]
|
||||||
|
return ActorRepresentationReference(
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
account_id=_required_principal_text(principal, "account_id"),
|
||||||
|
identity_id=_text(getattr(principal, "identity_id", None))
|
||||||
|
or _text(getattr(assignment, "identity_id", None)),
|
||||||
|
represented_account_id=_text(
|
||||||
|
getattr(principal, "acting_for_account_id", None)
|
||||||
|
),
|
||||||
|
represented_function_ref=case.context.function_ref,
|
||||||
|
function_assignment_ref=InstitutionalReference(
|
||||||
|
kind="function_assignment",
|
||||||
|
owner_module="access",
|
||||||
|
object_id=str(getattr(assignment, "id")),
|
||||||
|
tenant_id=case.reference.tenant_id,
|
||||||
|
valid_at=effective_at,
|
||||||
|
),
|
||||||
|
delegation_ref=_text(
|
||||||
|
getattr(assignment, "delegated_from_assignment_id", None)
|
||||||
|
),
|
||||||
|
mandate_ref=mandate.reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_matches(
|
||||||
|
assignment: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case: CaseRecord,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> bool:
|
||||||
|
organization = case.context.organization_unit_ref
|
||||||
|
function = case.context.function_ref
|
||||||
|
if organization is None or function is None:
|
||||||
|
raise CaseDecisionError(
|
||||||
|
"The Case lacks the responsible organization and function required for a formal Decision."
|
||||||
|
)
|
||||||
|
if str(getattr(assignment, "tenant_id", "")) != case.reference.tenant_id:
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "account_id", "")) != _required_principal_text(
|
||||||
|
principal, "account_id"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "function_id", "")) != function.object_id:
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "organization_unit_id", "")) != organization.object_id:
|
||||||
|
return False
|
||||||
|
if str(getattr(assignment, "status", "active")) != "active":
|
||||||
|
return False
|
||||||
|
valid_from = getattr(assignment, "valid_from", None)
|
||||||
|
valid_until = getattr(assignment, "valid_until", None)
|
||||||
|
return not (
|
||||||
|
(valid_from is not None and effective_at < valid_from)
|
||||||
|
or (valid_until is not None and effective_at >= valid_until)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_registry(registry: object | None) -> DecisionRegistry:
|
||||||
|
provider = _capability(registry, CAPABILITY_DECISION_REGISTRY)
|
||||||
|
if not isinstance(provider, DecisionRegistry):
|
||||||
|
raise CaseDecisionUnavailable(
|
||||||
|
"Formal Decisions require an enabled Decision registry."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_id(case: CaseRecord, idempotency_key: str) -> str:
|
||||||
|
return str(
|
||||||
|
uuid.uuid5(
|
||||||
|
_DECISION_NAMESPACE,
|
||||||
|
":".join(
|
||||||
|
(
|
||||||
|
case.reference.tenant_id,
|
||||||
|
case.reference.object_id,
|
||||||
|
str(case.revision),
|
||||||
|
idempotency_key.strip(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_command(command: CaseDecisionCommand) -> None:
|
||||||
|
for value, label in (
|
||||||
|
(command.decision_type, "Decision type"),
|
||||||
|
(command.operative_result, "Operative result"),
|
||||||
|
(command.reasoning, "Decision reasoning"),
|
||||||
|
(command.change_reason, "Decision change reason"),
|
||||||
|
(command.idempotency_key, "Decision idempotency key"),
|
||||||
|
):
|
||||||
|
if not value.strip():
|
||||||
|
raise CaseDecisionError(f"{label} is required.")
|
||||||
|
if command.expected_case_revision < 1:
|
||||||
|
raise CaseDecisionError("Expected Case revision must be positive.")
|
||||||
|
if command.effective_at.tzinfo is None or command.effective_at.utcoffset() is None:
|
||||||
|
raise CaseDecisionError("Decision effective_at must include a timezone.")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object | None) -> str | None:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _required_principal_text(principal: object, name: str) -> str:
|
||||||
|
value = _text(getattr(principal, name, None))
|
||||||
|
if value is None:
|
||||||
|
raise PermissionError(f"The current principal has no {name.replace('_', ' ')}.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CaseDecisionCommand",
|
||||||
|
"CaseDecisionError",
|
||||||
|
"CaseDecisionPath",
|
||||||
|
"CaseDecisionResult",
|
||||||
|
"CaseDecisionUnavailable",
|
||||||
|
"DECISION_READ_SCOPE",
|
||||||
|
"DECISION_SENSITIVE_READ_SCOPE",
|
||||||
|
"DECISION_WRITE_SCOPE",
|
||||||
|
]
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
EvidenceReference,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CASE_ACCESS_SUBJECT_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
"account",
|
||||||
|
"identity",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"organization_unit",
|
||||||
|
"service_account",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseGrant:
|
||||||
|
subject_kind: str
|
||||||
|
subject_id: str
|
||||||
|
permissions: tuple[str, ...] = ("read",)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.subject_kind not in CASE_ACCESS_SUBJECT_KINDS:
|
||||||
|
raise InstitutionalContextError("Case access subject kind is invalid.")
|
||||||
|
if not self.subject_id.strip():
|
||||||
|
raise InstitutionalContextError("Case access subject id is required.")
|
||||||
|
normalized = tuple(dict.fromkeys(self.permissions))
|
||||||
|
if not normalized or set(normalized) - CASE_ACCESS_PERMISSIONS:
|
||||||
|
raise InstitutionalContextError("Case access permissions are invalid.")
|
||||||
|
object.__setattr__(self, "permissions", normalized)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"subject_kind": self.subject_kind,
|
||||||
|
"subject_id": self.subject_id,
|
||||||
|
"permissions": list(self.permissions),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, object]) -> "CaseGrant":
|
||||||
|
raw_permissions = value.get("permissions", ["read"])
|
||||||
|
if not isinstance(raw_permissions, (list, tuple)):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case access permissions must be a list."
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
subject_kind=_text(value, "subject_kind"),
|
||||||
|
subject_id=_text(value, "subject_id"),
|
||||||
|
permissions=tuple(str(item) for item in raw_permissions),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseRecord:
|
||||||
|
reference: InstitutionalReference
|
||||||
|
case_number: str
|
||||||
|
case_type_key: str
|
||||||
|
status_key: str
|
||||||
|
title: str
|
||||||
|
context: GovernedContextEnvelope
|
||||||
|
opened_at: datetime
|
||||||
|
recorded_at: datetime
|
||||||
|
change_reason: str
|
||||||
|
access_mode: str = "tenant"
|
||||||
|
access_grants: tuple[CaseGrant, ...] = ()
|
||||||
|
service_ref: InstitutionalReference | None = None
|
||||||
|
party_refs: tuple[InstitutionalReference, ...] = ()
|
||||||
|
assignment_refs: tuple[InstitutionalReference, ...] = ()
|
||||||
|
evidence_refs: tuple[EvidenceReference, ...] = ()
|
||||||
|
decision_refs: tuple[InstitutionalReference, ...] = ()
|
||||||
|
record_refs: tuple[InstitutionalReference, ...] = ()
|
||||||
|
deadline_at: datetime | None = None
|
||||||
|
closed_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.reference.kind != "case" or self.reference.owner_module != "cases":
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case records require a Cases-owned case reference."
|
||||||
|
)
|
||||||
|
if not self.reference.version or not self.reference.version.isdigit():
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case references require a positive integer revision."
|
||||||
|
)
|
||||||
|
if int(self.reference.version) < 1:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case references require a positive integer revision."
|
||||||
|
)
|
||||||
|
for value, label in (
|
||||||
|
(self.case_number, "Case number"),
|
||||||
|
(self.case_type_key, "Case type"),
|
||||||
|
(self.status_key, "Case status"),
|
||||||
|
(self.title, "Case title"),
|
||||||
|
(self.change_reason, "Case change reason"),
|
||||||
|
):
|
||||||
|
if not value.strip():
|
||||||
|
raise InstitutionalContextError(f"{label} is required.")
|
||||||
|
if self.access_mode not in {"tenant", "restricted"}:
|
||||||
|
raise InstitutionalContextError("Unsupported case access mode.")
|
||||||
|
grant_keys = {
|
||||||
|
(item.subject_kind, item.subject_id) for item in self.access_grants
|
||||||
|
}
|
||||||
|
if len(grant_keys) != len(self.access_grants):
|
||||||
|
raise InstitutionalContextError("Case access grants must be unique.")
|
||||||
|
for value, label in (
|
||||||
|
(self.opened_at, "Case opened_at"),
|
||||||
|
(self.recorded_at, "Case recorded_at"),
|
||||||
|
(self.deadline_at, "Case deadline_at"),
|
||||||
|
(self.closed_at, "Case closed_at"),
|
||||||
|
):
|
||||||
|
_require_aware(value, label)
|
||||||
|
if self.closed_at is not None and self.closed_at < self.opened_at:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case closed_at cannot precede opened_at."
|
||||||
|
)
|
||||||
|
tenant_id = self.reference.tenant_id
|
||||||
|
if self.context.tenant_id != tenant_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case institutional context belongs to another tenant."
|
||||||
|
)
|
||||||
|
if self.context.case_ref is not None and not _same_object(
|
||||||
|
self.context.case_ref,
|
||||||
|
self.reference,
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case institutional context references another case."
|
||||||
|
)
|
||||||
|
if self.service_ref is not None and self.service_ref.kind != "service":
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case service_ref must identify an institutional Service."
|
||||||
|
)
|
||||||
|
_validate_references(
|
||||||
|
tenant_id,
|
||||||
|
self.party_refs,
|
||||||
|
expected_kinds={"party"},
|
||||||
|
label="Case parties",
|
||||||
|
)
|
||||||
|
_validate_references(
|
||||||
|
tenant_id,
|
||||||
|
self.assignment_refs,
|
||||||
|
expected_kinds={
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"organization_unit",
|
||||||
|
"work_item",
|
||||||
|
},
|
||||||
|
label="Case assignments",
|
||||||
|
)
|
||||||
|
_validate_references(
|
||||||
|
tenant_id,
|
||||||
|
self.decision_refs,
|
||||||
|
expected_kinds={"decision"},
|
||||||
|
label="Case decisions",
|
||||||
|
)
|
||||||
|
_validate_references(
|
||||||
|
tenant_id,
|
||||||
|
self.record_refs,
|
||||||
|
expected_kinds={"record"},
|
||||||
|
label="Case records",
|
||||||
|
)
|
||||||
|
references = (
|
||||||
|
(self.service_ref,) if self.service_ref is not None else ()
|
||||||
|
)
|
||||||
|
if any(item.tenant_id != tenant_id for item in references):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case references cannot cross tenants."
|
||||||
|
)
|
||||||
|
if any(item.tenant_id != tenant_id for item in self.evidence_refs):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case evidence cannot cross tenants."
|
||||||
|
)
|
||||||
|
if len(self.metadata) > 100:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case metadata is limited to 100 entries."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def revision(self) -> int:
|
||||||
|
return int(self.reference.version or "0")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"reference": self.reference.to_dict(),
|
||||||
|
"revision": self.revision,
|
||||||
|
"case_number": self.case_number,
|
||||||
|
"case_type_key": self.case_type_key,
|
||||||
|
"status_key": self.status_key,
|
||||||
|
"title": self.title,
|
||||||
|
"access_mode": self.access_mode,
|
||||||
|
"access_grants": [item.to_dict() for item in self.access_grants],
|
||||||
|
"context": self.context.to_dict(),
|
||||||
|
"service_ref": self.service_ref.to_dict() if self.service_ref else None,
|
||||||
|
"party_refs": [item.to_dict() for item in self.party_refs],
|
||||||
|
"assignment_refs": [item.to_dict() for item in self.assignment_refs],
|
||||||
|
"evidence_refs": [item.to_dict() for item in self.evidence_refs],
|
||||||
|
"decision_refs": [item.to_dict() for item in self.decision_refs],
|
||||||
|
"record_refs": [item.to_dict() for item in self.record_refs],
|
||||||
|
"opened_at": self.opened_at.isoformat(),
|
||||||
|
"recorded_at": self.recorded_at.isoformat(),
|
||||||
|
"deadline_at": self.deadline_at.isoformat() if self.deadline_at else None,
|
||||||
|
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
|
||||||
|
"change_reason": self.change_reason,
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, object]) -> "CaseRecord":
|
||||||
|
reference = _mapping(value, "reference")
|
||||||
|
context = _mapping(value, "context")
|
||||||
|
service_ref = value.get("service_ref")
|
||||||
|
metadata = value.get("metadata")
|
||||||
|
if metadata is None:
|
||||||
|
metadata = {}
|
||||||
|
if not isinstance(metadata, Mapping):
|
||||||
|
raise InstitutionalContextError("Case metadata must be an object.")
|
||||||
|
return cls(
|
||||||
|
reference=InstitutionalReference.from_mapping(reference),
|
||||||
|
case_number=_text(value, "case_number"),
|
||||||
|
case_type_key=_text(value, "case_type_key"),
|
||||||
|
status_key=_text(value, "status_key"),
|
||||||
|
title=_text(value, "title"),
|
||||||
|
access_mode=str(value.get("access_mode") or "tenant"),
|
||||||
|
access_grants=tuple(
|
||||||
|
CaseGrant.from_mapping(item)
|
||||||
|
for item in _items(value.get("access_grants"), "Case access grants")
|
||||||
|
),
|
||||||
|
context=GovernedContextEnvelope.from_mapping(context),
|
||||||
|
service_ref=(
|
||||||
|
InstitutionalReference.from_mapping(service_ref)
|
||||||
|
if isinstance(service_ref, Mapping)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
party_refs=_institutional_references(value.get("party_refs")),
|
||||||
|
assignment_refs=_institutional_references(
|
||||||
|
value.get("assignment_refs")
|
||||||
|
),
|
||||||
|
evidence_refs=_evidence_references(value.get("evidence_refs")),
|
||||||
|
decision_refs=_institutional_references(value.get("decision_refs")),
|
||||||
|
record_refs=_institutional_references(value.get("record_refs")),
|
||||||
|
opened_at=_datetime(value, "opened_at"),
|
||||||
|
recorded_at=_datetime(value, "recorded_at"),
|
||||||
|
deadline_at=_optional_datetime(value.get("deadline_at")),
|
||||||
|
closed_at=_optional_datetime(value.get("closed_at")),
|
||||||
|
change_reason=_text(value, "change_reason"),
|
||||||
|
metadata=dict(metadata),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_references(
|
||||||
|
tenant_id: str,
|
||||||
|
references: tuple[InstitutionalReference, ...],
|
||||||
|
*,
|
||||||
|
expected_kinds: set[str],
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if any(item.tenant_id != tenant_id for item in references):
|
||||||
|
raise InstitutionalContextError(f"{label} cannot cross tenants.")
|
||||||
|
invalid = {item.kind for item in references} - expected_kinds
|
||||||
|
if invalid:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
f"{label} contain unsupported reference kinds: "
|
||||||
|
+ ", ".join(sorted(invalid))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_object(
|
||||||
|
left: InstitutionalReference,
|
||||||
|
right: InstitutionalReference,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
left.kind,
|
||||||
|
left.owner_module,
|
||||||
|
left.object_id,
|
||||||
|
left.tenant_id,
|
||||||
|
) == (
|
||||||
|
right.kind,
|
||||||
|
right.owner_module,
|
||||||
|
right.object_id,
|
||||||
|
right.tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_aware(value: datetime | None, label: str) -> None:
|
||||||
|
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
||||||
|
raise InstitutionalContextError(f"{label} must include a timezone.")
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: Mapping[str, object], key: str) -> Mapping[str, object]:
|
||||||
|
item = value.get(key)
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
raise InstitutionalContextError(f"Case {key} must be an object.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Mapping[str, object], key: str) -> str:
|
||||||
|
item = str(value.get(key) or "").strip()
|
||||||
|
if not item:
|
||||||
|
raise InstitutionalContextError(f"Case {key} is required.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime(value: Mapping[str, object], key: str) -> datetime:
|
||||||
|
item = _optional_datetime(value.get(key))
|
||||||
|
if item is None:
|
||||||
|
raise InstitutionalContextError(f"Case {key} is required.")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_datetime(value: object) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
result = value
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise InstitutionalContextError("Case timestamp is invalid.") from exc
|
||||||
|
_require_aware(result, "Case timestamp")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _items(value: object, label: str) -> tuple[Mapping[str, object], ...]:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if not isinstance(value, (list, tuple)) or any(
|
||||||
|
not isinstance(item, Mapping) for item in value
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(f"{label} must be a list of objects.")
|
||||||
|
return tuple(value) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _institutional_references(
|
||||||
|
value: object,
|
||||||
|
) -> tuple[InstitutionalReference, ...]:
|
||||||
|
return tuple(
|
||||||
|
InstitutionalReference.from_mapping(item)
|
||||||
|
for item in _items(value, "Case references")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_references(value: object) -> tuple[EvidenceReference, ...]:
|
||||||
|
return tuple(
|
||||||
|
EvidenceReference.from_mapping(item)
|
||||||
|
for item in _items(value, "Case evidence")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CaseGrant", "CaseRecord"]
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CASES_DSAR_CAPABILITY = dsar_capability_name("cases")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
account_id: str | None
|
||||||
|
identity_id: str | None
|
||||||
|
membership_id: str | None
|
||||||
|
case_id: str | None
|
||||||
|
revision_id: str | None
|
||||||
|
access_grant_id: str | None
|
||||||
|
timeline_id: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
value
|
||||||
|
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_canonical_selector(self) -> bool:
|
||||||
|
return bool(self.actor_ids)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_direct_selector(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.case_id or self.revision_id or self.access_grant_id or self.timeline_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_recognized_selector(self) -> bool:
|
||||||
|
return self.has_canonical_selector or self.has_direct_selector
|
||||||
|
|
||||||
|
|
||||||
|
class CasesDsarProvider:
|
||||||
|
provider_id = "cases"
|
||||||
|
module_id = "cases"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
selectors = _subject_selectors(subject)
|
||||||
|
if selectors is None or not selectors.has_recognized_selector:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
identities = _matching_identities(db, tenant_id, selectors)
|
||||||
|
revisions = _matching_revisions(db, tenant_id, selectors)
|
||||||
|
grants = _matching_grants(db, tenant_id, selectors)
|
||||||
|
timeline = _matching_timeline(db, tenant_id, selectors)
|
||||||
|
if _direct_reference_conflicts(
|
||||||
|
selectors,
|
||||||
|
identities=identities,
|
||||||
|
revisions=revisions,
|
||||||
|
grants=grants,
|
||||||
|
timeline=timeline,
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
direct_case_ids = _direct_case_ids(
|
||||||
|
selectors,
|
||||||
|
identities=identities,
|
||||||
|
revisions=revisions,
|
||||||
|
grants=grants,
|
||||||
|
timeline=timeline,
|
||||||
|
)
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
key = (record.resource_type, record.resource_id)
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Cases DSAR result limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
for identity in identities:
|
||||||
|
if identity.case_id in direct_case_ids:
|
||||||
|
append(_case_identity_record(identity))
|
||||||
|
if identity.created_by in selectors.actor_ids:
|
||||||
|
append(
|
||||||
|
_operator_record(
|
||||||
|
resource_id=f"identity:{identity.id}",
|
||||||
|
case_id=identity.case_id,
|
||||||
|
activity="created_case_identity",
|
||||||
|
observed_at=identity.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for revision in revisions:
|
||||||
|
if _revision_is_direct(revision, selectors, direct_case_ids):
|
||||||
|
record = CaseRecord.from_mapping(revision.snapshot)
|
||||||
|
append(_revision_record(revision, record))
|
||||||
|
if revision.superseded_at is None and revision.closed_at is None:
|
||||||
|
append(_current_fact_record(revision, record))
|
||||||
|
if revision.changed_by in selectors.actor_ids:
|
||||||
|
append(
|
||||||
|
_operator_record(
|
||||||
|
resource_id=f"revision:{revision.id}",
|
||||||
|
case_id=revision.case_id,
|
||||||
|
activity="changed_case_revision",
|
||||||
|
observed_at=revision.recorded_at,
|
||||||
|
case_revision=revision.revision,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for grant in grants:
|
||||||
|
if _grant_subject_matches(grant, selectors) or (
|
||||||
|
_grant_is_direct(grant, selectors)
|
||||||
|
and not selectors.has_canonical_selector
|
||||||
|
):
|
||||||
|
append(_access_grant_record(grant))
|
||||||
|
if grant.created_by in selectors.actor_ids:
|
||||||
|
append(
|
||||||
|
_operator_record(
|
||||||
|
resource_id=f"grant:{grant.id}",
|
||||||
|
case_id=grant.case_id,
|
||||||
|
activity="changed_case_access_grant",
|
||||||
|
observed_at=grant.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for entry in timeline:
|
||||||
|
direct = _timeline_is_direct(entry, selectors, direct_case_ids)
|
||||||
|
actor_match = entry.actor_id in selectors.actor_ids
|
||||||
|
if direct or actor_match:
|
||||||
|
append(
|
||||||
|
_timeline_record(
|
||||||
|
entry,
|
||||||
|
expose_actor=actor_match,
|
||||||
|
match_fields=(
|
||||||
|
(["reference"] if direct else [])
|
||||||
|
+ (["actor_id"] if actor_match else [])
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
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("Cases DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
if record.immutable_evidence:
|
||||||
|
kind = "retain"
|
||||||
|
title = f"Retain {record.title}"
|
||||||
|
rationale = record.retention_reason or (
|
||||||
|
"Case history is retained as institutional evidence."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kind = "manual_review"
|
||||||
|
title = f"Review {record.title}"
|
||||||
|
rationale = (
|
||||||
|
"An authorized case operator must amend, close, supersede, or "
|
||||||
|
"deactivate the current fact through the governed case lifecycle "
|
||||||
|
"after reviewing legal, procedural, access, and third-party effects."
|
||||||
|
)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=f"cases:{kind}:{record.resource_type}:{record.resource_id}",
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind=kind,
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=title,
|
||||||
|
rationale=rationale,
|
||||||
|
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("Cases DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable:
|
||||||
|
raise ValueError(
|
||||||
|
"Cases DSAR does not publish executable erasure actions."
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Use the governed case or access lifecycle after legal, "
|
||||||
|
"institutional-evidence, and third-party review."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_identities(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[CaseIdentity]:
|
||||||
|
conditions = []
|
||||||
|
if selectors.actor_ids:
|
||||||
|
conditions.append(CaseIdentity.created_by.in_(selectors.actor_ids))
|
||||||
|
if selectors.case_id:
|
||||||
|
conditions.append(CaseIdentity.case_id == selectors.case_id)
|
||||||
|
return _query_conditions(session, CaseIdentity, tenant_id, conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_revisions(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[CaseRecordRevision]:
|
||||||
|
conditions = []
|
||||||
|
if selectors.actor_ids:
|
||||||
|
conditions.append(CaseRecordRevision.changed_by.in_(selectors.actor_ids))
|
||||||
|
if selectors.case_id:
|
||||||
|
conditions.append(CaseRecordRevision.case_id == selectors.case_id)
|
||||||
|
if selectors.revision_id:
|
||||||
|
conditions.append(CaseRecordRevision.id == selectors.revision_id)
|
||||||
|
return _query_conditions(session, CaseRecordRevision, tenant_id, conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_grants(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[CaseAccessGrant]:
|
||||||
|
conditions = []
|
||||||
|
for kind, value in (
|
||||||
|
("account", selectors.account_id),
|
||||||
|
("identity", selectors.identity_id),
|
||||||
|
):
|
||||||
|
if value:
|
||||||
|
conditions.append(
|
||||||
|
(CaseAccessGrant.subject_kind == kind)
|
||||||
|
& (CaseAccessGrant.subject_id == value)
|
||||||
|
)
|
||||||
|
if selectors.actor_ids:
|
||||||
|
conditions.append(CaseAccessGrant.created_by.in_(selectors.actor_ids))
|
||||||
|
if selectors.case_id:
|
||||||
|
conditions.append(CaseAccessGrant.case_id == selectors.case_id)
|
||||||
|
if selectors.access_grant_id:
|
||||||
|
conditions.append(CaseAccessGrant.id == selectors.access_grant_id)
|
||||||
|
return _query_conditions(session, CaseAccessGrant, tenant_id, conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_timeline(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> list[CaseTimelineEntry]:
|
||||||
|
conditions = []
|
||||||
|
if selectors.actor_ids:
|
||||||
|
conditions.append(CaseTimelineEntry.actor_id.in_(selectors.actor_ids))
|
||||||
|
if selectors.case_id:
|
||||||
|
conditions.append(CaseTimelineEntry.case_id == selectors.case_id)
|
||||||
|
if selectors.timeline_id:
|
||||||
|
conditions.append(CaseTimelineEntry.id == selectors.timeline_id)
|
||||||
|
return _query_conditions(session, CaseTimelineEntry, tenant_id, conditions)
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_reference_conflicts(
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
*,
|
||||||
|
identities: Sequence[CaseIdentity],
|
||||||
|
revisions: Sequence[CaseRecordRevision],
|
||||||
|
grants: Sequence[CaseAccessGrant],
|
||||||
|
timeline: Sequence[CaseTimelineEntry],
|
||||||
|
) -> bool:
|
||||||
|
referenced_case_ids: list[str] = []
|
||||||
|
if selectors.case_id:
|
||||||
|
if not any(row.case_id == selectors.case_id for row in identities):
|
||||||
|
return True
|
||||||
|
referenced_case_ids.append(selectors.case_id)
|
||||||
|
if selectors.revision_id:
|
||||||
|
row = next(
|
||||||
|
(item for item in revisions if item.id == selectors.revision_id), None
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
referenced_case_ids.append(row.case_id)
|
||||||
|
if selectors.access_grant_id:
|
||||||
|
row = next(
|
||||||
|
(item for item in grants if item.id == selectors.access_grant_id), None
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
if selectors.has_canonical_selector and not _grant_subject_matches(
|
||||||
|
row, selectors
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
referenced_case_ids.append(row.case_id)
|
||||||
|
if selectors.timeline_id:
|
||||||
|
row = next(
|
||||||
|
(item for item in timeline if item.id == selectors.timeline_id), None
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return True
|
||||||
|
if selectors.has_canonical_selector and row.actor_id not in selectors.actor_ids:
|
||||||
|
return True
|
||||||
|
referenced_case_ids.append(row.case_id)
|
||||||
|
if len(set(referenced_case_ids)) > 1:
|
||||||
|
return True
|
||||||
|
if not selectors.has_canonical_selector or not referenced_case_ids:
|
||||||
|
return False
|
||||||
|
related_case_ids = {
|
||||||
|
row.case_id for row in identities if row.created_by in selectors.actor_ids
|
||||||
|
}
|
||||||
|
related_case_ids.update(
|
||||||
|
row.case_id for row in revisions if row.changed_by in selectors.actor_ids
|
||||||
|
)
|
||||||
|
related_case_ids.update(
|
||||||
|
row.case_id for row in grants if _grant_subject_matches(row, selectors)
|
||||||
|
)
|
||||||
|
related_case_ids.update(
|
||||||
|
row.case_id for row in timeline if row.actor_id in selectors.actor_ids
|
||||||
|
)
|
||||||
|
return referenced_case_ids[0] not in related_case_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _direct_case_ids(
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
*,
|
||||||
|
identities: Sequence[CaseIdentity],
|
||||||
|
revisions: Sequence[CaseRecordRevision],
|
||||||
|
grants: Sequence[CaseAccessGrant],
|
||||||
|
timeline: Sequence[CaseTimelineEntry],
|
||||||
|
) -> set[str]:
|
||||||
|
values = {selectors.case_id} if selectors.case_id else set()
|
||||||
|
values.update(row.case_id for row in revisions if row.id == selectors.revision_id)
|
||||||
|
values.update(row.case_id for row in grants if row.id == selectors.access_grant_id)
|
||||||
|
values.update(row.case_id for row in timeline if row.id == selectors.timeline_id)
|
||||||
|
values.update(
|
||||||
|
row.case_id
|
||||||
|
for row in identities
|
||||||
|
if selectors.case_id and row.case_id == selectors.case_id
|
||||||
|
)
|
||||||
|
return {value for value in values if value}
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_is_direct(
|
||||||
|
row: CaseRecordRevision,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
direct_case_ids: set[str],
|
||||||
|
) -> bool:
|
||||||
|
if selectors.revision_id:
|
||||||
|
return row.id == selectors.revision_id
|
||||||
|
return bool(selectors.case_id and row.case_id in direct_case_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_is_direct(
|
||||||
|
row: CaseAccessGrant,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> bool:
|
||||||
|
return bool(selectors.access_grant_id and row.id == selectors.access_grant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_is_direct(
|
||||||
|
row: CaseTimelineEntry,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
direct_case_ids: set[str],
|
||||||
|
) -> bool:
|
||||||
|
if selectors.timeline_id:
|
||||||
|
return row.id == selectors.timeline_id
|
||||||
|
return bool(selectors.case_id and row.case_id in direct_case_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _grant_subject_matches(
|
||||||
|
row: CaseAccessGrant,
|
||||||
|
selectors: _SubjectSelectors,
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
(row.subject_kind == "account" and row.subject_id == selectors.account_id)
|
||||||
|
or (row.subject_kind == "identity" and row.subject_id == selectors.identity_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _case_identity_record(row: CaseIdentity) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"cases_case_identity",
|
||||||
|
row.id,
|
||||||
|
"case_identity",
|
||||||
|
"Case identity",
|
||||||
|
{
|
||||||
|
"match_fields": ["reference"],
|
||||||
|
"case_id": row.case_id,
|
||||||
|
"case_number": _bounded_text(row.case_number, 255),
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
},
|
||||||
|
observed_at=row.created_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"The stable case identifier and number are retained so immutable case and "
|
||||||
|
"record evidence remains reconstructable."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_record(
|
||||||
|
row: CaseRecordRevision,
|
||||||
|
record: CaseRecord,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"cases_case_revision",
|
||||||
|
row.id,
|
||||||
|
"case_history",
|
||||||
|
"Case revision",
|
||||||
|
{
|
||||||
|
"match_fields": ["reference"],
|
||||||
|
"case_id": row.case_id,
|
||||||
|
"case_number": _bounded_text(record.case_number, 255),
|
||||||
|
"revision": row.revision,
|
||||||
|
"previous_revision_id": row.previous_revision_id,
|
||||||
|
"case_type_key": row.case_type_key,
|
||||||
|
"status_key": row.status_key,
|
||||||
|
"title": _bounded_text(row.title, 500),
|
||||||
|
"access_mode": row.access_mode,
|
||||||
|
"opened_at": _iso(row.opened_at),
|
||||||
|
"deadline_at": _iso(row.deadline_at),
|
||||||
|
"closed_at": _iso(row.closed_at),
|
||||||
|
"recorded_at": _iso(row.recorded_at),
|
||||||
|
"superseded_at": _iso(row.superseded_at),
|
||||||
|
"service_ref": _institutional_reference(record.service_ref),
|
||||||
|
"party_reference_count": len(record.party_refs),
|
||||||
|
"assignment_reference_count": len(record.assignment_refs),
|
||||||
|
"evidence_reference_count": len(record.evidence_refs),
|
||||||
|
"decision_reference_count": len(record.decision_refs),
|
||||||
|
"record_reference_count": len(record.record_refs),
|
||||||
|
"access_grant_count": len(record.access_grants),
|
||||||
|
},
|
||||||
|
observed_at=row.recorded_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Case revisions are immutable procedure and accountability evidence; "
|
||||||
|
"corrections append a new governed revision."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_fact_record(
|
||||||
|
row: CaseRecordRevision,
|
||||||
|
record: CaseRecord,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"cases_current_case_fact",
|
||||||
|
row.case_id,
|
||||||
|
"current_case_fact",
|
||||||
|
"Current case fact",
|
||||||
|
{
|
||||||
|
"match_fields": ["reference"],
|
||||||
|
"revision_record_id": row.id,
|
||||||
|
"revision": row.revision,
|
||||||
|
"case_number": _bounded_text(record.case_number, 255),
|
||||||
|
"status_key": row.status_key,
|
||||||
|
"title": _bounded_text(row.title, 500),
|
||||||
|
"deadline_at": _iso(row.deadline_at),
|
||||||
|
},
|
||||||
|
observed_at=row.recorded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _access_grant_record(row: CaseAccessGrant) -> DsarRecordRef:
|
||||||
|
immutable = not row.active
|
||||||
|
return _record(
|
||||||
|
"cases_access_grant",
|
||||||
|
row.id,
|
||||||
|
"case_access_fact",
|
||||||
|
"Case access grant",
|
||||||
|
{
|
||||||
|
"match_fields": ["subject"],
|
||||||
|
"case_id": row.case_id,
|
||||||
|
"subject_kind": row.subject_kind,
|
||||||
|
"subject_id": row.subject_id,
|
||||||
|
"permissions": [str(value)[:40] for value in row.permissions[:20]],
|
||||||
|
"source": _bounded_text(row.source, 30),
|
||||||
|
"active": row.active,
|
||||||
|
"source_revision": row.source_revision,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"updated_at": _iso(row.updated_at),
|
||||||
|
},
|
||||||
|
observed_at=row.updated_at,
|
||||||
|
immutable=immutable,
|
||||||
|
retention_reason=(
|
||||||
|
"Inactive access-grant state is retained to explain historical case access."
|
||||||
|
if immutable
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_record(
|
||||||
|
row: CaseTimelineEntry,
|
||||||
|
*,
|
||||||
|
expose_actor: bool,
|
||||||
|
match_fields: Sequence[str],
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"cases_timeline_event",
|
||||||
|
row.id,
|
||||||
|
"case_lifecycle_evidence",
|
||||||
|
"Case lifecycle event",
|
||||||
|
{
|
||||||
|
"match_fields": list(match_fields),
|
||||||
|
"case_id": row.case_id,
|
||||||
|
"event_type": _bounded_text(row.event_type, 120),
|
||||||
|
"case_revision": row.case_revision,
|
||||||
|
"occurred_at": _iso(row.occurred_at),
|
||||||
|
"actor_id": row.actor_id if expose_actor else None,
|
||||||
|
},
|
||||||
|
observed_at=row.occurred_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Case timeline events are immutable lifecycle and accountability evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _operator_record(
|
||||||
|
*,
|
||||||
|
resource_id: str,
|
||||||
|
case_id: str,
|
||||||
|
activity: str,
|
||||||
|
observed_at: datetime | None,
|
||||||
|
case_revision: int | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return _record(
|
||||||
|
"cases_operator_attribution",
|
||||||
|
resource_id,
|
||||||
|
"operator_accountability_evidence",
|
||||||
|
"Case operator attribution",
|
||||||
|
{
|
||||||
|
"match_fields": ["actor_id"],
|
||||||
|
"case_id": case_id,
|
||||||
|
"activity": activity,
|
||||||
|
"case_revision": case_revision,
|
||||||
|
"observed_at": _iso(observed_at),
|
||||||
|
},
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Operator attribution is retained as accountability evidence; raw case, "
|
||||||
|
"party, evidence, change-reason, and event payload content is excluded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
groups = {
|
||||||
|
"account_id": (
|
||||||
|
subject.account_id,
|
||||||
|
subject.external_references.get("cases.account"),
|
||||||
|
subject.external_references.get("access.account"),
|
||||||
|
),
|
||||||
|
"identity_id": (
|
||||||
|
subject.identity_id,
|
||||||
|
subject.external_references.get("cases.identity"),
|
||||||
|
subject.external_references.get("identity.id"),
|
||||||
|
),
|
||||||
|
"membership_id": (
|
||||||
|
subject.membership_id,
|
||||||
|
subject.external_references.get("cases.membership"),
|
||||||
|
subject.external_references.get("tenancy.membership"),
|
||||||
|
),
|
||||||
|
"case_id": (subject.external_references.get("cases.case"),),
|
||||||
|
"revision_id": (subject.external_references.get("cases.revision"),),
|
||||||
|
"access_grant_id": (subject.external_references.get("cases.access_grant"),),
|
||||||
|
"timeline_id": (subject.external_references.get("cases.timeline"),),
|
||||||
|
}
|
||||||
|
normalized: dict[str, str | None] = {}
|
||||||
|
for key, values in groups.items():
|
||||||
|
distinct = {value for item in values if (value := _normalized_id(item))}
|
||||||
|
if len(distinct) > 1:
|
||||||
|
return None
|
||||||
|
normalized[key] = next(iter(distinct), None)
|
||||||
|
return _SubjectSelectors(**normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_conditions(
|
||||||
|
session: Session,
|
||||||
|
model: type,
|
||||||
|
tenant_id: str,
|
||||||
|
conditions: Sequence[object],
|
||||||
|
) -> list[object]:
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
rows = (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(model.id.asc())
|
||||||
|
.limit(_MAX_RECORDS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError("Cases DSAR match limit exceeded; narrow the selectors.")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _institutional_reference(value: object | None) -> dict[str, object] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"kind": str(getattr(value, "kind", "")),
|
||||||
|
"owner_module": str(getattr(value, "owner_module", "")),
|
||||||
|
"object_id": str(getattr(value, "object_id", "")),
|
||||||
|
"version": _bounded_text(getattr(value, "version", None), 120),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "cases" or record.module_id != "cases":
|
||||||
|
raise ValueError("Cases DSAR received a foreign provider record.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "cases" or action.module_id != "cases":
|
||||||
|
raise ValueError("Cases DSAR received a foreign provider action.")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None,
|
||||||
|
immutable: bool = False,
|
||||||
|
retention_reason: str | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="cases",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=retention_reason,
|
||||||
|
source_path="/cases",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Cases DSAR provider requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_text(value: str | None, limit: int) -> str | None:
|
||||||
|
return value[:limit] if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_id(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CASES_DSAR_CAPABILITY", "CasesDsarProvider"]
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from urllib.parse import parse_qs, quote, urlsplit
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import EvidenceReference
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
|
||||||
|
|
||||||
|
|
||||||
|
_SOURCE_CONTRACT = "cases.quick-access-reference.v1"
|
||||||
|
_SUPPORTED_REFERENCES = {
|
||||||
|
("files", "file-version"): "document",
|
||||||
|
("mail", "message"): "event",
|
||||||
|
("campaigns", "campaign"): "event",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def link_case_evidence(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
reference: Mapping[str, object],
|
||||||
|
recorded_at: datetime,
|
||||||
|
change_reason: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> CaseRecord:
|
||||||
|
base = _base_revision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
|
||||||
|
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
|
||||||
|
raise CaseStoreError("The owner reference is already linked to this Case.")
|
||||||
|
return update_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
changes={"evidence_refs": (*base.evidence_refs, evidence)},
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
change_reason=change_reason,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unlink_case_evidence(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
evidence_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
recorded_at: datetime,
|
||||||
|
change_reason: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> CaseRecord:
|
||||||
|
base = _base_revision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
evidence = next(
|
||||||
|
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if evidence is None or linked_source(evidence) is None:
|
||||||
|
raise CaseStoreError("The linked owner reference was not found.")
|
||||||
|
return update_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
changes={
|
||||||
|
"evidence_refs": tuple(
|
||||||
|
item for item in base.evidence_refs if item.evidence_id != evidence_id
|
||||||
|
)
|
||||||
|
},
|
||||||
|
recorded_at=recorded_at,
|
||||||
|
change_reason=change_reason,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def linked_source(evidence: EvidenceReference) -> Mapping[str, str] | None:
|
||||||
|
if evidence.source_ref is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(evidence.source_ref)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(value, Mapping) or value.get("contract") != _SOURCE_CONTRACT:
|
||||||
|
return None
|
||||||
|
result = {
|
||||||
|
key: str(value.get(key) or "")
|
||||||
|
for key in ("owner_module", "kind", "object_id", "path")
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
not all(result.values())
|
||||||
|
or (result["owner_module"], result["kind"]) not in _SUPPORTED_REFERENCES
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _base_revision(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
) -> CaseRecord:
|
||||||
|
base = get_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
revision=expected_revision,
|
||||||
|
)
|
||||||
|
if base is None:
|
||||||
|
raise LookupError("Case revision not found.")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_evidence(
|
||||||
|
value: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> EvidenceReference:
|
||||||
|
owner_module = _bounded(value.get("owner_module"), "Owner module", 120)
|
||||||
|
reference_kind = _bounded(value.get("kind"), "Owner reference kind", 120)
|
||||||
|
object_id = _bounded(value.get("object_id"), "Owner object id", 1_000)
|
||||||
|
reference_tenant = _bounded(value.get("tenant_id"), "Owner tenant id", 255)
|
||||||
|
version = _bounded(value.get("version"), "Owner version", 255)
|
||||||
|
path = _bounded(value.get("path"), "Owner path", 2_000)
|
||||||
|
evidence_kind = _SUPPORTED_REFERENCES.get((owner_module, reference_kind))
|
||||||
|
if evidence_kind is None:
|
||||||
|
raise CaseStoreError("The owner reference kind is not supported by Cases.")
|
||||||
|
if reference_tenant != tenant_id:
|
||||||
|
raise CaseStoreError("Owner references cannot cross Case tenants.")
|
||||||
|
_validate_owner_path(
|
||||||
|
owner_module=owner_module,
|
||||||
|
reference_kind=reference_kind,
|
||||||
|
object_id=object_id,
|
||||||
|
version=version,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
canonical = {
|
||||||
|
"contract": _SOURCE_CONTRACT,
|
||||||
|
"owner_module": owner_module,
|
||||||
|
"kind": reference_kind,
|
||||||
|
"object_id": object_id,
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
canonical,
|
||||||
|
ensure_ascii=True,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
f"{tenant_id}\x1f{encoded}\x1f{version}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
return EvidenceReference(
|
||||||
|
kind=evidence_kind, # type: ignore[arg-type]
|
||||||
|
owner_module=owner_module,
|
||||||
|
evidence_id=f"linked-{digest[:40]}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
version=version,
|
||||||
|
source_ref=encoded,
|
||||||
|
derived_from=(_SOURCE_CONTRACT,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_owner_path(
|
||||||
|
*,
|
||||||
|
owner_module: str,
|
||||||
|
reference_kind: str,
|
||||||
|
object_id: str,
|
||||||
|
version: str,
|
||||||
|
path: str,
|
||||||
|
) -> None:
|
||||||
|
parsed = urlsplit(path)
|
||||||
|
if parsed.scheme or parsed.netloc or not path.startswith("/") or path.startswith("//"):
|
||||||
|
raise CaseStoreError("Owner paths must be local platform routes.")
|
||||||
|
if (owner_module, reference_kind) == ("files", "file-version"):
|
||||||
|
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
if (
|
||||||
|
parsed.path != "/files"
|
||||||
|
or query.get("versionId") != [object_id]
|
||||||
|
or version != object_id
|
||||||
|
or not query.get("fileId", [""])[0]
|
||||||
|
):
|
||||||
|
raise CaseStoreError("Files links require an exact authorized version path.")
|
||||||
|
return
|
||||||
|
if (owner_module, reference_kind) == ("mail", "message"):
|
||||||
|
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
profile = query.get("profile", [""])[0]
|
||||||
|
folder = query.get("folder", [""])[0]
|
||||||
|
message = query.get("message", [""])[0]
|
||||||
|
expected_id = f"{profile}:{folder}:{message}"
|
||||||
|
if (
|
||||||
|
parsed.path != "/mail"
|
||||||
|
or not profile
|
||||||
|
or not folder
|
||||||
|
or not message
|
||||||
|
or object_id != expected_id
|
||||||
|
or version != message
|
||||||
|
):
|
||||||
|
raise CaseStoreError("Mail links require an exact mailbox message path.")
|
||||||
|
return
|
||||||
|
expected_path = f"/campaigns/{quote(object_id, safe='')}"
|
||||||
|
if path != expected_path:
|
||||||
|
raise CaseStoreError("Campaign links require an exact Campaign path.")
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded(value: object, label: str, maximum: int) -> str:
|
||||||
|
result = str(value or "").strip()
|
||||||
|
if not result:
|
||||||
|
raise CaseStoreError(f"{label} is required.")
|
||||||
|
if len(result) > maximum or any(ord(character) < 32 for character in result):
|
||||||
|
raise CaseStoreError(f"{label} is invalid.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"link_case_evidence",
|
||||||
|
"linked_source",
|
||||||
|
"unlink_case_evidence",
|
||||||
|
]
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.access import CAPABILITY_ACCESS_SEMANTIC_DIRECTORY
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
|
)
|
||||||
|
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.search import SearchSourceProviderRegistration
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
ModuleArchitectureDocumentation,
|
||||||
|
ModuleMaturityEvidence,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.party_context import (
|
||||||
|
CAPABILITY_CASES_PARTY_CONTEXT,
|
||||||
|
CasePartyContext,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.acl import CaseAclProvider
|
||||||
|
from govoplan_cases.backend.db import models as case_models
|
||||||
|
from govoplan_cases.backend.dsar_provider import CASES_DSAR_CAPABILITY, CasesDsarProvider
|
||||||
|
from govoplan_cases.backend.service_intake import (
|
||||||
|
CAPABILITY_CASES_SERVICE_INTAKE,
|
||||||
|
CaseServiceIntake,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service_launcher import (
|
||||||
|
CAPABILITY_CASES_SERVICE_LAUNCHER,
|
||||||
|
CaseServiceLauncher,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
CAPABILITY_CASES_REGISTRY,
|
||||||
|
SqlCaseRegistry,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.record_source import (
|
||||||
|
CAPABILITY_RECORD_SOURCE_CASES,
|
||||||
|
create_cases_record_source,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.search_source import create_cases_search_source
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "cases"
|
||||||
|
MODULE_VERSION = "0.1.19"
|
||||||
|
READ_SCOPE = "cases:case:read"
|
||||||
|
CREATE_SCOPE = "cases:case:create"
|
||||||
|
UPDATE_SCOPE = "cases:case:update"
|
||||||
|
ASSIGN_SCOPE = "cases:case:assign"
|
||||||
|
CLOSE_SCOPE = "cases:case:close"
|
||||||
|
SHARE_SCOPE = "cases:case:share"
|
||||||
|
ADMIN_SCOPE = "cases:case:admin"
|
||||||
|
|
||||||
|
|
||||||
|
def _party_context(context: ModuleContext) -> CasePartyContext:
|
||||||
|
return CasePartyContext(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_intake(context: ModuleContext) -> CaseServiceIntake:
|
||||||
|
del context
|
||||||
|
return CaseServiceIntake()
|
||||||
|
|
||||||
|
|
||||||
|
def _case_registry(context: ModuleContext) -> SqlCaseRegistry:
|
||||||
|
del context
|
||||||
|
return SqlCaseRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def _service_launcher(context: ModuleContext) -> CaseServiceLauncher:
|
||||||
|
del context
|
||||||
|
return CaseServiceLauncher()
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
|
||||||
|
del context
|
||||||
|
return CasesDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _router(context: ModuleContext):
|
||||||
|
del context
|
||||||
|
from govoplan_cases.backend.router import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
|
module_id, resource, action = scope.split(":", 2)
|
||||||
|
return PermissionDefinition(
|
||||||
|
scope=scope,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
category="Cases",
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
total = (
|
||||||
|
session.query(case_models.CaseIdentity)
|
||||||
|
.filter(case_models.CaseIdentity.tenant_id == tenant_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
open_cases = (
|
||||||
|
session.query(case_models.CaseRecordRevision)
|
||||||
|
.filter(
|
||||||
|
case_models.CaseRecordRevision.tenant_id == tenant_id,
|
||||||
|
case_models.CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
case_models.CaseRecordRevision.closed_at.is_(None),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return {"cases": total, "open_cases": open_cases}
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name="Cases",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
optional_dependencies=(
|
||||||
|
"access",
|
||||||
|
"addresses",
|
||||||
|
"services",
|
||||||
|
"parties",
|
||||||
|
"mandates",
|
||||||
|
"decisions",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
"records",
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"campaigns",
|
||||||
|
"quick_access",
|
||||||
|
),
|
||||||
|
optional_capabilities=(
|
||||||
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
|
||||||
|
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
|
||||||
|
_permission(UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."),
|
||||||
|
_permission(ASSIGN_SCOPE, "Assign cases", "Change stable function, assignment, or work-item references on a case."),
|
||||||
|
_permission(CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."),
|
||||||
|
_permission(SHARE_SCOPE, "Share cases", "Restrict a case and grant object-level access to selected principals."),
|
||||||
|
_permission(ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="case_manager",
|
||||||
|
name="Case manager",
|
||||||
|
description="Create, assign, update, and close tenant cases.",
|
||||||
|
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="case_reader",
|
||||||
|
name="Case reader",
|
||||||
|
description="Read cases and their governed history.",
|
||||||
|
permissions=(READ_SCOPE,),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="case_administrator",
|
||||||
|
name="Case administrator",
|
||||||
|
description="Configure case types and statuses and manage cases.",
|
||||||
|
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
route_factory=_router,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/cases",
|
||||||
|
label="Cases",
|
||||||
|
icon="briefcase-business",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/cases-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/cases",
|
||||||
|
component="CasesPage",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=35,
|
||||||
|
),
|
||||||
|
FrontendRoute(
|
||||||
|
path="/cases/:caseId",
|
||||||
|
component="CaseDetailPage",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=36,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/cases",
|
||||||
|
label="Cases",
|
||||||
|
icon="briefcase-business",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="services-cases",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.services_cases",
|
||||||
|
icon="landmark",
|
||||||
|
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||||
|
surface_ids=("cases.nav.cases", "cases.route.cases", "cases.route.cases.caseid"),
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.navigation",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="navigation",
|
||||||
|
label="Cases navigation",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.list",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Case list",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.list.filters",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Case search and filters",
|
||||||
|
parent_id="cases.list",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Case details",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.summary",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Case summary",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.editor",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Case lifecycle editor",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.references",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Institutional references",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.timeline",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Case timeline",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.history",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="section",
|
||||||
|
label="Immutable case history",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.access",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="action",
|
||||||
|
label="Case access",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="cases.detail.decision",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="action",
|
||||||
|
label="Prepare formal decision",
|
||||||
|
parent_id="cases.detail",
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="cases.service_intake", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="cases.party_context", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="cases.registry", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
|
||||||
|
CAPABILITY_CASES_PARTY_CONTEXT: _party_context,
|
||||||
|
CAPABILITY_CASES_REGISTRY: _case_registry,
|
||||||
|
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
|
||||||
|
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
|
||||||
|
CASES_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
|
||||||
|
label="Case service intake",
|
||||||
|
summary="Preserves a governed Service version in a case intake plan.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
CAPABILITY_CASES_PARTY_CONTEXT: CapabilityDocumentation(
|
||||||
|
label="Case party context",
|
||||||
|
summary="Resolves provider-owned procedure parties or a bounded local compatibility projection.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
CAPABILITY_CASES_REGISTRY: CapabilityDocumentation(
|
||||||
|
label="Case registry",
|
||||||
|
summary="Persists tenant-scoped case identities, immutable revisions, and lifecycle events.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
CAPABILITY_CASES_SERVICE_LAUNCHER: CapabilityDocumentation(
|
||||||
|
label="Case service launcher",
|
||||||
|
summary="Starts a replay-safe case from an exact available Service revision.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
CAPABILITY_RECORD_SOURCE_CASES: CapabilityDocumentation(
|
||||||
|
label="Cases record source",
|
||||||
|
summary="Resolves currently authorized immutable case revisions for Records filing.",
|
||||||
|
contract_version="1.0.0",
|
||||||
|
),
|
||||||
|
CASES_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Cases data-subject request provider",
|
||||||
|
summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.",
|
||||||
|
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(
|
||||||
|
case_models.CaseTimelineEntry,
|
||||||
|
case_models.CaseAccessGrant,
|
||||||
|
case_models.CaseRecordRevision,
|
||||||
|
case_models.CaseIdentity,
|
||||||
|
case_models.CaseTypeDefinition,
|
||||||
|
case_models.CaseStatusDefinition,
|
||||||
|
label="Cases",
|
||||||
|
),
|
||||||
|
retirement_notes="Destructive retirement requires a database snapshot and removes case identities, immutable revisions, catalogs, and timeline evidence.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
case_models.CaseIdentity,
|
||||||
|
case_models.CaseRecordRevision,
|
||||||
|
case_models.CaseTimelineEntry,
|
||||||
|
case_models.CaseAccessGrant,
|
||||||
|
case_models.CaseTypeDefinition,
|
||||||
|
case_models.CaseStatusDefinition,
|
||||||
|
label="Cases",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
resource_acl_providers=(CaseAclProvider(),),
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="cases.cases",
|
||||||
|
factory=create_cases_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.workflow.link-owner-references",
|
||||||
|
title="Link exact Files, Mail, and Campaign references to a Case",
|
||||||
|
summary=(
|
||||||
|
"Append or remove an owner-authorized reference without copying "
|
||||||
|
"the provider's content into Cases."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"On an active Case, Files, Mail, and Campaigns may return an exact "
|
||||||
|
"tenant-bound selection through the versioned Quick Access result "
|
||||||
|
"contract. Cases accepts only the supported owner and reference-kind "
|
||||||
|
"pairs, a matching tenant, a pinned version, and the owner's canonical "
|
||||||
|
"local route. Linking and unlinking recheck Case update authority, use "
|
||||||
|
"expected-revision concurrency and idempotency, and append immutable "
|
||||||
|
"Case history. Cases stores only a typed owner identifier, exact version, "
|
||||||
|
"and owner route; it never copies file bytes, message headers or bodies, "
|
||||||
|
"recipients, campaign content, or a source access decision. Opening a "
|
||||||
|
"link always enters the owning module, which rechecks current access. "
|
||||||
|
"If the owner module is disabled or source access is later revoked, the "
|
||||||
|
"Case retains the historical reference but does not disclose source "
|
||||||
|
"content. Unlinking removes only the reference from the next Case revision "
|
||||||
|
"and never deletes the owner object. Unsaved Case edits must be saved or "
|
||||||
|
"discarded before a Quick Access result can be linked."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("case_manager", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("quick_access", "files", "mail", "campaigns"),
|
||||||
|
order=12,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("cases",),
|
||||||
|
required_scopes=(UPDATE_SCOPE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Quick Access architecture",
|
||||||
|
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["cases.detail.references"],
|
||||||
|
"consequence_classes": {
|
||||||
|
"link_owner_reference": "Appends an immutable Case revision containing only the exact typed owner reference.",
|
||||||
|
"unlink_owner_reference": "Appends an immutable Case revision and leaves the owner object unchanged.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.data-subject-requests",
|
||||||
|
title="Case data-subject requests",
|
||||||
|
summary="Report governed case facts without guessing applicant identity or exposing opaque case payloads.",
|
||||||
|
body=(
|
||||||
|
"The Cases DSAR provider matches exact-tenant account, identity, and membership attribution, account/identity access grants, and explicit Cases references. "
|
||||||
|
"A canonical selector combined with a direct reference must be corroborated by a Cases-owned relationship or the search fails closed. Direct case and revision references export a typed, minimized lifecycle projection; raw snapshots, metadata, search text, free-text change reasons, timeline payloads and summaries, evidence identifiers, request digests, idempotency keys, audit identifiers, and unrelated access subjects are excluded. "
|
||||||
|
"Immutable case identities, revisions, timelines, and attribution are retained. Current open case and active access facts require authorized manual review through the normal case/access lifecycle; no automatic erasure is published. Applicant-to-identity linkage remains owned by Parties, so Cases does not infer it from party identifiers."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("parties", "records", "forms_runtime", "portal"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Cases concept",
|
||||||
|
href="govoplan-cases/docs/CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.workflow.quick-access-context",
|
||||||
|
title="Use task-local tools from an active Case",
|
||||||
|
summary="Launch an authorized full-page tool and return to the exact Case route and context.",
|
||||||
|
body=(
|
||||||
|
"After an exact Case revision loads, the Case detail page publishes a bounded active-object reference to Core. "
|
||||||
|
"Quick Access may carry that reference together with the current tenant, acting assignment, temporal selection, "
|
||||||
|
"View revision, and route when opening an owning module's full page. It never carries Case content or an access "
|
||||||
|
"decision. The destination reauthorizes its data and effects. Unsaved Case edits use the shared save/discard guard "
|
||||||
|
"before navigation, and the shell provides an explicit return action."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("case_manager", "operator", "module_admin"),
|
||||||
|
related_modules=("quick_access", "views", "access"),
|
||||||
|
order=13,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Quick Access architecture",
|
||||||
|
href="govoplan/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Aufgabenbezogene Werkzeuge aus einem aktiven Vorgang verwenden",
|
||||||
|
"summary": "Ein berechtigtes Werkzeug oeffnen und zum genauen Vorgangskontext zurueckkehren.",
|
||||||
|
"body": (
|
||||||
|
"Nach dem Laden einer genauen Vorgangsrevision veroeffentlicht die Detailseite einen begrenzten Objektverweis an Core. "
|
||||||
|
"Der Schnellzugriff darf diesen Verweis zusammen mit Mandant, handelnder Zuordnung, Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. "
|
||||||
|
"Vorgangsinhalte oder Zugriffsentscheidungen werden nicht uebertragen. Das Ziel prueft Daten und Wirkungen erneut; ungespeicherte Aenderungen "
|
||||||
|
"werden vor dem Wechsel durch den gemeinsamen Speichern-Verwerfen-Dialog geschuetzt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={"help_contexts": ["cases.detail.quick-access"]},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.workflow.record-formal-decision",
|
||||||
|
title="Record a formal Decision from a Case",
|
||||||
|
summary="Revalidate acting authority and preserve an exact Decision revision in the Case history.",
|
||||||
|
body=(
|
||||||
|
"The Case detail action accepts only the operative result, reasoning, conditions, effective time, and change reason. "
|
||||||
|
"Cases resolves the exact Case revision, verifies object update access, resolves the current acting assignment through "
|
||||||
|
"Access, and requires exactly one effective Mandate for cases.formal_decision and the selected Decision type. It then "
|
||||||
|
"records the outcome through the optional Decisions registry and links the exact Decision revision to a new Case revision "
|
||||||
|
"in the same database transaction. A replay uses a deterministic Decision identifier; changed replay payloads fail closed."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("case_manager", "decision_officer", "operator", "auditor"),
|
||||||
|
related_modules=("access", "mandates", "decisions", "records"),
|
||||||
|
order=14,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("cases", "access", "mandates", "decisions"),
|
||||||
|
required_scopes=("cases:case:update", "decisions:decision:write"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
|
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||||
|
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
|
||||||
|
"prerequisites": [
|
||||||
|
"The Case has an exact responsible organization, function, evidence, and legal-basis version.",
|
||||||
|
"An active acting assignment and exactly one effective Mandate authorize the Decision type.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Open the Case and select the appropriate acting assignment in the title bar.",
|
||||||
|
"Record the operative result, reasoning, optional conditions, effective time, and change reason.",
|
||||||
|
"Confirm the exact Decision and Case revisions in the Case timeline.",
|
||||||
|
"When Records is enabled, file the exact Decision or Case revision into the destination eAkte.",
|
||||||
|
],
|
||||||
|
"outcome": "The formal outcome is Decisions-owned and exact-version linked from the Case.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.workflow.file-exact-revision",
|
||||||
|
title="File an exact case revision into an eAkte",
|
||||||
|
summary="Preserve a reconstructable case snapshot in Records without moving case ownership.",
|
||||||
|
body=(
|
||||||
|
"When Records is enabled, Cases resolves an exact immutable case revision only after "
|
||||||
|
"current case permission and object-level access are checked. The filed reference includes "
|
||||||
|
"the case number, lifecycle state, represented valid interval, recorded time, and a SHA-256 "
|
||||||
|
"digest of the canonical snapshot. Cases remains authoritative for the case lifecycle."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("case_manager", "records_manager", "operator", "auditor"),
|
||||||
|
related_modules=("records",),
|
||||||
|
order=15,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("cases", "records"),
|
||||||
|
required_scopes=("cases:case:read", "records:workspace:write"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
|
DocumentationLink(label="Records", href="/records", kind="runtime"),
|
||||||
|
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["cases.detail", "records.action.file"],
|
||||||
|
"prerequisites": [
|
||||||
|
"You currently may read the case and file items into the destination record.",
|
||||||
|
"The exact immutable case revision has been identified.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Open the case and identify the revision that represents the evidence state.",
|
||||||
|
"Choose the destination record and state the access purpose and filing reason.",
|
||||||
|
"Confirm filing; Cases rechecks current access and resolves the exact revision.",
|
||||||
|
"Verify the case revision and snapshot digest in the record chronology.",
|
||||||
|
],
|
||||||
|
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.institutional-context",
|
||||||
|
title="Case institutional context",
|
||||||
|
summary="Cases retain service and party references without taking ownership of institutional definitions or subject masters.",
|
||||||
|
body=(
|
||||||
|
"Case types and statuses are tenant configuration. Every create or update writes "
|
||||||
|
"an immutable OCC-guarded revision and a replay-safe timeline event. Service "
|
||||||
|
"intake retains the exact Service, Mandate, jurisdiction, legal basis, form, "
|
||||||
|
"workflow, and result bindings. Procedure parties come from an optional provider "
|
||||||
|
"or a limited Cases-only compatibility projection. Assignment, evidence, Decision, "
|
||||||
|
"and record links remain stable references owned by their source modules. When "
|
||||||
|
"Search is enabled, Cases contributes a rebuildable metadata-only projection. "
|
||||||
|
"Evidence and protected Decision content are excluded, and every candidate is "
|
||||||
|
"rechecked against current Case access before disclosure."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Cases concept",
|
||||||
|
href="govoplan-cases/docs/CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"help_contexts": [
|
||||||
|
"cases.list",
|
||||||
|
"cases.detail",
|
||||||
|
"cases.search.result",
|
||||||
|
"cases.state.read-only",
|
||||||
|
"cases.state.restricted",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.reference.lifecycle-access-and-evidence",
|
||||||
|
title="Case lifecycle, access, and evidence reference",
|
||||||
|
summary="Explains revision, status, access, and reference fields together with their durable consequences.",
|
||||||
|
body=(
|
||||||
|
"Title and status changes append an immutable case revision guarded by the "
|
||||||
|
"expected revision and a stable idempotency key. Every accepted change also "
|
||||||
|
"appends a timeline entry with actor, time, and change reason; a terminal "
|
||||||
|
"status additionally requires the case-close permission. Case visibility is "
|
||||||
|
"tenant-wide or restricted. Restricted cases remain visible only through "
|
||||||
|
"administrative authority, assignment or unit context, creator authority, or "
|
||||||
|
"an explicit account/group grant. Access changes append another immutable "
|
||||||
|
"revision and require confirmation. Service, party, assignment, Decision, and "
|
||||||
|
"record references identify provider-owned objects; Cases preserves their "
|
||||||
|
"stable identifiers and versions without copying or silently changing them."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Cases interface pattern migration",
|
||||||
|
href="govoplan-cases/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"help_contexts": [
|
||||||
|
"cases.field.title",
|
||||||
|
"cases.field.status",
|
||||||
|
"cases.field.change-reason",
|
||||||
|
"cases.field.visibility",
|
||||||
|
"cases.field.access-grant",
|
||||||
|
"cases.state.close-unavailable",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"update_case": "append an OCC-guarded immutable revision and timeline event",
|
||||||
|
"close_case": "append a terminal revision after close-scope authorization",
|
||||||
|
"change_access": "append a confirmed visibility/grant revision and timeline event",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=ModuleArchitectureDeclaration(
|
||||||
|
layer="human_work_procedure",
|
||||||
|
kind="domain",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
evidence=(
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="test",
|
||||||
|
reference="tests/test_institutional_consumers.py",
|
||||||
|
summary="Proves versioned service intake and optional procedure-party resolution.",
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="documentation",
|
||||||
|
reference="docs/CONCEPT.md",
|
||||||
|
summary="Defines Cases ownership and optional institutional providers.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
known_limits=(
|
||||||
|
"The first Cases workspace covers list, detail, status/title revision, history, and timeline; richer procedure-specific panels remain module contributions.",
|
||||||
|
"The compatibility party path deliberately excludes representation lifecycle ownership.",
|
||||||
|
),
|
||||||
|
owned_concepts=("case identity", "case lifecycle", "case-local links"),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"institutional service definition",
|
||||||
|
"party subject master",
|
||||||
|
"representation power lifecycle",
|
||||||
|
"formal decision lifecycle",
|
||||||
|
),
|
||||||
|
reference_packages=("product.service-to-decision",),
|
||||||
|
documentation=ModuleArchitectureDocumentation(
|
||||||
|
migration=("docs/CONCEPT.md",),
|
||||||
|
recovery=("docs/CONCEPT.md",),
|
||||||
|
security=("docs/CONCEPT.md",),
|
||||||
|
operations=("docs/CONCEPT.md",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cases Alembic revisions."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Cases migration revisions."""
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""v0.1.8 Cases persistent lifecycle baseline.
|
||||||
|
|
||||||
|
Revision ID: c7a8e9f0b1d2
|
||||||
|
Revises: None
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c7a8e9f0b1d2"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = "4f2a9c8e7b6d"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"case_type_definitions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("type_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("initial_status_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("allowed_status_keys", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), 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_case_type_definitions")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "type_key", name="uq_case_type_tenant_key"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_case_type_definitions_tenant_id"), "case_type_definitions", ["tenant_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_case_type_definitions_type_key"), "case_type_definitions", ["type_key"], unique=False)
|
||||||
|
op.create_index(op.f("ix_case_type_definitions_active"), "case_type_definitions", ["active"], unique=False)
|
||||||
|
op.create_index("ix_case_type_catalog", "case_type_definitions", ["tenant_id", "active", "label"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"case_status_definitions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("status_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("terminal", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), 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_case_status_definitions")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "status_key", name="uq_case_status_tenant_key"),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "status_key", "terminal", "active"):
|
||||||
|
op.create_index(op.f(f"ix_case_status_definitions_{column}"), "case_status_definitions", [column], unique=False)
|
||||||
|
op.create_index("ix_case_status_catalog", "case_status_definitions", ["tenant_id", "active", "sort_order"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"case_identities",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("case_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("case_number", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("created_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.PrimaryKeyConstraint("id", name=op.f("pk_case_identities")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "case_id", name="uq_case_identity_tenant_id"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "case_number", name="uq_case_identity_tenant_number"),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "case_id", "case_number", "created_by"):
|
||||||
|
op.create_index(op.f(f"ix_case_identities_{column}"), "case_identities", [column], unique=False)
|
||||||
|
op.create_index("ix_case_identity_catalog", "case_identities", ["tenant_id", "case_number"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"case_record_revisions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("case_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("case_type_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("status_key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("search_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("snapshot", 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(["identity_id"], ["case_identities.id"], name=op.f("fk_case_record_revisions_identity_id_case_identities"), ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["previous_revision_id"], ["case_record_revisions.id"], name=op.f("fk_case_record_revisions_previous_revision_id_case_record_revisions"), ondelete="RESTRICT"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_case_record_revisions")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "case_id", "revision", name="uq_case_record_revision"),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "case_id", "identity_id", "previous_revision_id", "case_type_key", "status_key", "opened_at", "deadline_at", "closed_at", "recorded_at", "superseded_at", "changed_by"):
|
||||||
|
op.create_index(op.f(f"ix_case_record_revisions_{column}"), "case_record_revisions", [column], unique=False)
|
||||||
|
op.create_index("ix_case_record_current", "case_record_revisions", ["tenant_id", "case_id", "superseded_at"], unique=False)
|
||||||
|
op.create_index("ix_case_record_list", "case_record_revisions", ["tenant_id", "status_key", "case_type_key", "recorded_at"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"case_timeline_entries",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("case_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("case_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("summary", sa.String(length=500), 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("audit_event_id", sa.String(length=255), nullable=True),
|
||||||
|
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_case_timeline_entries")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "event_id", name="uq_case_timeline_event"),
|
||||||
|
sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_case_timeline_idempotency"),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "case_id", "event_id", "event_type", "occurred_at", "actor_id", "audit_event_id"):
|
||||||
|
op.create_index(op.f(f"ix_case_timeline_entries_{column}"), "case_timeline_entries", [column], unique=False)
|
||||||
|
op.create_index("ix_case_timeline_case", "case_timeline_entries", ["tenant_id", "case_id", "occurred_at"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("case_timeline_entries")
|
||||||
|
op.drop_table("case_record_revisions")
|
||||||
|
op.drop_table("case_identities")
|
||||||
|
op.drop_table("case_status_definitions")
|
||||||
|
op.drop_table("case_type_definitions")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""v0.1.14 case object sharing.
|
||||||
|
|
||||||
|
Revision ID: f6d3a8b1c4e7
|
||||||
|
Revises: c7a8e9f0b1d2
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f6d3a8b1c4e7"
|
||||||
|
down_revision = "c7a8e9f0b1d2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"case_record_revisions",
|
||||||
|
sa.Column(
|
||||||
|
"access_mode",
|
||||||
|
sa.String(length=30),
|
||||||
|
nullable=False,
|
||||||
|
server_default="tenant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_case_record_revisions_access_mode"),
|
||||||
|
"case_record_revisions",
|
||||||
|
["access_mode"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"case_access_grants",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("case_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("subject_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("subject_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("permissions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("source", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("source_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_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.PrimaryKeyConstraint("id", name=op.f("pk_case_access_grants")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"case_id",
|
||||||
|
"subject_kind",
|
||||||
|
"subject_id",
|
||||||
|
"source",
|
||||||
|
name="uq_case_access_grant_subject",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"case_id",
|
||||||
|
"subject_kind",
|
||||||
|
"subject_id",
|
||||||
|
"source",
|
||||||
|
"active",
|
||||||
|
"created_by",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_case_access_grants_{column}"),
|
||||||
|
"case_access_grants",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_case_access_grant_lookup",
|
||||||
|
"case_access_grants",
|
||||||
|
["tenant_id", "case_id", "active", "subject_kind", "subject_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("case_access_grants")
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_case_record_revisions_access_mode"),
|
||||||
|
table_name="case_record_revisions",
|
||||||
|
)
|
||||||
|
op.drop_column("case_record_revisions", "access_mode")
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
PartyRepresentation,
|
||||||
|
PartyResolver,
|
||||||
|
PartySubjectReference,
|
||||||
|
ProcedureParty,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CASES_PARTY_CONTEXT = "cases.party_context"
|
||||||
|
CasePartySource = Literal["provider", "compatibility"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CasePartyCompatibilityRecord:
|
||||||
|
party_id: str
|
||||||
|
role: str
|
||||||
|
subject: PartySubjectReference
|
||||||
|
valid_from: datetime
|
||||||
|
valid_to: datetime | None = None
|
||||||
|
preferred_channels: tuple[str, ...] = ()
|
||||||
|
permitted_channels: tuple[str, ...] = ()
|
||||||
|
delivery_recipient: bool = False
|
||||||
|
contact_snapshot_refs: tuple[str, ...] = ()
|
||||||
|
evidence: tuple[EvidenceReference, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CasePartySet:
|
||||||
|
case_ref: InstitutionalReference
|
||||||
|
effective_at: datetime
|
||||||
|
source: CasePartySource
|
||||||
|
parties: tuple[ProcedureParty, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CasePartyDeliveryTarget:
|
||||||
|
party_ref: InstitutionalReference
|
||||||
|
subject: PartySubjectReference
|
||||||
|
role: str
|
||||||
|
channel: str
|
||||||
|
contact_snapshot_refs: tuple[str, ...]
|
||||||
|
represented_party_refs: tuple[InstitutionalReference, ...] = ()
|
||||||
|
evidence: tuple[EvidenceReference, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class CasePartyContext:
|
||||||
|
"""Resolve case-local participation without copying subject master data."""
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
case_ref: InstitutionalReference,
|
||||||
|
effective_at: datetime,
|
||||||
|
compatibility: tuple[CasePartyCompatibilityRecord, ...] = (),
|
||||||
|
) -> CasePartySet:
|
||||||
|
if case_ref.kind != "case":
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case party resolution requires a case reference."
|
||||||
|
)
|
||||||
|
_require_aware(effective_at)
|
||||||
|
provider = _capability(self._registry, CAPABILITY_PARTY_RESOLVER)
|
||||||
|
if isinstance(provider, PartyResolver):
|
||||||
|
candidates = tuple(
|
||||||
|
provider.list_procedure_parties(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
procedure_ref=case_ref,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
source: CasePartySource = "provider"
|
||||||
|
else:
|
||||||
|
candidates = tuple(
|
||||||
|
_compatibility_party(case_ref, item) for item in compatibility
|
||||||
|
)
|
||||||
|
source = "compatibility"
|
||||||
|
|
||||||
|
effective = tuple(
|
||||||
|
item
|
||||||
|
for item in candidates
|
||||||
|
if item.status == "active" and item.temporal.effective_at(effective_at)
|
||||||
|
)
|
||||||
|
for item in effective:
|
||||||
|
if not _same_object(item.procedure_ref, case_ref):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Party provider returned a party for another procedure."
|
||||||
|
)
|
||||||
|
keys = tuple(
|
||||||
|
(
|
||||||
|
item.role,
|
||||||
|
item.subject.kind,
|
||||||
|
item.subject.provider,
|
||||||
|
item.subject.subject_id,
|
||||||
|
)
|
||||||
|
for item in effective
|
||||||
|
)
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Party resolution returned conflicting active role assignments."
|
||||||
|
)
|
||||||
|
return CasePartySet(
|
||||||
|
case_ref=case_ref,
|
||||||
|
effective_at=effective_at,
|
||||||
|
source=source,
|
||||||
|
parties=effective,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delivery_targets(
|
||||||
|
self,
|
||||||
|
party_set: CasePartySet,
|
||||||
|
*,
|
||||||
|
channel: str,
|
||||||
|
) -> tuple[CasePartyDeliveryTarget, ...]:
|
||||||
|
if not channel.strip():
|
||||||
|
raise InstitutionalContextError("Delivery channel is required.")
|
||||||
|
targets: list[CasePartyDeliveryTarget] = []
|
||||||
|
for party in party_set.parties:
|
||||||
|
if not party.delivery_recipient or channel not in party.permitted_channels:
|
||||||
|
continue
|
||||||
|
if not party.contact_snapshot_refs:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"A case delivery target requires frozen contact snapshots."
|
||||||
|
)
|
||||||
|
represented = tuple(
|
||||||
|
item.represented_party_ref
|
||||||
|
for item in party.representations
|
||||||
|
if _representation_effective(
|
||||||
|
item,
|
||||||
|
effective_at=party_set.effective_at,
|
||||||
|
action="receive",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
targets.append(
|
||||||
|
CasePartyDeliveryTarget(
|
||||||
|
party_ref=party.reference,
|
||||||
|
subject=party.subject,
|
||||||
|
role=party.role,
|
||||||
|
channel=channel,
|
||||||
|
contact_snapshot_refs=party.contact_snapshot_refs,
|
||||||
|
represented_party_refs=represented,
|
||||||
|
evidence=party.evidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _compatibility_party(
|
||||||
|
case_ref: InstitutionalReference,
|
||||||
|
item: CasePartyCompatibilityRecord,
|
||||||
|
) -> ProcedureParty:
|
||||||
|
if item.subject.tenant_id != case_ref.tenant_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Compatibility party subject belongs to another tenant."
|
||||||
|
)
|
||||||
|
return ProcedureParty(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="party",
|
||||||
|
owner_module="cases",
|
||||||
|
object_id=item.party_id,
|
||||||
|
tenant_id=case_ref.tenant_id,
|
||||||
|
version="compatibility-1",
|
||||||
|
valid_at=item.valid_from,
|
||||||
|
),
|
||||||
|
procedure_ref=case_ref,
|
||||||
|
role=item.role,
|
||||||
|
subject=item.subject,
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="compatibility-1",
|
||||||
|
valid_from=item.valid_from,
|
||||||
|
valid_to=item.valid_to,
|
||||||
|
recorded_at=item.valid_from,
|
||||||
|
change_reason="Cases compatibility party projection.",
|
||||||
|
),
|
||||||
|
preferred_channels=item.preferred_channels,
|
||||||
|
permitted_channels=item.permitted_channels,
|
||||||
|
delivery_recipient=item.delivery_recipient,
|
||||||
|
contact_snapshot_refs=item.contact_snapshot_refs,
|
||||||
|
evidence=item.evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _representation_effective(
|
||||||
|
representation: PartyRepresentation,
|
||||||
|
*,
|
||||||
|
effective_at: datetime,
|
||||||
|
action: str,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
action in representation.permitted_actions
|
||||||
|
and representation.temporal.effective_at(effective_at)
|
||||||
|
and (
|
||||||
|
representation.revoked_at is None
|
||||||
|
or representation.revoked_at > effective_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_object(
|
||||||
|
left: InstitutionalReference,
|
||||||
|
right: InstitutionalReference,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
left.kind,
|
||||||
|
left.owner_module,
|
||||||
|
left.object_id,
|
||||||
|
left.tenant_id,
|
||||||
|
) == (
|
||||||
|
right.kind,
|
||||||
|
right.owner_module,
|
||||||
|
right.object_id,
|
||||||
|
right.tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_aware(value: datetime) -> None:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case party effective time must include a timezone."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_CASES_PARTY_CONTEXT",
|
||||||
|
"CasePartyCompatibilityRecord",
|
||||||
|
"CasePartyContext",
|
||||||
|
"CasePartyDeliveryTarget",
|
||||||
|
"CasePartySet",
|
||||||
|
]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.records import (
|
||||||
|
RecordContractError,
|
||||||
|
RecordSourceLocator,
|
||||||
|
RecordSourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
|
||||||
|
from govoplan_cases.backend.service import can_access_case
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_RECORD_SOURCE_CASES = "records.source.cases"
|
||||||
|
|
||||||
|
|
||||||
|
class CasesRecordSource:
|
||||||
|
provider_id = "cases"
|
||||||
|
|
||||||
|
def resource_types(self) -> Sequence[str]:
|
||||||
|
return ("case_revision",)
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
locator: RecordSourceLocator,
|
||||||
|
purpose: str,
|
||||||
|
) -> RecordSourceReference:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise RecordContractError(
|
||||||
|
"Case record references require a database session."
|
||||||
|
)
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||||
|
if not tenant_id or locator.tenant_id != tenant_id:
|
||||||
|
raise RecordContractError("Case record references cannot cross tenants.")
|
||||||
|
if locator.source_module != "cases" or locator.resource_type != "case_revision":
|
||||||
|
raise RecordContractError("Unsupported Cases record source type.")
|
||||||
|
if not str(purpose or "").strip():
|
||||||
|
raise RecordContractError("Case record references require a purpose.")
|
||||||
|
if not hasattr(principal, "has") or not (
|
||||||
|
principal.has("cases:case:read") or principal.has("cases:case:admin")
|
||||||
|
):
|
||||||
|
raise RecordContractError("Current Cases read permission is required.")
|
||||||
|
if not can_access_case(
|
||||||
|
session, principal, case_id=locator.resource_id, permission="read"
|
||||||
|
):
|
||||||
|
raise RecordContractError("The current principal cannot read this case.")
|
||||||
|
try:
|
||||||
|
revision = int(locator.source_revision)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecordContractError("Case source revisions must be numeric.") from exc
|
||||||
|
row = (
|
||||||
|
session.query(CaseRecordRevision)
|
||||||
|
.filter(
|
||||||
|
CaseRecordRevision.tenant_id == tenant_id,
|
||||||
|
CaseRecordRevision.case_id == locator.resource_id,
|
||||||
|
CaseRecordRevision.revision == revision,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise RecordContractError("The exact case revision does not exist.")
|
||||||
|
identity = (
|
||||||
|
session.query(CaseIdentity)
|
||||||
|
.filter(
|
||||||
|
CaseIdentity.tenant_id == tenant_id,
|
||||||
|
CaseIdentity.case_id == locator.resource_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if identity is None:
|
||||||
|
raise RecordContractError("The case identity is unavailable.")
|
||||||
|
snapshot_json = json.dumps(
|
||||||
|
row.snapshot,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return RecordSourceReference(
|
||||||
|
locator=locator,
|
||||||
|
label=f"{identity.case_number} - {row.title}",
|
||||||
|
authority_mode="external_authoritative",
|
||||||
|
content_sha256=hashlib.sha256(snapshot_json).hexdigest(),
|
||||||
|
content_type="application/vnd.govoplan.case-revision+json",
|
||||||
|
size_bytes=len(snapshot_json),
|
||||||
|
valid_from=row.opened_at,
|
||||||
|
valid_to=row.closed_at,
|
||||||
|
recorded_at=row.recorded_at,
|
||||||
|
launch_url=f"/cases/{quote(row.case_id, safe='')}",
|
||||||
|
metadata={
|
||||||
|
"case_number": identity.case_number,
|
||||||
|
"case_type_key": row.case_type_key,
|
||||||
|
"status_key": row.status_key,
|
||||||
|
"access_mode": row.access_mode,
|
||||||
|
"snapshot_sha256": hashlib.sha256(snapshot_json).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_cases_record_source(_context: object) -> CasesRecordSource:
|
||||||
|
return CasesRecordSource()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_RECORD_SOURCE_CASES",
|
||||||
|
"CasesRecordSource",
|
||||||
|
"create_cases_record_source",
|
||||||
|
]
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.api.v1.schemas import (
|
||||||
|
ReferenceOptionListResponse,
|
||||||
|
ReferenceOptionResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.core.references import (
|
||||||
|
access_scope_reference_page,
|
||||||
|
access_scope_reference_provider_available,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||||
|
from govoplan_cases.backend.manifest import (
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
ASSIGN_SCOPE,
|
||||||
|
CLOSE_SCOPE,
|
||||||
|
CREATE_SCOPE,
|
||||||
|
READ_SCOPE,
|
||||||
|
SHARE_SCOPE,
|
||||||
|
UPDATE_SCOPE,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.schemas import (
|
||||||
|
CaseDecisionRequest,
|
||||||
|
CaseEvidenceLinkRequest,
|
||||||
|
CaseEvidenceUnlinkRequest,
|
||||||
|
CaseHistoryResponse,
|
||||||
|
CaseListResponse,
|
||||||
|
CaseStatusWriteRequest,
|
||||||
|
CaseTimelineResponse,
|
||||||
|
CaseTypeWriteRequest,
|
||||||
|
CaseUpdateRequest,
|
||||||
|
CaseWriteRequest,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.evidence_links import (
|
||||||
|
link_case_evidence,
|
||||||
|
unlink_case_evidence,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.decision_path import (
|
||||||
|
CaseDecisionCommand,
|
||||||
|
CaseDecisionError,
|
||||||
|
CaseDecisionPath,
|
||||||
|
CaseDecisionUnavailable,
|
||||||
|
DECISION_READ_SCOPE,
|
||||||
|
DECISION_SENSITIVE_READ_SCOPE,
|
||||||
|
DECISION_WRITE_SCOPE,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
CaseStoreError,
|
||||||
|
can_access_case,
|
||||||
|
case_history,
|
||||||
|
case_timeline,
|
||||||
|
create_case,
|
||||||
|
get_case,
|
||||||
|
list_case_catalog,
|
||||||
|
list_cases,
|
||||||
|
update_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cases", tags=["cases"])
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
if isinstance(exc, CaseDecisionUnavailable):
|
||||||
|
code = status.HTTP_424_FAILED_DEPENDENCY
|
||||||
|
elif isinstance(exc, LookupError):
|
||||||
|
code = 404
|
||||||
|
elif isinstance(exc, PermissionError):
|
||||||
|
code = 403
|
||||||
|
elif any(word in lowered for word in ("conflict", "already", "stale")):
|
||||||
|
code = 409
|
||||||
|
else:
|
||||||
|
code = 400
|
||||||
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog", response_model=dict[str, list[dict[str, Any]]])
|
||||||
|
def api_case_catalog(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
_require(principal, ADMIN_SCOPE if include_inactive else READ_SCOPE)
|
||||||
|
return list_case_catalog(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/catalog/statuses/{status_key}", response_model=dict[str, Any])
|
||||||
|
def api_upsert_case_status(
|
||||||
|
status_key: str,
|
||||||
|
payload: CaseStatusWriteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
if status_key != payload.status_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Case status path and payload keys must match.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
row = upsert_case_status(session, principal, **payload.model_dump())
|
||||||
|
session.commit()
|
||||||
|
except (CaseStoreError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {
|
||||||
|
"status_key": row.status_key,
|
||||||
|
"label": row.label,
|
||||||
|
"category": row.category,
|
||||||
|
"terminal": row.terminal,
|
||||||
|
"sort_order": row.sort_order,
|
||||||
|
"active": row.active,
|
||||||
|
"revision": row.revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/catalog/types/{type_key}", response_model=dict[str, Any])
|
||||||
|
def api_upsert_case_type(
|
||||||
|
type_key: str,
|
||||||
|
payload: CaseTypeWriteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, ADMIN_SCOPE)
|
||||||
|
if type_key != payload.type_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Case type path and payload keys must match.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
row = upsert_case_type(session, principal, **payload.model_dump())
|
||||||
|
session.commit()
|
||||||
|
except (CaseStoreError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return {
|
||||||
|
"type_key": row.type_key,
|
||||||
|
"label": row.label,
|
||||||
|
"description": row.description,
|
||||||
|
"initial_status_key": row.initial_status_key,
|
||||||
|
"allowed_status_keys": list(row.allowed_status_keys or ()),
|
||||||
|
"active": row.active,
|
||||||
|
"revision": row.revision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CaseListResponse)
|
||||||
|
def api_list_cases(
|
||||||
|
query: str = "",
|
||||||
|
status_key: list[str] | None = Query(default=None),
|
||||||
|
case_type_key: list[str] | None = Query(default=None),
|
||||||
|
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),
|
||||||
|
) -> CaseListResponse:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items, total = list_cases(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
status_keys=status_key,
|
||||||
|
case_type_keys=case_type_key,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except CaseStoreError as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return CaseListResponse(
|
||||||
|
cases=[item.to_dict() for item in items],
|
||||||
|
total=total,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED)
|
||||||
|
def api_create_case(
|
||||||
|
payload: CaseWriteRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, CREATE_SCOPE)
|
||||||
|
try:
|
||||||
|
record = CaseRecord.from_mapping(payload.record)
|
||||||
|
if record.access_mode == "restricted" or record.access_grants:
|
||||||
|
_require(principal, SHARE_SCOPE)
|
||||||
|
item = create_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
record=record,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (CaseStoreError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
|
||||||
|
def api_case_decisions(
|
||||||
|
case_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
_require(principal, DECISION_READ_SCOPE)
|
||||||
|
try:
|
||||||
|
items = CaseDecisionPath(get_registry()).linked(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
CaseDecisionError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE)
|
||||||
|
return {
|
||||||
|
"decisions": [
|
||||||
|
item.to_dict(include_protected=disclose)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{case_id}/decisions",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def api_record_case_decision(
|
||||||
|
case_id: str,
|
||||||
|
payload: CaseDecisionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, UPDATE_SCOPE)
|
||||||
|
_require(principal, DECISION_WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
result = CaseDecisionPath(get_registry()).record(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
command=CaseDecisionCommand(
|
||||||
|
expected_case_revision=payload.expected_case_revision,
|
||||||
|
effective_at=payload.effective_at,
|
||||||
|
decision_type=payload.decision_type,
|
||||||
|
operative_result=payload.operative_result,
|
||||||
|
reasoning=payload.reasoning,
|
||||||
|
conditions=tuple(payload.conditions),
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
CaseDecisionError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return result.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{case_id}", response_model=dict[str, Any])
|
||||||
|
def api_get_case(
|
||||||
|
case_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)
|
||||||
|
item = get_case(session, principal, case_id=case_id, revision=revision)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{case_id}", response_model=dict[str, Any])
|
||||||
|
def api_update_case(
|
||||||
|
case_id: str,
|
||||||
|
payload: CaseUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, UPDATE_SCOPE)
|
||||||
|
fields = payload.model_fields_set
|
||||||
|
if "assignment_refs" in fields:
|
||||||
|
_require(principal, ASSIGN_SCOPE)
|
||||||
|
if fields & {"access_mode", "access_grants"}:
|
||||||
|
_require(principal, SHARE_SCOPE)
|
||||||
|
changes = _update_changes(payload)
|
||||||
|
if "status_key" in changes:
|
||||||
|
catalog = list_case_catalog(session, principal)
|
||||||
|
terminal = {
|
||||||
|
str(item["status_key"]): bool(item["terminal"])
|
||||||
|
for item in catalog["statuses"]
|
||||||
|
}
|
||||||
|
if terminal.get(str(changes["status_key"]), False):
|
||||||
|
_require(principal, CLOSE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = update_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
changes=changes,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
CaseStoreError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{case_id}/evidence-links", response_model=dict[str, Any])
|
||||||
|
def api_link_case_evidence(
|
||||||
|
case_id: str,
|
||||||
|
payload: CaseEvidenceLinkRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, UPDATE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = link_case_evidence(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
reference=payload.reference.model_dump(),
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
CaseStoreError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{case_id}/evidence-links/{evidence_id}/unlink",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
)
|
||||||
|
def api_unlink_case_evidence(
|
||||||
|
case_id: str,
|
||||||
|
evidence_id: str,
|
||||||
|
payload: CaseEvidenceUnlinkRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_require(principal, UPDATE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = unlink_case_evidence(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
CaseStoreError,
|
||||||
|
InstitutionalContextError,
|
||||||
|
LookupError,
|
||||||
|
PermissionError,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{case_id}/share-target-options",
|
||||||
|
response_model=ReferenceOptionListResponse,
|
||||||
|
)
|
||||||
|
def api_case_share_target_options(
|
||||||
|
case_id: str,
|
||||||
|
target_type: Literal["user", "group"],
|
||||||
|
q: str = "",
|
||||||
|
selected: list[str] = Query(default=[]),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
cursor: str | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ReferenceOptionListResponse:
|
||||||
|
_require(principal, SHARE_SCOPE)
|
||||||
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="share",
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Case share access is denied")
|
||||||
|
try:
|
||||||
|
page = access_scope_reference_page(
|
||||||
|
get_registry(),
|
||||||
|
principal,
|
||||||
|
scope_type=target_type,
|
||||||
|
reference_kind="user" if target_type == "user" else "group",
|
||||||
|
query=q,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
administrative=True,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return ReferenceOptionListResponse(
|
||||||
|
options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options],
|
||||||
|
provider_available=access_scope_reference_provider_available(get_registry()),
|
||||||
|
next_cursor=page.next_cursor,
|
||||||
|
has_more=page.has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{case_id}/history", response_model=CaseHistoryResponse)
|
||||||
|
def api_case_history(
|
||||||
|
case_id: str,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> CaseHistoryResponse:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
return CaseHistoryResponse(
|
||||||
|
revisions=[
|
||||||
|
item.to_dict()
|
||||||
|
for item in case_history(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{case_id}/timeline", response_model=CaseTimelineResponse)
|
||||||
|
def api_case_timeline(
|
||||||
|
case_id: str,
|
||||||
|
limit: int = Query(default=200, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> CaseTimelineResponse:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
return CaseTimelineResponse(
|
||||||
|
entries=list(
|
||||||
|
case_timeline(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
|
||||||
|
excluded = {
|
||||||
|
"expected_revision",
|
||||||
|
"recorded_at",
|
||||||
|
"change_reason",
|
||||||
|
"idempotency_key",
|
||||||
|
}
|
||||||
|
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
|
||||||
|
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
|
||||||
|
if key in raw:
|
||||||
|
raw[key] = tuple(
|
||||||
|
InstitutionalReference.from_mapping(item)
|
||||||
|
for item in (raw[key] or ())
|
||||||
|
)
|
||||||
|
if "access_grants" in raw:
|
||||||
|
raw["access_grants"] = tuple(
|
||||||
|
CaseGrant.from_mapping(item) for item in (raw["access_grants"] or ())
|
||||||
|
)
|
||||||
|
if "evidence_refs" in raw:
|
||||||
|
raw["evidence_refs"] = tuple(
|
||||||
|
EvidenceReference.from_mapping(item)
|
||||||
|
for item in (raw["evidence_refs"] or ())
|
||||||
|
)
|
||||||
|
if "metadata" in raw and raw["metadata"] is None:
|
||||||
|
raw["metadata"] = {}
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CaseStatusWriteRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
status_key: str = Field(min_length=1, max_length=120)
|
||||||
|
label: str = Field(min_length=1, max_length=255)
|
||||||
|
category: str = Field(default="open", max_length=30)
|
||||||
|
terminal: bool = False
|
||||||
|
sort_order: int = Field(default=100, ge=-10_000, le=10_000)
|
||||||
|
active: bool = True
|
||||||
|
expected_revision: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseTypeWriteRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
type_key: str = Field(min_length=1, max_length=120)
|
||||||
|
label: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4_000)
|
||||||
|
initial_status_key: str = Field(min_length=1, max_length=120)
|
||||||
|
allowed_status_keys: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
active: bool = True
|
||||||
|
expected_revision: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseWriteRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
record: dict[str, Any]
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseGrantRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
subject_kind: Literal[
|
||||||
|
"account",
|
||||||
|
"identity",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"organization_unit",
|
||||||
|
"service_account",
|
||||||
|
]
|
||||||
|
subject_id: str = Field(min_length=1, max_length=255)
|
||||||
|
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
|
||||||
|
default_factory=lambda: ["read"],
|
||||||
|
min_length=1,
|
||||||
|
max_length=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
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)
|
||||||
|
status_key: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||||
|
access_mode: Literal["tenant", "restricted"] | None = None
|
||||||
|
access_grants: list[CaseGrantRequest] | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=500,
|
||||||
|
)
|
||||||
|
party_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
|
||||||
|
assignment_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
|
||||||
|
evidence_refs: list[dict[str, Any]] | None = Field(default=None, max_length=1_000)
|
||||||
|
decision_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
|
||||||
|
record_refs: list[dict[str, Any]] | None = Field(default=None, max_length=500)
|
||||||
|
deadline_at: datetime | None = None
|
||||||
|
closed_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaseOwnerReferenceRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
owner_module: str = Field(min_length=1, max_length=120)
|
||||||
|
kind: str = Field(min_length=1, max_length=120)
|
||||||
|
object_id: str = Field(min_length=1, max_length=1_000)
|
||||||
|
tenant_id: str = Field(min_length=1, max_length=255)
|
||||||
|
version: str = Field(min_length=1, max_length=255)
|
||||||
|
path: str = Field(min_length=1, max_length=2_000)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseEvidenceLinkRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
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)
|
||||||
|
reference: CaseOwnerReferenceRequest
|
||||||
|
|
||||||
|
|
||||||
|
class CaseEvidenceUnlinkRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
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 CaseDecisionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_case_revision: int = Field(ge=1)
|
||||||
|
effective_at: datetime
|
||||||
|
decision_type: str = Field(min_length=1, max_length=120)
|
||||||
|
operative_result: str = Field(min_length=1, max_length=20_000)
|
||||||
|
reasoning: str = Field(min_length=1, max_length=50_000)
|
||||||
|
conditions: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseListResponse(BaseModel):
|
||||||
|
cases: list[dict[str, Any]]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class CaseHistoryResponse(BaseModel):
|
||||||
|
revisions: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class CaseTimelineResponse(BaseModel):
|
||||||
|
entries: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CaseGrantRequest",
|
||||||
|
"CaseDecisionRequest",
|
||||||
|
"CaseEvidenceLinkRequest",
|
||||||
|
"CaseEvidenceUnlinkRequest",
|
||||||
|
"CaseHistoryResponse",
|
||||||
|
"CaseListResponse",
|
||||||
|
"CaseStatusWriteRequest",
|
||||||
|
"CaseTimelineResponse",
|
||||||
|
"CaseTypeWriteRequest",
|
||||||
|
"CaseUpdateRequest",
|
||||||
|
"CaseWriteRequest",
|
||||||
|
]
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillPage,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchDocument,
|
||||||
|
SearchIndexChange,
|
||||||
|
SearchResourceReference,
|
||||||
|
SearchResourceType,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service import can_access_case
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_ID = "cases.cases"
|
||||||
|
RESOURCE_TYPE = "case"
|
||||||
|
READ_SCOPE = "cases:case:read"
|
||||||
|
ADMIN_SCOPE = "cases:case:admin"
|
||||||
|
|
||||||
|
|
||||||
|
class CasesSearchSource:
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
return (
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
module_id="cases",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
label="Cases",
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
_assert_source(request.provider_id, request.resource_type)
|
||||||
|
db = _session(session)
|
||||||
|
statement = (
|
||||||
|
select(CaseRecordRevision, CaseIdentity)
|
||||||
|
.join(CaseIdentity, CaseIdentity.id == CaseRecordRevision.identity_id)
|
||||||
|
.where(
|
||||||
|
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||||
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if request.cursor:
|
||||||
|
statement = statement.where(CaseRecordRevision.case_id > request.cursor)
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
statement.order_by(CaseRecordRevision.case_id).limit(request.limit + 1)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
has_more = len(rows) > request.limit
|
||||||
|
selected = rows[: request.limit]
|
||||||
|
grants = _grants_by_case(
|
||||||
|
db,
|
||||||
|
request.tenant_id,
|
||||||
|
[row.case_id for row, _identity in selected],
|
||||||
|
)
|
||||||
|
high_watermark = db.scalar(
|
||||||
|
select(func.max(CaseRecordRevision.recorded_at)).where(
|
||||||
|
CaseRecordRevision.tenant_id == request.tenant_id,
|
||||||
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SearchBackfillPage(
|
||||||
|
documents=tuple(
|
||||||
|
_document(
|
||||||
|
row,
|
||||||
|
identity=identity,
|
||||||
|
grants=grants.get(row.case_id, ()),
|
||||||
|
)
|
||||||
|
for row, identity in selected
|
||||||
|
),
|
||||||
|
next_cursor=selected[-1][0].case_id if has_more and selected else None,
|
||||||
|
complete=not has_more,
|
||||||
|
high_watermark=high_watermark.isoformat() if high_watermark else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
decisions = {item.reference.key: False for item in requests}
|
||||||
|
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
|
||||||
|
return decisions
|
||||||
|
db = _session(session)
|
||||||
|
for request in requests:
|
||||||
|
reference = request.reference
|
||||||
|
if (
|
||||||
|
reference.tenant_id != principal.tenant_id
|
||||||
|
or reference.module_id != "cases"
|
||||||
|
or reference.resource_type != RESOURCE_TYPE
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
decisions[reference.key] = can_access_case(
|
||||||
|
db,
|
||||||
|
principal,
|
||||||
|
case_id=reference.resource_id,
|
||||||
|
permission="read",
|
||||||
|
)
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
def index_changes_for_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> Sequence[SearchIndexChange]:
|
||||||
|
if (
|
||||||
|
event.module_id != "cases"
|
||||||
|
or event.tenant is None
|
||||||
|
or event.resource is None
|
||||||
|
or event.resource.type != RESOURCE_TYPE
|
||||||
|
or event.resource.id is None
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
db = _session(session)
|
||||||
|
row = db.scalar(
|
||||||
|
select(CaseRecordRevision).where(
|
||||||
|
CaseRecordRevision.tenant_id == event.tenant.id,
|
||||||
|
CaseRecordRevision.case_id == event.resource.id,
|
||||||
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
identity = db.scalar(
|
||||||
|
select(CaseIdentity).where(
|
||||||
|
CaseIdentity.tenant_id == event.tenant.id,
|
||||||
|
CaseIdentity.case_id == event.resource.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
deleted = row is None or identity is None
|
||||||
|
cursor = event.event_id
|
||||||
|
document = None
|
||||||
|
if not deleted:
|
||||||
|
document = _document(
|
||||||
|
row,
|
||||||
|
identity=identity,
|
||||||
|
grants=_grants_by_case(
|
||||||
|
db,
|
||||||
|
event.tenant.id,
|
||||||
|
[row.case_id],
|
||||||
|
).get(row.case_id, ()),
|
||||||
|
change_cursor=cursor,
|
||||||
|
)
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id=event.tenant.id,
|
||||||
|
module_id="cases",
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id=event.resource.id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
SearchIndexChange(
|
||||||
|
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
kind="delete" if deleted else "upsert",
|
||||||
|
reference=reference,
|
||||||
|
source_revision=(document.source_revision if document else cursor),
|
||||||
|
cursor=cursor,
|
||||||
|
document=document,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_cases_search_source(_context: ModuleContext) -> CasesSearchSource:
|
||||||
|
return CasesSearchSource()
|
||||||
|
|
||||||
|
|
||||||
|
def _document(
|
||||||
|
row: CaseRecordRevision,
|
||||||
|
*,
|
||||||
|
identity: CaseIdentity,
|
||||||
|
grants: Sequence[CaseAccessGrant],
|
||||||
|
change_cursor: str | None = None,
|
||||||
|
) -> SearchDocument:
|
||||||
|
tokens = [f"scope:{READ_SCOPE}", f"scope:{ADMIN_SCOPE}"]
|
||||||
|
if identity.created_by:
|
||||||
|
tokens.extend(
|
||||||
|
(f"account:{identity.created_by}", f"membership:{identity.created_by}")
|
||||||
|
)
|
||||||
|
prefixes = {
|
||||||
|
"account": "account",
|
||||||
|
"identity": "identity",
|
||||||
|
"group": "group",
|
||||||
|
"role": "role",
|
||||||
|
"function": "function",
|
||||||
|
"function_assignment": "function",
|
||||||
|
}
|
||||||
|
for grant in grants:
|
||||||
|
prefix = prefixes.get(grant.subject_kind)
|
||||||
|
if prefix:
|
||||||
|
tokens.append(f"{prefix}:{grant.subject_id}")
|
||||||
|
return SearchDocument(
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
module_id="cases",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type=RESOURCE_TYPE,
|
||||||
|
resource_id=row.case_id,
|
||||||
|
title=row.title,
|
||||||
|
url=f"/cases/{quote(row.case_id, safe='')}",
|
||||||
|
summary=f"{identity.case_number} - {row.status_key}",
|
||||||
|
body=row.search_text[:200_000],
|
||||||
|
keywords=(
|
||||||
|
identity.case_number[:200],
|
||||||
|
row.case_type_key[:200],
|
||||||
|
row.status_key[:200],
|
||||||
|
),
|
||||||
|
visibility="restricted",
|
||||||
|
acl_tokens=tuple(dict.fromkeys(tokens)),
|
||||||
|
metadata={
|
||||||
|
"case_number": identity.case_number,
|
||||||
|
"case_type_key": row.case_type_key,
|
||||||
|
"status_key": row.status_key,
|
||||||
|
"access_mode": row.access_mode,
|
||||||
|
},
|
||||||
|
source_revision=str(row.revision),
|
||||||
|
change_cursor=change_cursor,
|
||||||
|
source_updated_at=row.recorded_at,
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _grants_by_case(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
case_ids: Sequence[str],
|
||||||
|
) -> dict[str, tuple[CaseAccessGrant, ...]]:
|
||||||
|
grouped: dict[str, list[CaseAccessGrant]] = {
|
||||||
|
case_id: [] for case_id in case_ids
|
||||||
|
}
|
||||||
|
if not case_ids:
|
||||||
|
return {}
|
||||||
|
rows = session.scalars(
|
||||||
|
select(CaseAccessGrant).where(
|
||||||
|
CaseAccessGrant.tenant_id == tenant_id,
|
||||||
|
CaseAccessGrant.case_id.in_(tuple(case_ids)),
|
||||||
|
CaseAccessGrant.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
grouped.setdefault(row.case_id, []).append(row)
|
||||||
|
return {key: tuple(value) for key, value in grouped.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||||
|
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||||
|
raise ValueError("Unsupported Cases search source.")
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Cases search requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CasesSearchSource",
|
||||||
|
"PROVIDER_ID",
|
||||||
|
"RESOURCE_TYPE",
|
||||||
|
"create_cases_search_source",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceDefinition,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CASES_SERVICE_INTAKE = "cases.service_intake"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CaseIntakePlan:
|
||||||
|
case_ref: InstitutionalReference
|
||||||
|
service_ref: InstitutionalReference
|
||||||
|
case_type_ref: str
|
||||||
|
context: GovernedContextEnvelope
|
||||||
|
form_refs: tuple[str, ...] = ()
|
||||||
|
workflow_refs: tuple[str, ...] = ()
|
||||||
|
result_refs: tuple[str, ...] = ()
|
||||||
|
required_evidence_types: tuple[str, ...] = ()
|
||||||
|
deadline_refs: tuple[str, ...] = ()
|
||||||
|
remedy_refs: tuple[str, ...] = ()
|
||||||
|
service_level_refs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class CaseServiceIntake:
|
||||||
|
"""Translate a provider-owned Service definition into a case intake plan."""
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
self,
|
||||||
|
definition: ServiceDefinition,
|
||||||
|
*,
|
||||||
|
case_id: str,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> CaseIntakePlan:
|
||||||
|
if definition.publication_state != "published":
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Only a published institutional service can start a case."
|
||||||
|
)
|
||||||
|
if not definition.temporal.effective_at(effective_at):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"The institutional service is not effective at case intake time."
|
||||||
|
)
|
||||||
|
case_bindings = tuple(
|
||||||
|
item for item in definition.bindings if item.kind == "case"
|
||||||
|
)
|
||||||
|
if len(case_bindings) != 1:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case intake requires exactly one case binding in the service definition."
|
||||||
|
)
|
||||||
|
case_ref = InstitutionalReference(
|
||||||
|
kind="case",
|
||||||
|
owner_module="cases",
|
||||||
|
object_id=case_id,
|
||||||
|
tenant_id=definition.reference.tenant_id,
|
||||||
|
version="1",
|
||||||
|
valid_at=effective_at,
|
||||||
|
)
|
||||||
|
temporal = TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=effective_at,
|
||||||
|
recorded_at=effective_at,
|
||||||
|
change_reason=(
|
||||||
|
f"Case intake from service {definition.key}@"
|
||||||
|
f"{definition.temporal.revision}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context = GovernedContextEnvelope(
|
||||||
|
tenant_id=definition.reference.tenant_id,
|
||||||
|
temporal=temporal,
|
||||||
|
organization_unit_ref=definition.responsible_organization_ref,
|
||||||
|
function_ref=definition.responsible_function_ref,
|
||||||
|
mandate_ref=definition.mandate_ref,
|
||||||
|
jurisdiction_refs=definition.jurisdiction_refs,
|
||||||
|
service_ref=definition.reference,
|
||||||
|
case_ref=case_ref,
|
||||||
|
legal_bases=definition.legal_bases,
|
||||||
|
)
|
||||||
|
return CaseIntakePlan(
|
||||||
|
case_ref=case_ref,
|
||||||
|
service_ref=definition.reference,
|
||||||
|
case_type_ref=case_bindings[0].reference,
|
||||||
|
context=context,
|
||||||
|
form_refs=_binding_refs(definition, "form"),
|
||||||
|
workflow_refs=_binding_refs(definition, "workflow"),
|
||||||
|
result_refs=_binding_refs(definition, "result"),
|
||||||
|
required_evidence_types=definition.required_evidence_types,
|
||||||
|
deadline_refs=definition.deadline_refs,
|
||||||
|
remedy_refs=definition.remedy_refs,
|
||||||
|
service_level_refs=definition.service_level_refs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _binding_refs(
|
||||||
|
definition: ServiceDefinition,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
return tuple(item.reference for item in definition.bindings if item.kind == kind)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CASES_SERVICE_INTAKE",
|
||||||
|
"CaseIntakePlan",
|
||||||
|
"CaseServiceIntake",
|
||||||
|
]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
import re
|
||||||
|
from urllib.parse import quote
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
InstitutionalContextError,
|
||||||
|
ServiceDefinition,
|
||||||
|
ServiceLaunchRequest,
|
||||||
|
ServiceLaunchResult,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service import create_case_from_intake, get_case
|
||||||
|
from govoplan_cases.backend.service_intake import CaseServiceIntake
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CASES_SERVICE_LAUNCHER = "cases.service_launcher"
|
||||||
|
_CASE_LAUNCH_NAMESPACE = uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_URL,
|
||||||
|
"https://govoplan.add-ideas.de/contracts/cases/service-launch/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseServiceLauncher:
|
||||||
|
"""Start a replay-safe case from an exact institutional Service revision."""
|
||||||
|
|
||||||
|
def launch_service(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
definition: ServiceDefinition,
|
||||||
|
request: ServiceLaunchRequest,
|
||||||
|
) -> ServiceLaunchResult:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case service launch requires a SQLAlchemy session."
|
||||||
|
)
|
||||||
|
if request.service_ref != definition.reference:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case service launch must use the requested exact Service revision."
|
||||||
|
)
|
||||||
|
if request.binding.kind != "case" or request.binding not in definition.bindings:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case service launch requires a case binding from the Service definition."
|
||||||
|
)
|
||||||
|
case_id = str(
|
||||||
|
uuid.uuid5(
|
||||||
|
_CASE_LAUNCH_NAMESPACE,
|
||||||
|
":".join(
|
||||||
|
(
|
||||||
|
definition.reference.tenant_id,
|
||||||
|
definition.reference.object_id,
|
||||||
|
definition.reference.version or "",
|
||||||
|
request.idempotency_key,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
replayed = get_case(session, principal, case_id=case_id) is not None
|
||||||
|
plan = CaseServiceIntake().plan(
|
||||||
|
definition,
|
||||||
|
case_id=case_id,
|
||||||
|
effective_at=request.requested_at,
|
||||||
|
)
|
||||||
|
title = _parameter_text(
|
||||||
|
request.parameters,
|
||||||
|
"title",
|
||||||
|
default=definition.title,
|
||||||
|
maximum=500,
|
||||||
|
)
|
||||||
|
case_number = _parameter_text(
|
||||||
|
request.parameters,
|
||||||
|
"case_number",
|
||||||
|
default=_default_case_number(definition, case_id),
|
||||||
|
maximum=255,
|
||||||
|
)
|
||||||
|
status_key = _optional_parameter_text(
|
||||||
|
request.parameters,
|
||||||
|
"status_key",
|
||||||
|
maximum=120,
|
||||||
|
)
|
||||||
|
item = create_case_from_intake(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
plan=plan,
|
||||||
|
case_number=case_number,
|
||||||
|
title=title,
|
||||||
|
status_key=status_key,
|
||||||
|
opened_at=request.requested_at,
|
||||||
|
recorded_at=request.requested_at,
|
||||||
|
change_reason=(
|
||||||
|
f"Started from service {definition.key}@"
|
||||||
|
f"{definition.reference.version}."
|
||||||
|
),
|
||||||
|
idempotency_key=request.idempotency_key,
|
||||||
|
metadata={
|
||||||
|
"service_launch_binding": request.binding.reference,
|
||||||
|
"service_launch_capability": CAPABILITY_CASES_SERVICE_LAUNCHER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ServiceLaunchResult(
|
||||||
|
service_ref=definition.reference,
|
||||||
|
binding=request.binding,
|
||||||
|
state="started",
|
||||||
|
target_ref=item.reference,
|
||||||
|
href=f"/cases/{quote(item.reference.object_id, safe='')}",
|
||||||
|
replayed=replayed,
|
||||||
|
metadata={
|
||||||
|
"case_number": item.case_number,
|
||||||
|
"case_revision": item.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_case_number(definition: ServiceDefinition, case_id: str) -> str:
|
||||||
|
prefix = re.sub(r"[^A-Za-z0-9]+", "-", definition.key).strip("-")
|
||||||
|
return f"{(prefix or 'CASE')[:40].upper()}-{case_id[:8].upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parameter_text(
|
||||||
|
parameters: Mapping[str, object],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
default: str,
|
||||||
|
maximum: int,
|
||||||
|
) -> str:
|
||||||
|
value = str(parameters.get(key) or default).strip()
|
||||||
|
if not value or len(value) > maximum:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
f"Case launch parameter {key!r} must contain at most {maximum} characters."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_parameter_text(
|
||||||
|
parameters: Mapping[str, object],
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
maximum: int,
|
||||||
|
) -> str | None:
|
||||||
|
raw = parameters.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
value = str(raw).strip()
|
||||||
|
if not value or len(value) > maximum:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
f"Case launch parameter {key!r} must contain at most {maximum} characters."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["CAPABILITY_CASES_SERVICE_LAUNCHER", "CaseServiceLauncher"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
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 (
|
||||||
|
EvidenceReference,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceBinding,
|
||||||
|
ServiceDefinition,
|
||||||
|
ServiceLaunchRequest,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
CaseStoreError,
|
||||||
|
case_history,
|
||||||
|
case_timeline,
|
||||||
|
create_case,
|
||||||
|
get_case,
|
||||||
|
list_cases,
|
||||||
|
update_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service_launcher import CaseServiceLauncher
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "account-1"
|
||||||
|
scopes: frozenset[str] = frozenset()
|
||||||
|
group_ids: tuple[str, ...] = ()
|
||||||
|
function_assignment_ids: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def ref(
|
||||||
|
kind: str,
|
||||||
|
object_id: str,
|
||||||
|
owner: str,
|
||||||
|
*,
|
||||||
|
version: str | None = "1",
|
||||||
|
) -> InstitutionalReference:
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
owner_module=owner,
|
||||||
|
object_id=object_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version=version,
|
||||||
|
valid_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record(*, title: str = "Permit application") -> CaseRecord:
|
||||||
|
case_ref = ref("case", "case-1", "cases")
|
||||||
|
context = GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Case intake.",
|
||||||
|
),
|
||||||
|
service_ref=ref("service", "permit", "services", version="4"),
|
||||||
|
case_ref=case_ref,
|
||||||
|
organization_unit_ref=ref(
|
||||||
|
"organization_unit",
|
||||||
|
"permits",
|
||||||
|
"organizations",
|
||||||
|
),
|
||||||
|
function_ref=ref("function", "case-worker", "organizations"),
|
||||||
|
)
|
||||||
|
return CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number="PERMIT-2026-0001",
|
||||||
|
case_type_key="permit-application",
|
||||||
|
status_key="intake",
|
||||||
|
title=title,
|
||||||
|
context=context,
|
||||||
|
service_ref=context.service_ref,
|
||||||
|
party_refs=(ref("party", "applicant", "parties"),),
|
||||||
|
assignment_refs=(
|
||||||
|
ref("function_assignment", "assignment-1", "idm"),
|
||||||
|
),
|
||||||
|
evidence_refs=(
|
||||||
|
EvidenceReference(
|
||||||
|
kind="document",
|
||||||
|
owner_module="files",
|
||||||
|
evidence_id="file-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
captured_at=NOW,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
deadline_at=NOW + timedelta(days=30),
|
||||||
|
change_reason="Application received.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseLifecycleTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
CaseStatusDefinition.__table__,
|
||||||
|
CaseTypeDefinition.__table__,
|
||||||
|
CaseIdentity.__table__,
|
||||||
|
CaseRecordRevision.__table__,
|
||||||
|
CaseAccessGrant.__table__,
|
||||||
|
CaseTimelineEntry.__table__,
|
||||||
|
):
|
||||||
|
table.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = Principal()
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_key="intake",
|
||||||
|
label="Intake",
|
||||||
|
)
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_key="review",
|
||||||
|
label="Review",
|
||||||
|
category="waiting",
|
||||||
|
)
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_key="closed",
|
||||||
|
label="Closed",
|
||||||
|
category="closed",
|
||||||
|
terminal=True,
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
type_key="permit-application",
|
||||||
|
label="Permit application",
|
||||||
|
initial_status_key="intake",
|
||||||
|
allowed_status_keys=("intake", "review", "closed"),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_case_revisions_are_replay_safe_occ_guarded_and_event_backed(self) -> None:
|
||||||
|
events = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", events.append)
|
||||||
|
with event_bus_context(bus):
|
||||||
|
created = create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=record(),
|
||||||
|
idempotency_key="case-create-1",
|
||||||
|
)
|
||||||
|
self.assertEqual([], events)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
replay = create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=record(),
|
||||||
|
idempotency_key="case-create-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, replay.revision)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "idempotency conflict"):
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=record(title="Another application"),
|
||||||
|
idempotency_key="case-create-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
revised = update_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"status_key": "review", "title": "Reviewed permit"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="Review started.",
|
||||||
|
idempotency_key="case-update-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(1, created.revision)
|
||||||
|
self.assertEqual(2, revised.revision)
|
||||||
|
self.assertEqual("review", revised.status_key)
|
||||||
|
self.assertEqual(
|
||||||
|
["cases.case.created", "cases.case.updated"],
|
||||||
|
[item.type for item in events],
|
||||||
|
)
|
||||||
|
self.assertEqual("case-1", events[-1].institutional_context.case_ref.object_id)
|
||||||
|
self.assertEqual(2, len(case_history(self.session, self.principal, case_id="case-1")))
|
||||||
|
timeline = case_timeline(self.session, self.principal, case_id="case-1")
|
||||||
|
self.assertEqual(2, len(timeline))
|
||||||
|
self.assertEqual(timeline[0]["event_id"], timeline[0]["audit_event_id"])
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||||
|
update_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"title": "Stale title"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
change_reason="Stale update.",
|
||||||
|
idempotency_key="case-update-stale",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_terminal_state_sets_closed_time_and_list_is_server_filtered(self) -> None:
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=record(),
|
||||||
|
idempotency_key="case-create-2",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
closed = update_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"status_key": "closed"},
|
||||||
|
recorded_at=NOW + timedelta(hours=1),
|
||||||
|
change_reason="Decision became final.",
|
||||||
|
idempotency_key="case-close-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(NOW + timedelta(hours=1), closed.closed_at)
|
||||||
|
items, total = list_cases(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_keys=("closed",),
|
||||||
|
query="permit-2026",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, total)
|
||||||
|
self.assertEqual("case-1", items[0].reference.object_id)
|
||||||
|
|
||||||
|
def test_tenant_boundary_and_catalog_revision_conflicts_fail_closed(self) -> None:
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=record(),
|
||||||
|
idempotency_key="case-create-3",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertIsNone(
|
||||||
|
get_case(
|
||||||
|
self.session,
|
||||||
|
Principal(tenant_id="tenant-2"),
|
||||||
|
case_id="case-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_key="review",
|
||||||
|
label="In review",
|
||||||
|
expected_revision=99,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "cannot be deactivated"):
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
type_key="permit-application",
|
||||||
|
label="Permit application",
|
||||||
|
initial_status_key="intake",
|
||||||
|
allowed_status_keys=("intake", "review", "closed"),
|
||||||
|
active=False,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_restricted_cases_filter_reads_and_honor_explicit_permissions(self) -> None:
|
||||||
|
restricted = record()
|
||||||
|
restricted = CaseRecord.from_mapping(
|
||||||
|
{
|
||||||
|
**restricted.to_dict(),
|
||||||
|
"access_mode": "restricted",
|
||||||
|
"access_grants": [
|
||||||
|
CaseGrant(
|
||||||
|
subject_kind="group",
|
||||||
|
subject_id="reviewers",
|
||||||
|
permissions=("read",),
|
||||||
|
).to_dict()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=restricted,
|
||||||
|
idempotency_key="case-restricted-create",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
outsider = Principal(account_id="account-2")
|
||||||
|
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
|
||||||
|
self.assertEqual(0, list_cases(self.session, outsider)[1])
|
||||||
|
self.assertEqual((), case_history(self.session, outsider, case_id="case-1"))
|
||||||
|
self.assertEqual((), case_timeline(self.session, outsider, case_id="case-1"))
|
||||||
|
|
||||||
|
reader = Principal(account_id="account-3", group_ids=("reviewers",))
|
||||||
|
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
|
||||||
|
self.assertEqual(1, list_cases(self.session, reader)[1])
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
update_case(
|
||||||
|
self.session,
|
||||||
|
reader,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"title": "Unauthorized title"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="Attempted update.",
|
||||||
|
idempotency_key="case-reader-update",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_assignment_grants_and_access_revision_are_effective(self) -> None:
|
||||||
|
restricted = CaseRecord.from_mapping(
|
||||||
|
{
|
||||||
|
**record().to_dict(),
|
||||||
|
"access_mode": "restricted",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=restricted,
|
||||||
|
idempotency_key="case-assignment-create",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
assignee = Principal(
|
||||||
|
account_id="account-4",
|
||||||
|
function_assignment_ids=("assignment-1",),
|
||||||
|
)
|
||||||
|
revised = update_case(
|
||||||
|
self.session,
|
||||||
|
assignee,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"title": "Assigned case"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="Assigned worker updated the case.",
|
||||||
|
idempotency_key="case-assignee-update",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual(2, revised.revision)
|
||||||
|
|
||||||
|
shared = update_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=2,
|
||||||
|
changes={
|
||||||
|
"access_grants": [
|
||||||
|
CaseGrant(
|
||||||
|
subject_kind="account",
|
||||||
|
subject_id="account-5",
|
||||||
|
permissions=("read", "update"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
change_reason="Granted direct collaboration access.",
|
||||||
|
idempotency_key="case-share-update",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual(3, shared.revision)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
|
||||||
|
)
|
||||||
|
self.assertEqual(3, len(case_history(self.session, self.principal, case_id="case-1")))
|
||||||
|
|
||||||
|
def test_malformed_access_grant_is_rejected(self) -> None:
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=record(),
|
||||||
|
idempotency_key="case-malformed-create",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "must be objects"):
|
||||||
|
update_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"access_grants": ["account-2"]},
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="Invalid share.",
|
||||||
|
idempotency_key="case-malformed-share",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_service_launch_creates_and_replays_one_case(self) -> None:
|
||||||
|
definition = ServiceDefinition(
|
||||||
|
reference=ref("service", "permit", "services", version="4"),
|
||||||
|
key="permit.apply",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="4",
|
||||||
|
valid_from=NOW - timedelta(days=1),
|
||||||
|
recorded_at=NOW - timedelta(days=2),
|
||||||
|
change_reason="Published permit service.",
|
||||||
|
),
|
||||||
|
title="Apply for a permit",
|
||||||
|
audience=("authenticated",),
|
||||||
|
bindings=(ServiceBinding("case", "permit-application"),),
|
||||||
|
publication_state="published",
|
||||||
|
)
|
||||||
|
request = ServiceLaunchRequest(
|
||||||
|
service_ref=definition.reference,
|
||||||
|
binding=definition.bindings[0],
|
||||||
|
idempotency_key="portal-launch-1",
|
||||||
|
requested_at=NOW,
|
||||||
|
parameters={},
|
||||||
|
)
|
||||||
|
|
||||||
|
first = CaseServiceLauncher().launch_service(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
second = CaseServiceLauncher().launch_service(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=definition,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(first.target_ref, second.target_ref)
|
||||||
|
self.assertFalse(first.replayed)
|
||||||
|
self.assertTrue(second.replayed)
|
||||||
|
self.assertEqual(1, list_cases(self.session, self.principal)[1])
|
||||||
|
self.assertEqual("4", get_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id=first.target_ref.object_id,
|
||||||
|
).service_ref.version)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
|
FunctionAssignmentRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_DECISION_REGISTRY,
|
||||||
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
|
EvidenceReference,
|
||||||
|
FormalDecision,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
LegalBasisReference,
|
||||||
|
MandateDefinition,
|
||||||
|
MandateResolution,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.decision_path import (
|
||||||
|
CaseDecisionCommand,
|
||||||
|
CaseDecisionError,
|
||||||
|
CaseDecisionPath,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
create_case,
|
||||||
|
get_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 6, 9, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "account-1"
|
||||||
|
identity_id: str = "identity-1"
|
||||||
|
acting_assignment_id: str | None = "assignment-1"
|
||||||
|
function_assignment_ids: tuple[str, ...] = ("assignment-1",)
|
||||||
|
scopes: frozenset[str] = frozenset(
|
||||||
|
{"cases:case:read", "cases:case:update", "decisions:decision:write"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(self, providers: dict[str, object]) -> None:
|
||||||
|
self.providers = providers
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self.providers
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
return self.providers[name]
|
||||||
|
|
||||||
|
|
||||||
|
class AccessDirectory:
|
||||||
|
def get_function_assignment(self, assignment_id: str):
|
||||||
|
if assignment_id != "assignment-1":
|
||||||
|
return None
|
||||||
|
return FunctionAssignmentRef(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
function_id="permit-officer",
|
||||||
|
organization_unit_id="permits",
|
||||||
|
valid_from=NOW - timedelta(days=30),
|
||||||
|
valid_until=NOW + timedelta(days=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Mandates:
|
||||||
|
def __init__(self, mandate: MandateDefinition) -> None:
|
||||||
|
self.mandate = mandate
|
||||||
|
|
||||||
|
def resolve_mandate(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
return MandateResolution(competent=True, mandates=(self.mandate,))
|
||||||
|
|
||||||
|
|
||||||
|
class Decisions:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.items: dict[tuple[str, str], FormalDecision] = {}
|
||||||
|
|
||||||
|
def get_decision(self, session, principal, *, reference):
|
||||||
|
del session, principal
|
||||||
|
return self.items.get((reference.object_id, reference.version or ""))
|
||||||
|
|
||||||
|
def record_decision(self, session, principal, *, decision, expected_revision=None):
|
||||||
|
del session, principal, expected_revision
|
||||||
|
key = (decision.reference.object_id, decision.reference.version or "")
|
||||||
|
existing = self.items.get(key)
|
||||||
|
if existing is not None and existing != decision:
|
||||||
|
raise ValueError("A different Decision payload already uses this revision.")
|
||||||
|
self.items[key] = decision
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
def ref(kind: str, object_id: str, owner: str, *, version: str = "1") -> InstitutionalReference:
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
owner_module=owner,
|
||||||
|
object_id=object_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version=version,
|
||||||
|
valid_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legal_basis() -> LegalBasisReference:
|
||||||
|
return LegalBasisReference(
|
||||||
|
kind="law",
|
||||||
|
authority="Example legislature",
|
||||||
|
reference="permit-law:3",
|
||||||
|
version="2026-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evidence() -> EvidenceReference:
|
||||||
|
return EvidenceReference(
|
||||||
|
kind="document",
|
||||||
|
owner_module="files",
|
||||||
|
evidence_id="application-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
captured_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def case_record() -> CaseRecord:
|
||||||
|
case_ref = ref("case", "case-1", "cases")
|
||||||
|
context = GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Case intake.",
|
||||||
|
),
|
||||||
|
organization_unit_ref=ref("organization_unit", "permits", "organizations"),
|
||||||
|
function_ref=ref("function", "permit-officer", "organizations"),
|
||||||
|
mandate_ref=ref("mandate", "permit-mandate", "mandates", version="4"),
|
||||||
|
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||||
|
service_ref=ref("service", "permit", "services", version="4"),
|
||||||
|
case_ref=case_ref,
|
||||||
|
legal_bases=(legal_basis(),),
|
||||||
|
)
|
||||||
|
return CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number="PERMIT-2026-0001",
|
||||||
|
case_type_key="permit-application",
|
||||||
|
status_key="review",
|
||||||
|
title="Permit application",
|
||||||
|
context=context,
|
||||||
|
service_ref=context.service_ref,
|
||||||
|
party_refs=(ref("party", "applicant", "parties"),),
|
||||||
|
assignment_refs=(ref("function_assignment", "assignment-1", "access"),),
|
||||||
|
evidence_refs=(evidence(),),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Application received.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mandate() -> MandateDefinition:
|
||||||
|
return MandateDefinition(
|
||||||
|
reference=ref("mandate", "permit-mandate", "mandates", version="4"),
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="4",
|
||||||
|
valid_from=NOW - timedelta(days=30),
|
||||||
|
valid_to=NOW + timedelta(days=30),
|
||||||
|
recorded_at=NOW - timedelta(days=31),
|
||||||
|
change_reason="Permit authority delegated.",
|
||||||
|
),
|
||||||
|
task_types=("cases.formal_decision",),
|
||||||
|
authority_types=("permit",),
|
||||||
|
organization_unit_refs=(ref("organization_unit", "permits", "organizations"),),
|
||||||
|
function_refs=(ref("function", "permit-officer", "organizations"),),
|
||||||
|
jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),),
|
||||||
|
legal_bases=(legal_basis(),),
|
||||||
|
evidence=(evidence(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command(
|
||||||
|
*,
|
||||||
|
key: str = "decision-1",
|
||||||
|
result: str = "Permit granted.",
|
||||||
|
expected_case_revision: int = 1,
|
||||||
|
) -> CaseDecisionCommand:
|
||||||
|
return CaseDecisionCommand(
|
||||||
|
expected_case_revision=expected_case_revision,
|
||||||
|
effective_at=NOW + timedelta(minutes=5),
|
||||||
|
decision_type="permit",
|
||||||
|
operative_result=result,
|
||||||
|
reasoning="The submitted evidence satisfies the effective rule.",
|
||||||
|
conditions=("Display the permit visibly.",),
|
||||||
|
change_reason="Formal permit Decision recorded.",
|
||||||
|
idempotency_key=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseDecisionPathTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
CaseStatusDefinition.__table__,
|
||||||
|
CaseTypeDefinition.__table__,
|
||||||
|
CaseIdentity.__table__,
|
||||||
|
CaseRecordRevision.__table__,
|
||||||
|
CaseAccessGrant.__table__,
|
||||||
|
CaseTimelineEntry.__table__,
|
||||||
|
):
|
||||||
|
table.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = Principal()
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_key="review",
|
||||||
|
label="Review",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
type_key="permit-application",
|
||||||
|
label="Permit application",
|
||||||
|
initial_status_key="review",
|
||||||
|
allowed_status_keys=("review",),
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=case_record(),
|
||||||
|
idempotency_key="case-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.decisions = Decisions()
|
||||||
|
self.path = CaseDecisionPath(
|
||||||
|
Registry(
|
||||||
|
{
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: AccessDirectory(),
|
||||||
|
CAPABILITY_MANDATE_RESOLVER: Mandates(mandate()),
|
||||||
|
CAPABILITY_DECISION_REGISTRY: self.decisions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_records_and_links_exact_decision_in_one_case_revision(self) -> None:
|
||||||
|
result = self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(2, result.case.revision)
|
||||||
|
self.assertEqual("Permit granted.", result.decision.operative_result)
|
||||||
|
self.assertEqual("assignment-1", result.decision.authority_context.actor.function_assignment_ref.object_id)
|
||||||
|
self.assertEqual("permit-mandate", result.decision.authority_context.mandate_ref.object_id)
|
||||||
|
self.assertEqual(result.decision.reference, result.case.decision_refs[0])
|
||||||
|
self.assertEqual(
|
||||||
|
result.decision,
|
||||||
|
self.path.linked(self.session, self.principal, case_id="case-1")[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unchanged_retry_replays_but_changed_payload_fails_closed(self) -> None:
|
||||||
|
first = self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
replay = self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(first.decision.reference, replay.decision.reference)
|
||||||
|
with self.assertRaisesRegex(ValueError, "different Decision payload"):
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(result="Permit denied."),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_case_revision_and_missing_acting_assignment_fail_closed(self) -> None:
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CaseDecisionError, "revision conflict"):
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(key="another-decision"),
|
||||||
|
)
|
||||||
|
without_assignment = Principal(
|
||||||
|
acting_assignment_id=None,
|
||||||
|
function_assignment_ids=(),
|
||||||
|
)
|
||||||
|
current = get_case(self.session, self.principal, case_id="case-1")
|
||||||
|
assert current is not None
|
||||||
|
with self.assertRaisesRegex(PermissionError, "No active acting assignment"):
|
||||||
|
self.path.record(
|
||||||
|
self.session,
|
||||||
|
without_assignment,
|
||||||
|
case_id="case-1",
|
||||||
|
command=command(
|
||||||
|
key="third-decision",
|
||||||
|
expected_case_revision=current.revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||||
|
from govoplan_cases.backend.dsar_provider import (
|
||||||
|
CASES_DSAR_CAPABILITY,
|
||||||
|
CasesDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.manifest import manifest
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
create_case,
|
||||||
|
update_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
EvidenceReference,
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
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, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Principal:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: CasesDsarProvider,
|
||||||
|
*,
|
||||||
|
cases_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.cases_active = cases_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (CASES_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "cases"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
cases_active = self.cases_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("cases",) if cases_active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "cases"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != CASES_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _ref(
|
||||||
|
kind: str,
|
||||||
|
object_id: str,
|
||||||
|
owner: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
version: str | None = "1",
|
||||||
|
) -> InstitutionalReference:
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
owner_module=owner,
|
||||||
|
object_id=object_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
version=version,
|
||||||
|
valid_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
case_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
title: str = "Subject permit",
|
||||||
|
grants: tuple[CaseGrant, ...] = (),
|
||||||
|
) -> CaseRecord:
|
||||||
|
case_ref = _ref("case", case_id, "cases", tenant_id=tenant_id)
|
||||||
|
service_ref = _ref("service", "permit", "services", tenant_id=tenant_id)
|
||||||
|
return CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number=f"PERMIT-{case_id}",
|
||||||
|
case_type_key="permit",
|
||||||
|
status_key="open",
|
||||||
|
title=title,
|
||||||
|
access_mode="restricted" if grants else "tenant",
|
||||||
|
access_grants=grants,
|
||||||
|
context=GovernedContextEnvelope(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="private-context-reason-do-not-export",
|
||||||
|
),
|
||||||
|
service_ref=service_ref,
|
||||||
|
case_ref=case_ref,
|
||||||
|
),
|
||||||
|
service_ref=service_ref,
|
||||||
|
party_refs=(
|
||||||
|
_ref(
|
||||||
|
"party",
|
||||||
|
"private-party-reference-do-not-export",
|
||||||
|
"parties",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
evidence_refs=(
|
||||||
|
EvidenceReference(
|
||||||
|
kind="document",
|
||||||
|
owner_module="files",
|
||||||
|
evidence_id="private-evidence-id-do-not-export",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
version="1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
deadline_at=NOW + timedelta(days=30),
|
||||||
|
change_reason="private-create-reason-do-not-export",
|
||||||
|
metadata={"secret": "private-metadata-do-not-export"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CasesDsarProviderTests(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 = CasesDsarProvider()
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
|
||||||
|
operator = _Principal("tenant-1", "operator-1")
|
||||||
|
other_operator = _Principal("tenant-1", "operator-other")
|
||||||
|
tenant_two_operator = _Principal("tenant-2", "operator-2")
|
||||||
|
for principal in (operator, tenant_two_operator):
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
status_key="open",
|
||||||
|
label="Open",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
type_key="permit",
|
||||||
|
label="Permit",
|
||||||
|
initial_status_key="open",
|
||||||
|
allowed_status_keys=("open",),
|
||||||
|
)
|
||||||
|
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
operator,
|
||||||
|
record=_record(
|
||||||
|
"case-1",
|
||||||
|
grants=(
|
||||||
|
CaseGrant("account", "account-subject", ("read",)),
|
||||||
|
CaseGrant("identity", "identity-subject", ("read", "update")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
idempotency_key="private-create-key-do-not-export",
|
||||||
|
)
|
||||||
|
update_case(
|
||||||
|
self.session,
|
||||||
|
operator,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
changes={
|
||||||
|
"title": "Subject permit revised",
|
||||||
|
"metadata": {"secret": "private-updated-metadata-do-not-export"},
|
||||||
|
},
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="private-update-reason-do-not-export",
|
||||||
|
idempotency_key="private-update-key-do-not-export",
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
other_operator,
|
||||||
|
record=_record(
|
||||||
|
"case-unrelated",
|
||||||
|
title="private-unrelated-title-do-not-export",
|
||||||
|
grants=(CaseGrant("account", "account-other", ("read",)),),
|
||||||
|
),
|
||||||
|
idempotency_key="unrelated-key-do-not-export",
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
tenant_two_operator,
|
||||||
|
record=_record(
|
||||||
|
"case-other-tenant",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
title="private-other-tenant-title-do-not-export",
|
||||||
|
grants=(CaseGrant("account", "account-subject", ("read",)),),
|
||||||
|
),
|
||||||
|
idempotency_key="other-tenant-key-do-not-export",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_subject_access_grants_are_exact_tenant_and_minimized(self) -> None:
|
||||||
|
account_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-subject"),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(account_records))
|
||||||
|
self.assertEqual("cases_access_grant", account_records[0].resource_type)
|
||||||
|
self.assertFalse(account_records[0].immutable_evidence)
|
||||||
|
self.assertEqual("account-subject", account_records[0].data["subject_id"])
|
||||||
|
|
||||||
|
identity_records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(identity_id="identity-subject"),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(identity_records))
|
||||||
|
self.assertEqual("identity", identity_records[0].data["subject_kind"])
|
||||||
|
|
||||||
|
exported = json.dumps(
|
||||||
|
[item.to_dict() for item in account_records + identity_records],
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
self.assertNotIn("private-unrelated-title-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-other-tenant-title-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-metadata-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-evidence-id-do-not-export", exported)
|
||||||
|
|
||||||
|
def test_direct_case_exports_typed_history_without_opaque_content(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(external_references={"cases.case": "case-1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
types = [item.resource_type for item in records]
|
||||||
|
self.assertEqual(1, types.count("cases_case_identity"))
|
||||||
|
self.assertEqual(2, types.count("cases_case_revision"))
|
||||||
|
self.assertEqual(1, types.count("cases_current_case_fact"))
|
||||||
|
self.assertEqual(2, types.count("cases_timeline_event"))
|
||||||
|
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
|
||||||
|
self.assertIn("Subject permit revised", exported)
|
||||||
|
self.assertNotIn("account-subject", exported)
|
||||||
|
self.assertNotIn("identity-subject", exported)
|
||||||
|
self.assertNotIn("private-party-reference-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-evidence-id-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-create-reason-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-update-reason-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-create-key-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-update-key-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-metadata-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-updated-metadata-do-not-export", exported)
|
||||||
|
|
||||||
|
def test_operator_attribution_excludes_raw_case_content(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="operator-1"),
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(records), 5)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
item.resource_type
|
||||||
|
in {"cases_operator_attribution", "cases_timeline_event"}
|
||||||
|
for item in records
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exported = json.dumps([item.to_dict() for item in records], sort_keys=True)
|
||||||
|
self.assertNotIn("Subject permit revised", exported)
|
||||||
|
self.assertNotIn("account-subject", exported)
|
||||||
|
self.assertNotIn("identity-subject", exported)
|
||||||
|
self.assertNotIn("private-update-reason-do-not-export", exported)
|
||||||
|
self.assertNotIn("private-updated-metadata-do-not-export", exported)
|
||||||
|
|
||||||
|
def test_direct_and_canonical_selector_conflicts_fail_closed(self) -> None:
|
||||||
|
conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-subject",
|
||||||
|
external_references={"cases.case": "case-unrelated"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), conflict)
|
||||||
|
|
||||||
|
alias_conflict = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-subject",
|
||||||
|
external_references={"cases.account": "account-other"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), alias_conflict)
|
||||||
|
|
||||||
|
def test_planning_retains_history_and_requires_current_fact_review(self) -> None:
|
||||||
|
subject = DsarSubjectRef(external_references={"cases.case": "case-1"})
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertEqual(5, sum(item.kind == "retain" for item in actions))
|
||||||
|
self.assertEqual(1, sum(item.kind == "manual_review" for item in actions))
|
||||||
|
self.assertTrue(all(not item.executable for item in actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-cases-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item.status == "blocked" for item in results))
|
||||||
|
|
||||||
|
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||||
|
subject = DsarSubjectRef(account_id="account-subject")
|
||||||
|
foreign_record = DsarRecordRef(
|
||||||
|
provider_id="foreign",
|
||||||
|
module_id="foreign",
|
||||||
|
resource_type="foreign",
|
||||||
|
resource_id="foreign-1",
|
||||||
|
category="foreign",
|
||||||
|
title="Foreign record",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(foreign_record,),
|
||||||
|
)
|
||||||
|
|
||||||
|
foreign_action = DsarErasureActionRef(
|
||||||
|
action_id="foreign:delete:1",
|
||||||
|
provider_id="foreign",
|
||||||
|
module_id="foreign",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="foreign",
|
||||||
|
resource_id="foreign-1",
|
||||||
|
title="Delete foreign",
|
||||||
|
rationale="No",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
actions=(foreign_action,),
|
||||||
|
request_id="dsar-cases-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workflow_discovers_only_the_active_tenant_capability(self) -> None:
|
||||||
|
active = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-CASES-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-subject"),
|
||||||
|
purpose="Subject access request",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-operator",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=active,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", active.status)
|
||||||
|
self.assertEqual(
|
||||||
|
[CASES_DSAR_CAPABILITY], active.coverage["provider_capabilities"]
|
||||||
|
)
|
||||||
|
self.assertEqual(["cases"], active.coverage["covered_modules"])
|
||||||
|
|
||||||
|
inactive = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-CASES-2",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-subject"),
|
||||||
|
purpose="Inactive module coverage",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-operator",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, cases_active=False),
|
||||||
|
row=inactive,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||||
|
self.assertEqual(
|
||||||
|
[CASES_DSAR_CAPABILITY],
|
||||||
|
inactive.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
self.assertEqual(0, inactive.search_result["record_count"])
|
||||||
|
|
||||||
|
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||||
|
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(CASES_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||||
|
self.assertIn(
|
||||||
|
CASES_DSAR_CAPABILITY,
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
topic.id == "cases.data-subject-requests"
|
||||||
|
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||||
|
for topic in manifest.documentation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.evidence_links import (
|
||||||
|
link_case_evidence,
|
||||||
|
linked_source,
|
||||||
|
unlink_case_evidence,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
CaseStoreError,
|
||||||
|
case_history,
|
||||||
|
case_timeline,
|
||||||
|
create_case,
|
||||||
|
update_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 21, 14, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
account_id: str = "account-1"
|
||||||
|
scopes: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
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 case_record() -> CaseRecord:
|
||||||
|
case_ref = ref("case", "case-1", "cases")
|
||||||
|
return CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number="PERMIT-2026-0001",
|
||||||
|
case_type_key="permit",
|
||||||
|
status_key="open",
|
||||||
|
title="Resident permit",
|
||||||
|
context=GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Case intake.",
|
||||||
|
),
|
||||||
|
case_ref=case_ref,
|
||||||
|
),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Case created.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def file_reference(**changes: str) -> dict[str, str]:
|
||||||
|
result = {
|
||||||
|
"owner_module": "files",
|
||||||
|
"kind": "file-version",
|
||||||
|
"object_id": "version-1",
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"version": "version-1",
|
||||||
|
"path": "/files?fileId=file-1&versionId=version-1",
|
||||||
|
}
|
||||||
|
result.update(changes)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CaseEvidenceLinkTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
CaseStatusDefinition.__table__,
|
||||||
|
CaseTypeDefinition.__table__,
|
||||||
|
CaseIdentity.__table__,
|
||||||
|
CaseRecordRevision.__table__,
|
||||||
|
CaseAccessGrant.__table__,
|
||||||
|
CaseTimelineEntry.__table__,
|
||||||
|
):
|
||||||
|
table.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = Principal()
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
status_key="open",
|
||||||
|
label="Open",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
type_key="permit",
|
||||||
|
label="Permit",
|
||||||
|
initial_status_key="open",
|
||||||
|
)
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
record=case_record(),
|
||||||
|
idempotency_key="create-case",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def link(self, **reference_changes: str) -> CaseRecord:
|
||||||
|
return link_case_evidence(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=1,
|
||||||
|
reference=file_reference(**reference_changes),
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
change_reason="Linked exact file version.",
|
||||||
|
idempotency_key="link-file-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_link_appends_minimized_immutable_revision_and_audit_event(self) -> None:
|
||||||
|
linked = self.link()
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(2, linked.revision)
|
||||||
|
self.assertEqual(1, len(linked.evidence_refs))
|
||||||
|
evidence = linked.evidence_refs[0]
|
||||||
|
self.assertEqual("files", evidence.owner_module)
|
||||||
|
self.assertEqual("version-1", evidence.version)
|
||||||
|
self.assertTrue(evidence.evidence_id.startswith("linked-"))
|
||||||
|
source = linked_source(evidence)
|
||||||
|
self.assertEqual("file-version", source["kind"])
|
||||||
|
self.assertEqual("/files?fileId=file-1&versionId=version-1", source["path"])
|
||||||
|
self.assertNotIn("label", evidence.source_ref or "")
|
||||||
|
self.assertNotIn("content", evidence.source_ref or "")
|
||||||
|
|
||||||
|
history = case_history(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
)
|
||||||
|
timeline = case_timeline(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
)
|
||||||
|
self.assertEqual([2, 1], [item.revision for item in history])
|
||||||
|
self.assertEqual("cases.case.updated", timeline[0]["event_type"])
|
||||||
|
self.assertNotIn(evidence.evidence_id, str(timeline[0]["payload"]))
|
||||||
|
|
||||||
|
def test_link_is_replay_safe_and_rejects_duplicate_or_stale_changes(self) -> None:
|
||||||
|
first = self.link()
|
||||||
|
replay = self.link()
|
||||||
|
self.assertEqual(first.reference, replay.reference)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "already linked"):
|
||||||
|
link_case_evidence(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=2,
|
||||||
|
reference=file_reference(),
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
change_reason="Duplicate link.",
|
||||||
|
idempotency_key="duplicate-link",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = update_case(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=2,
|
||||||
|
changes={"title": "Updated permit"},
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
change_reason="Updated title.",
|
||||||
|
idempotency_key="update-title",
|
||||||
|
)
|
||||||
|
self.assertEqual(3, updated.revision)
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||||
|
link_case_evidence(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
expected_revision=2,
|
||||||
|
reference={
|
||||||
|
"owner_module": "campaigns",
|
||||||
|
"kind": "campaign",
|
||||||
|
"object_id": "campaign-1",
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"version": "campaign-version-1",
|
||||||
|
"path": "/campaigns/campaign-1",
|
||||||
|
},
|
||||||
|
recorded_at=NOW + timedelta(minutes=3),
|
||||||
|
change_reason="Stale link.",
|
||||||
|
idempotency_key="stale-link",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_cross_tenant_unsupported_and_malformed_owner_paths(self) -> None:
|
||||||
|
for changes, message in (
|
||||||
|
({"tenant_id": "tenant-2"}, "cross Case tenants"),
|
||||||
|
({"owner_module": "unknown"}, "not supported"),
|
||||||
|
({"path": "/files?versionId=version-1"}, "exact authorized"),
|
||||||
|
({"path": "https://example.invalid/file"}, "local platform"),
|
||||||
|
):
|
||||||
|
with self.subTest(changes=changes):
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, message):
|
||||||
|
self.link(**changes)
|
||||||
|
|
||||||
|
def test_unlink_only_removes_cases_owned_links_and_is_replay_safe(self) -> None:
|
||||||
|
linked = self.link()
|
||||||
|
self.session.commit()
|
||||||
|
evidence_id = linked.evidence_refs[0].evidence_id
|
||||||
|
unlinked = unlink_case_evidence(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
expected_revision=2,
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
change_reason="Removed obsolete link.",
|
||||||
|
idempotency_key="unlink-file-1",
|
||||||
|
)
|
||||||
|
replay = unlink_case_evidence(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
expected_revision=2,
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
change_reason="Removed obsolete link.",
|
||||||
|
idempotency_key="unlink-file-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(3, unlinked.revision)
|
||||||
|
self.assertEqual(unlinked.reference, replay.reference)
|
||||||
|
self.assertEqual((), unlinked.evidence_refs)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CaseStoreError, "not found"):
|
||||||
|
unlink_case_evidence(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
case_id="case-1",
|
||||||
|
evidence_id="ordinary-evidence",
|
||||||
|
expected_revision=3,
|
||||||
|
recorded_at=NOW + timedelta(minutes=3),
|
||||||
|
change_reason="Invalid unlink.",
|
||||||
|
idempotency_key="invalid-unlink",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
LegalBasisReference,
|
||||||
|
PartyRepresentation,
|
||||||
|
PartySubjectReference,
|
||||||
|
ProcedureParty,
|
||||||
|
ServiceBinding,
|
||||||
|
ServiceDefinition,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.manifest import get_manifest
|
||||||
|
from govoplan_cases.backend.party_context import (
|
||||||
|
CasePartyCompatibilityRecord,
|
||||||
|
CasePartyContext,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.service_intake import CaseServiceIntake
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def reference(kind: str, object_id: str) -> InstitutionalReference:
|
||||||
|
owners = {
|
||||||
|
"organization_unit": "organizations",
|
||||||
|
"function": "organizations",
|
||||||
|
"mandate": "committee",
|
||||||
|
"jurisdiction": "organizations",
|
||||||
|
"service": "portal",
|
||||||
|
"case": "cases",
|
||||||
|
"party": "cases",
|
||||||
|
}
|
||||||
|
return InstitutionalReference(
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
owner_module=owners[kind],
|
||||||
|
object_id=object_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="1",
|
||||||
|
valid_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evidence() -> EvidenceReference:
|
||||||
|
return EvidenceReference(
|
||||||
|
kind="snapshot",
|
||||||
|
owner_module="addresses",
|
||||||
|
evidence_id="contact-snapshot-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="1",
|
||||||
|
captured_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service() -> ServiceDefinition:
|
||||||
|
return ServiceDefinition(
|
||||||
|
reference=replace(reference("service", "permit"), version="4"),
|
||||||
|
key="permit.apply",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="4",
|
||||||
|
valid_from=NOW - timedelta(days=1),
|
||||||
|
valid_to=NOW + timedelta(days=1),
|
||||||
|
recorded_at=NOW - timedelta(days=2),
|
||||||
|
),
|
||||||
|
title="Apply for a permit",
|
||||||
|
audience=("resident",),
|
||||||
|
legal_bases=(
|
||||||
|
LegalBasisReference(
|
||||||
|
kind="law",
|
||||||
|
authority="Example legislature",
|
||||||
|
reference="permit-law:3",
|
||||||
|
version="2026-01",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
required_evidence_types=("identity", "application"),
|
||||||
|
deadline_refs=("deadline:permit:90d",),
|
||||||
|
responsible_organization_ref=reference("organization_unit", "unit-1"),
|
||||||
|
responsible_function_ref=reference("function", "function-1"),
|
||||||
|
mandate_ref=reference("mandate", "mandate-1"),
|
||||||
|
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
|
||||||
|
bindings=(
|
||||||
|
ServiceBinding("case", "permit-application"),
|
||||||
|
ServiceBinding("form", "forms:permit-application"),
|
||||||
|
ServiceBinding("workflow", "workflow:permit-review"),
|
||||||
|
ServiceBinding("result", "decision:permit"),
|
||||||
|
),
|
||||||
|
publication_state="published",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def procedure_party(*, revoked_at: datetime | None = None) -> ProcedureParty:
|
||||||
|
represented = reference("party", "applicant")
|
||||||
|
representative = reference("party", "representative")
|
||||||
|
return ProcedureParty(
|
||||||
|
reference=representative,
|
||||||
|
procedure_ref=reference("case", "case-1"),
|
||||||
|
role="representative",
|
||||||
|
subject=PartySubjectReference(
|
||||||
|
kind="identity",
|
||||||
|
provider="identity",
|
||||||
|
subject_id="identity-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
),
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="2",
|
||||||
|
valid_from=NOW - timedelta(days=2),
|
||||||
|
recorded_at=NOW - timedelta(days=2),
|
||||||
|
),
|
||||||
|
preferred_channels=("postbox",),
|
||||||
|
permitted_channels=("postbox", "mail"),
|
||||||
|
delivery_recipient=True,
|
||||||
|
representations=(
|
||||||
|
PartyRepresentation(
|
||||||
|
representative_party_ref=representative,
|
||||||
|
represented_party_ref=represented,
|
||||||
|
power_ref="power-1",
|
||||||
|
permitted_actions=("submit", "receive"),
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW - timedelta(days=1),
|
||||||
|
recorded_at=NOW - timedelta(days=1),
|
||||||
|
),
|
||||||
|
evidence=(evidence(),),
|
||||||
|
revoked_at=revoked_at,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
contact_snapshot_refs=("addresses:snapshot-1",),
|
||||||
|
evidence=(evidence(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PartyProvider:
|
||||||
|
def __init__(self, party: ProcedureParty) -> None:
|
||||||
|
self.party = party
|
||||||
|
|
||||||
|
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
|
||||||
|
return (self.party,)
|
||||||
|
|
||||||
|
|
||||||
|
class DenyingPartyProvider:
|
||||||
|
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
|
||||||
|
raise InstitutionalContextError("Party provider access denied.")
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(self, provider: object | None = None) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == CAPABILITY_PARTY_RESOLVER and self.provider is not None
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
|
||||||
|
class InstitutionalConsumerTests(unittest.TestCase):
|
||||||
|
def test_service_definition_drives_versioned_case_intake(self) -> None:
|
||||||
|
plan = CaseServiceIntake().plan(service(), case_id="case-1", effective_at=NOW)
|
||||||
|
|
||||||
|
self.assertEqual("permit-application", plan.case_type_ref)
|
||||||
|
self.assertEqual("4", plan.service_ref.version)
|
||||||
|
self.assertEqual("permit", plan.context.service_ref.object_id)
|
||||||
|
self.assertEqual("mandate-1", plan.context.mandate_ref.object_id)
|
||||||
|
self.assertEqual("city-1", plan.context.jurisdiction_refs[0].object_id)
|
||||||
|
self.assertEqual(("workflow:permit-review",), plan.workflow_refs)
|
||||||
|
|
||||||
|
def test_case_party_provider_drives_frozen_delivery_authority(self) -> None:
|
||||||
|
context = CasePartyContext(Registry(PartyProvider(procedure_party())))
|
||||||
|
party_set = context.resolve(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
case_ref=reference("case", "case-1"),
|
||||||
|
effective_at=NOW,
|
||||||
|
)
|
||||||
|
targets = context.delivery_targets(party_set, channel="postbox")
|
||||||
|
|
||||||
|
self.assertEqual("provider", party_set.source)
|
||||||
|
self.assertEqual(("addresses:snapshot-1",), targets[0].contact_snapshot_refs)
|
||||||
|
self.assertEqual("applicant", targets[0].represented_party_refs[0].object_id)
|
||||||
|
|
||||||
|
def test_revoked_representation_is_not_delivery_authority(self) -> None:
|
||||||
|
context = CasePartyContext(
|
||||||
|
Registry(PartyProvider(procedure_party(revoked_at=NOW)))
|
||||||
|
)
|
||||||
|
party_set = context.resolve(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
case_ref=reference("case", "case-1"),
|
||||||
|
effective_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
context.delivery_targets(party_set, channel="postbox")[0].represented_party_refs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cases_only_compatibility_path_is_bounded(self) -> None:
|
||||||
|
subject = PartySubjectReference(
|
||||||
|
kind="organization",
|
||||||
|
provider="organizations",
|
||||||
|
subject_id="organization-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
party_set = CasePartyContext().resolve(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
case_ref=reference("case", "case-1"),
|
||||||
|
effective_at=NOW,
|
||||||
|
compatibility=(
|
||||||
|
CasePartyCompatibilityRecord(
|
||||||
|
party_id="party-1",
|
||||||
|
role="applicant",
|
||||||
|
subject=subject,
|
||||||
|
valid_from=NOW - timedelta(days=1),
|
||||||
|
permitted_channels=("mail",),
|
||||||
|
delivery_recipient=True,
|
||||||
|
contact_snapshot_refs=("addresses:snapshot-2",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("compatibility", party_set.source)
|
||||||
|
self.assertEqual("organization-1", party_set.parties[0].subject.subject_id)
|
||||||
|
self.assertEqual((), party_set.parties[0].representations)
|
||||||
|
|
||||||
|
def test_cross_tenant_compatibility_subject_fails_closed(self) -> None:
|
||||||
|
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||||
|
CasePartyContext().resolve(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
case_ref=reference("case", "case-1"),
|
||||||
|
effective_at=NOW,
|
||||||
|
compatibility=(
|
||||||
|
CasePartyCompatibilityRecord(
|
||||||
|
party_id="party-1",
|
||||||
|
role="applicant",
|
||||||
|
subject=PartySubjectReference(
|
||||||
|
kind="identity",
|
||||||
|
provider="identity",
|
||||||
|
subject_id="identity-9",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
),
|
||||||
|
valid_from=NOW,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_provider_access_denial_never_falls_back_to_local_rows(self) -> None:
|
||||||
|
compatibility = CasePartyCompatibilityRecord(
|
||||||
|
party_id="party-1",
|
||||||
|
role="applicant",
|
||||||
|
subject=PartySubjectReference(
|
||||||
|
kind="identity",
|
||||||
|
provider="identity",
|
||||||
|
subject_id="identity-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
valid_from=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(InstitutionalContextError, "access denied"):
|
||||||
|
CasePartyContext(Registry(DenyingPartyProvider())).resolve(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
case_ref=reference("case", "case-1"),
|
||||||
|
effective_at=NOW,
|
||||||
|
compatibility=(compatibility,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_exposes_optional_party_provider(self) -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
|
||||||
|
self.assertEqual("cases", manifest.id)
|
||||||
|
self.assertEqual((), manifest.dependencies)
|
||||||
|
self.assertIn(CAPABILITY_PARTY_RESOLVER, manifest.optional_capabilities)
|
||||||
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
|
self.assertIn("cases.registry", manifest.capability_factories)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_cases.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
|
||||||
|
frontend = get_manifest().frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"cases.navigation",
|
||||||
|
"cases.list",
|
||||||
|
"cases.list.filters",
|
||||||
|
"cases.detail",
|
||||||
|
"cases.detail.summary",
|
||||||
|
"cases.detail.editor",
|
||||||
|
"cases.detail.references",
|
||||||
|
"cases.detail.timeline",
|
||||||
|
"cases.detail.history",
|
||||||
|
"cases.detail.access",
|
||||||
|
"cases.detail.decision",
|
||||||
|
},
|
||||||
|
set(surfaces),
|
||||||
|
)
|
||||||
|
self.assertEqual("cases.list", surfaces["cases.list.filters"].parent_id)
|
||||||
|
for surface_id in (
|
||||||
|
"cases.detail.summary",
|
||||||
|
"cases.detail.editor",
|
||||||
|
"cases.detail.references",
|
||||||
|
"cases.detail.timeline",
|
||||||
|
"cases.detail.history",
|
||||||
|
"cases.detail.access",
|
||||||
|
"cases.detail.decision",
|
||||||
|
):
|
||||||
|
self.assertEqual("cases.detail", surfaces[surface_id].parent_id)
|
||||||
|
|
||||||
|
def test_help_and_consequence_metadata_remain_published(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in get_manifest().documentation}
|
||||||
|
context = topics["cases.institutional-context"]
|
||||||
|
reference = topics["cases.reference.lifecycle-access-and-evidence"]
|
||||||
|
owner_links = topics["cases.workflow.link-owner-references"]
|
||||||
|
|
||||||
|
self.assertIn("cases.state.read-only", context.metadata["help_contexts"])
|
||||||
|
self.assertIn("cases.field.access-grant", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("update_case", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("close_case", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn("change_access", reference.metadata["consequence_classes"])
|
||||||
|
self.assertIn(
|
||||||
|
"link_owner_reference",
|
||||||
|
owner_links.metadata["consequence_classes"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||||
|
list_page = (
|
||||||
|
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
detail_page = (
|
||||||
|
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
access_dialog = (
|
||||||
|
REPO_ROOT / "webui/src/features/cases/CaseShareDialog.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("DocumentationHelpLink", list_page)
|
||||||
|
for component in (
|
||||||
|
"ActionBlockerHint",
|
||||||
|
"DocumentationHelpLink",
|
||||||
|
"FormField",
|
||||||
|
"useUnsavedDraftGuard",
|
||||||
|
"QUICK_ACCESS_RESULT_EVENT",
|
||||||
|
"linkCaseEvidence",
|
||||||
|
"unlinkCaseEvidence",
|
||||||
|
):
|
||||||
|
self.assertIn(component, detail_page)
|
||||||
|
for component in (
|
||||||
|
"ConfirmDialog",
|
||||||
|
"DocumentationHelpLink",
|
||||||
|
"ReferenceSelect",
|
||||||
|
"useUnsavedDraftGuard",
|
||||||
|
):
|
||||||
|
self.assertIn(component, access_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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_cases.backend.manifest import get_manifest
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
|
||||||
|
|
||||||
|
class CasesMigrationTests(unittest.TestCase):
|
||||||
|
def test_fresh_migration_creates_case_sharing_schema_and_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-cases-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'cases.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("cases",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"case_access_grants",
|
||||||
|
"case_identities",
|
||||||
|
"case_record_revisions",
|
||||||
|
"case_status_definitions",
|
||||||
|
"case_timeline_entries",
|
||||||
|
"case_type_definitions",
|
||||||
|
}.issubset(inspector.get_table_names())
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"access_mode",
|
||||||
|
{
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_columns("case_record_revisions")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"f6d3a8b1c4e7",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.records import RecordContractError, RecordSourceLocator
|
||||||
|
from govoplan_cases.backend.db.models import CaseIdentity, CaseRecordRevision
|
||||||
|
from govoplan_cases.backend.record_source import CasesRecordSource
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Principal:
|
||||||
|
tenant_id: str = "tenant-1"
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope == "cases:case:read"
|
||||||
|
|
||||||
|
|
||||||
|
class CasesRecordSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
CaseIdentity.__table__.create(self.engine)
|
||||||
|
CaseRecordRevision.__table__.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
identity = CaseIdentity(
|
||||||
|
id="identity-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
case_id="case-1",
|
||||||
|
case_number="2026/C-1",
|
||||||
|
created_by="account-1",
|
||||||
|
)
|
||||||
|
revision = CaseRecordRevision(
|
||||||
|
id="revision-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
case_id="case-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
revision=3,
|
||||||
|
case_type_key="permit",
|
||||||
|
status_key="review",
|
||||||
|
title="Permit application",
|
||||||
|
access_mode="restricted",
|
||||||
|
search_text="permit application",
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
snapshot={
|
||||||
|
"case_id": "case-1",
|
||||||
|
"revision": 3,
|
||||||
|
"title": "Permit application",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.session.add_all((identity, revision))
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def locator(self) -> RecordSourceLocator:
|
||||||
|
return RecordSourceLocator(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_module="cases",
|
||||||
|
resource_type="case_revision",
|
||||||
|
resource_id="case-1",
|
||||||
|
source_revision="3",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_cases.backend.record_source.can_access_case", return_value=True
|
||||||
|
):
|
||||||
|
result = CasesRecordSource().resolve(
|
||||||
|
self.session,
|
||||||
|
Principal(),
|
||||||
|
locator=self.locator(),
|
||||||
|
purpose="preserve decision basis",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("2026/C-1 - Permit application", result.label)
|
||||||
|
self.assertEqual(64, len(result.content_sha256 or ""))
|
||||||
|
self.assertEqual("review", result.metadata["status_key"])
|
||||||
|
|
||||||
|
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_cases.backend.record_source.can_access_case",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(RecordContractError, "cannot read"),
|
||||||
|
):
|
||||||
|
CasesRecordSource().resolve(
|
||||||
|
self.session,
|
||||||
|
Principal(),
|
||||||
|
locator=self.locator(),
|
||||||
|
purpose="preserve decision basis",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchResourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.db.models import (
|
||||||
|
CaseAccessGrant,
|
||||||
|
CaseIdentity,
|
||||||
|
CaseRecordRevision,
|
||||||
|
CaseStatusDefinition,
|
||||||
|
CaseTimelineEntry,
|
||||||
|
CaseTypeDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_cases.backend.domain import CaseRecord
|
||||||
|
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
|
||||||
|
from govoplan_cases.backend.service import (
|
||||||
|
create_case,
|
||||||
|
upsert_case_status,
|
||||||
|
upsert_case_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 6, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class CasesSearchSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
CaseStatusDefinition.__table__,
|
||||||
|
CaseTypeDefinition.__table__,
|
||||||
|
CaseIdentity.__table__,
|
||||||
|
CaseRecordRevision.__table__,
|
||||||
|
CaseAccessGrant.__table__,
|
||||||
|
CaseTimelineEntry.__table__,
|
||||||
|
):
|
||||||
|
table.create(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
principal = _principal({"cases:case:read", "cases:case:create"})
|
||||||
|
upsert_case_status(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
status_key="review",
|
||||||
|
label="Review",
|
||||||
|
)
|
||||||
|
upsert_case_type(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
type_key="permit",
|
||||||
|
label="Permit",
|
||||||
|
initial_status_key="review",
|
||||||
|
)
|
||||||
|
case_ref = _ref("case", "case-1", "cases")
|
||||||
|
create_case(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
record=CaseRecord(
|
||||||
|
reference=case_ref,
|
||||||
|
case_number="PERMIT-1",
|
||||||
|
case_type_key="permit",
|
||||||
|
status_key="review",
|
||||||
|
title="Permit application",
|
||||||
|
context=GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="1",
|
||||||
|
valid_from=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Created.",
|
||||||
|
),
|
||||||
|
case_ref=case_ref,
|
||||||
|
),
|
||||||
|
opened_at=NOW,
|
||||||
|
recorded_at=NOW,
|
||||||
|
change_reason="Created.",
|
||||||
|
),
|
||||||
|
idempotency_key="case-create",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.source = CasesSearchSource()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_backfill_authorization_and_event_change_are_bounded(self) -> None:
|
||||||
|
page = self.source.backfill(
|
||||||
|
self.session,
|
||||||
|
request=SearchBackfillRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type="case",
|
||||||
|
rebuild_id="rebuild-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents))
|
||||||
|
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
)
|
||||||
|
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||||
|
self.assertTrue(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal({"cases:case:read"}),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal(set()),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
changes = self.source.index_changes_for_event(
|
||||||
|
self.session,
|
||||||
|
event=PlatformEvent(
|
||||||
|
type="cases.case.updated",
|
||||||
|
module_id="cases",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
resource=EventObjectRef(type="case", id="case-1"),
|
||||||
|
),
|
||||||
|
delivery_key="delivery-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("upsert", changes[0].kind)
|
||||||
|
|
||||||
|
|
||||||
|
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 _principal(scopes: set[str]) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="membership-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/cases-webui",
|
||||||
|
"version": "0.1.19",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/cases.css": "./src/styles/cases.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,297 @@
|
|||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
apiPath,
|
||||||
|
apiReferenceOptionProvider,
|
||||||
|
type ApiSettings,
|
||||||
|
type ReferenceOptionProvider
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
export type InstitutionalReference = {
|
||||||
|
kind: string;
|
||||||
|
owner_module: string;
|
||||||
|
object_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EvidenceReference = {
|
||||||
|
kind: string;
|
||||||
|
owner_module: string;
|
||||||
|
evidence_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
version?: string | null;
|
||||||
|
source_ref?: string | null;
|
||||||
|
derived_from?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnerReference = {
|
||||||
|
ownerModule: string;
|
||||||
|
kind: string;
|
||||||
|
objectId: string;
|
||||||
|
tenantId: string;
|
||||||
|
version?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseRecord = {
|
||||||
|
reference: InstitutionalReference;
|
||||||
|
revision: number;
|
||||||
|
case_number: string;
|
||||||
|
case_type_key: string;
|
||||||
|
status_key: string;
|
||||||
|
title: string;
|
||||||
|
access_mode: "tenant" | "restricted";
|
||||||
|
access_grants: CaseGrant[];
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
service_ref?: InstitutionalReference | null;
|
||||||
|
party_refs: InstitutionalReference[];
|
||||||
|
assignment_refs: InstitutionalReference[];
|
||||||
|
evidence_refs: EvidenceReference[];
|
||||||
|
decision_refs: InstitutionalReference[];
|
||||||
|
record_refs: InstitutionalReference[];
|
||||||
|
opened_at: string;
|
||||||
|
recorded_at: string;
|
||||||
|
deadline_at?: string | null;
|
||||||
|
closed_at?: string | null;
|
||||||
|
change_reason: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseGrant = {
|
||||||
|
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
|
||||||
|
subject_id: string;
|
||||||
|
permissions: Array<"read" | "update" | "share" | "admin">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseStatusDefinition = {
|
||||||
|
status_key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
terminal: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
active: boolean;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseTypeDefinition = {
|
||||||
|
type_key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
initial_status_key: string;
|
||||||
|
allowed_status_keys: string[];
|
||||||
|
active: boolean;
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseCatalog = {
|
||||||
|
statuses: CaseStatusDefinition[];
|
||||||
|
types: CaseTypeDefinition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseListResponse = {
|
||||||
|
cases: CaseRecord[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseTimelineEntry = {
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
case_revision: number;
|
||||||
|
summary: string;
|
||||||
|
actor_id?: string | null;
|
||||||
|
occurred_at: string;
|
||||||
|
audit_event_id?: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormalDecision = {
|
||||||
|
reference: InstitutionalReference;
|
||||||
|
temporal: {
|
||||||
|
revision: string;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_to?: string | null;
|
||||||
|
recorded_at?: string | null;
|
||||||
|
change_reason?: string | null;
|
||||||
|
};
|
||||||
|
decision_type: string;
|
||||||
|
state: string;
|
||||||
|
assurance_level: string;
|
||||||
|
operative_result?: string | null;
|
||||||
|
reasoning?: string | null;
|
||||||
|
conditions: string[];
|
||||||
|
authority_context: Record<string, unknown>;
|
||||||
|
delivery_refs: string[];
|
||||||
|
remedy_refs: string[];
|
||||||
|
review_refs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseDecisionResult = {
|
||||||
|
case: CaseRecord;
|
||||||
|
decision: FormalDecision;
|
||||||
|
replayed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listCases(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {
|
||||||
|
query?: string;
|
||||||
|
statuses?: string[];
|
||||||
|
caseTypes?: string[];
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<CaseListResponse> {
|
||||||
|
return apiFetch<CaseListResponse>(settings, apiPath("/api/v1/cases", {
|
||||||
|
query: options.query,
|
||||||
|
status_key: options.statuses,
|
||||||
|
case_type_key: options.caseTypes,
|
||||||
|
offset: options.offset,
|
||||||
|
limit: options.limit
|
||||||
|
}), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCase(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<CaseRecord> {
|
||||||
|
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCaseCatalog(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<CaseCatalog> {
|
||||||
|
return apiFetch<CaseCatalog>(settings, "/api/v1/cases/catalog", { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseHistory(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ revisions: CaseRecord[] }> {
|
||||||
|
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseTimeline(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ entries: CaseTimelineEntry[] }> {
|
||||||
|
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCaseDecisions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<{ decisions: FormalDecision[] }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordCaseDecision(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
payload: {
|
||||||
|
expected_case_revision: number;
|
||||||
|
effective_at: string;
|
||||||
|
decision_type: string;
|
||||||
|
operative_result: string;
|
||||||
|
reasoning: string;
|
||||||
|
conditions: string[];
|
||||||
|
change_reason: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
}
|
||||||
|
): Promise<CaseDecisionResult> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCase(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
recorded_at: string;
|
||||||
|
change_reason: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
title?: string;
|
||||||
|
status_key?: string;
|
||||||
|
access_mode?: "tenant" | "restricted";
|
||||||
|
access_grants?: CaseGrant[];
|
||||||
|
}
|
||||||
|
): Promise<CaseRecord> {
|
||||||
|
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkCaseEvidence(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
recorded_at: string;
|
||||||
|
change_reason: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
reference: {
|
||||||
|
owner_module: string;
|
||||||
|
kind: string;
|
||||||
|
object_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
version: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
): Promise<CaseRecord> {
|
||||||
|
return apiFetch<CaseRecord>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlinkCaseEvidence(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
evidenceId: string,
|
||||||
|
payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
recorded_at: string;
|
||||||
|
change_reason: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
}
|
||||||
|
): Promise<CaseRecord> {
|
||||||
|
return apiFetch<CaseRecord>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/evidence-links/${encodeURIComponent(evidenceId)}/unlink`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseShareTargetProvider(
|
||||||
|
settings: ApiSettings,
|
||||||
|
caseId: string,
|
||||||
|
targetType: "user" | "group"
|
||||||
|
): ReferenceOptionProvider {
|
||||||
|
return apiReferenceOptionProvider(
|
||||||
|
settings,
|
||||||
|
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
|
||||||
|
{ target_type: targetType }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Scale } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { FormGrid,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
i18nMessage,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
recordCaseDecision,
|
||||||
|
type CaseDecisionResult,
|
||||||
|
type CaseRecord
|
||||||
|
} from "../../api/cases";
|
||||||
|
import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
export default function CaseDecisionDialog({
|
||||||
|
settings,
|
||||||
|
record,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
record: CaseRecord;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (result: CaseDecisionResult) => void;
|
||||||
|
}) {
|
||||||
|
const [decisionType, setDecisionType] = useState("");
|
||||||
|
const [effectiveAt, setEffectiveAt] = useState("");
|
||||||
|
const [operativeResult, setOperativeResult] = useState("");
|
||||||
|
const [reasoning, setReasoning] = useState("");
|
||||||
|
const [conditions, setConditions] = useState("");
|
||||||
|
const [changeReason, setChangeReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const idempotencyKey = useRef(crypto.randomUUID());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDecisionType(defaultDecisionType(record));
|
||||||
|
setEffectiveAt(dateTimeLocalValue(new Date()));
|
||||||
|
setOperativeResult("");
|
||||||
|
setReasoning("");
|
||||||
|
setConditions("");
|
||||||
|
setChangeReason("");
|
||||||
|
setError("");
|
||||||
|
setConfirmOpen(false);
|
||||||
|
idempotencyKey.current = crypto.randomUUID();
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
const disabledReason = useMemo(() => {
|
||||||
|
if (busy) return "The formal Decision is being recorded.";
|
||||||
|
if (
|
||||||
|
!decisionType.trim()
|
||||||
|
|| !effectiveAt
|
||||||
|
|| !operativeResult.trim()
|
||||||
|
|| !reasoning.trim()
|
||||||
|
|| !changeReason.trim()
|
||||||
|
) {
|
||||||
|
return "Complete the Decision type, effective time, result, reasoning, and change reason.";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [busy, changeReason, decisionType, effectiveAt, operativeResult, reasoning]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (disabledReason) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await recordCaseDecision(settings, record.reference.object_id, {
|
||||||
|
expected_case_revision: record.revision,
|
||||||
|
effective_at: new Date(effectiveAt).toISOString(),
|
||||||
|
decision_type: decisionType.trim(),
|
||||||
|
operative_result: operativeResult.trim(),
|
||||||
|
reasoning: reasoning.trim(),
|
||||||
|
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
||||||
|
change_reason: changeReason.trim(),
|
||||||
|
idempotency_key: idempotencyKey.current
|
||||||
|
});
|
||||||
|
setConfirmOpen(false);
|
||||||
|
onSaved(result);
|
||||||
|
onClose();
|
||||||
|
} catch (reason) {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
setError(reason instanceof Error ? reason.message : "The formal Decision could not be recorded.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={busy}
|
||||||
|
portal
|
||||||
|
className="case-decision-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={busy} onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabledReason={disabledReason}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
helpContextId="cases.action.decide"
|
||||||
|
>
|
||||||
|
<Scale size={16} aria-hidden="true" />
|
||||||
|
Record decision
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="case-decision-content" data-help-context-id="cases.decision.editor">
|
||||||
|
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||||
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||||
|
<p className="case-decision-explanation">
|
||||||
|
The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.
|
||||||
|
</p>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="case-decision-grid">
|
||||||
|
<FormField label="Decision type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
value={decisionType}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(event) => setDecisionType(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Effective at" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={effectiveAt}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setEffectiveAt(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<FormField label="Operative result" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={operativeResult}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={20_000}
|
||||||
|
onChange={(event) => setOperativeResult(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Reasoning" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={7}
|
||||||
|
value={reasoning}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={50_000}
|
||||||
|
onChange={(event) => setReasoning(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Conditions (one per line)" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={conditions}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setConditions(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
value={changeReason}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={1_000}
|
||||||
|
onChange={(event) => setChangeReason(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
title="Record formal Decision"
|
||||||
|
message="Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision."
|
||||||
|
confirmLabel="Record decision"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmOpen(false)}
|
||||||
|
onConfirm={() => void save()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function defaultDecisionType(record: CaseRecord): string {
|
||||||
|
const resultRefs = Array.isArray(record.metadata.result_refs)
|
||||||
|
? record.metadata.result_refs
|
||||||
|
: [];
|
||||||
|
const result = resultRefs.find((item): item is string => typeof item === "string");
|
||||||
|
if (result) return result.replace(/^decision:/, "");
|
||||||
|
return record.case_type_key.replace(/-application$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function dateTimeLocalValue(value: Date): string {
|
||||||
|
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { ActionToolbar,
|
||||||
|
ActionBlockerHint,
|
||||||
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
IconButton,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
QUICK_ACCESS_RESULT_EVENT,
|
||||||
|
hasScope,
|
||||||
|
usePlatformModuleInstalled,
|
||||||
|
useGuardedNavigate,
|
||||||
|
useRegisterActiveObject,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type PlatformRouteContext,
|
||||||
|
type QuickAccessLaunchContext,
|
||||||
|
type QuickAccessResult
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
caseHistory,
|
||||||
|
caseTimeline,
|
||||||
|
getCase,
|
||||||
|
linkCaseEvidence,
|
||||||
|
listCaseDecisions,
|
||||||
|
listCaseCatalog,
|
||||||
|
updateCase,
|
||||||
|
unlinkCaseEvidence,
|
||||||
|
type CaseCatalog,
|
||||||
|
type CaseRecord,
|
||||||
|
type CaseTimelineEntry,
|
||||||
|
type EvidenceReference,
|
||||||
|
type FormalDecision,
|
||||||
|
type InstitutionalReference
|
||||||
|
} from "../../api/cases";
|
||||||
|
import CaseShareDialog from "./CaseShareDialog";
|
||||||
|
import CaseDecisionDialog from "./CaseDecisionDialog";
|
||||||
|
import {
|
||||||
|
CASES_DOCUMENTATION,
|
||||||
|
CASES_FIELDS_DOCUMENTATION,
|
||||||
|
CASES_I18N
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
|
||||||
|
const { caseId = "" } = useParams();
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const [record, setRecord] = useState<CaseRecord | null>(null);
|
||||||
|
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||||
|
const [history, setHistory] = useState<CaseRecord[]>([]);
|
||||||
|
const [timeline, setTimeline] = useState<CaseTimelineEntry[]>([]);
|
||||||
|
const [decisions, setDecisions] = useState<FormalDecision[]>([]);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [changeReason, setChangeReason] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
const [decisionOpen, setDecisionOpen] = useState(false);
|
||||||
|
const [linkingEvidence, setLinkingEvidence] = useState(false);
|
||||||
|
const idempotencyKey = useRef(crypto.randomUUID());
|
||||||
|
const canUpdate = hasScope(auth, "cases:case:update");
|
||||||
|
const canClose = hasScope(auth, "cases:case:close");
|
||||||
|
const canShare = hasScope(auth, "cases:case:share");
|
||||||
|
const decisionsAvailable = usePlatformModuleInstalled("decisions");
|
||||||
|
const mandatesAvailable = usePlatformModuleInstalled("mandates");
|
||||||
|
const accessAvailable = usePlatformModuleInstalled("access");
|
||||||
|
const recordsAvailable = usePlatformModuleInstalled("records");
|
||||||
|
const filesAvailable = usePlatformModuleInstalled("files");
|
||||||
|
const mailAvailable = usePlatformModuleInstalled("mail");
|
||||||
|
const campaignsAvailable = usePlatformModuleInstalled("campaigns");
|
||||||
|
const canReadDecisions = decisionsAvailable && hasScope(auth, "decisions:decision:read");
|
||||||
|
const decisionDisabledReason = !canUpdate
|
||||||
|
? "Your account may not update this Case."
|
||||||
|
: !hasScope(auth, "decisions:decision:write")
|
||||||
|
? "Your account may not record formal Decisions."
|
||||||
|
: !accessAvailable
|
||||||
|
? "Enable Access to verify the acting assignment."
|
||||||
|
: !mandatesAvailable
|
||||||
|
? "Enable Mandates to verify formal authority."
|
||||||
|
: undefined;
|
||||||
|
const canFileRecords = recordsAvailable && hasScope(auth, "records:workspace:write");
|
||||||
|
const activeCase = useMemo(() => record ? {
|
||||||
|
ownerModule: record.reference.owner_module,
|
||||||
|
kind: record.reference.kind,
|
||||||
|
objectId: record.reference.object_id,
|
||||||
|
tenantId: record.reference.tenant_id,
|
||||||
|
label: `${record.case_number} · ${record.title}`,
|
||||||
|
version: record.reference.version ?? String(record.revision),
|
||||||
|
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
|
||||||
|
} : null, [record]);
|
||||||
|
useRegisterActiveObject(activeCase);
|
||||||
|
|
||||||
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
return Promise.all([
|
||||||
|
getCase(settings, caseId, signal),
|
||||||
|
listCaseCatalog(settings, signal),
|
||||||
|
caseHistory(settings, caseId, signal),
|
||||||
|
caseTimeline(settings, caseId, signal),
|
||||||
|
canReadDecisions
|
||||||
|
? listCaseDecisions(settings, caseId, signal)
|
||||||
|
: Promise.resolve({ decisions: [] as FormalDecision[] })
|
||||||
|
]).
|
||||||
|
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
|
||||||
|
setRecord(nextRecord);
|
||||||
|
setCatalog(nextCatalog);
|
||||||
|
setHistory(nextHistory.revisions);
|
||||||
|
setTimeline(nextTimeline.entries);
|
||||||
|
setDecisions(nextDecisions.decisions);
|
||||||
|
setTitle(nextRecord.title);
|
||||||
|
setStatus(nextRecord.status_key);
|
||||||
|
setChangeReason("");
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
}, [canReadDecisions, caseId, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
load(controller.signal).catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Case could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const statuses = useMemo(() => {
|
||||||
|
const type = catalog.types.find((item) => item.type_key === record?.case_type_key);
|
||||||
|
const allowed = new Set(type?.allowed_status_keys ?? []);
|
||||||
|
return catalog.statuses.filter((item) =>
|
||||||
|
(allowed.size === 0 || allowed.has(item.status_key))
|
||||||
|
&& (canClose || !item.terminal || item.status_key === record?.status_key)
|
||||||
|
);
|
||||||
|
}, [canClose, catalog, record]);
|
||||||
|
const changed = Boolean(record && (title.trim() !== record.title || status !== record.status_key));
|
||||||
|
const draftDirty = Boolean(record && canUpdate && (changed || changeReason.trim()));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!record || !canUpdate) return;
|
||||||
|
const receiveReference = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{
|
||||||
|
launchContext?: QuickAccessLaunchContext;
|
||||||
|
result?: QuickAccessResult;
|
||||||
|
}>).detail;
|
||||||
|
const sourceCase = detail?.launchContext?.activeObject;
|
||||||
|
const result = detail?.result;
|
||||||
|
if (
|
||||||
|
!sourceCase
|
||||||
|
|| sourceCase.ownerModule !== "cases"
|
||||||
|
|| sourceCase.kind !== "case"
|
||||||
|
|| sourceCase.objectId !== record.reference.object_id
|
||||||
|
|| sourceCase.tenantId !== record.reference.tenant_id
|
||||||
|
|| result?.outcome !== "completed"
|
||||||
|
|| result.action !== "selected"
|
||||||
|
|| !result.reference
|
||||||
|
) return;
|
||||||
|
if (draftDirty) {
|
||||||
|
setError(
|
||||||
|
"Save or discard the current Case edits before linking the selected reference."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reference = result.reference;
|
||||||
|
if (!reference.version || !reference.path) {
|
||||||
|
setError("The selected owner reference is not an exact versioned link.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkingEvidence(true);
|
||||||
|
setError("");
|
||||||
|
void linkCaseEvidence(settings, caseId, {
|
||||||
|
expected_revision: record.revision,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
reference: {
|
||||||
|
owner_module: reference.ownerModule,
|
||||||
|
kind: reference.kind,
|
||||||
|
object_id: reference.objectId,
|
||||||
|
tenant_id: reference.tenantId,
|
||||||
|
version: reference.version,
|
||||||
|
path: reference.path
|
||||||
|
}
|
||||||
|
}).then((saved) => {
|
||||||
|
setRecord(saved);
|
||||||
|
setTitle(saved.title);
|
||||||
|
setStatus(saved.status_key);
|
||||||
|
return load();
|
||||||
|
}).catch((reason) => {
|
||||||
|
setError(reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "The selected reference could not be linked to this Case.");
|
||||||
|
}).finally(() => setLinkingEvidence(false));
|
||||||
|
};
|
||||||
|
window.addEventListener(QUICK_ACCESS_RESULT_EVENT, receiveReference);
|
||||||
|
return () => window.removeEventListener(
|
||||||
|
QUICK_ACCESS_RESULT_EVENT,
|
||||||
|
receiveReference
|
||||||
|
);
|
||||||
|
}, [canUpdate, caseId, draftDirty, load, record, settings]);
|
||||||
|
|
||||||
|
function discardDraft() {
|
||||||
|
if (!record) return;
|
||||||
|
setTitle(record.title);
|
||||||
|
setStatus(record.status_key);
|
||||||
|
setChangeReason("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
if (!record || !changed || !title.trim() || !changeReason.trim()) return false;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const saved = await updateCase(settings, caseId, {
|
||||||
|
expected_revision: record.revision,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
change_reason: changeReason.trim(),
|
||||||
|
idempotency_key: idempotencyKey.current,
|
||||||
|
...(title.trim() !== record.title ? { title: title.trim() } : {}),
|
||||||
|
...(status !== record.status_key ? { status_key: status } : {})
|
||||||
|
});
|
||||||
|
setRecord(saved);
|
||||||
|
setTitle(saved.title);
|
||||||
|
setStatus(saved.status_key);
|
||||||
|
setChangeReason("");
|
||||||
|
idempotencyKey.current = crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
} catch (reloadError) {
|
||||||
|
setError(reloadError instanceof Error
|
||||||
|
? `The case was saved, but its history could not be refreshed. ${reloadError.message}`
|
||||||
|
: "The case was saved, but its history could not be refreshed.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Case could not be saved.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkEvidence(evidence: EvidenceReference): Promise<void> {
|
||||||
|
if (!record || draftDirty || linkingEvidence) return;
|
||||||
|
if (!window.confirm("Remove this owner reference from the Case? The owner data is not deleted.")) return;
|
||||||
|
setLinkingEvidence(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const saved = await unlinkCaseEvidence(
|
||||||
|
settings,
|
||||||
|
caseId,
|
||||||
|
evidence.evidence_id,
|
||||||
|
{
|
||||||
|
expected_revision: record.revision,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
|
||||||
|
idempotency_key: crypto.randomUUID()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setRecord(saved);
|
||||||
|
setTitle(saved.title);
|
||||||
|
setStatus(saved.status_key);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "The owner reference could not be removed from this Case.");
|
||||||
|
} finally {
|
||||||
|
setLinkingEvidence(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: draftDirty,
|
||||||
|
title: "i18n:govoplan-cases.unsaved_title",
|
||||||
|
message: "i18n:govoplan-cases.unsaved_message",
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: discardDraft
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveDisabledReason = saving
|
||||||
|
? CASES_I18N.saving
|
||||||
|
: !changed
|
||||||
|
? CASES_I18N.noChanges
|
||||||
|
: !title.trim() || !changeReason.trim()
|
||||||
|
? CASES_I18N.incomplete
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="cases-page">
|
||||||
|
<div className="case-detail-shell">
|
||||||
|
<ActionToolbar className="case-detail-toolbar">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
|
||||||
|
<ArrowLeft size={16} aria-hidden="true" />
|
||||||
|
Cases
|
||||||
|
</button>
|
||||||
|
{record && <span>{record.case_number}</span>}
|
||||||
|
{record ? (
|
||||||
|
<div className="case-detail-actions">
|
||||||
|
{decisionsAvailable ? (
|
||||||
|
<IconButton
|
||||||
|
label="Record formal decision"
|
||||||
|
icon={<Scale size={16} />}
|
||||||
|
disabledReason={decisionDisabledReason}
|
||||||
|
onClick={() => setDecisionOpen(true)}
|
||||||
|
helpContextId="cases.action.decide"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canFileRecords ? (
|
||||||
|
<IconButton
|
||||||
|
label="File Case in eAkte"
|
||||||
|
icon={<Archive size={16} />}
|
||||||
|
onClick={() => navigate(recordFilingPath(record.reference, record.case_number, "cases", "case_revision"))}
|
||||||
|
helpContextId="records.action.file"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
|
label="Manage case access"
|
||||||
|
icon={<Share2 size={16} />}
|
||||||
|
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||||
|
onClick={() => setShareOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
|
||||||
|
</ActionToolbar>
|
||||||
|
<PageScrollViewport className="case-detail-viewport">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading case" />}
|
||||||
|
{!loading && record &&
|
||||||
|
<div className="case-detail-content">
|
||||||
|
<section className="case-detail-main">
|
||||||
|
<div className="case-detail-title-row">
|
||||||
|
<div>
|
||||||
|
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
|
||||||
|
<h1>{record.title}</h1>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canUpdate ? (
|
||||||
|
<ActionBlockerHint
|
||||||
|
tone="info"
|
||||||
|
reason={{
|
||||||
|
summary: "Case editing is read-only",
|
||||||
|
details: CASES_I18N.updateReason,
|
||||||
|
requiredAction: "Ask a case manager to make the required lifecycle change.",
|
||||||
|
actor: "A user with the Cases update permission",
|
||||||
|
target: "Case role or object access assignment"
|
||||||
|
}}
|
||||||
|
documentation={CASES_FIELDS_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canUpdate && !canClose ? (
|
||||||
|
<ActionBlockerHint
|
||||||
|
tone="info"
|
||||||
|
reason={{
|
||||||
|
summary: "Terminal case states are unavailable",
|
||||||
|
details: CASES_I18N.closeReason,
|
||||||
|
requiredAction: "Ask a case closer to complete the lifecycle transition.",
|
||||||
|
actor: "A user with the Cases close permission",
|
||||||
|
target: "Case role assignment"
|
||||||
|
}}
|
||||||
|
documentation={CASES_FIELDS_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canUpdate &&
|
||||||
|
<div className="case-edit-panel">
|
||||||
|
<FormField label="Title" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Status" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
{statuses.map((item) => <option key={item.status_key} value={item.status_key}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<div className="case-change-reason">
|
||||||
|
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input value={changeReason} onChange={(event) => setChangeReason(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabledReason={saveDisabledReason}
|
||||||
|
onClick={() => void save()}>
|
||||||
|
<Save size={16} aria-hidden="true" />
|
||||||
|
{saving ? "Saving" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="case-facts">
|
||||||
|
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
|
||||||
|
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
|
||||||
|
<Fact label="Revision" value={String(record.revision)} />
|
||||||
|
<Fact label="Last change" value={record.change_reason} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReferenceSection title="Parties" references={record.party_refs} />
|
||||||
|
<ReferenceSection title="Assignments" references={record.assignment_refs} />
|
||||||
|
<EvidenceSection
|
||||||
|
references={record.evidence_refs}
|
||||||
|
providers={{
|
||||||
|
files: filesAvailable,
|
||||||
|
mail: mailAvailable,
|
||||||
|
campaigns: campaignsAvailable
|
||||||
|
}}
|
||||||
|
canUnlink={canUpdate && !draftDirty && !linkingEvidence}
|
||||||
|
onUnlink={(evidence) => void unlinkEvidence(evidence)}
|
||||||
|
/>
|
||||||
|
{canReadDecisions ? (
|
||||||
|
<DecisionSection
|
||||||
|
decisions={decisions}
|
||||||
|
canFile={canFileRecords}
|
||||||
|
onFile={(decision) => navigate(recordFilingPath(
|
||||||
|
decision.reference,
|
||||||
|
`${humanize(decision.decision_type)} Decision`,
|
||||||
|
"decisions",
|
||||||
|
"decision_revision"
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReferenceSection title="Decisions" references={record.decision_refs} />
|
||||||
|
)}
|
||||||
|
<ReferenceSection title="Records" references={record.record_refs} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="case-detail-aside">
|
||||||
|
<section>
|
||||||
|
<h2>Timeline</h2>
|
||||||
|
<ol className="case-timeline">
|
||||||
|
{timeline.map((entry) =>
|
||||||
|
<li key={entry.event_id}>
|
||||||
|
<strong>{humanize(entry.event_type)}</strong>
|
||||||
|
<span>{entry.summary}</span>
|
||||||
|
<time>{formatDateTime(entry.occurred_at)}</time>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>History</h2>
|
||||||
|
<div className="case-history-list">
|
||||||
|
{history.map((revision) =>
|
||||||
|
<div key={revision.revision}>
|
||||||
|
<strong>Revision {revision.revision}</strong>
|
||||||
|
<span>{revision.change_reason}</span>
|
||||||
|
<time>{formatDateTime(revision.recorded_at)}</time>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
{record ? (
|
||||||
|
<CaseShareDialog
|
||||||
|
settings={settings}
|
||||||
|
record={record}
|
||||||
|
open={shareOpen}
|
||||||
|
onClose={() => setShareOpen(false)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setRecord(saved);
|
||||||
|
void load().catch((reason) => {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{record ? (
|
||||||
|
<CaseDecisionDialog
|
||||||
|
settings={settings}
|
||||||
|
record={record}
|
||||||
|
open={decisionOpen}
|
||||||
|
onClose={() => setDecisionOpen(false)}
|
||||||
|
onSaved={(result) => {
|
||||||
|
setRecord(result.case);
|
||||||
|
setDecisions((current) => [
|
||||||
|
result.decision,
|
||||||
|
...current.filter((item) => item.reference.object_id !== result.decision.reference.object_id)
|
||||||
|
]);
|
||||||
|
void load().catch((reason) => {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Case could not be reloaded.");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({ label, value }: { label: string; value: string }) {
|
||||||
|
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferenceSection({ title, references }: { title: string; references: InstitutionalReference[] }) {
|
||||||
|
if (references.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="case-reference-section">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<div className="case-reference-list">
|
||||||
|
{references.map((reference) =>
|
||||||
|
<span key={`${reference.owner_module}:${reference.object_id}:${reference.version ?? "current"}`}>
|
||||||
|
<strong>{humanize(reference.kind)}</strong>
|
||||||
|
{reference.object_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceSection({
|
||||||
|
references,
|
||||||
|
providers,
|
||||||
|
canUnlink,
|
||||||
|
onUnlink
|
||||||
|
}: {
|
||||||
|
references: EvidenceReference[];
|
||||||
|
providers: Record<string, boolean>;
|
||||||
|
canUnlink: boolean;
|
||||||
|
onUnlink: (reference: EvidenceReference) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="case-reference-section case-evidence-section">
|
||||||
|
<h2>Linked files, mail, and campaigns</h2>
|
||||||
|
<p className="muted small-note">
|
||||||
|
Select an authorized exact item in Quick Access to link it. Cases stores
|
||||||
|
only the owner reference; the owning module checks access again when opened.
|
||||||
|
</p>
|
||||||
|
{references.length ? (
|
||||||
|
<div className="case-reference-list">
|
||||||
|
{references.map((reference) => {
|
||||||
|
const source = linkedEvidenceSource(reference);
|
||||||
|
const available = source
|
||||||
|
? providers[source.owner_module] !== false
|
||||||
|
: providers[reference.owner_module] !== false;
|
||||||
|
return (
|
||||||
|
<span key={`${reference.owner_module}:${reference.evidence_id}:${reference.version ?? "current"}`}>
|
||||||
|
<strong>{humanize(source?.kind ?? reference.kind)}</strong>
|
||||||
|
<small>{reference.owner_module} · version {reference.version ?? "not pinned"}</small>
|
||||||
|
{source && available ? (
|
||||||
|
<a href={source.path} className="btn btn-secondary">
|
||||||
|
<ExternalLink size={15} aria-hidden="true" /> Open in owner
|
||||||
|
</a>
|
||||||
|
) : source ? (
|
||||||
|
<small>Owner module unavailable; the reference remains in Case history.</small>
|
||||||
|
) : null}
|
||||||
|
{source ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={!canUnlink}
|
||||||
|
disabledReason={!canUnlink
|
||||||
|
? "Save or discard Case edits before removing a link."
|
||||||
|
: undefined}
|
||||||
|
onClick={() => onUnlink(reference)}
|
||||||
|
>
|
||||||
|
<Unlink size={15} aria-hidden="true" /> Unlink
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No owner references are linked.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkedEvidenceSource(reference: EvidenceReference): {
|
||||||
|
owner_module: string;
|
||||||
|
kind: string;
|
||||||
|
path: string;
|
||||||
|
} | null {
|
||||||
|
if (!reference.source_ref) return null;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(reference.source_ref) as Record<string, unknown>;
|
||||||
|
const owner = String(value.owner_module ?? "");
|
||||||
|
const kind = String(value.kind ?? "");
|
||||||
|
const path = String(value.path ?? "");
|
||||||
|
if (
|
||||||
|
value.contract !== "cases.quick-access-reference.v1"
|
||||||
|
|| !owner
|
||||||
|
|| !kind
|
||||||
|
|| !path.startsWith("/")
|
||||||
|
|| path.startsWith("//")
|
||||||
|
) return null;
|
||||||
|
return { owner_module: owner, kind, path };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecisionSection({
|
||||||
|
decisions,
|
||||||
|
canFile,
|
||||||
|
onFile
|
||||||
|
}: {
|
||||||
|
decisions: FormalDecision[];
|
||||||
|
canFile: boolean;
|
||||||
|
onFile: (decision: FormalDecision) => void;
|
||||||
|
}) {
|
||||||
|
if (decisions.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="case-reference-section">
|
||||||
|
<h2>Decisions</h2>
|
||||||
|
<div className="case-decision-list">
|
||||||
|
{decisions.map((decision) => (
|
||||||
|
<article key={`${decision.reference.object_id}:${decision.reference.version ?? "current"}`}>
|
||||||
|
<div className="case-decision-heading">
|
||||||
|
<div>
|
||||||
|
<strong>{humanize(decision.decision_type)}</strong>
|
||||||
|
<span>Revision {decision.reference.version ?? decision.temporal.revision}</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={decision.state === "effective" || decision.state === "decided" ? "active" : "inactive"} label={humanize(decision.state)} />
|
||||||
|
{canFile ? (
|
||||||
|
<IconButton
|
||||||
|
label="File Decision in eAkte"
|
||||||
|
icon={<Archive size={16} />}
|
||||||
|
onClick={() => onFile(decision)}
|
||||||
|
helpContextId="records.action.file"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{decision.operative_result ? <p><strong>Operative result</strong>{decision.operative_result}</p> : null}
|
||||||
|
{decision.reasoning ? <p><strong>Reasoning</strong>{decision.reasoning}</p> : null}
|
||||||
|
{!decision.operative_result && !decision.reasoning ? (
|
||||||
|
<p className="case-decision-protected">Protected Decision details require the sensitive-read permission.</p>
|
||||||
|
) : null}
|
||||||
|
{decision.conditions.length > 0 ? (
|
||||||
|
<ul>{decision.conditions.map((condition) => <li key={condition}>{condition}</li>)}</ul>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFilingPath(
|
||||||
|
reference: InstitutionalReference,
|
||||||
|
label: string,
|
||||||
|
sourceModule: string,
|
||||||
|
resourceType: string
|
||||||
|
): string {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
sourceModule,
|
||||||
|
resourceType,
|
||||||
|
resourceId: reference.object_id,
|
||||||
|
sourceRevision: reference.version ?? "",
|
||||||
|
sourceLabel: label
|
||||||
|
});
|
||||||
|
return `/records?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null): string {
|
||||||
|
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
IconButton,
|
||||||
|
ReferenceSelect,
|
||||||
|
ToggleSwitch,
|
||||||
|
i18nMessage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
caseShareTargetProvider,
|
||||||
|
updateCase,
|
||||||
|
type CaseGrant,
|
||||||
|
type CaseRecord
|
||||||
|
} from "../../api/cases";
|
||||||
|
import {
|
||||||
|
CASES_FIELDS_DOCUMENTATION,
|
||||||
|
CASES_I18N
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
type TargetType = "user" | "group";
|
||||||
|
type Permission = CaseGrant["permissions"][number];
|
||||||
|
|
||||||
|
export default function CaseShareDialog({
|
||||||
|
settings,
|
||||||
|
record,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
record: CaseRecord;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (record: CaseRecord) => void;
|
||||||
|
}) {
|
||||||
|
const [restricted, setRestricted] = useState(record.access_mode === "restricted");
|
||||||
|
const [grants, setGrants] = useState<CaseGrant[]>(record.access_grants);
|
||||||
|
const [targetType, setTargetType] = useState<TargetType>("user");
|
||||||
|
const [targetId, setTargetId] = useState("");
|
||||||
|
const [permission, setPermission] = useState<Permission>("read");
|
||||||
|
const [changeReason, setChangeReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const idempotencyKey = useRef(crypto.randomUUID());
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const targetProvider = useMemo(
|
||||||
|
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
|
||||||
|
[record.reference.object_id, settings, targetType]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setRestricted(record.access_mode === "restricted");
|
||||||
|
setGrants(record.access_grants);
|
||||||
|
setTargetType("user");
|
||||||
|
setTargetId("");
|
||||||
|
setPermission("read");
|
||||||
|
setChangeReason("");
|
||||||
|
setError("");
|
||||||
|
setConfirmOpen(false);
|
||||||
|
idempotencyKey.current = crypto.randomUUID();
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
const changed = restricted !== (record.access_mode === "restricted")
|
||||||
|
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
||||||
|
const draftDirty = changed || Boolean(targetId.trim() || changeReason.trim());
|
||||||
|
|
||||||
|
function discardDraft() {
|
||||||
|
setRestricted(record.access_mode === "restricted");
|
||||||
|
setGrants(record.access_grants);
|
||||||
|
setTargetType("user");
|
||||||
|
setTargetId("");
|
||||||
|
setPermission("read");
|
||||||
|
setChangeReason("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGrant() {
|
||||||
|
const subjectId = targetId.trim();
|
||||||
|
if (!subjectId) return;
|
||||||
|
const subjectKind = targetType === "user" ? "account" : "group";
|
||||||
|
setGrants((current) => [
|
||||||
|
...current.filter(
|
||||||
|
(item) => !(item.subject_kind === subjectKind && item.subject_id === subjectId)
|
||||||
|
),
|
||||||
|
{
|
||||||
|
subject_kind: subjectKind,
|
||||||
|
subject_id: subjectId,
|
||||||
|
permissions: [permission]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
setTargetId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
if (!changed || !changeReason.trim()) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const saved = await updateCase(settings, record.reference.object_id, {
|
||||||
|
expected_revision: record.revision,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
change_reason: changeReason.trim(),
|
||||||
|
idempotency_key: idempotencyKey.current,
|
||||||
|
access_mode: restricted ? "restricted" : "tenant",
|
||||||
|
access_grants: grants
|
||||||
|
});
|
||||||
|
onSaved(saved);
|
||||||
|
setRestricted(saved.access_mode === "restricted");
|
||||||
|
setGrants(saved.access_grants);
|
||||||
|
setTargetId("");
|
||||||
|
setChangeReason("");
|
||||||
|
idempotencyKey.current = crypto.randomUUID();
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Case access could not be saved.");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: open && draftDirty,
|
||||||
|
title: "i18n:govoplan-cases.unsaved_access_title",
|
||||||
|
message: "i18n:govoplan-cases.unsaved_access_message",
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: discardDraft
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (busy) return;
|
||||||
|
if (draftDirty) requestDiscard(onClose);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSave() {
|
||||||
|
const saved = await save();
|
||||||
|
if (!saved) return;
|
||||||
|
setConfirmOpen(false);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDisabledReason = busy
|
||||||
|
? CASES_I18N.saving
|
||||||
|
: !changed
|
||||||
|
? CASES_I18N.noChanges
|
||||||
|
: !changeReason.trim()
|
||||||
|
? CASES_I18N.incomplete
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
|
||||||
|
onClose={close}
|
||||||
|
closeDisabled={busy}
|
||||||
|
portal
|
||||||
|
className="case-share-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button disabled={busy} onClick={close}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabledReason={saveDisabledReason}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
{busy ? "Saving" : "Save access"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="case-share-content">
|
||||||
|
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
|
||||||
|
{error ? (
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Case visibility"
|
||||||
|
inactiveLabel="Tenant"
|
||||||
|
activeLabel="Restricted"
|
||||||
|
checked={restricted}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={setRestricted}
|
||||||
|
/>
|
||||||
|
<p className="case-share-explanation">
|
||||||
|
Tenant cases follow the Cases read permission. Restricted cases are visible only to
|
||||||
|
their creator, case administrators, assigned functions or units, and the explicit
|
||||||
|
grants below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="case-share-add-row">
|
||||||
|
<FormField label="Target type" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<select
|
||||||
|
value={targetType}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTargetType(event.target.value as TargetType);
|
||||||
|
setTargetId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="group">Group</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Target" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<ReferenceSelect
|
||||||
|
value={targetId}
|
||||||
|
onChange={setTargetId}
|
||||||
|
provider={targetProvider}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder={`Select a ${targetType}`}
|
||||||
|
aria-label={`Case access ${targetType}`}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Permission" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<select
|
||||||
|
value={permission}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setPermission(event.target.value as Permission)}
|
||||||
|
>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="update">Update</option>
|
||||||
|
<option value="share">Share</option>
|
||||||
|
<option value="admin">Administer</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<IconButton
|
||||||
|
label="Add access grant"
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
variant="primary"
|
||||||
|
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
|
||||||
|
onClick={addGrant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="case-share-grants" aria-label="Explicit access grants">
|
||||||
|
{grants.length === 0 ? (
|
||||||
|
<p>No explicit access grants.</p>
|
||||||
|
) : grants.map((grant) => (
|
||||||
|
<div
|
||||||
|
key={`${grant.subject_kind}:${grant.subject_id}`}
|
||||||
|
className="case-share-grant"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{humanize(grant.subject_kind)}</strong>
|
||||||
|
{grant.subject_id}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label={`Permission for ${grant.subject_id}`}
|
||||||
|
value={grant.permissions[0] ?? "read"}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setGrants((current) => current.map((item) =>
|
||||||
|
item === grant
|
||||||
|
? { ...item, permissions: [event.target.value as Permission] }
|
||||||
|
: item
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="update">Update</option>
|
||||||
|
<option value="share">Share</option>
|
||||||
|
<option value="admin">Administer</option>
|
||||||
|
</select>
|
||||||
|
<IconButton
|
||||||
|
label={`Remove access for ${grant.subject_id}`}
|
||||||
|
icon={<Trash2 size={16} />}
|
||||||
|
variant="danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Change reason" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
value={changeReason}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={1000}
|
||||||
|
onChange={(event) => setChangeReason(event.target.value)}
|
||||||
|
placeholder="Why is case access changing?"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
title="i18n:govoplan-cases.access_confirm_title"
|
||||||
|
message={i18nMessage("i18n:govoplan-cases.access_confirm_message", {
|
||||||
|
value0: record.case_number,
|
||||||
|
value1: restricted
|
||||||
|
? "i18n:govoplan-cases.visibility_restricted"
|
||||||
|
: "i18n:govoplan-cases.visibility_tenant",
|
||||||
|
value2: grants.length
|
||||||
|
})}
|
||||||
|
confirmLabel="Save access"
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmOpen(false)}
|
||||||
|
onConfirm={() => void confirmSave()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
import { Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
DismissibleAlert,
|
||||||
|
FilterBar,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
|
StatusBadge,
|
||||||
|
i18nMessage,
|
||||||
|
useGuardedNavigate,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
listCaseCatalog,
|
||||||
|
listCases,
|
||||||
|
type CaseCatalog,
|
||||||
|
type CaseRecord
|
||||||
|
} from "../../api/cases";
|
||||||
|
import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
|
||||||
|
export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [cases, setCases] = useState<CaseRecord[]>([]);
|
||||||
|
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
Promise.all([
|
||||||
|
listCases(settings, {
|
||||||
|
query: submittedQuery,
|
||||||
|
statuses: status ? [status] : undefined,
|
||||||
|
limit: 200
|
||||||
|
}, controller.signal),
|
||||||
|
listCaseCatalog(settings, controller.signal)
|
||||||
|
]).
|
||||||
|
then(([result, nextCatalog]) => {
|
||||||
|
setCases(result.cases);
|
||||||
|
setTotal(result.total);
|
||||||
|
setCatalog(nextCatalog);
|
||||||
|
}).
|
||||||
|
catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Cases could not be loaded.");
|
||||||
|
}
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [reloadKey, settings, status, submittedQuery]);
|
||||||
|
|
||||||
|
const statusLabels = useMemo(
|
||||||
|
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
|
||||||
|
[catalog.statuses]
|
||||||
|
);
|
||||||
|
const typeLabels = useMemo(
|
||||||
|
() => new Map(catalog.types.map((item) => [item.type_key, item.label])),
|
||||||
|
[catalog.types]
|
||||||
|
);
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmittedQuery(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="cases-page">
|
||||||
|
<WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="collection-pane"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => setReloadKey((value) => value + 1), loading }}
|
||||||
|
className="cases-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
<FilterBar as="form" surface="control" wrap="never" width="default" className="cases-search" onSubmit={submit}>
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
aria-label="Search cases"
|
||||||
|
placeholder="Search cases"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabledReason={loading ? CASES_I18N.loading : undefined}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</FilterBar>
|
||||||
|
<label className="cases-status-filter">
|
||||||
|
<span>Status</span>
|
||||||
|
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{catalog.statuses.map((item) =>
|
||||||
|
<option key={item.status_key} value={item.status_key}>{item.label}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
|
||||||
|
</>}
|
||||||
|
helpAction={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
|
||||||
|
/>
|
||||||
|
<PageScrollViewport className="cases-list-viewport">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading cases" />}
|
||||||
|
{!loading && !error && cases.length === 0 &&
|
||||||
|
<StatePanel size="compact" description="No matching cases." />
|
||||||
|
}
|
||||||
|
{!loading && cases.length > 0 &&
|
||||||
|
<SelectionList variant="navigation" label="Cases">
|
||||||
|
{cases.map((item) => {
|
||||||
|
const statusDefinition = statusLabels.get(item.status_key);
|
||||||
|
return (
|
||||||
|
<SelectionListItem
|
||||||
|
selected={false}
|
||||||
|
key={item.reference.object_id}
|
||||||
|
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
|
||||||
|
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
|
||||||
|
<StatusBadge
|
||||||
|
status={statusDefinition?.terminal ? "inactive" : "active"}
|
||||||
|
label={statusDefinition?.label ?? humanize(item.status_key)}
|
||||||
|
/>
|
||||||
|
</SelectionListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectionList>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null): string {
|
||||||
|
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value)) : "No deadline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const CASES_DOCUMENTATION = {
|
||||||
|
topicId: "cases.institutional-context",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const CASES_FIELDS_DOCUMENTATION = {
|
||||||
|
topicId: "cases.reference.lifecycle-access-and-evidence",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const CASES_I18N = {
|
||||||
|
loading: "i18n:govoplan-cases.loading_reason",
|
||||||
|
saving: "i18n:govoplan-cases.saving_reason",
|
||||||
|
updateReason: "i18n:govoplan-cases.update_reason",
|
||||||
|
closeReason: "i18n:govoplan-cases.close_reason",
|
||||||
|
shareReason: "i18n:govoplan-cases.share_reason",
|
||||||
|
noChanges: "i18n:govoplan-cases.no_changes_reason",
|
||||||
|
incomplete: "i18n:govoplan-cases.incomplete_reason",
|
||||||
|
targetRequired: "i18n:govoplan-cases.target_required_reason"
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
"i18n:govoplan-cases.cases": "Cases",
|
||||||
|
"i18n:govoplan-cases.navigation": "Cases navigation",
|
||||||
|
"i18n:govoplan-cases.list": "Case list",
|
||||||
|
"i18n:govoplan-cases.filters": "Case search and filters",
|
||||||
|
"i18n:govoplan-cases.detail": "Case details",
|
||||||
|
"i18n:govoplan-cases.summary": "Case summary",
|
||||||
|
"i18n:govoplan-cases.editor": "Case lifecycle editor",
|
||||||
|
"i18n:govoplan-cases.references": "Institutional references",
|
||||||
|
"i18n:govoplan-cases.timeline": "Case timeline",
|
||||||
|
"i18n:govoplan-cases.history": "Immutable case history",
|
||||||
|
"i18n:govoplan-cases.access": "Case access",
|
||||||
|
"i18n:govoplan-cases.decision": "Record formal Decision",
|
||||||
|
"i18n:govoplan-cases.decision_title": "Record formal Decision - {value0}",
|
||||||
|
"i18n:govoplan-cases.loading_reason": "The case is still loading.",
|
||||||
|
"i18n:govoplan-cases.saving_reason": "The case change is still being saved.",
|
||||||
|
"i18n:govoplan-cases.update_reason": "Your account may inspect this case but may not change its title or lifecycle state.",
|
||||||
|
"i18n:govoplan-cases.close_reason": "Closing a case requires the case-close permission.",
|
||||||
|
"i18n:govoplan-cases.share_reason": "Changing case visibility or grants requires the case-share permission.",
|
||||||
|
"i18n:govoplan-cases.no_changes_reason": "There are no case changes to save.",
|
||||||
|
"i18n:govoplan-cases.incomplete_reason": "Enter a title and a reason that explains the recorded change.",
|
||||||
|
"i18n:govoplan-cases.target_required_reason": "Select a user or group before adding an access grant.",
|
||||||
|
"i18n:govoplan-cases.unsaved_title": "Unsaved case change",
|
||||||
|
"i18n:govoplan-cases.unsaved_message": "Save or discard the case title, status, and reason before leaving.",
|
||||||
|
"i18n:govoplan-cases.unsaved_access_title": "Unsaved case access change",
|
||||||
|
"i18n:govoplan-cases.unsaved_access_message": "Save or discard the visibility and grant changes before leaving this dialog.",
|
||||||
|
"i18n:govoplan-cases.access_confirm_title": "Change case access",
|
||||||
|
"i18n:govoplan-cases.access_confirm_message": "Change {value0} to {value1} visibility with {value2} explicit grant(s)? This appends an immutable case revision and timeline entry.",
|
||||||
|
"i18n:govoplan-cases.case_access_title": "Case access - {value0}",
|
||||||
|
"i18n:govoplan-cases.case_count": "{value0} cases",
|
||||||
|
"i18n:govoplan-cases.visibility_tenant": "tenant",
|
||||||
|
"i18n:govoplan-cases.visibility_restricted": "restricted",
|
||||||
|
"Case editing is read-only": "Case editing is read-only",
|
||||||
|
"Ask a case manager to make the required lifecycle change.": "Ask a case manager to make the required lifecycle change.",
|
||||||
|
"A user with the Cases update permission": "A user with the Cases update permission",
|
||||||
|
"Case role or object access assignment": "Case role or object access assignment",
|
||||||
|
"Terminal case states are unavailable": "Terminal case states are unavailable",
|
||||||
|
"Ask a case closer to complete the lifecycle transition.": "Ask a case closer to complete the lifecycle transition.",
|
||||||
|
"A user with the Cases close permission": "A user with the Cases close permission",
|
||||||
|
"Case role assignment": "Case role assignment",
|
||||||
|
"Cases": "Cases",
|
||||||
|
"Loading cases": "Loading cases",
|
||||||
|
"Loading case": "Loading case",
|
||||||
|
"User": "User",
|
||||||
|
"Group": "Group",
|
||||||
|
"Read": "Read",
|
||||||
|
"Update": "Update",
|
||||||
|
"Share": "Share",
|
||||||
|
"Administer": "Administer",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Add access grant": "Add access grant",
|
||||||
|
"Explicit access grants": "Explicit access grants",
|
||||||
|
"Why is case access changing?": "Why is case access changing?",
|
||||||
|
"Search cases": "Search cases",
|
||||||
|
"Search": "Search",
|
||||||
|
"Status": "Status",
|
||||||
|
"All statuses": "All statuses",
|
||||||
|
"No matching cases.": "No matching cases.",
|
||||||
|
"Manage case access": "Manage case access",
|
||||||
|
"Title": "Title",
|
||||||
|
"Change reason": "Change reason",
|
||||||
|
"Save": "Save",
|
||||||
|
"Saving": "Saving",
|
||||||
|
"Opened": "Opened",
|
||||||
|
"Deadline": "Deadline",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Last change": "Last change",
|
||||||
|
"Parties": "Parties",
|
||||||
|
"Assignments": "Assignments",
|
||||||
|
"Decisions": "Decisions",
|
||||||
|
"Records": "Records",
|
||||||
|
"Timeline": "Timeline",
|
||||||
|
"History": "History",
|
||||||
|
"Case visibility": "Case visibility",
|
||||||
|
"Tenant": "Tenant",
|
||||||
|
"Restricted": "Restricted",
|
||||||
|
"Target type": "Target type",
|
||||||
|
"Target": "Target",
|
||||||
|
"Permission": "Permission",
|
||||||
|
"Save access": "Save access",
|
||||||
|
"No explicit access grants.": "No explicit access grants.",
|
||||||
|
"Record formal decision": "Record formal decision",
|
||||||
|
"File Case in eAkte": "File Case in eAkte",
|
||||||
|
"File Decision in eAkte": "File Decision in eAkte",
|
||||||
|
"Record decision": "Record decision",
|
||||||
|
"The formal Decision is being recorded.": "The formal Decision is being recorded.",
|
||||||
|
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Complete the Decision type, effective time, result, reasoning, and change reason.",
|
||||||
|
"The formal Decision could not be recorded.": "The formal Decision could not be recorded.",
|
||||||
|
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.",
|
||||||
|
"Decision type": "Decision type",
|
||||||
|
"Effective at": "Effective at",
|
||||||
|
"Operative result": "Operative result",
|
||||||
|
"Reasoning": "Reasoning",
|
||||||
|
"Conditions (one per line)": "Conditions (one per line)",
|
||||||
|
"Record formal Decision": "Record formal Decision",
|
||||||
|
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.",
|
||||||
|
"Protected Decision details require the sensitive-read permission.": "Protected Decision details require the sensitive-read permission.",
|
||||||
|
"Your account may not update this Case.": "Your account may not update this Case.",
|
||||||
|
"Your account may not record formal Decisions.": "Your account may not record formal Decisions.",
|
||||||
|
"Enable Access to verify the acting assignment.": "Enable Access to verify the acting assignment.",
|
||||||
|
"Enable Mandates to verify formal authority.": "Enable Mandates to verify formal authority."
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const de: Record<keyof typeof en, string> = {
|
||||||
|
"i18n:govoplan-cases.cases": "Vorgänge",
|
||||||
|
"i18n:govoplan-cases.navigation": "Vorgangsnavigation",
|
||||||
|
"i18n:govoplan-cases.list": "Vorgangsliste",
|
||||||
|
"i18n:govoplan-cases.filters": "Vorgangssuche und Filter",
|
||||||
|
"i18n:govoplan-cases.detail": "Vorgangsdetails",
|
||||||
|
"i18n:govoplan-cases.summary": "Vorgangszusammenfassung",
|
||||||
|
"i18n:govoplan-cases.editor": "Vorgangsstatus bearbeiten",
|
||||||
|
"i18n:govoplan-cases.references": "Institutionelle Referenzen",
|
||||||
|
"i18n:govoplan-cases.timeline": "Vorgangszeitachse",
|
||||||
|
"i18n:govoplan-cases.history": "Unveränderliche Vorgangshistorie",
|
||||||
|
"i18n:govoplan-cases.access": "Vorgangszugriff",
|
||||||
|
"i18n:govoplan-cases.decision": "Formelle Entscheidung erfassen",
|
||||||
|
"i18n:govoplan-cases.decision_title": "Formelle Entscheidung erfassen - {value0}",
|
||||||
|
"i18n:govoplan-cases.loading_reason": "Der Vorgang wird noch geladen.",
|
||||||
|
"i18n:govoplan-cases.saving_reason": "Die Vorgangsänderung wird noch gespeichert.",
|
||||||
|
"i18n:govoplan-cases.update_reason": "Ihr Konto darf diesen Vorgang einsehen, aber Titel und Status nicht ändern.",
|
||||||
|
"i18n:govoplan-cases.close_reason": "Zum Schließen eines Vorgangs ist die Berechtigung zum Vorgangsabschluss erforderlich.",
|
||||||
|
"i18n:govoplan-cases.share_reason": "Zum Ändern von Sichtbarkeit oder Freigaben ist die Freigabeberechtigung erforderlich.",
|
||||||
|
"i18n:govoplan-cases.no_changes_reason": "Es gibt keine Vorgangsänderungen zu speichern.",
|
||||||
|
"i18n:govoplan-cases.incomplete_reason": "Geben Sie einen Titel und eine Begründung für die protokollierte Änderung ein.",
|
||||||
|
"i18n:govoplan-cases.target_required_reason": "Wählen Sie eine Person oder Gruppe aus, bevor Sie eine Zugriffsfreigabe hinzufügen.",
|
||||||
|
"i18n:govoplan-cases.unsaved_title": "Ungespeicherte Vorgangsänderung",
|
||||||
|
"i18n:govoplan-cases.unsaved_message": "Speichern oder verwerfen Sie Titel, Status und Begründung, bevor Sie fortfahren.",
|
||||||
|
"i18n:govoplan-cases.unsaved_access_title": "Ungespeicherte Zugriffsänderung",
|
||||||
|
"i18n:govoplan-cases.unsaved_access_message": "Speichern oder verwerfen Sie Sichtbarkeit und Freigaben, bevor Sie den Dialog verlassen.",
|
||||||
|
"i18n:govoplan-cases.access_confirm_title": "Vorgangszugriff ändern",
|
||||||
|
"i18n:govoplan-cases.access_confirm_message": "Sichtbarkeit von {value0} auf {value1} mit {value2} ausdrücklichen Freigabe(n) ändern? Dadurch werden eine unveränderliche Vorgangsrevision und ein Zeitachseneintrag angelegt.",
|
||||||
|
"i18n:govoplan-cases.case_access_title": "Vorgangszugriff - {value0}",
|
||||||
|
"i18n:govoplan-cases.case_count": "{value0} Vorgänge",
|
||||||
|
"i18n:govoplan-cases.visibility_tenant": "mandantenweit",
|
||||||
|
"i18n:govoplan-cases.visibility_restricted": "eingeschränkt",
|
||||||
|
"Case editing is read-only": "Der Vorgang kann nur gelesen werden",
|
||||||
|
"Ask a case manager to make the required lifecycle change.": "Bitten Sie eine Vorgangsverwaltung, die erforderliche Statusänderung vorzunehmen.",
|
||||||
|
"A user with the Cases update permission": "Eine Person mit der Berechtigung zur Vorgangsänderung",
|
||||||
|
"Case role or object access assignment": "Vorgangsrolle oder Objektfreigabe",
|
||||||
|
"Terminal case states are unavailable": "Abschließende Vorgangsstatus sind nicht verfügbar",
|
||||||
|
"Ask a case closer to complete the lifecycle transition.": "Bitten Sie eine berechtigte Person, den Vorgangsabschluss vorzunehmen.",
|
||||||
|
"A user with the Cases close permission": "Eine Person mit der Berechtigung zum Vorgangsabschluss",
|
||||||
|
"Case role assignment": "Vorgangsrollenzuweisung",
|
||||||
|
"Cases": "Vorgänge",
|
||||||
|
"Loading cases": "Vorgänge werden geladen",
|
||||||
|
"Loading case": "Vorgang wird geladen",
|
||||||
|
"User": "Person",
|
||||||
|
"Group": "Gruppe",
|
||||||
|
"Read": "Lesen",
|
||||||
|
"Update": "Ändern",
|
||||||
|
"Share": "Freigeben",
|
||||||
|
"Administer": "Verwalten",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Add access grant": "Zugriffsfreigabe hinzufügen",
|
||||||
|
"Explicit access grants": "Ausdrückliche Zugriffsfreigaben",
|
||||||
|
"Why is case access changing?": "Warum wird der Vorgangszugriff geändert?",
|
||||||
|
"Search cases": "Vorgänge suchen",
|
||||||
|
"Search": "Suchen",
|
||||||
|
"Status": "Status",
|
||||||
|
"All statuses": "Alle Status",
|
||||||
|
"No matching cases.": "Keine passenden Vorgänge.",
|
||||||
|
"Manage case access": "Vorgangszugriff verwalten",
|
||||||
|
"Title": "Titel",
|
||||||
|
"Change reason": "Änderungsbegründung",
|
||||||
|
"Save": "Speichern",
|
||||||
|
"Saving": "Speichert",
|
||||||
|
"Opened": "Eröffnet",
|
||||||
|
"Deadline": "Frist",
|
||||||
|
"Revision": "Revision",
|
||||||
|
"Last change": "Letzte Änderung",
|
||||||
|
"Parties": "Beteiligte",
|
||||||
|
"Assignments": "Zuweisungen",
|
||||||
|
"Decisions": "Entscheidungen",
|
||||||
|
"Records": "Akten",
|
||||||
|
"Timeline": "Zeitachse",
|
||||||
|
"History": "Historie",
|
||||||
|
"Case visibility": "Vorgangssichtbarkeit",
|
||||||
|
"Tenant": "Mandant",
|
||||||
|
"Restricted": "Eingeschränkt",
|
||||||
|
"Target type": "Zieltyp",
|
||||||
|
"Target": "Ziel",
|
||||||
|
"Permission": "Berechtigung",
|
||||||
|
"Save access": "Zugriff speichern",
|
||||||
|
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
|
||||||
|
"Record formal decision": "Formelle Entscheidung erfassen",
|
||||||
|
"File Case in eAkte": "Vorgang in eAkte verakten",
|
||||||
|
"File Decision in eAkte": "Entscheidung in eAkte verakten",
|
||||||
|
"Record decision": "Entscheidung erfassen",
|
||||||
|
"The formal Decision is being recorded.": "Die formelle Entscheidung wird erfasst.",
|
||||||
|
"Complete the Decision type, effective time, result, reasoning, and change reason.": "Vervollständigen Sie Entscheidungsart, Gültigkeitszeitpunkt, Entscheidungssatz, Begründung und Änderungsbegründung.",
|
||||||
|
"The formal Decision could not be recorded.": "Die formelle Entscheidung konnte nicht erfasst werden.",
|
||||||
|
"The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.": "Der Server prüft vor der Erfassung den aktuellen Funktionskontext, das wirksame Mandat, die exakte Vorgangsrevision, die Nachweise und die Rechtsgrundlage.",
|
||||||
|
"Decision type": "Entscheidungsart",
|
||||||
|
"Effective at": "Gültig ab",
|
||||||
|
"Operative result": "Entscheidungssatz",
|
||||||
|
"Reasoning": "Begründung",
|
||||||
|
"Conditions (one per line)": "Nebenbestimmungen (eine pro Zeile)",
|
||||||
|
"Record formal Decision": "Formelle Entscheidung erfassen",
|
||||||
|
"Record this formal outcome against the exact Case revision? The protected result and reasoning become an immutable Decisions revision.": "Diese formelle Entscheidung zur exakten Vorgangsrevision erfassen? Entscheidungssatz und Begründung werden als unveränderliche Entscheidungsrevision gespeichert.",
|
||||||
|
"Protected Decision details require the sensitive-read permission.": "Geschützte Entscheidungsinhalte erfordern die Berechtigung zum Lesen sensibler Entscheidungen.",
|
||||||
|
"Your account may not update this Case.": "Ihr Konto darf diesen Vorgang nicht ändern.",
|
||||||
|
"Your account may not record formal Decisions.": "Ihr Konto darf keine formellen Entscheidungen erfassen.",
|
||||||
|
"Enable Access to verify the acting assignment.": "Aktivieren Sie Access, damit der Funktionskontext geprüft werden kann.",
|
||||||
|
"Enable Mandates to verify formal authority.": "Aktivieren Sie Mandates, damit die formelle Zuständigkeit geprüft werden kann."
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, casesModule } from "./module";
|
||||||
|
export * from "./api/cases";
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/cases.css";
|
||||||
|
|
||||||
|
|
||||||
|
const CasesPage = lazy(() => import("./features/cases/CasesPage"));
|
||||||
|
const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
|
||||||
|
|
||||||
|
export const casesModule: PlatformWebModule = {
|
||||||
|
id: "cases",
|
||||||
|
label: "i18n:govoplan-cases.cases",
|
||||||
|
version: "0.1.8",
|
||||||
|
optionalDependencies: [
|
||||||
|
"access",
|
||||||
|
"addresses",
|
||||||
|
"services",
|
||||||
|
"parties",
|
||||||
|
"mandates",
|
||||||
|
"decisions",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
"records"
|
||||||
|
],
|
||||||
|
translations: generatedTranslations,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/cases",
|
||||||
|
anyOf: ["cases:case:read"],
|
||||||
|
order: 35,
|
||||||
|
surfaceId: "cases.list",
|
||||||
|
render: (context) => createElement(CasesPage, context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/cases/:caseId",
|
||||||
|
anyOf: ["cases:case:read"],
|
||||||
|
order: 36,
|
||||||
|
surfaceId: "cases.detail",
|
||||||
|
render: (context) => createElement(CaseDetailPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/cases",
|
||||||
|
label: "i18n:govoplan-cases.cases",
|
||||||
|
iconName: "briefcase-business",
|
||||||
|
anyOf: ["cases:case:read"],
|
||||||
|
order: 35,
|
||||||
|
surfaceId: "cases.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "cases.navigation", moduleId: "cases", kind: "navigation", label: "i18n:govoplan-cases.navigation", order: 10 },
|
||||||
|
{ id: "cases.list", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.list", order: 20 },
|
||||||
|
{ id: "cases.list.filters", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.filters", parentId: "cases.list", order: 10 },
|
||||||
|
{ id: "cases.detail", moduleId: "cases", kind: "route", label: "i18n:govoplan-cases.detail", order: 30 },
|
||||||
|
{ id: "cases.detail.summary", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.summary", parentId: "cases.detail", order: 10 },
|
||||||
|
{ id: "cases.detail.editor", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.editor", parentId: "cases.detail", order: 20 },
|
||||||
|
{ id: "cases.detail.references", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.references", parentId: "cases.detail", order: 30 },
|
||||||
|
{ id: "cases.detail.timeline", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.timeline", parentId: "cases.detail", order: 40 },
|
||||||
|
{ id: "cases.detail.history", moduleId: "cases", kind: "section", label: "i18n:govoplan-cases.history", parentId: "cases.detail", order: 50 },
|
||||||
|
{ id: "cases.detail.access", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.access", parentId: "cases.detail", order: 60 },
|
||||||
|
{ id: "cases.detail.decision", moduleId: "cases", kind: "action", label: "i18n:govoplan-cases.decision", parentId: "cases.detail", order: 70 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default casesModule;
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
.cases-page,
|
||||||
|
.case-detail-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cases-search { flex: 1 1 560px; }
|
||||||
|
|
||||||
|
.cases-status-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cases-status-filter > span,
|
||||||
|
.cases-count {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cases-count {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cases-list-viewport,
|
||||||
|
.case-detail-viewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 1380px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-main,
|
||||||
|
.case-detail-aside {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-title-row h1 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-main > .action-blocker-hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-eyebrow {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-edit-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 2fr) minmax(150px, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-edit-panel label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-edit-panel label > span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-change-reason {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-edit-panel .btn {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
margin-top: 18px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-facts > div {
|
||||||
|
display: flex;
|
||||||
|
min-height: 66px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-facts span,
|
||||||
|
.case-history-list time,
|
||||||
|
.case-timeline time {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-reference-section,
|
||||||
|
.case-detail-aside section {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-reference-section h2,
|
||||||
|
.case-detail-aside h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-reference-list,
|
||||||
|
.case-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-reference-list > span,
|
||||||
|
.case-history-list > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-evidence-section .case-reference-list > span {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-evidence-section .case-reference-list small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-timeline {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-timeline li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 0 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-dialog {
|
||||||
|
width: min(860px, calc(100vw - 32px));
|
||||||
|
max-height: min(760px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-dialog {
|
||||||
|
width: min(900px, calc(100vw - 32px));
|
||||||
|
max-height: min(820px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-content {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-explanation,
|
||||||
|
.case-decision-protected {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.7fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-content textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list > article {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-heading > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-heading span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list p {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-decision-list ul {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-content {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-explanation,
|
||||||
|
.case-share-grants > p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-add-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 0.7fr) minmax(220px, 1.7fr) minmax(130px, 0.8fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-add-row .icon-button {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-grants {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 280px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-grant {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(130px, 180px) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-grant > span {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-grant > span strong {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.cases-count {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-add-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-detail-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-share-add-row .icon-button {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user