Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ebf2c7e6 | ||
|
|
b23e6e4eee | ||
|
|
4408a9486f | ||
|
|
9f17de7a3f | ||
|
|
aa4ed0b7c1 | ||
|
|
d1e3b1cbfd | ||
|
|
f34a6b17a9 | ||
|
|
8b67f28ace | ||
|
|
bc0eb60064 | ||
|
|
5bae5bbc3e | ||
|
|
1a7b9fbf16 | ||
|
|
8a3724bf8b | ||
|
|
30fcdbe832 | ||
|
|
3ebe8c2c99 | ||
|
|
18e4df348a | ||
|
|
aab7f25e86 | ||
|
|
5d58742928 | ||
|
|
a33c7abd31 | ||
|
|
8940c4ed9e | ||
|
|
fcfd67b0b5 | ||
|
|
97acfcba7d | ||
|
|
3f0f38d226 | ||
|
|
7dd013dd7a | ||
|
|
daeaa4bcb8 | ||
|
|
17ab9c9c8d | ||
|
|
06bd1c9003 | ||
|
|
326bf3f56e | ||
|
|
5ebdffc0ec | ||
|
|
84ca4f39ae | ||
|
|
58857654e9 | ||
|
|
00212ea331 | ||
|
|
35aebe8759 | ||
|
|
28f799e426 | ||
|
|
df91c70491 | ||
|
|
81e532fd54 | ||
|
|
025067eb87 | ||
|
|
6b0b2d2d0b | ||
|
|
f09fdf2ef7 | ||
|
|
c240778ad2 | ||
|
|
45cda1a33f |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Organizations Codex Guide
|
# GovOPlaN Organizations Codex Guide
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Organizations internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This repository owns the canonical GovOPlaN organizational model: tenant-local
|
This repository owns the canonical GovOPlaN organizational model: tenant-local
|
||||||
|
|||||||
@@ -40,10 +40,27 @@ organizations module is active. Those controls are limited to governance and
|
|||||||
policy settings such as tenant model customization, change-request
|
policy settings such as tenant model customization, change-request
|
||||||
requirements, audit detail, and retention behavior.
|
requirements, audit detail, and retention behavior.
|
||||||
|
|
||||||
|
The reviewed surface inventory, consequence classes, availability rules, and
|
||||||
|
accessibility evidence are recorded in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
## Module Contract
|
## Module Contract
|
||||||
|
|
||||||
The module registers the `organizations.directory` capability from
|
The module registers organization-directory capabilities from
|
||||||
`govoplan_core.core.organizations`.
|
`govoplan_core.core.organizations` and a privacy capability:
|
||||||
|
|
||||||
|
- `organizations.directory` for backward-compatible direct unit/function
|
||||||
|
lookup;
|
||||||
|
- `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly
|
||||||
|
structure-scoped hierarchy and path resolution.
|
||||||
|
- `privacy.dsar.organizations` for tenant-scoped account attribution on model
|
||||||
|
instantiations and upgrades.
|
||||||
|
|
||||||
|
The DSAR provider does not treat institutional units or functions as personal
|
||||||
|
records. It retains model-change attribution as governance evidence and excludes
|
||||||
|
global templates, opaque definitions, previews, decisions, provenance,
|
||||||
|
idempotency material, and other tenants. Identity-to-function assignments are
|
||||||
|
covered by IDM, which owns that relationship.
|
||||||
|
|
||||||
Feature modules should consume the capability instead of importing
|
Feature modules should consume the capability instead of importing
|
||||||
organization ORM models.
|
organization ORM models.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Organizations Interface Pattern Migration
|
||||||
|
|
||||||
|
This document records the bounded migration of Organizations-owned WebUI
|
||||||
|
surfaces to the GovOPlaN interface pattern language. Core owns shared controls
|
||||||
|
and host shells. Organizations owns tenant-local model definitions, concrete
|
||||||
|
units, relations, functions, settings, and their mutation consequences.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/organizations` model section | Repeated administration and definition library | Change model vocabulary | Shared DataGrid/cards/dialogs, stable row actions, governed change reference, contextual help |
|
||||||
|
| `/organizations` units section | Hierarchy explorer and directory | Change hierarchy and routing context | Shared ExplorerTree/DataGrid, parent selection, explicit write blockers, guarded drafts |
|
||||||
|
| `/organizations` relations section | Repeated administration | Change structure traversal | Typed source/target editor, lifecycle state, contextual field help |
|
||||||
|
| `/organizations` functions section | Repeated administration | Change institutional responsibility vocabulary | Stable function rows, optional capability actions, explicit Access boundary |
|
||||||
|
| `organizations.admin.tenant` | Effective tenant configuration | Change governance, audit, and retention behavior | Shared admin layout, permission blocker, dirty-state guard, contextual help |
|
||||||
|
| `organizations.functionPicker` | Governed reference selector | Select an active function | Tenant-safe labels, bounded loading/error state, no sibling-private import |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Organization hierarchies and settings are tenant-owned. A tenant may start
|
||||||
|
from a versioned system template, but it does not inherit one mutable global
|
||||||
|
hierarchy.
|
||||||
|
- Unit types, structures, relation types, and function types define the model;
|
||||||
|
units, relations, and functions are concrete facts within it.
|
||||||
|
- Parent and relation changes can affect downstream routing, Postbox
|
||||||
|
resolution, reporting, and policy context. Slugs are stable integration
|
||||||
|
references.
|
||||||
|
- Deactivation preserves the record and audit evidence while removing it from
|
||||||
|
active selection. The UI does not represent deactivation as deletion.
|
||||||
|
- Delegation and act-in-place flags describe organizational semantics only.
|
||||||
|
Access and governed workflows still decide effective authority.
|
||||||
|
- When tenant governance requires it, model mutations need an approved
|
||||||
|
change-request reference. Dirty dialogs, section changes, and reloads use the
|
||||||
|
shared unsaved-change guard.
|
||||||
|
- Unavailable actions remain visible and identify the missing permission,
|
||||||
|
responsible administrator, and administration destination.
|
||||||
|
|
||||||
|
## State And Accessibility Evidence
|
||||||
|
|
||||||
|
The module uses Core `WorkspaceLayout` and `PageLayout` for both the standalone
|
||||||
|
workspace and embedded administration contribution, plus shared subnavigation,
|
||||||
|
grid, tree, card, dialog, loading, alert, status, blocker, field-help, and
|
||||||
|
disabled-action controls. Pane width, content inset, headings, route actions,
|
||||||
|
notices, scrolling, and responsive navigation are therefore Core-owned rather
|
||||||
|
than repeated in Organizations CSS. Stable table
|
||||||
|
action slots remain keyboard reachable; dialogs retain shared focus containment
|
||||||
|
and return. Labels and accessible properties use the English/German module
|
||||||
|
catalogue, and API errors are bounded to the current tenant operation.
|
||||||
|
|
||||||
|
The manifest contributes stable help contexts for the workspace, sections,
|
||||||
|
settings, fields, lifecycle changes, and governed change references. Backend
|
||||||
|
and WebUI contract tests prevent those boundaries and explanations from
|
||||||
|
silently regressing.
|
||||||
@@ -16,6 +16,48 @@ The organization model answers where responsibility lives.
|
|||||||
- Function: a named responsibility in an organization unit, such as clerk,
|
- Function: a named responsibility in an organization unit, such as clerk,
|
||||||
reviewer, approver, committee secretary, intake desk, or resource manager.
|
reviewer, approver, committee secretary, intake desk, or resource manager.
|
||||||
|
|
||||||
|
A function says what responsibility exists and where. It does not by itself
|
||||||
|
prove that the institution or function is legally or organizationally
|
||||||
|
competent for a subject, territory, population, decision type, signature, or
|
||||||
|
period. That effective mandate/jurisdiction belongs to a separate shared
|
||||||
|
Mandates contract. Organizations retains only stable references needed to
|
||||||
|
explain how a mandate attaches to a unit or function.
|
||||||
|
|
||||||
|
## Governance And Templates
|
||||||
|
|
||||||
|
Concrete organization models are tenant-owned. Units, structures, relation
|
||||||
|
types, relations, function types, and functions are never shared as one live
|
||||||
|
global hierarchy across tenants.
|
||||||
|
|
||||||
|
The system may provide versioned organization-model templates. A tenant
|
||||||
|
explicitly instantiates one template version and receives tenant-owned concrete
|
||||||
|
records. The template reference and version are provenance, not a live parent
|
||||||
|
model:
|
||||||
|
|
||||||
|
- tenants may run different template versions;
|
||||||
|
- a template update never silently mutates a tenant hierarchy;
|
||||||
|
- upgrading is an explicit diff and migration with preview, conflict
|
||||||
|
reporting, and recorded provenance;
|
||||||
|
- system policy may constrain permitted unit, relation, structure, and
|
||||||
|
function types;
|
||||||
|
- tenant administrators customize concrete records only within the effective
|
||||||
|
policy.
|
||||||
|
|
||||||
|
This avoids ambiguous inheritance when institutions model responsibility
|
||||||
|
differently. Template catalogue, instantiation, and explicit upgrades are
|
||||||
|
available through the Organizations API and administration surface. The
|
||||||
|
existing organization tables remain the canonical tenant-local state.
|
||||||
|
|
||||||
|
An upgrade starts by persisting a three-way comparison of the source template,
|
||||||
|
the current tenant-owned model, and a newer published template version. The
|
||||||
|
preview distinguishes compatible additions and changes from local divergence,
|
||||||
|
destructive remapping, and invalid references. Local-only changes are retained;
|
||||||
|
conflicts require an explicit keep, replace, or bounded mapping decision. The
|
||||||
|
apply operation rejects stale source, target, or local state and records a new
|
||||||
|
instantiation plus platform event. Cancelling a preview records the outcome but
|
||||||
|
does not change tenant data. Consumers of organization references must respond
|
||||||
|
to the applied event; Organizations does not rewrite another module's records.
|
||||||
|
|
||||||
## Boundary With Identity And IDM
|
## Boundary With Identity And IDM
|
||||||
|
|
||||||
This module does not own login accounts, identity lifecycle, account linking,
|
This module does not own login accounts, identity lifecycle, account linking,
|
||||||
@@ -66,6 +108,36 @@ The close-out condition is that Access role resolution works with canonical
|
|||||||
Organizations plus IDM installed and still works through projection fallback
|
Organizations plus IDM installed and still works through projection fallback
|
||||||
for transition deployments.
|
for transition deployments.
|
||||||
|
|
||||||
|
## Directory Contracts
|
||||||
|
|
||||||
|
`organizations.directory` remains the small compatibility contract for direct
|
||||||
|
unit and function lookup. It deliberately retains the legacy `parent_id`
|
||||||
|
projection needed by existing Access and IDM integrations.
|
||||||
|
|
||||||
|
`organizations.hierarchyDirectory` is the structure-aware contract for new
|
||||||
|
consumers. It provides:
|
||||||
|
|
||||||
|
- tenant-scoped unit-type and function-type references;
|
||||||
|
- function resolution by type and an explicit set of units;
|
||||||
|
- unit resolution by type, optionally bounded to one named structure;
|
||||||
|
- batched ancestor, descendant, and path resolution with a required structure,
|
||||||
|
optional relation-type filter, and bounded depth;
|
||||||
|
- the exact structure, relation type, and relation edge for every path step;
|
||||||
|
- explicit missing, inactive, unreachable, invalid-filter, cycle, and
|
||||||
|
depth-limit state.
|
||||||
|
|
||||||
|
An edge is traversed from `source_unit_id` to `target_unit_id` for descendant
|
||||||
|
lookups and in reverse for ancestor lookups. Consumers must select a structure;
|
||||||
|
the provider never treats `parent_id` or one arbitrary structure as the
|
||||||
|
institution's universal hierarchy.
|
||||||
|
|
||||||
|
Committed changes emit versioned `organizations.<resource>.<action>.v1`
|
||||||
|
platform events for units, functions, their types, structures, relation types,
|
||||||
|
and relation edges. The event payload contains schema version `1`, tenant and
|
||||||
|
resource references, status, changed fields, and routing-relevant IDs so
|
||||||
|
directory consumers can invalidate derived addresses without importing this
|
||||||
|
module's models.
|
||||||
|
|
||||||
## UI Boundary
|
## UI Boundary
|
||||||
|
|
||||||
The organization workspace at `/organizations` is the primary module UI for
|
The organization workspace at `/organizations` is the primary module UI for
|
||||||
|
|||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/organizations-webui",
|
"name": "@govoplan/organizations-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": ">=8.3.0 <9",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+3
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-organizations"
|
name = "govoplan-organizations"
|
||||||
version = "0.1.8"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN organizational model module."
|
description = "GovOPlaN organizational model module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.45",
|
||||||
"govoplan-tenancy>=0.1.8",
|
"govoplan-tenancy>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN organizations module."""
|
"""GovOPlaN organizations module."""
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.21"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
import re
|
import re
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,10 +16,26 @@ from govoplan_core.core.configuration_control import (
|
|||||||
ensure_configuration_change_allowed,
|
ensure_configuration_change_allowed,
|
||||||
record_configuration_change_applied,
|
record_configuration_change_applied,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION,
|
||||||
|
organization_lifecycle_event_type,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
OrganizationFunctionType,
|
OrganizationFunctionType,
|
||||||
|
OrganizationModelInstantiation,
|
||||||
|
OrganizationModelTemplate,
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
OrganizationRelation,
|
OrganizationRelation,
|
||||||
OrganizationRelationType,
|
OrganizationRelationType,
|
||||||
OrganizationTenantSettings,
|
OrganizationTenantSettings,
|
||||||
@@ -31,9 +49,22 @@ from .schemas import (
|
|||||||
FunctionTypeCreateRequest,
|
FunctionTypeCreateRequest,
|
||||||
FunctionTypeUpdateRequest,
|
FunctionTypeUpdateRequest,
|
||||||
FunctionUpdateRequest,
|
FunctionUpdateRequest,
|
||||||
|
OrganizationModelInstantiationItem,
|
||||||
|
OrganizationModelUpgradeApplyRequest,
|
||||||
|
OrganizationModelUpgradeApplyResponse,
|
||||||
|
OrganizationModelUpgradeCancelRequest,
|
||||||
|
OrganizationModelUpgradeItem,
|
||||||
|
OrganizationModelUpgradeListResponse,
|
||||||
|
OrganizationModelUpgradePreviewRequest,
|
||||||
OrganizationFunctionItem,
|
OrganizationFunctionItem,
|
||||||
OrganizationFunctionTypeItem,
|
OrganizationFunctionTypeItem,
|
||||||
OrganizationModelResponse,
|
OrganizationModelResponse,
|
||||||
|
OrganizationModelTemplateCatalogItem,
|
||||||
|
OrganizationModelTemplateCatalogResponse,
|
||||||
|
OrganizationModelTemplateCreateRequest,
|
||||||
|
OrganizationModelTemplateItem,
|
||||||
|
OrganizationModelTemplateVersionCreateRequest,
|
||||||
|
OrganizationModelTemplateVersionItem,
|
||||||
OrganizationRelationItem,
|
OrganizationRelationItem,
|
||||||
OrganizationRelationTypeItem,
|
OrganizationRelationTypeItem,
|
||||||
OrganizationSettingsItem,
|
OrganizationSettingsItem,
|
||||||
@@ -52,9 +83,24 @@ from .schemas import (
|
|||||||
UnitTypeUpdateRequest,
|
UnitTypeUpdateRequest,
|
||||||
UnitUpdateRequest,
|
UnitUpdateRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_organizations.backend.templates import (
|
||||||
|
OrganizationTemplateError,
|
||||||
|
canonical_template_definition,
|
||||||
|
instantiate_template_version,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.upgrades import (
|
||||||
|
OrganizationUpgradeError,
|
||||||
|
apply_model_upgrade,
|
||||||
|
cancel_model_upgrade,
|
||||||
|
create_model_upgrade_preview,
|
||||||
|
current_model_instantiation,
|
||||||
|
list_model_upgrades,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||||
|
ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000
|
||||||
|
ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000
|
||||||
|
|
||||||
ORG_READ_SCOPES = (
|
ORG_READ_SCOPES = (
|
||||||
"organizations:model:read",
|
"organizations:model:read",
|
||||||
@@ -69,6 +115,7 @@ ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:
|
|||||||
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
|
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
|
||||||
ORG_CHANGE_CONTROL_KEY = "organizations.model"
|
ORG_CHANGE_CONTROL_KEY = "organizations.model"
|
||||||
ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated"
|
ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated"
|
||||||
|
ORG_TEMPLATE_ADMIN_SCOPES = ("system:settings:write",)
|
||||||
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT")
|
ModelT = TypeVar("ModelT")
|
||||||
@@ -128,7 +175,29 @@ def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str
|
|||||||
|
|
||||||
|
|
||||||
def _commit(session: Session, item: ModelT) -> ModelT:
|
def _commit(session: Session, item: ModelT) -> ModelT:
|
||||||
|
lifecycle = _organization_lifecycle_change(item)
|
||||||
try:
|
try:
|
||||||
|
session.flush()
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=getattr(item, "tenant_id", None),
|
||||||
|
source_module="organizations",
|
||||||
|
resource_type=item.__class__.__name__,
|
||||||
|
resource_id=str(
|
||||||
|
getattr(
|
||||||
|
item,
|
||||||
|
"id",
|
||||||
|
getattr(item, "tenant_id", "system"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if lifecycle is not None:
|
||||||
|
_emit_organization_lifecycle_event(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
action=lifecycle[0],
|
||||||
|
changes=lifecycle[1],
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
@@ -137,6 +206,121 @@ def _commit(session: Session, item: ModelT) -> ModelT:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
_LIFECYCLE_RESOURCES = {
|
||||||
|
OrganizationUnitType: "unit_type",
|
||||||
|
OrganizationStructure: "structure",
|
||||||
|
OrganizationRelationType: "relation_type",
|
||||||
|
OrganizationUnit: "unit",
|
||||||
|
OrganizationRelation: "relation",
|
||||||
|
OrganizationFunctionType: "function_type",
|
||||||
|
OrganizationFunction: "function",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_lifecycle_change(
|
||||||
|
item: object,
|
||||||
|
) -> tuple[str, dict[str, dict[str, object | None]]] | None:
|
||||||
|
resource_type = _LIFECYCLE_RESOURCES.get(type(item))
|
||||||
|
if resource_type is None:
|
||||||
|
return None
|
||||||
|
state = sqlalchemy_inspect(item)
|
||||||
|
changes: dict[str, dict[str, object | None]] = {}
|
||||||
|
for attribute in state.mapper.column_attrs:
|
||||||
|
history = state.attrs[attribute.key].history
|
||||||
|
if not history.has_changes():
|
||||||
|
continue
|
||||||
|
changes[attribute.key] = {
|
||||||
|
"before": (
|
||||||
|
_organization_event_value(history.deleted[0])
|
||||||
|
if history.deleted
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"after": (
|
||||||
|
_organization_event_value(history.added[0])
|
||||||
|
if history.added
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if state.pending:
|
||||||
|
action = "created"
|
||||||
|
elif (
|
||||||
|
"is_active" in changes
|
||||||
|
and changes["is_active"]["after"] is False
|
||||||
|
):
|
||||||
|
action = "deactivated"
|
||||||
|
elif resource_type == "unit" and "parent_id" in changes:
|
||||||
|
action = "moved"
|
||||||
|
else:
|
||||||
|
action = "updated"
|
||||||
|
return action, changes
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_event_value(value: object) -> object:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_organization_lifecycle_event(
|
||||||
|
session: Session,
|
||||||
|
item: object,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
changes: dict[str, dict[str, object | None]],
|
||||||
|
) -> None:
|
||||||
|
resource_type = _LIFECYCLE_RESOURCES[type(item)]
|
||||||
|
tenant_id = str(getattr(item, "tenant_id"))
|
||||||
|
item_id = str(getattr(item, "id"))
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"schema_version": ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": item_id,
|
||||||
|
"status": (
|
||||||
|
"active"
|
||||||
|
if bool(getattr(item, "is_active", True))
|
||||||
|
else "inactive"
|
||||||
|
),
|
||||||
|
"changed_fields": sorted(changes),
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
for field in (
|
||||||
|
"slug",
|
||||||
|
"unit_type_id",
|
||||||
|
"organization_unit_id",
|
||||||
|
"function_type_id",
|
||||||
|
"structure_id",
|
||||||
|
"relation_type_id",
|
||||||
|
"source_unit_id",
|
||||||
|
"target_unit_id",
|
||||||
|
):
|
||||||
|
value = getattr(item, field, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[field] = str(value)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=organization_lifecycle_event_type(
|
||||||
|
resource_type, # type: ignore[arg-type]
|
||||||
|
action, # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
module_id="organizations",
|
||||||
|
tenant=EventTenantRef(id=tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type=f"organization_{resource_type}",
|
||||||
|
id=item_id,
|
||||||
|
label=str(getattr(item, "name", None) or "") or None,
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type=f"organization_{resource_type}",
|
||||||
|
id=item_id,
|
||||||
|
),
|
||||||
|
payload=payload,
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _requires_organization_change_request(session: Session, tenant_id: str) -> bool:
|
def _requires_organization_change_request(session: Session, tenant_id: str) -> bool:
|
||||||
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||||
return bool(item and item.require_model_change_requests)
|
return bool(item and item.require_model_change_requests)
|
||||||
@@ -257,6 +441,142 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
|
|||||||
return OrganizationFunctionItem(**_row_fields(item))
|
return OrganizationFunctionItem(**_row_fields(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _template_item(
|
||||||
|
item: OrganizationModelTemplate,
|
||||||
|
) -> OrganizationModelTemplateItem:
|
||||||
|
return OrganizationModelTemplateItem(
|
||||||
|
id=item.id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=dict(item.settings or {}),
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_version_item(
|
||||||
|
item: OrganizationModelTemplateVersion,
|
||||||
|
) -> OrganizationModelTemplateVersionItem:
|
||||||
|
return OrganizationModelTemplateVersionItem(
|
||||||
|
id=item.id,
|
||||||
|
template_id=item.template_id,
|
||||||
|
version=item.version,
|
||||||
|
schema_version=item.schema_version,
|
||||||
|
status=item.status,
|
||||||
|
definition=item.definition,
|
||||||
|
definition_sha256=item.definition_sha256,
|
||||||
|
release_notes=item.release_notes,
|
||||||
|
published_at=item.published_at,
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instantiation_item(
|
||||||
|
item: OrganizationModelInstantiation,
|
||||||
|
) -> OrganizationModelInstantiationItem:
|
||||||
|
return OrganizationModelInstantiationItem(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
template_id=item.template_id,
|
||||||
|
template_version_id=item.template_version_id,
|
||||||
|
source_definition_sha256=item.source_definition_sha256,
|
||||||
|
status=item.status,
|
||||||
|
object_counts=dict(item.object_counts or {}),
|
||||||
|
provenance=dict(item.provenance or {}),
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_item(item: OrganizationModelUpgrade) -> OrganizationModelUpgradeItem:
|
||||||
|
return OrganizationModelUpgradeItem(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
template_id=item.template_id,
|
||||||
|
source_instantiation_id=item.source_instantiation_id,
|
||||||
|
source_template_version_id=item.source_template_version_id,
|
||||||
|
target_template_version_id=item.target_template_version_id,
|
||||||
|
status=item.status,
|
||||||
|
revision=item.revision,
|
||||||
|
base_definition_sha256=item.base_definition_sha256,
|
||||||
|
local_definition_sha256=item.local_definition_sha256,
|
||||||
|
target_definition_sha256=item.target_definition_sha256,
|
||||||
|
preview=item.preview,
|
||||||
|
decisions=item.decisions,
|
||||||
|
requested_by_account_id=item.requested_by_account_id,
|
||||||
|
applied_by_account_id=item.applied_by_account_id,
|
||||||
|
cancelled_by_account_id=item.cancelled_by_account_id,
|
||||||
|
applied_at=item.applied_at,
|
||||||
|
cancelled_at=item.cancelled_at,
|
||||||
|
provenance=dict(item.provenance or {}),
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_model_upgrade_event(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
item: OrganizationModelUpgrade,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=f"organizations.model_upgrade.{action}.v1",
|
||||||
|
module_id="organizations",
|
||||||
|
tenant=EventTenantRef(id=item.tenant_id),
|
||||||
|
actor=EventActorRef(type="account", id=principal.account_id),
|
||||||
|
subject=EventObjectRef(type="organization_model_upgrade", id=item.id),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="organization_model_template_version",
|
||||||
|
id=item.target_template_version_id,
|
||||||
|
),
|
||||||
|
payload={
|
||||||
|
"schema_version": "1",
|
||||||
|
"tenant_id": item.tenant_id,
|
||||||
|
"upgrade_id": item.id,
|
||||||
|
"status": item.status,
|
||||||
|
"revision": item.revision,
|
||||||
|
"source_template_version_id": item.source_template_version_id,
|
||||||
|
"target_template_version_id": item.target_template_version_id,
|
||||||
|
"requires_decisions": int(
|
||||||
|
dict(item.preview or {}).get("requires_decisions", 0)
|
||||||
|
),
|
||||||
|
"blocking_invalid_references": int(
|
||||||
|
dict(item.preview or {}).get(
|
||||||
|
"blocking_invalid_references", 0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"decision_count": len(dict(item.decisions or {})),
|
||||||
|
"silent_mutation": False,
|
||||||
|
},
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_version(
|
||||||
|
session: Session,
|
||||||
|
template_id: str,
|
||||||
|
version: str,
|
||||||
|
) -> OrganizationModelTemplateVersion:
|
||||||
|
item = (
|
||||||
|
session.query(OrganizationModelTemplateVersion)
|
||||||
|
.filter(
|
||||||
|
OrganizationModelTemplateVersion.template_id == template_id,
|
||||||
|
OrganizationModelTemplateVersion.version == version,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise _not_found("Organization model template version")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _row_fields(item: object) -> dict[str, Any]:
|
def _row_fields(item: object) -> dict[str, Any]:
|
||||||
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
||||||
return {key: getattr(item, key) for key in keys}
|
return {key: getattr(item, key) for key in keys}
|
||||||
@@ -329,21 +649,439 @@ def update_organization_settings(
|
|||||||
return _item_settings(_commit(session, item))
|
return _item_settings(_commit(session, item))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/model-templates",
|
||||||
|
response_model=OrganizationModelTemplateCatalogResponse,
|
||||||
|
)
|
||||||
|
def list_organization_model_templates(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
|
) -> OrganizationModelTemplateCatalogResponse:
|
||||||
|
del principal
|
||||||
|
templates = (
|
||||||
|
session.query(OrganizationModelTemplate)
|
||||||
|
.filter(OrganizationModelTemplate.is_active.is_(True))
|
||||||
|
.order_by(
|
||||||
|
OrganizationModelTemplate.name.asc(),
|
||||||
|
OrganizationModelTemplate.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
versions = (
|
||||||
|
session.query(OrganizationModelTemplateVersion)
|
||||||
|
.filter(
|
||||||
|
OrganizationModelTemplateVersion.template_id.in_(
|
||||||
|
[item.id for item in templates]
|
||||||
|
),
|
||||||
|
OrganizationModelTemplateVersion.status == "published",
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
OrganizationModelTemplateVersion.template_id.asc(),
|
||||||
|
OrganizationModelTemplateVersion.published_at.desc(),
|
||||||
|
OrganizationModelTemplateVersion.version.desc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if templates
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
by_template: dict[str, list[OrganizationModelTemplateVersion]] = {}
|
||||||
|
for version in versions:
|
||||||
|
by_template.setdefault(version.template_id, []).append(version)
|
||||||
|
return OrganizationModelTemplateCatalogResponse(
|
||||||
|
templates=[
|
||||||
|
OrganizationModelTemplateCatalogItem(
|
||||||
|
**_template_item(item).model_dump(),
|
||||||
|
versions=[
|
||||||
|
_template_version_item(version)
|
||||||
|
for version in by_template.get(item.id, ())
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for item in templates
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates",
|
||||||
|
response_model=OrganizationModelTemplateItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_organization_model_template(
|
||||||
|
payload: OrganizationModelTemplateCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelTemplateItem:
|
||||||
|
item = OrganizationModelTemplate(
|
||||||
|
slug=payload.slug,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
settings=payload.settings,
|
||||||
|
created_by_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict("An organization model template with this slug already exists") from exc
|
||||||
|
session.refresh(item)
|
||||||
|
return _template_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates/{template_id}/versions",
|
||||||
|
response_model=OrganizationModelTemplateVersionItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_organization_model_template_version(
|
||||||
|
template_id: str,
|
||||||
|
payload: OrganizationModelTemplateVersionCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelTemplateVersionItem:
|
||||||
|
template = session.get(OrganizationModelTemplate, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise _not_found("Organization model template")
|
||||||
|
try:
|
||||||
|
definition, definition_sha256 = canonical_template_definition(
|
||||||
|
payload.definition
|
||||||
|
)
|
||||||
|
except OrganizationTemplateError as exc:
|
||||||
|
raise _invalid(str(exc)) from exc
|
||||||
|
item = OrganizationModelTemplateVersion(
|
||||||
|
template_id=template.id,
|
||||||
|
version=payload.version,
|
||||||
|
definition=definition,
|
||||||
|
definition_sha256=definition_sha256,
|
||||||
|
release_notes=payload.release_notes,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict("This organization model template version already exists") from exc
|
||||||
|
session.refresh(item)
|
||||||
|
return _template_version_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates/{template_id}/versions/{version}/publish",
|
||||||
|
response_model=OrganizationModelTemplateVersionItem,
|
||||||
|
)
|
||||||
|
def publish_organization_model_template_version(
|
||||||
|
template_id: str,
|
||||||
|
version: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelTemplateVersionItem:
|
||||||
|
item = _template_version(session, template_id, version)
|
||||||
|
if item.status == "retired":
|
||||||
|
raise _conflict("A retired organization template version cannot be published")
|
||||||
|
if item.status == "draft":
|
||||||
|
item.status = "published"
|
||||||
|
item.published_at = datetime.now(UTC)
|
||||||
|
item.published_by_account_id = principal.account_id
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return _template_version_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates/{template_id}/versions/{version}/instantiate",
|
||||||
|
response_model=OrganizationModelInstantiationItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def instantiate_organization_model_template(
|
||||||
|
template_id: str,
|
||||||
|
version: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelInstantiationItem:
|
||||||
|
template = session.get(OrganizationModelTemplate, template_id)
|
||||||
|
if template is None or not template.is_active:
|
||||||
|
raise _not_found("Organization model template")
|
||||||
|
item = _template_version(session, template_id, version)
|
||||||
|
try:
|
||||||
|
instantiation = instantiate_template_version(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
template=template,
|
||||||
|
version=item,
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(instantiation)
|
||||||
|
except OrganizationTemplateError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return _instantiation_item(instantiation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/model-upgrades",
|
||||||
|
response_model=OrganizationModelUpgradeListResponse,
|
||||||
|
)
|
||||||
|
def list_organization_model_upgrades(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
|
) -> OrganizationModelUpgradeListResponse:
|
||||||
|
current = current_model_instantiation(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
)
|
||||||
|
return OrganizationModelUpgradeListResponse(
|
||||||
|
current_instantiation=(
|
||||||
|
_instantiation_item(current) if current is not None else None
|
||||||
|
),
|
||||||
|
upgrades=[
|
||||||
|
_upgrade_item(item)
|
||||||
|
for item in list_model_upgrades(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-upgrades/preview",
|
||||||
|
response_model=OrganizationModelUpgradeItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def preview_organization_model_upgrade(
|
||||||
|
payload: OrganizationModelUpgradePreviewRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelUpgradeItem:
|
||||||
|
target = session.get(
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
payload.target_template_version_id,
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
raise _not_found("Organization model template version")
|
||||||
|
try:
|
||||||
|
item = create_model_upgrade_preview(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
_emit_model_upgrade_event(session, principal, item, "previewed")
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
except OrganizationUpgradeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return _upgrade_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-upgrades/{upgrade_id}/cancel",
|
||||||
|
response_model=OrganizationModelUpgradeItem,
|
||||||
|
)
|
||||||
|
def cancel_organization_model_upgrade(
|
||||||
|
upgrade_id: str,
|
||||||
|
payload: OrganizationModelUpgradeCancelRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelUpgradeItem:
|
||||||
|
try:
|
||||||
|
item = cancel_model_upgrade(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
upgrade_id=upgrade_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
_emit_model_upgrade_event(session, principal, item, "cancelled")
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
except OrganizationUpgradeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return _upgrade_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-upgrades/{upgrade_id}/apply",
|
||||||
|
response_model=OrganizationModelUpgradeApplyResponse,
|
||||||
|
)
|
||||||
|
def apply_organization_model_upgrade(
|
||||||
|
upgrade_id: str,
|
||||||
|
payload: OrganizationModelUpgradeApplyRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelUpgradeApplyResponse:
|
||||||
|
approval, control_target, _value = _ensure_organization_change_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
resource_type="model_upgrade",
|
||||||
|
operation="applied",
|
||||||
|
payload=payload,
|
||||||
|
resource_id=upgrade_id,
|
||||||
|
)
|
||||||
|
before = _upgrade_item(
|
||||||
|
_get_tenant_row(
|
||||||
|
session,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
|
upgrade_id,
|
||||||
|
principal.tenant_id,
|
||||||
|
"Organization model upgrade",
|
||||||
|
)
|
||||||
|
).model_dump(mode="json")
|
||||||
|
try:
|
||||||
|
item, instantiation = apply_model_upgrade(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
upgrade_id=upgrade_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
decisions={
|
||||||
|
key: value.model_dump(exclude_none=True)
|
||||||
|
for key, value in payload.decisions.items()
|
||||||
|
},
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_module="organizations",
|
||||||
|
resource_type="organization_model_upgrade",
|
||||||
|
resource_id=item.id,
|
||||||
|
)
|
||||||
|
_emit_model_upgrade_event(session, principal, item, "applied")
|
||||||
|
response = OrganizationModelUpgradeApplyResponse(
|
||||||
|
upgrade=_upgrade_item(item),
|
||||||
|
instantiation=_instantiation_item(instantiation),
|
||||||
|
)
|
||||||
|
if approval is None:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
_record_organization_change_applied(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
approval=approval,
|
||||||
|
target=control_target,
|
||||||
|
before=before,
|
||||||
|
after=response.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
session.refresh(item)
|
||||||
|
session.refresh(instantiation)
|
||||||
|
except OrganizationUpgradeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return OrganizationModelUpgradeApplyResponse(
|
||||||
|
upgrade=_upgrade_item(item),
|
||||||
|
instantiation=_instantiation_item(instantiation),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/model", response_model=OrganizationModelResponse)
|
@router.get("/model", response_model=OrganizationModelResponse)
|
||||||
def get_organization_model(
|
def get_organization_model(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
) -> OrganizationModelResponse:
|
) -> OrganizationModelResponse:
|
||||||
tenant_id = _tenant_id(principal)
|
tenant_id = _tenant_id(principal)
|
||||||
return OrganizationModelResponse(
|
unit_types = _bounded_organization_rows(
|
||||||
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
|
session.query(OrganizationUnitType)
|
||||||
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
|
.filter(OrganizationUnitType.tenant_id == tenant_id)
|
||||||
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
|
.order_by(OrganizationUnitType.name.asc()),
|
||||||
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
|
"unit types",
|
||||||
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()],
|
|
||||||
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
|
|
||||||
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
|
|
||||||
)
|
)
|
||||||
|
structures = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationStructure)
|
||||||
|
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationStructure.name.asc()),
|
||||||
|
"structures",
|
||||||
|
)
|
||||||
|
relation_types = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationRelationType)
|
||||||
|
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationRelationType.name.asc()),
|
||||||
|
"relation types",
|
||||||
|
)
|
||||||
|
units = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationUnit)
|
||||||
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationUnit.name.asc()),
|
||||||
|
"units",
|
||||||
|
)
|
||||||
|
relations = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationRelation)
|
||||||
|
.filter(OrganizationRelation.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationRelation.created_at.asc()),
|
||||||
|
"relations",
|
||||||
|
)
|
||||||
|
function_types = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationFunctionType)
|
||||||
|
.filter(OrganizationFunctionType.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationFunctionType.name.asc()),
|
||||||
|
"function types",
|
||||||
|
)
|
||||||
|
functions = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationFunction)
|
||||||
|
.filter(OrganizationFunction.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationFunction.name.asc()),
|
||||||
|
"functions",
|
||||||
|
)
|
||||||
|
if sum(
|
||||||
|
len(items)
|
||||||
|
for items in (
|
||||||
|
unit_types,
|
||||||
|
structures,
|
||||||
|
relation_types,
|
||||||
|
units,
|
||||||
|
relations,
|
||||||
|
function_types,
|
||||||
|
functions,
|
||||||
|
)
|
||||||
|
) > ORGANIZATION_MODEL_TOTAL_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
"The organization model is too large for the aggregate "
|
||||||
|
"endpoint and cannot be returned as one response."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return OrganizationModelResponse(
|
||||||
|
unit_types=[_item_unit_type(item) for item in unit_types],
|
||||||
|
structures=[_item_structure(item) for item in structures],
|
||||||
|
relation_types=[_item_relation_type(item) for item in relation_types],
|
||||||
|
units=[_item_unit(item) for item in units],
|
||||||
|
relations=[_item_relation(item) for item in relations],
|
||||||
|
function_types=[_item_function_type(item) for item in function_types],
|
||||||
|
functions=[_item_function(item) for item in functions],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_organization_rows(query: Any, label: str) -> list[Any]:
|
||||||
|
rows = query.limit(ORGANIZATION_MODEL_COLLECTION_LIMIT + 1).all()
|
||||||
|
if len(rows) > ORGANIZATION_MODEL_COLLECTION_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
f"The organization model has more than "
|
||||||
|
f"{ORGANIZATION_MODEL_COLLECTION_LIMIT} {label} and cannot "
|
||||||
|
"be returned as one response."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -3,11 +3,277 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
||||||
AuditDetailLevel = Literal["summary", "standard", "full"]
|
AuditDetailLevel = Literal["summary", "standard", "full"]
|
||||||
|
TemplateVersionStatus = Literal["draft", "published", "retired"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateBaseDefinition(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
slug: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||||
|
)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateUnitTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateStructureDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
structure_kind: StructureKind = "hierarchy"
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateRelationTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
structure_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
source_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
target_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
is_hierarchical: bool = True
|
||||||
|
allow_cycles: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateUnitDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
parent_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateRelationDefinition(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
structure_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
relation_type_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
source_unit_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
target_unit_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateFunctionTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
organization_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
delegable: bool = False
|
||||||
|
act_in_place_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateFunctionDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
function_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
organization_unit_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
delegable: bool = False
|
||||||
|
act_in_place_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateDefinition(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
unit_types: list[OrganizationTemplateUnitTypeDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
structures: list[OrganizationTemplateStructureDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
relation_types: list[OrganizationTemplateRelationTypeDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
units: list[OrganizationTemplateUnitDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
relations: list[OrganizationTemplateRelationDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
function_types: list[OrganizationTemplateFunctionTypeDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
functions: list[OrganizationTemplateFunctionDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
slug: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||||
|
)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateVersionCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
version: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=50,
|
||||||
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._+-]*$",
|
||||||
|
)
|
||||||
|
definition: OrganizationModelTemplateDefinition
|
||||||
|
release_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
settings: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateVersionItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
template_id: str
|
||||||
|
version: str
|
||||||
|
schema_version: int
|
||||||
|
status: TemplateVersionStatus
|
||||||
|
definition: OrganizationModelTemplateDefinition
|
||||||
|
definition_sha256: str
|
||||||
|
release_notes: str | None = None
|
||||||
|
published_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateCatalogItem(OrganizationModelTemplateItem):
|
||||||
|
versions: list[OrganizationModelTemplateVersionItem] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateCatalogResponse(BaseModel):
|
||||||
|
templates: list[OrganizationModelTemplateCatalogItem] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelInstantiationItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
template_id: str
|
||||||
|
template_version_id: str
|
||||||
|
source_definition_sha256: str
|
||||||
|
status: str
|
||||||
|
object_counts: dict[str, int]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
UpgradeClassification = Literal[
|
||||||
|
"unchanged",
|
||||||
|
"compatible_addition",
|
||||||
|
"compatible_change",
|
||||||
|
"destructive_remapping",
|
||||||
|
"local_divergence",
|
||||||
|
"invalid_reference",
|
||||||
|
]
|
||||||
|
UpgradeDecisionAction = Literal["keep_local", "use_target", "map_to"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradePreviewRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
target_template_version_id: str = Field(min_length=1, max_length=36)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeDecision(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
action: UpgradeDecisionAction
|
||||||
|
target_key: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeApplyRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
decisions: dict[str, OrganizationModelUpgradeDecision] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
change_request_id: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeCancelRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeDiffEntry(BaseModel):
|
||||||
|
id: str
|
||||||
|
collection: str
|
||||||
|
key: str
|
||||||
|
classification: UpgradeClassification
|
||||||
|
requires_decision: bool
|
||||||
|
base: dict[str, Any] | None = None
|
||||||
|
local: dict[str, Any] | None = None
|
||||||
|
target: dict[str, Any] | None = None
|
||||||
|
allowed_actions: list[UpgradeDecisionAction] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradePreview(BaseModel):
|
||||||
|
entries: list[OrganizationModelUpgradeDiffEntry] = Field(default_factory=list)
|
||||||
|
counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
requires_decisions: int = 0
|
||||||
|
blocking_invalid_references: int = 0
|
||||||
|
silent_mutation: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
template_id: str
|
||||||
|
source_instantiation_id: str
|
||||||
|
source_template_version_id: str
|
||||||
|
target_template_version_id: str
|
||||||
|
status: str
|
||||||
|
revision: int
|
||||||
|
base_definition_sha256: str
|
||||||
|
local_definition_sha256: str
|
||||||
|
target_definition_sha256: str
|
||||||
|
preview: OrganizationModelUpgradePreview
|
||||||
|
decisions: dict[str, OrganizationModelUpgradeDecision] = Field(default_factory=dict)
|
||||||
|
requested_by_account_id: str | None = None
|
||||||
|
applied_by_account_id: str | None = None
|
||||||
|
cancelled_by_account_id: str | None = None
|
||||||
|
applied_at: datetime | None = None
|
||||||
|
cancelled_at: datetime | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeListResponse(BaseModel):
|
||||||
|
current_instantiation: OrganizationModelInstantiationItem | None = None
|
||||||
|
upgrades: list[OrganizationModelUpgradeItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeApplyResponse(BaseModel):
|
||||||
|
upgrade: OrganizationModelUpgradeItem
|
||||||
|
instantiation: OrganizationModelInstantiationItem
|
||||||
|
|
||||||
|
|
||||||
class OrganizationSettingsItem(BaseModel):
|
class OrganizationSettingsItem(BaseModel):
|
||||||
|
|||||||
@@ -55,6 +55,178 @@ class OrganizationTenantSettings(Base, TimestampMixin):
|
|||||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplate(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_templates"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
slug: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateVersion(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_template_versions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"template_id",
|
||||||
|
"version",
|
||||||
|
name="uq_organizations_model_template_versions",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_org_model_template_versions_template_status",
|
||||||
|
"template_id",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
template_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_templates.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False)
|
||||||
|
definition: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
release_notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
published_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelInstantiation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_instantiations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"template_version_id",
|
||||||
|
name="uq_organizations_model_instantiation_version",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_organizations_model_instantiations_tenant",
|
||||||
|
"tenant_id",
|
||||||
|
"created_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)
|
||||||
|
template_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
template_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"organizations_model_template_versions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="applied",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
instantiated_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
object_counts: Mapped[dict[str, int]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgrade(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_upgrades"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_organizations_model_upgrade_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_organizations_model_upgrades_tenant_status",
|
||||||
|
"tenant_id",
|
||||||
|
"status",
|
||||||
|
"updated_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)
|
||||||
|
template_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_instantiation_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_instantiations.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_template_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"organizations_model_template_versions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
target_template_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"organizations_model_template_versions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30), default="previewed", nullable=False, index=True
|
||||||
|
)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
base_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
local_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
target_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
preview: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
decisions: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
requested_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
applied_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
cancelled_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationStructure(Base, TimestampMixin):
|
class OrganizationStructure(Base, TimestampMixin):
|
||||||
__tablename__ = "organizations_structures"
|
__tablename__ = "organizations_structures"
|
||||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
||||||
@@ -153,6 +325,10 @@ class OrganizationFunction(Base, TimestampMixin):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"OrganizationFunction",
|
"OrganizationFunction",
|
||||||
"OrganizationFunctionType",
|
"OrganizationFunctionType",
|
||||||
|
"OrganizationModelInstantiation",
|
||||||
|
"OrganizationModelTemplate",
|
||||||
|
"OrganizationModelTemplateVersion",
|
||||||
|
"OrganizationModelUpgrade",
|
||||||
"OrganizationRelation",
|
"OrganizationRelation",
|
||||||
"OrganizationRelationType",
|
"OrganizationRelationType",
|
||||||
"OrganizationStructure",
|
"OrganizationStructure",
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.organizations import (
|
from govoplan_core.core.organizations import (
|
||||||
OrganizationDirectory,
|
OrganizationDirectory,
|
||||||
OrganizationFunctionRef,
|
OrganizationFunctionRef,
|
||||||
|
OrganizationFunctionTypeRef,
|
||||||
|
OrganizationFunctionTypeResolution,
|
||||||
|
OrganizationHierarchyCatalogRef,
|
||||||
|
OrganizationHierarchyDirection,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
OrganizationHierarchyEdgeRef,
|
||||||
|
OrganizationHierarchyMatchRef,
|
||||||
|
OrganizationHierarchyPathResolution,
|
||||||
|
OrganizationHierarchyResolution,
|
||||||
|
OrganizationRelationTypeRef,
|
||||||
|
OrganizationResolutionStatus,
|
||||||
|
OrganizationStructureRef,
|
||||||
OrganizationUnitRef,
|
OrganizationUnitRef,
|
||||||
|
OrganizationUnitTypeRef,
|
||||||
|
OrganizationUnitTypeResolution,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
OrganizationUnit,
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_DIRECTORY_BATCH = 500
|
||||||
|
MAX_HIERARCHY_DEPTH = 100
|
||||||
|
|
||||||
|
|
||||||
def _status(active: bool) -> str:
|
def _status(active: bool) -> str:
|
||||||
return "active" if active else "inactive"
|
return "active" if active else "inactive"
|
||||||
|
|
||||||
@@ -29,6 +61,17 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_type_ref(item: OrganizationUnitType) -> OrganizationUnitTypeRef:
|
||||||
|
return OrganizationUnitTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||||
return OrganizationFunctionRef(
|
return OrganizationFunctionRef(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -41,27 +84,148 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
|||||||
delegable=item.delegable,
|
delegable=item.delegable,
|
||||||
act_in_place_allowed=item.act_in_place_allowed,
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
status=_status(item.is_active), # type: ignore[arg-type]
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
settings=dict(item.settings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SqlOrganizationDirectory(OrganizationDirectory):
|
def _function_type_ref(
|
||||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
item: OrganizationFunctionType,
|
||||||
with get_database().session() as session:
|
) -> OrganizationFunctionTypeRef:
|
||||||
|
return OrganizationFunctionTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
organization_unit_type_id=item.organization_unit_type_id,
|
||||||
|
description=item.description,
|
||||||
|
delegable=item.delegable,
|
||||||
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
settings=dict(item.settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _structure_ref(item: OrganizationStructure) -> OrganizationStructureRef:
|
||||||
|
return OrganizationStructureRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
structure_kind=item.structure_kind,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relation_type_ref(
|
||||||
|
item: OrganizationRelationType,
|
||||||
|
) -> OrganizationRelationTypeRef:
|
||||||
|
return OrganizationRelationTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
structure_id=item.structure_id,
|
||||||
|
source_unit_type_id=item.source_unit_type_id,
|
||||||
|
target_unit_type_id=item.target_unit_type_id,
|
||||||
|
is_hierarchical=item.is_hierarchical,
|
||||||
|
allow_cycles=item.allow_cycles,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_ids(values: Sequence[str], *, label: str) -> tuple[str, ...]:
|
||||||
|
result = tuple(dict.fromkeys(str(value).strip() for value in values))
|
||||||
|
if any(not value for value in result):
|
||||||
|
raise ValueError(f"{label} must not contain empty IDs.")
|
||||||
|
if len(result) > MAX_DIRECTORY_BATCH:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} is limited to {MAX_DIRECTORY_BATCH} entries."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_direction(
|
||||||
|
value: OrganizationHierarchyDirection,
|
||||||
|
) -> OrganizationHierarchyDirection:
|
||||||
|
if value not in {"ancestors", "descendants"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Hierarchy direction must be ancestors or descendants."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_depth(value: int) -> int:
|
||||||
|
depth = int(value)
|
||||||
|
if depth < 1 or depth > MAX_HIERARCHY_DEPTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Hierarchy depth must be between 1 and {MAX_HIERARCHY_DEPTH}."
|
||||||
|
)
|
||||||
|
return depth
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _HierarchyGraph:
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
structure_id: str
|
||||||
|
structure: OrganizationStructureRef | None
|
||||||
|
relation_type_ids: tuple[str, ...]
|
||||||
|
units: dict[str, OrganizationUnitRef]
|
||||||
|
outgoing: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||||
|
incoming: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||||
|
diagnostics: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class SqlOrganizationDirectory(
|
||||||
|
OrganizationDirectory,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_factory: Callable[[], Session] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _session(self) -> Iterator[Session]:
|
||||||
|
if self._session_factory is None:
|
||||||
|
with get_database().session() as session:
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
session = self._session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_organization_unit(
|
||||||
|
self,
|
||||||
|
organization_unit_id: str,
|
||||||
|
) -> OrganizationUnitRef | None:
|
||||||
|
with self._session() as session:
|
||||||
item = session.get(OrganizationUnit, organization_unit_id)
|
item = session.get(OrganizationUnit, organization_unit_id)
|
||||||
return _unit_ref(item) if item is not None else None
|
return _unit_ref(item) if item is not None else None
|
||||||
|
|
||||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
def organization_units_for_tenant(
|
||||||
with get_database().session() as session:
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> tuple[OrganizationUnitRef, ...]:
|
||||||
|
with self._session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
session.query(OrganizationUnit)
|
session.query(OrganizationUnit)
|
||||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||||
.order_by(OrganizationUnit.name.asc())
|
.order_by(OrganizationUnit.name.asc(), OrganizationUnit.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_unit_ref(item) for item in rows)
|
return tuple(_unit_ref(item) for item in rows)
|
||||||
|
|
||||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
def get_function(
|
||||||
with get_database().session() as session:
|
self,
|
||||||
|
function_id: str,
|
||||||
|
) -> OrganizationFunctionRef | None:
|
||||||
|
with self._session() as session:
|
||||||
item = session.get(OrganizationFunction, function_id)
|
item = session.get(OrganizationFunction, function_id)
|
||||||
return _function_ref(item) if item is not None else None
|
return _function_ref(item) if item is not None else None
|
||||||
|
|
||||||
@@ -71,7 +235,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
*,
|
*,
|
||||||
include_subunits: bool = False,
|
include_subunits: bool = False,
|
||||||
) -> tuple[OrganizationFunctionRef, ...]:
|
) -> tuple[OrganizationFunctionRef, ...]:
|
||||||
with get_database().session() as session:
|
with self._session() as session:
|
||||||
|
unit = session.get(OrganizationUnit, organization_unit_id)
|
||||||
|
if unit is None:
|
||||||
|
return ()
|
||||||
unit_ids = {organization_unit_id}
|
unit_ids = {organization_unit_id}
|
||||||
if include_subunits:
|
if include_subunits:
|
||||||
pending = [organization_unit_id]
|
pending = [organization_unit_id]
|
||||||
@@ -80,7 +247,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
child_ids = [
|
child_ids = [
|
||||||
row[0]
|
row[0]
|
||||||
for row in session.query(OrganizationUnit.id)
|
for row in session.query(OrganizationUnit.id)
|
||||||
.filter(OrganizationUnit.parent_id == parent_id)
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == unit.tenant_id,
|
||||||
|
OrganizationUnit.parent_id == parent_id,
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
for child_id in child_ids:
|
for child_id in child_ids:
|
||||||
@@ -89,8 +259,688 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
pending.append(child_id)
|
pending.append(child_id)
|
||||||
rows = (
|
rows = (
|
||||||
session.query(OrganizationFunction)
|
session.query(OrganizationFunction)
|
||||||
.filter(OrganizationFunction.organization_unit_id.in_(unit_ids))
|
.filter(
|
||||||
.order_by(OrganizationFunction.name.asc())
|
OrganizationFunction.tenant_id == unit.tenant_id,
|
||||||
|
OrganizationFunction.organization_unit_id.in_(unit_ids),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
OrganizationFunction.name.asc(),
|
||||||
|
OrganizationFunction.id,
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_function_ref(item) for item in rows)
|
return tuple(_function_ref(item) for item in rows)
|
||||||
|
|
||||||
|
def get_unit_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
) -> OrganizationUnitTypeRef | None:
|
||||||
|
with self._session() as session:
|
||||||
|
item = session.get(OrganizationUnitType, unit_type_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return _unit_type_ref(item)
|
||||||
|
|
||||||
|
def hierarchy_catalog(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> OrganizationHierarchyCatalogRef:
|
||||||
|
with self._session() as session:
|
||||||
|
structures = (
|
||||||
|
session.query(OrganizationStructure)
|
||||||
|
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationStructure.name, OrganizationStructure.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
relation_types = (
|
||||||
|
session.query(OrganizationRelationType)
|
||||||
|
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||||
|
.order_by(
|
||||||
|
OrganizationRelationType.structure_id,
|
||||||
|
OrganizationRelationType.name,
|
||||||
|
OrganizationRelationType.id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return OrganizationHierarchyCatalogRef(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structures=tuple(_structure_ref(item) for item in structures),
|
||||||
|
relation_types=tuple(
|
||||||
|
_relation_type_ref(item) for item in relation_types
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_function_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
) -> OrganizationFunctionTypeRef | None:
|
||||||
|
with self._session() as session:
|
||||||
|
item = session.get(OrganizationFunctionType, function_type_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return _function_type_ref(item)
|
||||||
|
|
||||||
|
def resolve_functions_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
*,
|
||||||
|
organization_unit_ids: Sequence[str] = (),
|
||||||
|
) -> OrganizationFunctionTypeResolution:
|
||||||
|
requested_ids = _unique_ids(
|
||||||
|
organization_unit_ids,
|
||||||
|
label="Organization unit scope",
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
function_type = session.get(
|
||||||
|
OrganizationFunctionType,
|
||||||
|
function_type_id,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
function_type is None
|
||||||
|
or function_type.tenant_id != tenant_id
|
||||||
|
):
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=requested_ids,
|
||||||
|
status="missing",
|
||||||
|
diagnostics=("function_type_missing",),
|
||||||
|
)
|
||||||
|
units: dict[str, OrganizationUnit] = {}
|
||||||
|
if requested_ids:
|
||||||
|
units = {
|
||||||
|
item.id: item
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(requested_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
query = session.query(OrganizationFunction).filter(
|
||||||
|
OrganizationFunction.tenant_id == tenant_id,
|
||||||
|
OrganizationFunction.function_type_id == function_type_id,
|
||||||
|
)
|
||||||
|
if requested_ids:
|
||||||
|
query = query.filter(
|
||||||
|
OrganizationFunction.organization_unit_id.in_(
|
||||||
|
tuple(units)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = query.order_by(
|
||||||
|
OrganizationFunction.organization_unit_id,
|
||||||
|
OrganizationFunction.name,
|
||||||
|
OrganizationFunction.id,
|
||||||
|
).all()
|
||||||
|
relevant_unit_ids = (
|
||||||
|
requested_ids
|
||||||
|
if requested_ids
|
||||||
|
else tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.organization_unit_id for item in rows
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not requested_ids and relevant_unit_ids:
|
||||||
|
units = {
|
||||||
|
item.id: item
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(relevant_unit_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
missing = tuple(
|
||||||
|
item_id
|
||||||
|
for item_id in relevant_unit_ids
|
||||||
|
if item_id not in units
|
||||||
|
)
|
||||||
|
inactive = tuple(
|
||||||
|
item_id
|
||||||
|
for item_id in relevant_unit_ids
|
||||||
|
if item_id in units and not units[item_id].is_active
|
||||||
|
)
|
||||||
|
diagnostics = []
|
||||||
|
if missing:
|
||||||
|
diagnostics.append("organization_units_missing")
|
||||||
|
if inactive:
|
||||||
|
diagnostics.append("organization_units_inactive")
|
||||||
|
if not rows:
|
||||||
|
diagnostics.append("no_matching_functions")
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=requested_ids,
|
||||||
|
status=(
|
||||||
|
"active" if function_type.is_active else "inactive"
|
||||||
|
),
|
||||||
|
function_type=_function_type_ref(function_type),
|
||||||
|
matches=tuple(_function_ref(item) for item in rows),
|
||||||
|
missing_unit_ids=missing,
|
||||||
|
inactive_unit_ids=inactive,
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_units_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
*,
|
||||||
|
structure_id: str | None = None,
|
||||||
|
root_unit_id: str | None = None,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "descendants",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> OrganizationUnitTypeResolution:
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
if bool(structure_id) != bool(root_unit_id):
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="invalid",
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
diagnostics=(
|
||||||
|
"structure_and_root_must_be_supplied_together",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
unit_type = session.get(OrganizationUnitType, unit_type_id)
|
||||||
|
if unit_type is None or unit_type.tenant_id != tenant_id:
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="missing",
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
diagnostics=("unit_type_missing",),
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.unit_type_id == unit_type_id,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationUnit.name, OrganizationUnit.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
status: OrganizationResolutionStatus = (
|
||||||
|
"active" if unit_type.is_active else "inactive"
|
||||||
|
)
|
||||||
|
if structure_id and root_unit_id:
|
||||||
|
hierarchy = self.resolve_hierarchy_relatives(
|
||||||
|
tenant_id,
|
||||||
|
(root_unit_id,),
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)[0]
|
||||||
|
scope_ids = {
|
||||||
|
root_unit_id,
|
||||||
|
*(match.unit.id for match in hierarchy.matches),
|
||||||
|
}
|
||||||
|
rows = [item for item in rows if item.id in scope_ids]
|
||||||
|
diagnostics = hierarchy.diagnostics
|
||||||
|
if hierarchy.status in {"missing", "invalid"}:
|
||||||
|
status = hierarchy.status
|
||||||
|
elif hierarchy.status == "inactive" and status == "active":
|
||||||
|
status = "inactive"
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status=status,
|
||||||
|
unit_type=_unit_type_ref(unit_type),
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
matches=tuple(_unit_ref(item) for item in rows),
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_relatives(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
organization_unit_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "ancestors",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> tuple[OrganizationHierarchyResolution, ...]:
|
||||||
|
root_ids = _unique_ids(
|
||||||
|
organization_unit_ids,
|
||||||
|
label="Organization hierarchy roots",
|
||||||
|
)
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
graph = self._hierarchy_graph(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
required_unit_ids=root_ids,
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
self._relative_resolution(
|
||||||
|
graph,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_id,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
for root_id in root_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_paths(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_pairs: Sequence[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "descendants",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> tuple[OrganizationHierarchyPathResolution, ...]:
|
||||||
|
if len(unit_pairs) > MAX_DIRECTORY_BATCH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Organization path batches are limited to "
|
||||||
|
f"{MAX_DIRECTORY_BATCH} entries."
|
||||||
|
)
|
||||||
|
pairs = tuple(
|
||||||
|
(str(source).strip(), str(target).strip())
|
||||||
|
for source, target in unit_pairs
|
||||||
|
)
|
||||||
|
if any(not source or not target for source, target in pairs):
|
||||||
|
raise ValueError("Organization paths require source and target IDs.")
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
required_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item
|
||||||
|
for pair in pairs
|
||||||
|
for item in pair
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
graph = self._hierarchy_graph(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
required_unit_ids=required_ids,
|
||||||
|
)
|
||||||
|
cache: dict[str, OrganizationHierarchyResolution] = {}
|
||||||
|
results: list[OrganizationHierarchyPathResolution] = []
|
||||||
|
for source_id, target_id in pairs:
|
||||||
|
if source_id not in cache:
|
||||||
|
cache[source_id] = self._relative_resolution(
|
||||||
|
graph,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=source_id,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
relative = cache[source_id]
|
||||||
|
target = graph.units.get(target_id)
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in relative.matches
|
||||||
|
if item.unit.id == target_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if source_id == target_id and relative.root is not None:
|
||||||
|
status: OrganizationResolutionStatus = (
|
||||||
|
"active"
|
||||||
|
if relative.root.status == "active"
|
||||||
|
else "inactive"
|
||||||
|
)
|
||||||
|
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
|
||||||
|
elif relative.root is None or target is None:
|
||||||
|
status = "missing"
|
||||||
|
path = ()
|
||||||
|
elif match is None:
|
||||||
|
status = (
|
||||||
|
"invalid"
|
||||||
|
if relative.status == "invalid"
|
||||||
|
else "unreachable"
|
||||||
|
)
|
||||||
|
path = ()
|
||||||
|
else:
|
||||||
|
status = (
|
||||||
|
"active"
|
||||||
|
if (
|
||||||
|
relative.root.status == "active"
|
||||||
|
and target.status == "active"
|
||||||
|
)
|
||||||
|
else "inactive"
|
||||||
|
)
|
||||||
|
path = match.path
|
||||||
|
results.append(
|
||||||
|
OrganizationHierarchyPathResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_unit_id=source_id,
|
||||||
|
target_unit_id=target_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=depth,
|
||||||
|
status=status,
|
||||||
|
source=relative.root,
|
||||||
|
target=target,
|
||||||
|
path=path,
|
||||||
|
cycle_detected=relative.cycle_detected,
|
||||||
|
depth_limited=relative.depth_limited,
|
||||||
|
diagnostics=relative.diagnostics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
def _hierarchy_graph(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: tuple[str, ...],
|
||||||
|
required_unit_ids: tuple[str, ...],
|
||||||
|
) -> _HierarchyGraph:
|
||||||
|
structure = session.get(OrganizationStructure, structure_id)
|
||||||
|
if structure is None or structure.tenant_id != tenant_id:
|
||||||
|
return _HierarchyGraph(
|
||||||
|
status="missing",
|
||||||
|
structure_id=structure_id,
|
||||||
|
structure=None,
|
||||||
|
relation_type_ids=relation_type_ids,
|
||||||
|
units={},
|
||||||
|
outgoing={},
|
||||||
|
incoming={},
|
||||||
|
diagnostics=("structure_missing",),
|
||||||
|
)
|
||||||
|
relation_query = session.query(OrganizationRelationType).filter(
|
||||||
|
OrganizationRelationType.tenant_id == tenant_id,
|
||||||
|
OrganizationRelationType.is_hierarchical.is_(True),
|
||||||
|
or_(
|
||||||
|
OrganizationRelationType.structure_id.is_(None),
|
||||||
|
OrganizationRelationType.structure_id == structure_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if relation_type_ids:
|
||||||
|
relation_query = relation_query.filter(
|
||||||
|
OrganizationRelationType.id.in_(relation_type_ids)
|
||||||
|
)
|
||||||
|
relation_types = relation_query.all()
|
||||||
|
relation_type_by_id = {
|
||||||
|
item.id: item
|
||||||
|
for item in relation_types
|
||||||
|
if item.is_active
|
||||||
|
}
|
||||||
|
diagnostics: list[str] = []
|
||||||
|
invalid_relation_filter = False
|
||||||
|
if relation_type_ids:
|
||||||
|
missing_relation_ids = tuple(
|
||||||
|
item
|
||||||
|
for item in relation_type_ids
|
||||||
|
if item not in {row.id for row in relation_types}
|
||||||
|
)
|
||||||
|
inactive_relation_ids = tuple(
|
||||||
|
item.id for item in relation_types if not item.is_active
|
||||||
|
)
|
||||||
|
if missing_relation_ids:
|
||||||
|
diagnostics.append("relation_types_missing_or_invalid")
|
||||||
|
invalid_relation_filter = True
|
||||||
|
if inactive_relation_ids:
|
||||||
|
diagnostics.append("relation_types_inactive")
|
||||||
|
invalid_relation_filter = True
|
||||||
|
selected_ids = tuple(sorted(relation_type_by_id))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
relations = []
|
||||||
|
if selected_ids:
|
||||||
|
relations = (
|
||||||
|
session.query(OrganizationRelation)
|
||||||
|
.filter(
|
||||||
|
OrganizationRelation.tenant_id == tenant_id,
|
||||||
|
OrganizationRelation.structure_id == structure_id,
|
||||||
|
OrganizationRelation.relation_type_id.in_(selected_ids),
|
||||||
|
OrganizationRelation.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(OrganizationRelation.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
relations = [
|
||||||
|
item
|
||||||
|
for item in relations
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
item.valid_from is None
|
||||||
|
or _utc_datetime(item.valid_from) <= now
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
item.valid_until is None
|
||||||
|
or _utc_datetime(item.valid_until) > now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
unit_ids = {
|
||||||
|
*required_unit_ids,
|
||||||
|
*(item.source_unit_id for item in relations),
|
||||||
|
*(item.target_unit_id for item in relations),
|
||||||
|
}
|
||||||
|
units = {
|
||||||
|
item.id: _unit_ref(item)
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(unit_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
} if unit_ids else {}
|
||||||
|
structure_ref = _structure_ref(structure)
|
||||||
|
outgoing_lists: dict[
|
||||||
|
str,
|
||||||
|
list[OrganizationHierarchyEdgeRef],
|
||||||
|
] = {}
|
||||||
|
incoming_lists: dict[
|
||||||
|
str,
|
||||||
|
list[OrganizationHierarchyEdgeRef],
|
||||||
|
] = {}
|
||||||
|
for relation in relations:
|
||||||
|
relation_type = relation_type_by_id[relation.relation_type_id]
|
||||||
|
if (
|
||||||
|
relation.source_unit_id not in units
|
||||||
|
or relation.target_unit_id not in units
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
f"relation_unit_missing:{relation.id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
edge = OrganizationHierarchyEdgeRef(
|
||||||
|
id=relation.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure=structure_ref,
|
||||||
|
relation_type=_relation_type_ref(relation_type),
|
||||||
|
source_unit_id=relation.source_unit_id,
|
||||||
|
target_unit_id=relation.target_unit_id,
|
||||||
|
valid_from=relation.valid_from,
|
||||||
|
valid_until=relation.valid_until,
|
||||||
|
)
|
||||||
|
outgoing_lists.setdefault(edge.source_unit_id, []).append(edge)
|
||||||
|
incoming_lists.setdefault(edge.target_unit_id, []).append(edge)
|
||||||
|
return _HierarchyGraph(
|
||||||
|
status=(
|
||||||
|
"inactive"
|
||||||
|
if not structure.is_active
|
||||||
|
else "invalid"
|
||||||
|
if invalid_relation_filter
|
||||||
|
else "active"
|
||||||
|
),
|
||||||
|
structure_id=structure_id,
|
||||||
|
structure=structure_ref,
|
||||||
|
relation_type_ids=selected_ids,
|
||||||
|
units=units,
|
||||||
|
outgoing={
|
||||||
|
key: tuple(sorted(value, key=lambda item: item.id))
|
||||||
|
for key, value in outgoing_lists.items()
|
||||||
|
},
|
||||||
|
incoming={
|
||||||
|
key: tuple(sorted(value, key=lambda item: item.id))
|
||||||
|
for key, value in incoming_lists.items()
|
||||||
|
},
|
||||||
|
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _relative_resolution(
|
||||||
|
self,
|
||||||
|
graph: _HierarchyGraph,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
root_unit_id: str,
|
||||||
|
direction: OrganizationHierarchyDirection,
|
||||||
|
max_depth: int,
|
||||||
|
) -> OrganizationHierarchyResolution:
|
||||||
|
root = graph.units.get(root_unit_id)
|
||||||
|
if root is None:
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=(
|
||||||
|
graph.structure_id
|
||||||
|
),
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status="missing",
|
||||||
|
diagnostics=tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
(*graph.diagnostics, "root_unit_missing")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if graph.status in {"missing", "invalid"}:
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=(
|
||||||
|
graph.structure_id
|
||||||
|
),
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status=graph.status,
|
||||||
|
root=root,
|
||||||
|
diagnostics=graph.diagnostics,
|
||||||
|
)
|
||||||
|
adjacency = (
|
||||||
|
graph.incoming
|
||||||
|
if direction == "ancestors"
|
||||||
|
else graph.outgoing
|
||||||
|
)
|
||||||
|
pending = deque(
|
||||||
|
[(root_unit_id, 0, (), frozenset({root_unit_id}))]
|
||||||
|
)
|
||||||
|
best_depth = {root_unit_id: 0}
|
||||||
|
matches: dict[str, OrganizationHierarchyMatchRef] = {}
|
||||||
|
diagnostics = list(graph.diagnostics)
|
||||||
|
cycle_detected = False
|
||||||
|
depth_limited = False
|
||||||
|
while pending:
|
||||||
|
current_id, current_depth, path, path_units = pending.popleft()
|
||||||
|
edges = adjacency.get(current_id, ())
|
||||||
|
if current_depth >= max_depth:
|
||||||
|
if edges:
|
||||||
|
depth_limited = True
|
||||||
|
continue
|
||||||
|
for edge in edges:
|
||||||
|
next_id = (
|
||||||
|
edge.source_unit_id
|
||||||
|
if direction == "ancestors"
|
||||||
|
else edge.target_unit_id
|
||||||
|
)
|
||||||
|
if next_id in path_units:
|
||||||
|
cycle_detected = True
|
||||||
|
diagnostics.append(f"cycle_detected:{edge.id}")
|
||||||
|
continue
|
||||||
|
unit = graph.units.get(next_id)
|
||||||
|
if unit is None:
|
||||||
|
diagnostics.append(f"unit_missing:{next_id}")
|
||||||
|
continue
|
||||||
|
next_depth = current_depth + 1
|
||||||
|
previous_depth = best_depth.get(next_id)
|
||||||
|
if (
|
||||||
|
previous_depth is not None
|
||||||
|
and previous_depth <= next_depth
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
next_path = (*path, edge)
|
||||||
|
best_depth[next_id] = next_depth
|
||||||
|
matches[next_id] = OrganizationHierarchyMatchRef(
|
||||||
|
unit=unit,
|
||||||
|
depth=next_depth,
|
||||||
|
path=next_path,
|
||||||
|
)
|
||||||
|
pending.append(
|
||||||
|
(
|
||||||
|
next_id,
|
||||||
|
next_depth,
|
||||||
|
next_path,
|
||||||
|
path_units | {next_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if depth_limited:
|
||||||
|
diagnostics.append("maximum_depth_reached")
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=graph.structure_id,
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status=(
|
||||||
|
"active"
|
||||||
|
if root.status == "active" and graph.status == "active"
|
||||||
|
else "inactive"
|
||||||
|
),
|
||||||
|
root=root,
|
||||||
|
matches=tuple(
|
||||||
|
sorted(
|
||||||
|
matches.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
item.depth,
|
||||||
|
item.unit.name.casefold(),
|
||||||
|
item.unit.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cycle_detected=cycle_detected,
|
||||||
|
depth_limited=depth_limited,
|
||||||
|
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SqlOrganizationDirectory"]
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
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_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationModelInstantiation,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ORGANIZATIONS_DSAR_CAPABILITY = dsar_capability_name("organizations")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SubjectSelectors:
|
||||||
|
account_id: str | None
|
||||||
|
instantiation_id: str | None
|
||||||
|
upgrade_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationsDsarProvider:
|
||||||
|
provider_id = "organizations"
|
||||||
|
module_id = "organizations"
|
||||||
|
|
||||||
|
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.account_id:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
|
||||||
|
instantiation_query = db.query(OrganizationModelInstantiation).filter(
|
||||||
|
OrganizationModelInstantiation.tenant_id == tenant_id,
|
||||||
|
OrganizationModelInstantiation.instantiated_by_account_id
|
||||||
|
== selectors.account_id,
|
||||||
|
)
|
||||||
|
if selectors.instantiation_id:
|
||||||
|
instantiation_query = instantiation_query.filter(
|
||||||
|
OrganizationModelInstantiation.id == selectors.instantiation_id
|
||||||
|
)
|
||||||
|
for row in _bounded_rows(
|
||||||
|
instantiation_query.order_by(OrganizationModelInstantiation.id)
|
||||||
|
):
|
||||||
|
records.append(
|
||||||
|
_record(
|
||||||
|
"organizations_model_instantiation",
|
||||||
|
row.id,
|
||||||
|
"organization_model_governance_evidence",
|
||||||
|
"Organization model instantiation attribution",
|
||||||
|
{
|
||||||
|
"match_fields": ["instantiated_by_account_id"],
|
||||||
|
"template_id": row.template_id,
|
||||||
|
"template_version_id": row.template_version_id,
|
||||||
|
"source_definition_sha256": row.source_definition_sha256,
|
||||||
|
"status": row.status,
|
||||||
|
},
|
||||||
|
observed_at=row.updated_at,
|
||||||
|
retention_reason=(
|
||||||
|
"Model-instantiation attribution is retained to explain which "
|
||||||
|
"reviewed institutional model became tenant-owned."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
upgrade_query = db.query(OrganizationModelUpgrade).filter(
|
||||||
|
OrganizationModelUpgrade.tenant_id == tenant_id,
|
||||||
|
or_(
|
||||||
|
OrganizationModelUpgrade.requested_by_account_id
|
||||||
|
== selectors.account_id,
|
||||||
|
OrganizationModelUpgrade.applied_by_account_id == selectors.account_id,
|
||||||
|
OrganizationModelUpgrade.cancelled_by_account_id
|
||||||
|
== selectors.account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if selectors.upgrade_id:
|
||||||
|
upgrade_query = upgrade_query.filter(
|
||||||
|
OrganizationModelUpgrade.id == selectors.upgrade_id
|
||||||
|
)
|
||||||
|
for row in _bounded_rows(upgrade_query.order_by(OrganizationModelUpgrade.id)):
|
||||||
|
records.append(
|
||||||
|
_record(
|
||||||
|
"organizations_model_upgrade",
|
||||||
|
row.id,
|
||||||
|
"organization_model_governance_evidence",
|
||||||
|
"Organization model upgrade attribution",
|
||||||
|
{
|
||||||
|
"match_fields": _actor_match_fields(
|
||||||
|
row,
|
||||||
|
selectors.account_id,
|
||||||
|
(
|
||||||
|
"requested_by_account_id",
|
||||||
|
"applied_by_account_id",
|
||||||
|
"cancelled_by_account_id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"template_id": row.template_id,
|
||||||
|
"source_instantiation_id": row.source_instantiation_id,
|
||||||
|
"source_template_version_id": (row.source_template_version_id),
|
||||||
|
"target_template_version_id": (row.target_template_version_id),
|
||||||
|
"status": row.status,
|
||||||
|
"revision": row.revision,
|
||||||
|
"base_definition_sha256": row.base_definition_sha256,
|
||||||
|
"local_definition_sha256": row.local_definition_sha256,
|
||||||
|
"target_definition_sha256": row.target_definition_sha256,
|
||||||
|
"applied_at": _iso(row.applied_at),
|
||||||
|
"cancelled_at": _iso(row.cancelled_at),
|
||||||
|
},
|
||||||
|
observed_at=row.updated_at,
|
||||||
|
retention_reason=(
|
||||||
|
"Upgrade request, application, and cancellation attribution "
|
||||||
|
"is retained as organization-model change evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(records) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Organizations DSAR result limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
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("Organizations DSAR subject selectors conflict.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"organizations:retain:{record.resource_type}:"
|
||||||
|
f"{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"Retain {record.title}",
|
||||||
|
rationale=record.retention_reason
|
||||||
|
or "Organization-model governance evidence must be retained.",
|
||||||
|
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("Organizations DSAR subject selectors conflict.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
if action.executable:
|
||||||
|
raise ValueError(
|
||||||
|
"Organizations DSAR does not publish executable erasure actions."
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Organization-model change attribution is retained as "
|
||||||
|
"institutional governance evidence."
|
||||||
|
),
|
||||||
|
evidence={"request_id": request_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||||
|
account_ids = {
|
||||||
|
value
|
||||||
|
for item in (
|
||||||
|
subject.account_id,
|
||||||
|
subject.external_references.get("organizations.account"),
|
||||||
|
subject.external_references.get("access.account"),
|
||||||
|
)
|
||||||
|
if (value := _normalized_id(item))
|
||||||
|
}
|
||||||
|
if len(account_ids) > 1:
|
||||||
|
return None
|
||||||
|
return _SubjectSelectors(
|
||||||
|
account_id=next(iter(account_ids), None),
|
||||||
|
instantiation_id=_normalized_id(
|
||||||
|
subject.external_references.get("organizations.model_instantiation")
|
||||||
|
),
|
||||||
|
upgrade_id=_normalized_id(
|
||||||
|
subject.external_references.get("organizations.model_upgrade")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_match_fields(
|
||||||
|
row: object,
|
||||||
|
account_id: str,
|
||||||
|
fields: Sequence[str],
|
||||||
|
) -> list[str]:
|
||||||
|
return [field for field in fields if getattr(row, field, None) == account_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "organizations" or record.module_id != "organizations":
|
||||||
|
raise ValueError("Organizations DSAR received a foreign provider record.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "organizations" or action.module_id != "organizations":
|
||||||
|
raise ValueError("Organizations 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,
|
||||||
|
retention_reason: str,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="organizations",
|
||||||
|
module_id="organizations",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=retention_reason,
|
||||||
|
source_path="/admin?section=tenant-organization-settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Organizations DSAR provider requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_rows(query: object) -> list[object]:
|
||||||
|
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Organizations DSAR match limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_id(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = str(value).strip()
|
||||||
|
return value 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__ = ["ORGANIZATIONS_DSAR_CAPABILITY", "OrganizationsDsarProvider"]
|
||||||
@@ -2,22 +2,36 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||||
|
from govoplan_organizations.backend.dsar_provider import ORGANIZATIONS_DSAR_CAPABILITY
|
||||||
|
|
||||||
|
|
||||||
ORGANIZATIONS_READ_SCOPES = (
|
ORGANIZATIONS_READ_SCOPES = (
|
||||||
@@ -43,14 +57,46 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission("organizations:model:read", "View organization model", "Read organization meta-model definitions such as unit types, structures, and relation types."),
|
_permission(
|
||||||
_permission("organizations:model:write", "Manage organization model", "Create and edit organization meta-model definitions."),
|
"organizations:model:read",
|
||||||
_permission("organizations:settings:read", "View organization settings", "Read organization governance, audit, and retention settings."),
|
"View organization model",
|
||||||
_permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."),
|
"Read organization meta-model definitions such as unit types, structures, and relation types.",
|
||||||
_permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."),
|
),
|
||||||
_permission("organizations:unit:write", "Manage organization units", "Create and edit concrete organization units and relations."),
|
_permission(
|
||||||
_permission("organizations:function:read", "View organization functions", "Read function definitions."),
|
"organizations:model:write",
|
||||||
_permission("organizations:function:write", "Manage organization functions", "Create and edit function definitions."),
|
"Manage organization model",
|
||||||
|
"Create and edit organization meta-model definitions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:settings:read",
|
||||||
|
"View organization settings",
|
||||||
|
"Read organization governance, audit, and retention settings.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:settings:write",
|
||||||
|
"Manage organization settings",
|
||||||
|
"Edit organization governance, audit, and retention settings.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:unit:read",
|
||||||
|
"View organization units",
|
||||||
|
"Read concrete organization units and relations.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:unit:write",
|
||||||
|
"Manage organization units",
|
||||||
|
"Create and edit concrete organization units and relations.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:function:read",
|
||||||
|
"View organization functions",
|
||||||
|
"Read function definitions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:function:write",
|
||||||
|
"Manage organization functions",
|
||||||
|
"Create and edit function definitions.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -64,7 +110,12 @@ ROLE_TEMPLATES = (
|
|||||||
slug="organization_viewer",
|
slug="organization_viewer",
|
||||||
name="Organization viewer",
|
name="Organization viewer",
|
||||||
description="Read organization model, organization units, and functions.",
|
description="Read organization model, organization units, and functions.",
|
||||||
permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"),
|
permissions=(
|
||||||
|
"organizations:model:read",
|
||||||
|
"organizations:settings:read",
|
||||||
|
"organizations:unit:read",
|
||||||
|
"organizations:function:read",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,21 +134,96 @@ def _organization_directory(context: ModuleContext) -> object:
|
|||||||
return SqlOrganizationDirectory()
|
return SqlOrganizationDirectory()
|
||||||
|
|
||||||
|
|
||||||
|
def _organizations_dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_organizations.backend.dsar_provider import OrganizationsDsarProvider
|
||||||
|
|
||||||
|
return OrganizationsDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="organizations",
|
id="organizations",
|
||||||
name="Organizations",
|
name="Organizations",
|
||||||
version="0.1.8",
|
version="0.1.21",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="organizations.directory",
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="organizations.hierarchy_directory",
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=ORGANIZATIONS_DSAR_CAPABILITY,
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/organizations",
|
||||||
|
label="Organizations",
|
||||||
|
icon="building-2",
|
||||||
|
required_any=ORGANIZATIONS_READ_SCOPES,
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="organizations",
|
module_id="organizations",
|
||||||
package_name="@govoplan/organizations-webui",
|
package_name="@govoplan/organizations-webui",
|
||||||
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
routes=(
|
||||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
FrontendRoute(
|
||||||
|
path="/organizations",
|
||||||
|
component="OrganizationsPage",
|
||||||
|
required_any=ORGANIZATIONS_READ_SCOPES,
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/organizations",
|
||||||
|
label="Organizations",
|
||||||
|
icon="building-2",
|
||||||
|
required_any=ORGANIZATIONS_READ_SCOPES,
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="people-responsibility",
|
||||||
|
module_id="organizations",
|
||||||
|
label="i18n:govoplan-core.product_area.people_responsibility",
|
||||||
|
icon="users",
|
||||||
|
description="i18n:govoplan-core.product_area.people_responsibility_description",
|
||||||
|
surface_ids=("organizations.nav.organizations", "organizations.route.organizations"),
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="organizations.admin.tenant",
|
||||||
|
module_id="organizations",
|
||||||
|
kind="section",
|
||||||
|
label="Organizations administration",
|
||||||
|
order=85,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="organizations.admin.template-upgrades",
|
||||||
|
module_id="organizations",
|
||||||
|
kind="section",
|
||||||
|
label="Organization template upgrades",
|
||||||
|
parent_id="organizations.admin.tenant",
|
||||||
|
order=86,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="organizations",
|
module_id="organizations",
|
||||||
@@ -108,6 +234,10 @@ manifest = ModuleManifest(
|
|||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
organization_models.OrganizationUnitType,
|
organization_models.OrganizationUnitType,
|
||||||
organization_models.OrganizationTenantSettings,
|
organization_models.OrganizationTenantSettings,
|
||||||
|
organization_models.OrganizationModelTemplate,
|
||||||
|
organization_models.OrganizationModelTemplateVersion,
|
||||||
|
organization_models.OrganizationModelInstantiation,
|
||||||
|
organization_models.OrganizationModelUpgrade,
|
||||||
organization_models.OrganizationStructure,
|
organization_models.OrganizationStructure,
|
||||||
organization_models.OrganizationRelationType,
|
organization_models.OrganizationRelationType,
|
||||||
organization_models.OrganizationRelation,
|
organization_models.OrganizationRelation,
|
||||||
@@ -119,8 +249,172 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
|
||||||
|
ORGANIZATIONS_DSAR_CAPABILITY: _organizations_dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
ORGANIZATIONS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Organizations data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Finds tenant-scoped account attribution on organization-model "
|
||||||
|
"instantiations and upgrades without exporting opaque model payloads."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "organization_admin", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="organizations.workspace-layout",
|
||||||
|
title="Organizations workspace layout",
|
||||||
|
summary="Find workspace actions and read consistently arranged content.",
|
||||||
|
body="Organization model tables use the complete card width; headings and creation actions stay in the card header, while the shared DataGrid owns filtering, sizing, pagination, and row actions. Unit types, structures, relation types, function types, units, relations, and functions follow the same layout. Model upgrades keep their meaningful version and decision context separate from the result table. Administrators and configurators must use Core's table-card layout instead of local padding or width overrides. The change does not modify model permissions, effective dates, or template-copy semantics.",
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "module_admin", "operator"),
|
||||||
|
order=5,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Organisationen: Aufbau des Arbeitsbereichs",
|
||||||
|
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
|
||||||
|
"body": "Tabellen des Organisationsmodells nutzen die gesamte Kartenbreite. Überschrift und Anlegen-Aktionen bleiben im Kartenkopf; das gemeinsame DataGrid übernimmt Filter, Größe, Seitennavigation und Zeilenaktionen. Einheitentypen, Strukturen, Beziehungstypen, Funktionstypen, Einheiten, Beziehungen und Funktionen folgen demselben Layout. Modell-Upgrades behalten ihren relevanten Versions- und Entscheidungskontext getrennt von der Ergebnistabelle. Administratoren und Konfiguratoren verwenden Cores Tabellenkartenlayout statt lokaler Innenabstands- oder Breitenregeln. Rechte, Gültigkeitsdaten und das Kopierprinzip der Vorlagen ändern sich dadurch nicht.",
|
||||||
|
}},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="organizations.privacy.data-subject-requests",
|
||||||
|
title="Review Organizations data in a data-subject request",
|
||||||
|
summary=(
|
||||||
|
"Collect tenant-scoped organization-model change attribution while "
|
||||||
|
"preserving institutional governance evidence."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Organizations stores institutional units, structures, relations, and "
|
||||||
|
"functions rather than personal incumbency. IDM owns the links between "
|
||||||
|
"identities and functions and must answer for those assignments. The "
|
||||||
|
"Organizations DSAR provider therefore searches only corroborated account "
|
||||||
|
"attribution on tenant model instantiations and upgrade requests, "
|
||||||
|
"applications, or cancellations. It exports the affected template and "
|
||||||
|
"version references, status, revision, timestamps, and integrity hashes. "
|
||||||
|
"Global template authorship, unrelated institutional model objects, "
|
||||||
|
"template definitions, upgrade previews and decisions, provenance, "
|
||||||
|
"idempotency keys, request digests, opaque settings, and other tenants are "
|
||||||
|
"excluded. The attribution is retained as institutional model-change "
|
||||||
|
"evidence and the provider publishes no automatic erasure action."
|
||||||
|
),
|
||||||
|
layer="static",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=(
|
||||||
|
"privacy_officer",
|
||||||
|
"organization_admin",
|
||||||
|
"records_manager",
|
||||||
|
"operator",
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "idm", "records"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Data-subject requests",
|
||||||
|
href="/admin?section=tenant-data-subject-requests",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organizations administration",
|
||||||
|
href="/admin?section=tenant-organization-settings",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "guide",
|
||||||
|
"help_contexts": [
|
||||||
|
"organizations.admin.tenant",
|
||||||
|
"organizations.admin.template-upgrades",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=24,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Organizations-Daten in einer Datenschutzanfrage prüfen",
|
||||||
|
"summary": (
|
||||||
|
"Mandantenbezogene Zuordnungen von Änderungen am Organisationsmodell sammeln und institutionelle Governance-Nachweise bewahren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Organizations speichert institutionelle Einheiten, Strukturen, Beziehungen und Funktionen statt persönlicher "
|
||||||
|
"Funktionsbesetzungen. IDM führt die Verknüpfungen zwischen Identitäten und Funktionen und beantwortet Anfragen "
|
||||||
|
"zu diesen Zuordnungen. Der DSAR-Anbieter von Organizations sucht deshalb nur bestätigte Kontozuordnungen an "
|
||||||
|
"Mandantenmodellinstanzen sowie Anträgen, Ausführungen oder Abbrüchen von Upgrades. Er gibt betroffene Vorlagen- "
|
||||||
|
"und Versionsverweise, Status, Revision, Zeitangaben und Integritätsprüfsummen aus. Globale Vorlagenurheberschaft, "
|
||||||
|
"fremde institutionelle Modellobjekte, Vorlagendefinitionen, Upgrade-Vorschauen und -Entscheidungen, Provenienz, "
|
||||||
|
"Idempotenzschlüssel, Anfrageprüfsummen, undurchsichtige Einstellungen und andere Mandanten bleiben ausgeschlossen. "
|
||||||
|
"Die Zuordnung bleibt als institutioneller Nachweis einer Modelländerung erhalten; der Anbieter veröffentlicht "
|
||||||
|
"keine automatische Löschaktion."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="organizations.template-upgrades",
|
||||||
|
title="Upgrade a tenant organization model",
|
||||||
|
summary=(
|
||||||
|
"Compare an immutable system template version with the current "
|
||||||
|
"tenant-owned model before explicitly applying an upgrade."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Open Organizations administration and create a preview for a newer "
|
||||||
|
"published version of the template used by the tenant. The persisted "
|
||||||
|
"three-way comparison separates compatible additions, compatible "
|
||||||
|
"changes, tenant-only divergence, destructive remapping, and invalid "
|
||||||
|
"references. Tenant-only changes are preserved. Conflicting or "
|
||||||
|
"destructive entries require an explicit keep, replace, or bounded "
|
||||||
|
"mapping decision. Applying the reviewed preview creates a new "
|
||||||
|
"tenant-owned instantiation and supersedes the previous provenance; "
|
||||||
|
"it never creates live inheritance. A stale preview is rejected if the "
|
||||||
|
"tenant model or either template version changed. Cancelling retains "
|
||||||
|
"the review record without modifying organization data."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("tenant_admin", "operator"),
|
||||||
|
related_modules=("policy", "audit", "idm", "access"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organizations administration",
|
||||||
|
href="/admin?section=tenant-organization-settings",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organization upgrade API",
|
||||||
|
href="/api/v1/organizations/model-upgrades",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "guide",
|
||||||
|
"help_contexts": [
|
||||||
|
"organizations.admin.template-upgrades",
|
||||||
|
"organizations.template-upgrade.preview",
|
||||||
|
"organizations.template-upgrade.decision",
|
||||||
|
"organizations.template-upgrade.apply",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=27,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Organisationsmodell eines Mandanten aktualisieren",
|
||||||
|
"summary": (
|
||||||
|
"Eine unveränderliche Systemvorlagenversion mit dem aktuellen mandanteneigenen Modell vergleichen, bevor ein Upgrade ausdrücklich angewendet wird."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"In der Organizations-Administration wird eine Vorschau für eine neuere veröffentlichte Version der vom "
|
||||||
|
"Mandanten verwendeten Vorlage erstellt. Der gespeicherte Drei-Wege-Vergleich trennt kompatible Ergänzungen, "
|
||||||
|
"kompatible Änderungen, reine Mandantenabweichungen, destruktive Neuzuordnungen und ungültige Verweise. Reine "
|
||||||
|
"Mandantenänderungen bleiben erhalten. Konfliktbehaftete oder destruktive Einträge erfordern eine ausdrückliche "
|
||||||
|
"Entscheidung zum Behalten, Ersetzen oder zu einer begrenzten Zuordnung. Das Anwenden der geprüften Vorschau "
|
||||||
|
"erzeugt eine neue mandanteneigene Instanz und ersetzt die frühere Provenienz; eine laufende Vererbung entsteht "
|
||||||
|
"nie. Eine veraltete Vorschau wird abgewiesen, wenn sich Mandantenmodell oder eine der Vorlagenversionen geändert "
|
||||||
|
"hat. Ein Abbruch bewahrt den Prüfdatensatz, ohne Organisationsdaten zu verändern."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="organizations.model",
|
id="organizations.model",
|
||||||
title="Organization model",
|
title="Organization model",
|
||||||
@@ -128,13 +422,164 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
|
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
|
||||||
"A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
|
"A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
|
||||||
"Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights."
|
"Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights. "
|
||||||
|
"A function does not itself prove mandate, jurisdiction, decision authority, or signature authority; those effective institutional facts belong to a separate provider contract."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator"),
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(any_scopes=ORGANIZATIONS_READ_SCOPES),
|
||||||
|
),
|
||||||
|
related_modules=("tenancy", "access", "idm", "policy", "audit"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organizations workspace",
|
||||||
|
href="/organizations",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organization model API",
|
||||||
|
href="/api/v1/organizations/model",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"organizations.workspace",
|
||||||
|
"organizations.model",
|
||||||
|
"organizations.units",
|
||||||
|
"organizations.relations",
|
||||||
|
"organizations.functions",
|
||||||
|
"organizations.admin.tenant",
|
||||||
|
"organizations.blocked",
|
||||||
|
],
|
||||||
|
},
|
||||||
order=25,
|
order=25,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Organisationsmodell",
|
||||||
|
"summary": (
|
||||||
|
"Organizations führt Einheiten, Hierarchie und Funktionen. IDM verknüpft Identitäten mit Funktionen; Access bildet angenommene Fakten auf Rollen und Rechte ab."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Organisationseinheitsarten, Strukturen und Beziehungsarten modellieren die Selbstbeschreibung der Institution. "
|
||||||
|
"Eine konkrete Organisationseinheit kann gleichzeitig an mehreren Strukturen teilnehmen, etwa einer "
|
||||||
|
"Arbeitgeberhierarchie und einer akademischen Struktur. Funktionen beschreiben Verantwortlichkeiten in "
|
||||||
|
"Organisationseinheiten. IDM verknüpft Identitäten mit diesen Funktionen; Access bildet angenommene Fakten auf "
|
||||||
|
"Rollen und Rechte ab. Eine Funktion beweist für sich weder Mandat, Zuständigkeit, Entscheidungsbefugnis noch "
|
||||||
|
"Unterschriftsbefugnis; diese wirksamen institutionellen Fakten gehören in einen getrennten Anbietervertrag."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="organizations.reference.fields-and-consequences",
|
||||||
|
title="Organization model fields and consequences",
|
||||||
|
summary=(
|
||||||
|
"Distinguish tenant-owned model definitions, concrete units, "
|
||||||
|
"relations, functions, governance references, and lifecycle state."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Unit types, structures, relation types, and function types define "
|
||||||
|
"the tenant-owned model. Units, relations, and functions are concrete "
|
||||||
|
"institutional facts within that model. Slugs are stable references for "
|
||||||
|
"integrations; parent and relation changes affect hierarchy traversal and "
|
||||||
|
"downstream routing. Deactivation retains the record and evidence but "
|
||||||
|
"removes it from active selection. Delegation and act-in-place flags only "
|
||||||
|
"describe permitted organizational semantics; Access and governed workflows "
|
||||||
|
"still decide effective authority. A delegable function permits a bounded "
|
||||||
|
"substitute to act as themself; act-in-place permits an explicitly selected "
|
||||||
|
"representation context that retains the real and represented accounts. Both "
|
||||||
|
"remain source-linked, time-bounded, revocable facts in IDM rather than inferred "
|
||||||
|
"group membership. When configured, a recorded change-request "
|
||||||
|
"ID is required before model mutations. Organization settings are tenant-owned "
|
||||||
|
"and do not inherit a live global hierarchy."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
|
related_modules=("tenancy", "access", "idm", "policy", "audit"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organizations workspace",
|
||||||
|
href="/organizations",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organization settings API",
|
||||||
|
href="/api/v1/organizations/settings",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"organizations.field.name",
|
||||||
|
"organizations.field.slug",
|
||||||
|
"organizations.field.parent",
|
||||||
|
"organizations.field.relation",
|
||||||
|
"organizations.field.function",
|
||||||
|
"organizations.field.change-request",
|
||||||
|
"organizations.field.audit-retention",
|
||||||
|
"organizations.action.deactivate",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"model_change": "Changes the valid vocabulary and constraints for tenant-owned organization facts.",
|
||||||
|
"hierarchy_change": "Changes traversal, routing, and inherited institutional context for downstream modules.",
|
||||||
|
"deactivate": "Retains the fact and evidence while removing it from active selection.",
|
||||||
|
"settings": "Changes tenant-owned governance, audit detail, and retention behavior.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order=26,
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Felder und Folgen des Organisationsmodells",
|
||||||
|
"summary": (
|
||||||
|
"Mandanteneigene Modelldefinitionen, konkrete Einheiten, Beziehungen, Funktionen, Governance-Verweise und Lebenszykluszustände unterscheiden."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Einheitsarten, Strukturen, Beziehungsarten und Funktionsarten definieren das mandanteneigene Modell. Einheiten, "
|
||||||
|
"Beziehungen und Funktionen sind konkrete institutionelle Fakten darin. Slugs sind stabile Integrationsverweise; "
|
||||||
|
"Änderungen an Eltern und Beziehungen wirken auf Hierarchietraversierung und nachgelagerte Weiterleitung. "
|
||||||
|
"Deaktivierung bewahrt Datensatz und Nachweise, entfernt ihn aber aus der aktiven Auswahl. Kennzeichen für "
|
||||||
|
"Delegation und Handeln-an-Stelle beschreiben nur zulässige Organisationssemantik; Access und gesteuerte Workflows "
|
||||||
|
"entscheiden weiterhin über wirksame Befugnis. Eine delegierbare Funktion erlaubt einer begrenzten Vertretung, als "
|
||||||
|
"sie selbst zu handeln; Handeln-an-Stelle erlaubt einen ausdrücklich gewählten Vertretungskontext, der reales und "
|
||||||
|
"vertretenes Konto bewahrt. Beides bleibt in IDM quellverknüpft, zeitlich begrenzt und widerrufbar, statt aus "
|
||||||
|
"Gruppenmitgliedschaft abgeleitet zu werden. Wenn konfiguriert, ist vor Modelländerungen eine erfasste "
|
||||||
|
"Änderungsantragskennung erforderlich. Organisationseinstellungen gehören dem Mandanten und erben keine laufende "
|
||||||
|
"globale Hierarchie."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
structured_translation_version="1",
|
||||||
|
structured_translations={
|
||||||
|
"de": {
|
||||||
|
"consequence_classes": {
|
||||||
|
"model_change": "Ändert zulässiges Vokabular und Einschränkungen mandanteneigener Organisationsfakten.",
|
||||||
|
"hierarchy_change": "Ändert Traversierung, Weiterleitung und geerbten institutionellen Kontext nachgelagerter Module.",
|
||||||
|
"deactivate": "Bewahrt Fakt und Nachweis und entfernt sie aus der aktiven Auswahl.",
|
||||||
|
"settings": "Ändert mandanteneigene Governance, Prüftiefe und Aufbewahrungsverhalten.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="institutional_foundation",
|
||||||
|
kind="foundation",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/ORGANIZATION_MODEL.md",
|
||||||
|
test_ref="tests/test_model_templates.py",
|
||||||
|
known_limits=(
|
||||||
|
"Downstream modules must reconcile organization-reference changes from the emitted upgrade event; cross-module records are not rewritten directly.",
|
||||||
|
),
|
||||||
|
owned_concepts=("organization unit", "organization structure", "organization relation", "organization function"),
|
||||||
|
non_owned_concepts=("function incumbency", "identity", "application role", "mandate"),
|
||||||
|
recovery_docs=("docs/ORGANIZATION_MODEL.md",),
|
||||||
|
security_docs=("docs/ORGANIZATION_MODEL.md",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
"""organization model templates
|
||||||
|
|
||||||
|
Revision ID: 7e8f9a0b1c2d
|
||||||
|
Revises: 6d7e8f9a0b1c
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
|
revision = "7e8f9a0b1c2d"
|
||||||
|
down_revision = "6d7e8f9a0b1c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_release = import_module(
|
||||||
|
"govoplan_organizations.backend.migrations.versions."
|
||||||
|
"7e8f9a0b1c2d_organization_model_templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_release.upgrade()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_release.downgrade()
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
"""Development mirror for the organization template upgrade ledger."""
|
||||||
|
|
||||||
|
from govoplan_organizations.backend.migrations.versions.a61e4d9c72b8_organization_template_upgrades import ( # noqa: F401
|
||||||
|
branch_labels,
|
||||||
|
depends_on,
|
||||||
|
downgrade,
|
||||||
|
down_revision,
|
||||||
|
revision,
|
||||||
|
upgrade,
|
||||||
|
)
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
"""organization model templates
|
||||||
|
|
||||||
|
Revision ID: 7e8f9a0b1c2d
|
||||||
|
Revises: 6d7e8f9a0b1c
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "7e8f9a0b1c2d"
|
||||||
|
down_revision = "6d7e8f9a0b1c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_templates",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("settings", 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_organizations_model_templates"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"slug",
|
||||||
|
name=op.f("uq_organizations_model_templates_slug"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_templates_slug"),
|
||||||
|
"organizations_model_templates",
|
||||||
|
["slug"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_templates_created_by_account_id"),
|
||||||
|
"organizations_model_templates",
|
||||||
|
["created_by_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("version", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("definition", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("release_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("published_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["template_id"],
|
||||||
|
["organizations_model_templates.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_organizations_model_template_versions_template_id_organizations_model_templates"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_organizations_model_template_versions"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"template_id",
|
||||||
|
"version",
|
||||||
|
name="uq_organizations_model_template_versions",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_template_versions_template_id"),
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
["template_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f(
|
||||||
|
"ix_organizations_model_template_versions_published_by_account_id"
|
||||||
|
),
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
["published_by_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_org_model_template_versions_template_status",
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
["template_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_version_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("instantiated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("object_counts", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("provenance", 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.ForeignKeyConstraint(
|
||||||
|
["template_id"],
|
||||||
|
["organizations_model_templates.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_organizations_model_instantiations_template_id_organizations_model_templates"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["template_version_id"],
|
||||||
|
["organizations_model_template_versions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_organizations_model_instantiations_template_version_id_organizations_model_template_versions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_organizations_model_instantiations"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"template_version_id",
|
||||||
|
name="uq_organizations_model_instantiation_version",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_tenant_id"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_template_id"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["template_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_template_version_id"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["template_version_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_status"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f(
|
||||||
|
"ix_organizations_model_instantiations_instantiated_by_account_id"
|
||||||
|
),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["instantiated_by_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_organizations_model_instantiations_tenant",
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["tenant_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("organizations_model_instantiations")
|
||||||
|
op.drop_table("organizations_model_template_versions")
|
||||||
|
op.drop_table("organizations_model_templates")
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"""organization template upgrade ledger
|
||||||
|
|
||||||
|
Revision ID: a61e4d9c72b8
|
||||||
|
Revises: 7e8f9a0b1c2d
|
||||||
|
Create Date: 2026-08-04 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a61e4d9c72b8"
|
||||||
|
down_revision = "7e8f9a0b1c2d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_upgrades",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_instantiation_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_template_version_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("target_template_version_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("base_definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("local_definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("target_definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("preview", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("decisions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("request_digest", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("requested_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("applied_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("cancelled_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("provenance", 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.ForeignKeyConstraint(
|
||||||
|
["template_id"],
|
||||||
|
["organizations_model_templates.id"],
|
||||||
|
name=op.f("fk_organizations_model_upgrades_template_id_organizations_model_templates"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["source_instantiation_id"],
|
||||||
|
["organizations_model_instantiations.id"],
|
||||||
|
name=op.f("fk_organizations_model_upgrades_source_instantiation_id_organizations_model_instantiations"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["source_template_version_id"],
|
||||||
|
["organizations_model_template_versions.id"],
|
||||||
|
name=op.f("fk_organizations_model_upgrades_source_template_version_id_organizations_model_template_versions"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["target_template_version_id"],
|
||||||
|
["organizations_model_template_versions.id"],
|
||||||
|
name=op.f("fk_organizations_model_upgrades_target_template_version_id_organizations_model_template_versions"),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_model_upgrades")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_organizations_model_upgrade_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"template_id",
|
||||||
|
"source_instantiation_id",
|
||||||
|
"source_template_version_id",
|
||||||
|
"target_template_version_id",
|
||||||
|
"status",
|
||||||
|
"requested_by_account_id",
|
||||||
|
"applied_by_account_id",
|
||||||
|
"cancelled_by_account_id",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_organizations_model_upgrades_{column}"),
|
||||||
|
"organizations_model_upgrades",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_organizations_model_upgrades_tenant_status",
|
||||||
|
"organizations_model_upgrades",
|
||||||
|
["tenant_id", "status", "updated_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("organizations_model_upgrades")
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_organizations.backend.api.v1.schemas import (
|
||||||
|
OrganizationModelTemplateDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationModelInstantiation,
|
||||||
|
OrganizationModelTemplate,
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_MODEL_TYPES = (
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationStructure,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_template_definition(
|
||||||
|
definition: OrganizationModelTemplateDefinition,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
_validate_definition_references(definition)
|
||||||
|
payload = definition.model_dump(mode="json")
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return payload, hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def instantiate_template_version(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
template: OrganizationModelTemplate,
|
||||||
|
version: OrganizationModelTemplateVersion,
|
||||||
|
actor_account_id: str | None,
|
||||||
|
) -> OrganizationModelInstantiation:
|
||||||
|
if version.template_id != template.id or version.status != "published":
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
"Only a published version of the selected template can be instantiated"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
session.query(model).filter(model.tenant_id == tenant_id).first()
|
||||||
|
is not None
|
||||||
|
for model in TENANT_MODEL_TYPES
|
||||||
|
):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
"Organization templates can currently be instantiated only into an empty tenant model"
|
||||||
|
)
|
||||||
|
existing = (
|
||||||
|
session.query(OrganizationModelInstantiation)
|
||||||
|
.filter(
|
||||||
|
OrganizationModelInstantiation.tenant_id == tenant_id,
|
||||||
|
OrganizationModelInstantiation.template_version_id == version.id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
definition = OrganizationModelTemplateDefinition.model_validate(
|
||||||
|
version.definition
|
||||||
|
)
|
||||||
|
_validate_definition_references(definition)
|
||||||
|
provenance_base = {
|
||||||
|
"template_id": template.id,
|
||||||
|
"template_slug": template.slug,
|
||||||
|
"template_version_id": version.id,
|
||||||
|
"template_version": version.version,
|
||||||
|
"definition_sha256": version.definition_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
unit_types = {
|
||||||
|
item.slug: OrganizationUnitType(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(item.settings, provenance_base, item.slug),
|
||||||
|
)
|
||||||
|
for item in definition.unit_types
|
||||||
|
}
|
||||||
|
structures = {
|
||||||
|
item.slug: OrganizationStructure(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
structure_kind=item.structure_kind,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(item.settings, provenance_base, item.slug),
|
||||||
|
)
|
||||||
|
for item in definition.structures
|
||||||
|
}
|
||||||
|
session.add_all([*unit_types.values(), *structures.values()])
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
relation_types = {
|
||||||
|
item.slug: OrganizationRelationType(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=_ref_id(structures, item.structure_slug),
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
source_unit_type_id=_ref_id(
|
||||||
|
unit_types,
|
||||||
|
item.source_unit_type_slug,
|
||||||
|
),
|
||||||
|
target_unit_type_id=_ref_id(
|
||||||
|
unit_types,
|
||||||
|
item.target_unit_type_slug,
|
||||||
|
),
|
||||||
|
is_hierarchical=item.is_hierarchical,
|
||||||
|
allow_cycles=item.allow_cycles,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(item.settings, provenance_base, item.slug),
|
||||||
|
)
|
||||||
|
for item in definition.relation_types
|
||||||
|
}
|
||||||
|
function_types = {
|
||||||
|
item.slug: OrganizationFunctionType(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
organization_unit_type_id=_ref_id(
|
||||||
|
unit_types,
|
||||||
|
item.organization_unit_type_slug,
|
||||||
|
),
|
||||||
|
delegable=item.delegable,
|
||||||
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(item.settings, provenance_base, item.slug),
|
||||||
|
)
|
||||||
|
for item in definition.function_types
|
||||||
|
}
|
||||||
|
session.add_all([*relation_types.values(), *function_types.values()])
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
units = {
|
||||||
|
item.slug: OrganizationUnit(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=_ref_id(unit_types, item.unit_type_slug),
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(item.settings, provenance_base, item.slug),
|
||||||
|
)
|
||||||
|
for item in definition.units
|
||||||
|
}
|
||||||
|
session.add_all(list(units.values()))
|
||||||
|
session.flush()
|
||||||
|
for item in definition.units:
|
||||||
|
units[item.slug].parent_id = _ref_id(units, item.parent_slug)
|
||||||
|
|
||||||
|
relations = [
|
||||||
|
OrganizationRelation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structures[item.structure_slug].id,
|
||||||
|
relation_type_id=relation_types[item.relation_type_slug].id,
|
||||||
|
source_unit_id=units[item.source_unit_slug].id,
|
||||||
|
target_unit_id=units[item.target_unit_slug].id,
|
||||||
|
valid_from=item.valid_from,
|
||||||
|
valid_until=item.valid_until,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(
|
||||||
|
item.settings,
|
||||||
|
provenance_base,
|
||||||
|
(
|
||||||
|
f"{item.structure_slug}:{item.relation_type_slug}:"
|
||||||
|
f"{item.source_unit_slug}:{item.target_unit_slug}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in definition.relations
|
||||||
|
]
|
||||||
|
functions = [
|
||||||
|
OrganizationFunction(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=_ref_id(
|
||||||
|
function_types,
|
||||||
|
item.function_type_slug,
|
||||||
|
),
|
||||||
|
organization_unit_id=units[item.organization_unit_slug].id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
delegable=item.delegable,
|
||||||
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=_settings(item.settings, provenance_base, item.slug),
|
||||||
|
)
|
||||||
|
for item in definition.functions
|
||||||
|
]
|
||||||
|
session.add_all([*relations, *functions])
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"unit_types": len(unit_types),
|
||||||
|
"structures": len(structures),
|
||||||
|
"relation_types": len(relation_types),
|
||||||
|
"units": len(units),
|
||||||
|
"relations": len(relations),
|
||||||
|
"function_types": len(function_types),
|
||||||
|
"functions": len(functions),
|
||||||
|
}
|
||||||
|
instantiation = OrganizationModelInstantiation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
template_id=template.id,
|
||||||
|
template_version_id=version.id,
|
||||||
|
source_definition_sha256=version.definition_sha256,
|
||||||
|
instantiated_by_account_id=actor_account_id,
|
||||||
|
object_counts=counts,
|
||||||
|
provenance={
|
||||||
|
**provenance_base,
|
||||||
|
"copy_semantics": "tenant_owned_no_live_inheritance",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.add(instantiation)
|
||||||
|
session.flush()
|
||||||
|
return instantiation
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition_references(
|
||||||
|
definition: OrganizationModelTemplateDefinition,
|
||||||
|
) -> None:
|
||||||
|
collections = {
|
||||||
|
"unit type": [item.slug for item in definition.unit_types],
|
||||||
|
"structure": [item.slug for item in definition.structures],
|
||||||
|
"relation type": [item.slug for item in definition.relation_types],
|
||||||
|
"unit": [item.slug for item in definition.units],
|
||||||
|
"function type": [item.slug for item in definition.function_types],
|
||||||
|
"function": [item.slug for item in definition.functions],
|
||||||
|
}
|
||||||
|
for label, slugs in collections.items():
|
||||||
|
if len(slugs) != len(set(slugs)):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Template contains duplicate {label} slugs"
|
||||||
|
)
|
||||||
|
unit_types = set(collections["unit type"])
|
||||||
|
structures = set(collections["structure"])
|
||||||
|
relation_types = set(collections["relation type"])
|
||||||
|
units = set(collections["unit"])
|
||||||
|
function_types = set(collections["function type"])
|
||||||
|
unit_by_slug = {item.slug: item for item in definition.units}
|
||||||
|
relation_type_by_slug = {
|
||||||
|
item.slug: item for item in definition.relation_types
|
||||||
|
}
|
||||||
|
function_type_by_slug = {
|
||||||
|
item.slug: item for item in definition.function_types
|
||||||
|
}
|
||||||
|
for item in definition.relation_types:
|
||||||
|
_require_ref(structures, item.structure_slug, "structure")
|
||||||
|
_require_ref(unit_types, item.source_unit_type_slug, "source unit type")
|
||||||
|
_require_ref(unit_types, item.target_unit_type_slug, "target unit type")
|
||||||
|
for item in definition.units:
|
||||||
|
_require_ref(unit_types, item.unit_type_slug, "unit type")
|
||||||
|
_require_ref(units, item.parent_slug, "parent unit")
|
||||||
|
if item.parent_slug == item.slug:
|
||||||
|
raise OrganizationTemplateError("A unit cannot be its own parent")
|
||||||
|
_require_acyclic_graph(
|
||||||
|
{
|
||||||
|
item.slug: {item.parent_slug}
|
||||||
|
for item in definition.units
|
||||||
|
if item.parent_slug is not None
|
||||||
|
},
|
||||||
|
label="unit parent hierarchy",
|
||||||
|
)
|
||||||
|
relation_edges: dict[str, dict[str, set[str]]] = {}
|
||||||
|
relation_keys: set[tuple[str, str, str, str]] = set()
|
||||||
|
for item in definition.relations:
|
||||||
|
_require_ref(structures, item.structure_slug, "structure")
|
||||||
|
_require_ref(relation_types, item.relation_type_slug, "relation type")
|
||||||
|
_require_ref(units, item.source_unit_slug, "source unit")
|
||||||
|
_require_ref(units, item.target_unit_slug, "target unit")
|
||||||
|
if (
|
||||||
|
item.valid_from is not None
|
||||||
|
and item.valid_until is not None
|
||||||
|
and item.valid_until < item.valid_from
|
||||||
|
):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
"A relation validity end cannot precede its start"
|
||||||
|
)
|
||||||
|
relation_type = relation_type_by_slug[item.relation_type_slug]
|
||||||
|
if (
|
||||||
|
relation_type.structure_slug is not None
|
||||||
|
and relation_type.structure_slug != item.structure_slug
|
||||||
|
):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Relation {item.relation_type_slug!r} belongs to structure "
|
||||||
|
f"{relation_type.structure_slug!r}, not {item.structure_slug!r}"
|
||||||
|
)
|
||||||
|
source_unit_type = unit_by_slug[item.source_unit_slug].unit_type_slug
|
||||||
|
target_unit_type = unit_by_slug[item.target_unit_slug].unit_type_slug
|
||||||
|
if (
|
||||||
|
relation_type.source_unit_type_slug is not None
|
||||||
|
and relation_type.source_unit_type_slug != source_unit_type
|
||||||
|
):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Relation {item.relation_type_slug!r} does not allow source "
|
||||||
|
f"unit {item.source_unit_slug!r}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
relation_type.target_unit_type_slug is not None
|
||||||
|
and relation_type.target_unit_type_slug != target_unit_type
|
||||||
|
):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Relation {item.relation_type_slug!r} does not allow target "
|
||||||
|
f"unit {item.target_unit_slug!r}"
|
||||||
|
)
|
||||||
|
relation_key = (
|
||||||
|
item.structure_slug,
|
||||||
|
item.relation_type_slug,
|
||||||
|
item.source_unit_slug,
|
||||||
|
item.target_unit_slug,
|
||||||
|
)
|
||||||
|
if relation_key in relation_keys:
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
"Template contains a duplicate organization relation"
|
||||||
|
)
|
||||||
|
relation_keys.add(relation_key)
|
||||||
|
if relation_type.is_hierarchical and not relation_type.allow_cycles:
|
||||||
|
targets = relation_edges.setdefault(item.relation_type_slug, {})
|
||||||
|
targets.setdefault(item.source_unit_slug, set()).add(
|
||||||
|
item.target_unit_slug
|
||||||
|
)
|
||||||
|
for relation_type_slug, edges in relation_edges.items():
|
||||||
|
_require_acyclic_graph(
|
||||||
|
edges,
|
||||||
|
label=f"relation hierarchy {relation_type_slug!r}",
|
||||||
|
)
|
||||||
|
for item in definition.function_types:
|
||||||
|
_require_ref(
|
||||||
|
unit_types,
|
||||||
|
item.organization_unit_type_slug,
|
||||||
|
"organization unit type",
|
||||||
|
)
|
||||||
|
for item in definition.functions:
|
||||||
|
_require_ref(function_types, item.function_type_slug, "function type")
|
||||||
|
_require_ref(units, item.organization_unit_slug, "organization unit")
|
||||||
|
if item.function_type_slug is None:
|
||||||
|
continue
|
||||||
|
expected_unit_type = function_type_by_slug[
|
||||||
|
item.function_type_slug
|
||||||
|
].organization_unit_type_slug
|
||||||
|
actual_unit_type = unit_by_slug[
|
||||||
|
item.organization_unit_slug
|
||||||
|
].unit_type_slug
|
||||||
|
if (
|
||||||
|
expected_unit_type is not None
|
||||||
|
and expected_unit_type != actual_unit_type
|
||||||
|
):
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Function type {item.function_type_slug!r} does not apply to "
|
||||||
|
f"unit {item.organization_unit_slug!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ref(
|
||||||
|
values: set[str],
|
||||||
|
value: str | None,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if value is not None and value not in values:
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Template references an unknown {label}: {value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_id(rows: dict[str, Any], slug: str | None) -> str | None:
|
||||||
|
return rows[slug].id if slug is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_acyclic_graph(
|
||||||
|
edges: dict[str, set[str]],
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
complete: set[str] = set()
|
||||||
|
active: set[str] = set()
|
||||||
|
|
||||||
|
def visit(node: str) -> None:
|
||||||
|
if node in complete:
|
||||||
|
return
|
||||||
|
if node in active:
|
||||||
|
raise OrganizationTemplateError(
|
||||||
|
f"Template contains a cycle in {label}"
|
||||||
|
)
|
||||||
|
active.add(node)
|
||||||
|
for target in edges.get(node, ()):
|
||||||
|
visit(target)
|
||||||
|
active.remove(node)
|
||||||
|
complete.add(node)
|
||||||
|
|
||||||
|
for start in edges:
|
||||||
|
visit(start)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(
|
||||||
|
settings: dict[str, Any],
|
||||||
|
provenance_base: dict[str, Any],
|
||||||
|
source_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
**dict(settings),
|
||||||
|
"template_provenance": {
|
||||||
|
**provenance_base,
|
||||||
|
"source_key": source_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OrganizationTemplateError",
|
||||||
|
"canonical_template_definition",
|
||||||
|
"instantiate_template_version",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.events import EventBus, event_bus_context
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_organizations.backend.api.v1.routes import (
|
||||||
|
create_function,
|
||||||
|
create_unit,
|
||||||
|
get_organization_model,
|
||||||
|
update_function,
|
||||||
|
update_unit,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.api.v1.schemas import (
|
||||||
|
FunctionCreateRequest,
|
||||||
|
FunctionUpdateRequest,
|
||||||
|
UnitCreateRequest,
|
||||||
|
UnitUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
|
OrganizationTenantSettings,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationAdministrationRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
self.tables = [
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
OrganizationUnitType.__table__,
|
||||||
|
OrganizationStructure.__table__,
|
||||||
|
OrganizationRelationType.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationRelation.__table__,
|
||||||
|
OrganizationFunctionType.__table__,
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
OrganizationTenantSettings.__table__,
|
||||||
|
]
|
||||||
|
Base.metadata.create_all(self.engine, tables=self.tables)
|
||||||
|
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
self.principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
scopes=frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(self.engine, tables=list(reversed(self.tables)))
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_unit_tree_and_function_create_update_list_deactivate(self) -> None:
|
||||||
|
events: list[object] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", events.append)
|
||||||
|
|
||||||
|
with event_bus_context(bus):
|
||||||
|
root = create_unit(
|
||||||
|
UnitCreateRequest(name="Central administration", slug="central"),
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
child = create_unit(
|
||||||
|
UnitCreateRequest(
|
||||||
|
name="Procurement",
|
||||||
|
slug="procurement",
|
||||||
|
parent_id=root.id,
|
||||||
|
),
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
function = create_function(
|
||||||
|
FunctionCreateRequest(
|
||||||
|
name="Procurement lead",
|
||||||
|
slug="lead",
|
||||||
|
organization_unit_id=child.id,
|
||||||
|
delegable=True,
|
||||||
|
),
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_child = update_unit(
|
||||||
|
child.id,
|
||||||
|
UnitUpdateRequest(
|
||||||
|
name="Strategic procurement",
|
||||||
|
is_active=False,
|
||||||
|
),
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
updated_function = update_function(
|
||||||
|
function.id,
|
||||||
|
FunctionUpdateRequest(is_active=False),
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = get_organization_model(
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(root.id, next(item for item in model.units if item.id == child.id).parent_id)
|
||||||
|
self.assertEqual("Strategic procurement", updated_child.name)
|
||||||
|
self.assertFalse(updated_child.is_active)
|
||||||
|
self.assertFalse(updated_function.is_active)
|
||||||
|
self.assertEqual(child.id, updated_function.organization_unit_id)
|
||||||
|
self.assertEqual(2, len(model.units))
|
||||||
|
self.assertEqual(1, len(model.functions))
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"organizations.unit.created.v1",
|
||||||
|
"organizations.unit.created.v1",
|
||||||
|
"organizations.function.created.v1",
|
||||||
|
"organizations.unit.deactivated.v1",
|
||||||
|
"organizations.function.deactivated.v1",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
event.type
|
||||||
|
for event in events
|
||||||
|
if event.module_id == "organizations"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_model_listing_is_tenant_scoped(self) -> None:
|
||||||
|
create_unit(
|
||||||
|
UnitCreateRequest(name="Visible unit", slug="visible"),
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
create_unit(
|
||||||
|
UnitCreateRequest(name="Other unit", slug="other"),
|
||||||
|
session=self.session,
|
||||||
|
principal=SimpleNamespace(
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
account_id="account-2",
|
||||||
|
membership_id="membership-2",
|
||||||
|
scopes=frozenset(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
model = get_organization_model(
|
||||||
|
session=self.session,
|
||||||
|
principal=self.principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["Visible unit"], [item.name for item in model.units])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarProvider,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationModelInstantiation,
|
||||||
|
OrganizationModelTemplate,
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.dsar_provider import (
|
||||||
|
ORGANIZATIONS_DSAR_CAPABILITY,
|
||||||
|
OrganizationsDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: OrganizationsDsarProvider,
|
||||||
|
*,
|
||||||
|
organizations_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.organizations_active = organizations_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (ORGANIZATIONS_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "organizations"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
organizations_active = self.organizations_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"effective_modules": (
|
||||||
|
("organizations",) if organizations_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": "organizations"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != ORGANIZATIONS_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationsDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
self.template = OrganizationModelTemplate(
|
||||||
|
id="template-1",
|
||||||
|
slug="municipality",
|
||||||
|
name="Municipality",
|
||||||
|
description="Global institutional template",
|
||||||
|
created_by_account_id="account-1",
|
||||||
|
settings={"secret": "global-template-settings-do-not-export"},
|
||||||
|
)
|
||||||
|
self.version_one = OrganizationModelTemplateVersion(
|
||||||
|
id="version-1",
|
||||||
|
template_id=self.template.id,
|
||||||
|
version="1.0.0",
|
||||||
|
status="published",
|
||||||
|
definition={"secret": "definition-one-do-not-export"},
|
||||||
|
definition_sha256="a" * 64,
|
||||||
|
published_at=now,
|
||||||
|
published_by_account_id="account-1",
|
||||||
|
)
|
||||||
|
self.version_two = OrganizationModelTemplateVersion(
|
||||||
|
id="version-2",
|
||||||
|
template_id=self.template.id,
|
||||||
|
version="2.0.0",
|
||||||
|
status="published",
|
||||||
|
definition={"secret": "definition-two-do-not-export"},
|
||||||
|
definition_sha256="b" * 64,
|
||||||
|
published_at=now,
|
||||||
|
published_by_account_id="account-1",
|
||||||
|
)
|
||||||
|
self.version_three = OrganizationModelTemplateVersion(
|
||||||
|
id="version-3",
|
||||||
|
template_id=self.template.id,
|
||||||
|
version="3.0.0",
|
||||||
|
status="published",
|
||||||
|
definition={"secret": "definition-three-do-not-export"},
|
||||||
|
definition_sha256="c" * 64,
|
||||||
|
)
|
||||||
|
self.instantiation = OrganizationModelInstantiation(
|
||||||
|
id="instantiation-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=self.template.id,
|
||||||
|
template_version_id=self.version_one.id,
|
||||||
|
source_definition_sha256=self.version_one.definition_sha256,
|
||||||
|
status="superseded",
|
||||||
|
instantiated_by_account_id="account-1",
|
||||||
|
object_counts={"units": 5},
|
||||||
|
provenance={"secret": "instantiation-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
unrelated_instantiation = OrganizationModelInstantiation(
|
||||||
|
id="instantiation-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=self.template.id,
|
||||||
|
template_version_id=self.version_three.id,
|
||||||
|
source_definition_sha256=self.version_three.definition_sha256,
|
||||||
|
status="applied",
|
||||||
|
instantiated_by_account_id="account-other",
|
||||||
|
object_counts={"units": 9},
|
||||||
|
provenance={"secret": "unrelated-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
tenant_two_instantiation = OrganizationModelInstantiation(
|
||||||
|
id="instantiation-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
template_id=self.template.id,
|
||||||
|
template_version_id=self.version_one.id,
|
||||||
|
source_definition_sha256=self.version_one.definition_sha256,
|
||||||
|
status="applied",
|
||||||
|
instantiated_by_account_id="account-1",
|
||||||
|
object_counts={"units": 99},
|
||||||
|
provenance={"secret": "other-tenant-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
self.upgrade = OrganizationModelUpgrade(
|
||||||
|
id="upgrade-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=self.template.id,
|
||||||
|
source_instantiation_id=self.instantiation.id,
|
||||||
|
source_template_version_id=self.version_one.id,
|
||||||
|
target_template_version_id=self.version_two.id,
|
||||||
|
status="applied",
|
||||||
|
revision=2,
|
||||||
|
base_definition_sha256="d" * 64,
|
||||||
|
local_definition_sha256="e" * 64,
|
||||||
|
target_definition_sha256="f" * 64,
|
||||||
|
preview={"secret": "upgrade-preview-do-not-export"},
|
||||||
|
decisions={"secret": "upgrade-decisions-do-not-export"},
|
||||||
|
idempotency_key="idempotency-key-do-not-export",
|
||||||
|
request_digest="1" * 64,
|
||||||
|
requested_by_account_id="account-1",
|
||||||
|
applied_by_account_id="account-other",
|
||||||
|
applied_at=now,
|
||||||
|
provenance={"secret": "upgrade-provenance-do-not-export"},
|
||||||
|
)
|
||||||
|
unrelated_upgrade = OrganizationModelUpgrade(
|
||||||
|
id="upgrade-unrelated",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template_id=self.template.id,
|
||||||
|
source_instantiation_id=unrelated_instantiation.id,
|
||||||
|
source_template_version_id=self.version_three.id,
|
||||||
|
target_template_version_id=self.version_two.id,
|
||||||
|
status="cancelled",
|
||||||
|
base_definition_sha256="2" * 64,
|
||||||
|
local_definition_sha256="3" * 64,
|
||||||
|
target_definition_sha256="4" * 64,
|
||||||
|
idempotency_key="unrelated-key",
|
||||||
|
request_digest="5" * 64,
|
||||||
|
requested_by_account_id="account-other",
|
||||||
|
cancelled_by_account_id="account-other",
|
||||||
|
)
|
||||||
|
tenant_two_upgrade = OrganizationModelUpgrade(
|
||||||
|
id="upgrade-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
template_id=self.template.id,
|
||||||
|
source_instantiation_id=tenant_two_instantiation.id,
|
||||||
|
source_template_version_id=self.version_one.id,
|
||||||
|
target_template_version_id=self.version_two.id,
|
||||||
|
status="previewed",
|
||||||
|
base_definition_sha256="6" * 64,
|
||||||
|
local_definition_sha256="7" * 64,
|
||||||
|
target_definition_sha256="8" * 64,
|
||||||
|
idempotency_key="tenant-two-key",
|
||||||
|
request_digest="9" * 64,
|
||||||
|
requested_by_account_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.template,
|
||||||
|
self.version_one,
|
||||||
|
self.version_two,
|
||||||
|
self.version_three,
|
||||||
|
self.instantiation,
|
||||||
|
unrelated_instantiation,
|
||||||
|
tenant_two_instantiation,
|
||||||
|
self.upgrade,
|
||||||
|
unrelated_upgrade,
|
||||||
|
tenant_two_upgrade,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = OrganizationsDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||||
|
provided = {item.name for item in manifest.provides_interfaces}
|
||||||
|
self.assertIn(ORGANIZATIONS_DSAR_CAPABILITY, provided)
|
||||||
|
provider = manifest.capability_factories[ORGANIZATIONS_DSAR_CAPABILITY](None)
|
||||||
|
self.assertIsInstance(provider, DsarProvider)
|
||||||
|
|
||||||
|
def test_search_is_tenant_scoped_narrow_and_minimized(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"organizations_model_instantiation",
|
||||||
|
"organizations_model_upgrade",
|
||||||
|
},
|
||||||
|
{record.resource_type for record in records},
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
self.assertIn("instantiation-1", serialized)
|
||||||
|
self.assertIn("upgrade-1", serialized)
|
||||||
|
excluded = (
|
||||||
|
"global-template-settings-do-not-export",
|
||||||
|
"definition-one-do-not-export",
|
||||||
|
"instantiation-provenance-do-not-export",
|
||||||
|
"upgrade-preview-do-not-export",
|
||||||
|
"upgrade-decisions-do-not-export",
|
||||||
|
"idempotency-key-do-not-export",
|
||||||
|
"upgrade-provenance-do-not-export",
|
||||||
|
"instantiation-unrelated",
|
||||||
|
"upgrade-unrelated",
|
||||||
|
"instantiation-tenant-2",
|
||||||
|
"upgrade-tenant-2",
|
||||||
|
"other-tenant-provenance-do-not-export",
|
||||||
|
)
|
||||||
|
for value in excluded:
|
||||||
|
self.assertNotIn(value, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_account_selectors_fail_closed(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"organizations.account": "account-other"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_retains_governance_evidence_and_execution_is_non_mutating(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({"retain"}, {action.kind for action in actions})
|
||||||
|
self.assertFalse(any(action.executable for action in actions))
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-organizations-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"blocked"}, {result.status for result in results})
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.session.get(OrganizationModelUpgrade, self.upgrade.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_execution_rejects_foreign_and_forged_executable_actions(self) -> None:
|
||||||
|
actions = (
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="idm:delete:upgrade:upgrade-1",
|
||||||
|
provider_id="idm",
|
||||||
|
module_id="idm",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="organizations_model_upgrade",
|
||||||
|
resource_id=self.upgrade.id,
|
||||||
|
title="Foreign action",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id="organizations:delete:upgrade:upgrade-1",
|
||||||
|
provider_id="organizations",
|
||||||
|
module_id="organizations",
|
||||||
|
kind="delete",
|
||||||
|
resource_type="organizations_model_upgrade",
|
||||||
|
resource_id=self.upgrade.id,
|
||||||
|
title="Forged action",
|
||||||
|
rationale="Must be rejected",
|
||||||
|
executable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for action in actions:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(action,),
|
||||||
|
request_id="dsar-organizations-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_and_inactive_provider(self) -> None:
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ORGANIZATIONS-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Respond to an authorized privacy request.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(["organizations"], request.coverage["covered_modules"])
|
||||||
|
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ORGANIZATIONS-DISABLED",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Verify disabled-module coverage.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, organizations_active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, disabled.search_result["record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[ORGANIZATIONS_DSAR_CAPABILITY],
|
||||||
|
disabled.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.events import EventBus, event_bus_context
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_organizations.backend.api.v1.routes import _commit
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.directory import (
|
||||||
|
SqlOrganizationDirectory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationHierarchyDirectoryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
OrganizationUnitType.__table__,
|
||||||
|
OrganizationStructure.__table__,
|
||||||
|
OrganizationRelationType.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationRelation.__table__,
|
||||||
|
OrganizationFunctionType.__table__,
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
self._seed()
|
||||||
|
self.directory = SqlOrganizationDirectory(
|
||||||
|
session_factory=self.Session,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
OrganizationFunctionType.__table__,
|
||||||
|
OrganizationRelation.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationRelationType.__table__,
|
||||||
|
OrganizationStructure.__table__,
|
||||||
|
OrganizationUnitType.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
self.unit_type = OrganizationUnitType(
|
||||||
|
id="type-department",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="department",
|
||||||
|
name="Department",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.function_type = OrganizationFunctionType(
|
||||||
|
id="type-intake",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="intake",
|
||||||
|
name="Intake",
|
||||||
|
organization_unit_type_id=self.unit_type.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.employer = OrganizationStructure(
|
||||||
|
id="structure-employer",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="employer",
|
||||||
|
name="Employer hierarchy",
|
||||||
|
structure_kind="hierarchy",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.project = OrganizationStructure(
|
||||||
|
id="structure-project",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="project",
|
||||||
|
name="Project hierarchy",
|
||||||
|
structure_kind="hierarchy",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.employer_parent = OrganizationRelationType(
|
||||||
|
id="relation-type-employer",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
slug="contains",
|
||||||
|
name="Contains",
|
||||||
|
is_hierarchical=True,
|
||||||
|
allow_cycles=False,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.project_parent = OrganizationRelationType(
|
||||||
|
id="relation-type-project",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.project.id,
|
||||||
|
slug="project-contains",
|
||||||
|
name="Contains",
|
||||||
|
is_hierarchical=True,
|
||||||
|
allow_cycles=False,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.root = self._unit("unit-root", "Root")
|
||||||
|
self.child = self._unit("unit-child", "Child")
|
||||||
|
self.grandchild = self._unit("unit-grandchild", "Grandchild")
|
||||||
|
self.project_root = self._unit("unit-project", "Project root")
|
||||||
|
self.other_tenant = self._unit(
|
||||||
|
"unit-other-tenant",
|
||||||
|
"Other tenant",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
)
|
||||||
|
self.employer_child = OrganizationRelation(
|
||||||
|
id="edge-employer-child",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
relation_type_id=self.employer_parent.id,
|
||||||
|
source_unit_id=self.root.id,
|
||||||
|
target_unit_id=self.child.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.employer_grandchild = OrganizationRelation(
|
||||||
|
id="edge-employer-grandchild",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
relation_type_id=self.employer_parent.id,
|
||||||
|
source_unit_id=self.child.id,
|
||||||
|
target_unit_id=self.grandchild.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.project_child = OrganizationRelation(
|
||||||
|
id="edge-project-child",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.project.id,
|
||||||
|
relation_type_id=self.project_parent.id,
|
||||||
|
source_unit_id=self.project_root.id,
|
||||||
|
target_unit_id=self.child.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.function = OrganizationFunction(
|
||||||
|
id="function-child-intake",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
function_type_id=self.function_type.id,
|
||||||
|
organization_unit_id=self.child.id,
|
||||||
|
slug="intake",
|
||||||
|
name="Child intake",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.unit_type,
|
||||||
|
self.function_type,
|
||||||
|
self.employer,
|
||||||
|
self.project,
|
||||||
|
self.employer_parent,
|
||||||
|
self.project_parent,
|
||||||
|
self.root,
|
||||||
|
self.child,
|
||||||
|
self.grandchild,
|
||||||
|
self.project_root,
|
||||||
|
self.other_tenant,
|
||||||
|
self.employer_child,
|
||||||
|
self.employer_grandchild,
|
||||||
|
self.project_child,
|
||||||
|
self.function,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def _unit(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
) -> OrganizationUnit:
|
||||||
|
return OrganizationUnit(
|
||||||
|
id=item_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=(
|
||||||
|
self.unit_type.id if tenant_id == "tenant-1" else None
|
||||||
|
),
|
||||||
|
slug=item_id,
|
||||||
|
name=name,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parallel_structures_preserve_distinct_ancestor_provenance(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
employer = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.child.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
project = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.child.id,),
|
||||||
|
structure_id=self.project.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual([self.root.id], [item.unit.id for item in employer.matches])
|
||||||
|
self.assertEqual(
|
||||||
|
[self.project_root.id],
|
||||||
|
[item.unit.id for item in project.matches],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.employer.id,
|
||||||
|
employer.matches[0].path[0].structure.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.project_parent.id,
|
||||||
|
project.matches[0].path[0].relation_type.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hierarchy_catalog_is_tenant_scoped_and_preserves_structure_links(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
catalog = self.directory.hierarchy_catalog("tenant-1")
|
||||||
|
empty = self.directory.hierarchy_catalog("tenant-2")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{self.employer.id, self.project.id},
|
||||||
|
{item.id for item in catalog.structures},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
(self.employer_parent.id, self.employer.id),
|
||||||
|
(self.project_parent.id, self.project.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
(item.id, item.structure_id)
|
||||||
|
for item in catalog.relation_types
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual((), empty.structures)
|
||||||
|
self.assertEqual((), empty.relation_types)
|
||||||
|
|
||||||
|
def test_bounded_paths_report_depth_and_cycles(self) -> None:
|
||||||
|
bounded = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.root.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="descendants",
|
||||||
|
max_depth=1,
|
||||||
|
)[0]
|
||||||
|
path = self.directory.resolve_hierarchy_paths(
|
||||||
|
"tenant-1",
|
||||||
|
((self.child.id, self.root.id),),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual([self.child.id], [item.unit.id for item in bounded.matches])
|
||||||
|
self.assertTrue(bounded.depth_limited)
|
||||||
|
self.assertEqual("active", path.status)
|
||||||
|
self.assertEqual(
|
||||||
|
[self.employer_child.id],
|
||||||
|
[edge.id for edge in path.path],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.employer_parent.allow_cycles = True
|
||||||
|
self.session.add(
|
||||||
|
OrganizationRelation(
|
||||||
|
id="edge-cycle",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
relation_type_id=self.employer_parent.id,
|
||||||
|
source_unit_id=self.grandchild.id,
|
||||||
|
target_unit_id=self.root.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
cycle = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.root.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="descendants",
|
||||||
|
)[0]
|
||||||
|
self.assertTrue(cycle.cycle_detected)
|
||||||
|
self.assertTrue(
|
||||||
|
any(item.startswith("cycle_detected:") for item in cycle.diagnostics)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_function_and_unit_type_resolution_explains_missing_state(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
functions = self.directory.resolve_functions_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
self.function_type.id,
|
||||||
|
organization_unit_ids=(self.child.id, "missing-unit"),
|
||||||
|
)
|
||||||
|
missing_type = self.directory.resolve_functions_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
"missing-type",
|
||||||
|
organization_unit_ids=(self.child.id,),
|
||||||
|
)
|
||||||
|
units = self.directory.resolve_units_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
self.unit_type.id,
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
root_unit_id=self.root.id,
|
||||||
|
direction="descendants",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([self.function.id], [item.id for item in functions.matches])
|
||||||
|
self.assertEqual(("missing-unit",), functions.missing_unit_ids)
|
||||||
|
self.assertEqual("missing", missing_type.status)
|
||||||
|
self.assertEqual(
|
||||||
|
{self.root.id, self.child.id, self.grandchild.id},
|
||||||
|
{item.id for item in units.matches},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_moved_deactivated_and_cross_tenant_units_are_current(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.employer_child.source_unit_id = self.project_root.id
|
||||||
|
self.child.is_active = False
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
moved = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.child.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
isolated = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.other_tenant.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
functions = self.directory.resolve_functions_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
self.function_type.id,
|
||||||
|
organization_unit_ids=(self.child.id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("inactive", moved.status)
|
||||||
|
self.assertEqual(
|
||||||
|
[self.project_root.id],
|
||||||
|
[item.unit.id for item in moved.matches],
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", isolated.status)
|
||||||
|
self.assertEqual((self.child.id,), functions.inactive_unit_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationLifecycleEventTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.Session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_changes_emit_versioned_commit_coupled_events(self) -> None:
|
||||||
|
events = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", events.append)
|
||||||
|
with event_bus_context(bus):
|
||||||
|
unit_type = OrganizationUnitType(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="department",
|
||||||
|
name="Department",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.session.add(unit_type)
|
||||||
|
_commit(self.session, unit_type)
|
||||||
|
unit_type.name = "Office"
|
||||||
|
_commit(self.session, unit_type)
|
||||||
|
unit_type.is_active = False
|
||||||
|
_commit(self.session, unit_type)
|
||||||
|
|
||||||
|
organization_events = [
|
||||||
|
event
|
||||||
|
for event in events
|
||||||
|
if event.module_id == "organizations"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"organizations.unit_type.created.v1",
|
||||||
|
"organizations.unit_type.updated.v1",
|
||||||
|
"organizations.unit_type.deactivated.v1",
|
||||||
|
],
|
||||||
|
[event.type for event in organization_events],
|
||||||
|
)
|
||||||
|
self.assertTrue(unit_type.id)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
organization_events[0].payload["schema_version"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
unit_type.id,
|
||||||
|
organization_events[0].payload["resource_id"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"name",
|
||||||
|
organization_events[1].payload["changed_fields"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"inactive",
|
||||||
|
organization_events[2].payload["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
documentation_structured_translation_issues,
|
||||||
|
localizable_documentation_metadata_keys,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationsInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_route_and_admin_surface_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual(
|
||||||
|
{"/organizations"},
|
||||||
|
{route.path for route in frontend.routes}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"organizations.admin.tenant",
|
||||||
|
"organizations.admin.template-upgrades",
|
||||||
|
},
|
||||||
|
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(item.icon == "building-2" for item in manifest.nav_items)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_topics_publish_stable_help_and_consequence_metadata(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertIn("organizations.model", topics)
|
||||||
|
self.assertIn("organizations.reference.fields-and-consequences", topics)
|
||||||
|
self.assertIn(
|
||||||
|
"organizations.admin.tenant",
|
||||||
|
topics["organizations.model"].metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
reference = topics["organizations.reference.fields-and-consequences"]
|
||||||
|
self.assertIn(
|
||||||
|
"organizations.field.change-request",
|
||||||
|
reference.metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
self.assertIn("hierarchy_change", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
def test_german_reference_documentation_is_complete(self) -> None:
|
||||||
|
topics = manifest.documentation
|
||||||
|
self.assertEqual(4, len(topics))
|
||||||
|
for topic in topics:
|
||||||
|
translation = topic.translations.get("de", {})
|
||||||
|
self.assertTrue(translation.get("title"), topic.id)
|
||||||
|
self.assertTrue(translation.get("summary"), topic.id)
|
||||||
|
self.assertTrue(translation.get("body"), topic.id)
|
||||||
|
if localizable_documentation_metadata_keys(topic):
|
||||||
|
self.assertEqual("1", topic.structured_translation_version, topic.id)
|
||||||
|
self.assertIn("de", topic.structured_translations, topic.id)
|
||||||
|
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||||
|
|
||||||
|
kinds = {topic.metadata.get("kind") for topic in topics}
|
||||||
|
self.assertIn("workflow", kinds)
|
||||||
|
self.assertIn("reference", kinds)
|
||||||
|
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
|
||||||
|
self.assertTrue(workflow.conditions)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_organizations.backend.api.v1.schemas import (
|
||||||
|
OrganizationModelTemplateDefinition,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationModelInstantiation,
|
||||||
|
OrganizationModelTemplate,
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.templates import (
|
||||||
|
OrganizationTemplateError,
|
||||||
|
canonical_template_definition,
|
||||||
|
instantiate_template_version,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.upgrades import (
|
||||||
|
OrganizationUpgradeError,
|
||||||
|
apply_model_upgrade,
|
||||||
|
cancel_model_upgrade,
|
||||||
|
create_model_upgrade_preview,
|
||||||
|
current_model_instantiation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
OrganizationModelTemplate.__table__,
|
||||||
|
OrganizationModelTemplateVersion.__table__,
|
||||||
|
OrganizationUnitType.__table__,
|
||||||
|
OrganizationStructure.__table__,
|
||||||
|
OrganizationRelationType.__table__,
|
||||||
|
OrganizationFunctionType.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
OrganizationRelation.__table__,
|
||||||
|
OrganizationModelInstantiation.__table__,
|
||||||
|
OrganizationModelUpgrade.__table__,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine, tables=TABLES)
|
||||||
|
self.session: Session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(self.engine, tables=reversed(TABLES))
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_published_template_is_copied_into_tenant_owned_records(self) -> None:
|
||||||
|
definition, fingerprint = canonical_template_definition(_definition())
|
||||||
|
template = OrganizationModelTemplate(
|
||||||
|
id="template-1",
|
||||||
|
slug="municipality",
|
||||||
|
name="Municipality",
|
||||||
|
)
|
||||||
|
version = OrganizationModelTemplateVersion(
|
||||||
|
id="template-version-1",
|
||||||
|
template_id=template.id,
|
||||||
|
version="1.0.0",
|
||||||
|
status="published",
|
||||||
|
definition=definition,
|
||||||
|
definition_sha256=fingerprint,
|
||||||
|
)
|
||||||
|
self.session.add_all([template, version])
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
result = instantiate_template_version(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template=template,
|
||||||
|
version=version,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"unit_types": 1,
|
||||||
|
"structures": 1,
|
||||||
|
"relation_types": 1,
|
||||||
|
"units": 2,
|
||||||
|
"relations": 1,
|
||||||
|
"function_types": 1,
|
||||||
|
"functions": 1,
|
||||||
|
},
|
||||||
|
result.object_counts,
|
||||||
|
)
|
||||||
|
office = (
|
||||||
|
self.session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == "tenant-1",
|
||||||
|
OrganizationUnit.slug == "office",
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
root = (
|
||||||
|
self.session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == "tenant-1",
|
||||||
|
OrganizationUnit.slug == "municipality",
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
self.assertEqual(root.id, office.parent_id)
|
||||||
|
self.assertEqual(
|
||||||
|
"tenant_owned_no_live_inheritance",
|
||||||
|
result.provenance["copy_semantics"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"1.0.0",
|
||||||
|
office.settings["template_provenance"]["template_version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
version.definition = {}
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual("Office", office.name)
|
||||||
|
|
||||||
|
def test_instantiation_refuses_implicit_merge_into_existing_model(self) -> None:
|
||||||
|
definition, fingerprint = canonical_template_definition(_definition())
|
||||||
|
template = OrganizationModelTemplate(
|
||||||
|
id="template-2",
|
||||||
|
slug="municipality-2",
|
||||||
|
name="Municipality",
|
||||||
|
)
|
||||||
|
version = OrganizationModelTemplateVersion(
|
||||||
|
id="template-version-2",
|
||||||
|
template_id=template.id,
|
||||||
|
version="1",
|
||||||
|
status="published",
|
||||||
|
definition=definition,
|
||||||
|
definition_sha256=fingerprint,
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
template,
|
||||||
|
version,
|
||||||
|
OrganizationUnitType(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="existing",
|
||||||
|
name="Existing",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
OrganizationTemplateError,
|
||||||
|
"only into an empty tenant model",
|
||||||
|
):
|
||||||
|
instantiate_template_version(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template=template,
|
||||||
|
version=version,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_template_reference_is_rejected_before_storage(self) -> None:
|
||||||
|
definition = _definition().model_copy(deep=True)
|
||||||
|
definition.units[1].unit_type_slug = "unknown"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
OrganizationTemplateError,
|
||||||
|
"unknown unit type",
|
||||||
|
):
|
||||||
|
canonical_template_definition(definition)
|
||||||
|
|
||||||
|
def test_parent_cycles_are_rejected_before_storage(self) -> None:
|
||||||
|
definition = _definition().model_copy(deep=True)
|
||||||
|
definition.units[0].parent_slug = "office"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
OrganizationTemplateError,
|
||||||
|
"cycle in unit parent hierarchy",
|
||||||
|
):
|
||||||
|
canonical_template_definition(definition)
|
||||||
|
|
||||||
|
def test_relation_unit_type_mismatch_is_rejected(self) -> None:
|
||||||
|
definition = _definition().model_copy(deep=True)
|
||||||
|
definition.unit_types.append(
|
||||||
|
definition.unit_types[0].model_copy(
|
||||||
|
update={"slug": "other", "name": "Other"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
definition.units[1].unit_type_slug = "other"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
OrganizationTemplateError,
|
||||||
|
"does not allow source unit",
|
||||||
|
):
|
||||||
|
canonical_template_definition(definition)
|
||||||
|
|
||||||
|
def test_function_unit_type_mismatch_is_rejected(self) -> None:
|
||||||
|
definition = _definition().model_copy(deep=True)
|
||||||
|
definition.unit_types.append(
|
||||||
|
definition.unit_types[0].model_copy(
|
||||||
|
update={"slug": "other", "name": "Other"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
definition.units[1].unit_type_slug = "other"
|
||||||
|
definition.relations = []
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
OrganizationTemplateError,
|
||||||
|
"does not apply to unit",
|
||||||
|
):
|
||||||
|
canonical_template_definition(definition)
|
||||||
|
|
||||||
|
def test_three_way_upgrade_previews_additions_and_local_divergence(self) -> None:
|
||||||
|
template, source, target = self._instantiated_upgrade_fixture(
|
||||||
|
mutate_target=lambda definition: definition.units.append(
|
||||||
|
definition.units[1].model_copy(
|
||||||
|
update={
|
||||||
|
"slug": "service-office",
|
||||||
|
"name": "Service office",
|
||||||
|
"parent_slug": "municipality",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
office = (
|
||||||
|
self.session.query(OrganizationUnit)
|
||||||
|
.filter_by(tenant_id="tenant-1", slug="office")
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
office.name = "Tenant-specific office"
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
preview = create_model_upgrade_preview(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
idempotency_key="preview-additive",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(1, preview.preview["counts"]["compatible_addition"])
|
||||||
|
self.assertEqual(1, preview.preview["counts"]["local_divergence"])
|
||||||
|
self.assertEqual(0, preview.preview["requires_decisions"])
|
||||||
|
applied, instantiation = apply_model_upgrade(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
upgrade_id=preview.id,
|
||||||
|
expected_revision=1,
|
||||||
|
decisions={},
|
||||||
|
actor_account_id="account-2",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
self.assertEqual("applied", applied.status)
|
||||||
|
self.assertEqual(target.id, instantiation.template_version_id)
|
||||||
|
self.assertEqual(
|
||||||
|
"Tenant-specific office",
|
||||||
|
self.session.query(OrganizationUnit)
|
||||||
|
.filter_by(tenant_id="tenant-1", slug="office")
|
||||||
|
.one()
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.session.query(OrganizationUnit)
|
||||||
|
.filter_by(tenant_id="tenant-1", slug="service-office")
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
self.assertEqual("superseded", source.status)
|
||||||
|
self.assertEqual(instantiation.id, current_model_instantiation(self.session, tenant_id="tenant-1").id)
|
||||||
|
self.assertEqual(template.id, instantiation.template_id)
|
||||||
|
|
||||||
|
def test_divergent_upgrade_requires_bounded_decision_and_detects_stale_state(self) -> None:
|
||||||
|
_template, _source, target = self._instantiated_upgrade_fixture(
|
||||||
|
mutate_target=lambda definition: setattr(
|
||||||
|
definition.units[1], "name", "Template office"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
office = self.session.query(OrganizationUnit).filter_by(
|
||||||
|
tenant_id="tenant-1", slug="office"
|
||||||
|
).one()
|
||||||
|
office.name = "Local office"
|
||||||
|
self.session.commit()
|
||||||
|
preview = create_model_upgrade_preview(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
idempotency_key="preview-divergent",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
conflict = next(
|
||||||
|
entry
|
||||||
|
for entry in preview.preview["entries"]
|
||||||
|
if entry["id"] == "units:office"
|
||||||
|
)
|
||||||
|
self.assertTrue(conflict["requires_decision"])
|
||||||
|
with self.assertRaisesRegex(OrganizationUpgradeError, "decision is required"):
|
||||||
|
apply_model_upgrade(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
upgrade_id=preview.id,
|
||||||
|
expected_revision=1,
|
||||||
|
decisions={},
|
||||||
|
actor_account_id="account-2",
|
||||||
|
)
|
||||||
|
self.session.rollback()
|
||||||
|
|
||||||
|
office = self.session.query(OrganizationUnit).filter_by(
|
||||||
|
tenant_id="tenant-1", slug="office"
|
||||||
|
).one()
|
||||||
|
office.description = "Changed after preview"
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(OrganizationUpgradeError, "changed after this preview"):
|
||||||
|
apply_model_upgrade(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
upgrade_id=preview.id,
|
||||||
|
expected_revision=1,
|
||||||
|
decisions={"units:office": {"action": "use_target"}},
|
||||||
|
actor_account_id="account-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_divergent_upgrade_applies_explicit_target_decision(self) -> None:
|
||||||
|
_template, _source, target = self._instantiated_upgrade_fixture(
|
||||||
|
mutate_target=lambda definition: setattr(
|
||||||
|
definition.units[1], "name", "Template office"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
office = self.session.query(OrganizationUnit).filter_by(
|
||||||
|
tenant_id="tenant-1", slug="office"
|
||||||
|
).one()
|
||||||
|
office.name = "Local office"
|
||||||
|
self.session.commit()
|
||||||
|
preview = create_model_upgrade_preview(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
idempotency_key="preview-explicit-decision",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
applied, _instantiation = apply_model_upgrade(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
upgrade_id=preview.id,
|
||||||
|
expected_revision=1,
|
||||||
|
decisions={"units:office": {"action": "use_target"}},
|
||||||
|
actor_account_id="account-2",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual("applied", applied.status)
|
||||||
|
self.assertEqual(
|
||||||
|
"Template office",
|
||||||
|
self.session.query(OrganizationUnit)
|
||||||
|
.filter_by(tenant_id="tenant-1", slug="office")
|
||||||
|
.one()
|
||||||
|
.name,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"action": "use_target"},
|
||||||
|
applied.decisions["units:office"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_local_references_block_and_preview_can_be_cancelled(self) -> None:
|
||||||
|
_template, _source, target = self._instantiated_upgrade_fixture()
|
||||||
|
office = self.session.query(OrganizationUnit).filter_by(
|
||||||
|
tenant_id="tenant-1", slug="office"
|
||||||
|
).one()
|
||||||
|
office.unit_type_id = "missing-unit-type"
|
||||||
|
self.session.commit()
|
||||||
|
preview = create_model_upgrade_preview(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
idempotency_key="preview-invalid",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertGreater(preview.preview["blocking_invalid_references"], 0)
|
||||||
|
with self.assertRaisesRegex(OrganizationUpgradeError, "Invalid tenant references"):
|
||||||
|
apply_model_upgrade(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
upgrade_id=preview.id,
|
||||||
|
expected_revision=1,
|
||||||
|
decisions={},
|
||||||
|
actor_account_id="account-2",
|
||||||
|
)
|
||||||
|
self.session.rollback()
|
||||||
|
cancelled = cancel_model_upgrade(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
upgrade_id=preview.id,
|
||||||
|
expected_revision=1,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual("cancelled", cancelled.status)
|
||||||
|
self.assertEqual(2, cancelled.revision)
|
||||||
|
self.assertIsNone(cancelled.applied_at)
|
||||||
|
|
||||||
|
def test_unchanged_upgrade_preview_has_no_changes(self) -> None:
|
||||||
|
_template, _source, target = self._instantiated_upgrade_fixture()
|
||||||
|
preview = create_model_upgrade_preview(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
idempotency_key="preview-unchanged",
|
||||||
|
)
|
||||||
|
self.assertEqual([], preview.preview["entries"])
|
||||||
|
self.assertEqual(0, preview.preview["requires_decisions"])
|
||||||
|
|
||||||
|
def _instantiated_upgrade_fixture(self, mutate_target=None):
|
||||||
|
source_definition = _definition()
|
||||||
|
source_payload, source_hash = canonical_template_definition(source_definition)
|
||||||
|
target_definition = source_definition.model_copy(deep=True)
|
||||||
|
if mutate_target is not None:
|
||||||
|
mutate_target(target_definition)
|
||||||
|
target_payload, target_hash = canonical_template_definition(target_definition)
|
||||||
|
template = OrganizationModelTemplate(
|
||||||
|
id="template-upgrade",
|
||||||
|
slug="upgrade-template",
|
||||||
|
name="Upgrade template",
|
||||||
|
)
|
||||||
|
source_version = OrganizationModelTemplateVersion(
|
||||||
|
id="template-upgrade-v1",
|
||||||
|
template_id=template.id,
|
||||||
|
version="1.0.0",
|
||||||
|
status="published",
|
||||||
|
definition=source_payload,
|
||||||
|
definition_sha256=source_hash,
|
||||||
|
)
|
||||||
|
target_version = OrganizationModelTemplateVersion(
|
||||||
|
id="template-upgrade-v2",
|
||||||
|
template_id=template.id,
|
||||||
|
version="2.0.0",
|
||||||
|
status="published",
|
||||||
|
definition=target_payload,
|
||||||
|
definition_sha256=target_hash,
|
||||||
|
)
|
||||||
|
self.session.add_all([template, source_version, target_version])
|
||||||
|
self.session.flush()
|
||||||
|
source = instantiate_template_version(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
template=template,
|
||||||
|
version=source_version,
|
||||||
|
actor_account_id="account-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
return template, source, target_version
|
||||||
|
|
||||||
|
|
||||||
|
def _definition() -> OrganizationModelTemplateDefinition:
|
||||||
|
return OrganizationModelTemplateDefinition.model_validate(
|
||||||
|
{
|
||||||
|
"unit_types": [
|
||||||
|
{
|
||||||
|
"slug": "administrative-unit",
|
||||||
|
"name": "Administrative unit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"structures": [
|
||||||
|
{
|
||||||
|
"slug": "administrative",
|
||||||
|
"name": "Administrative hierarchy",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relation_types": [
|
||||||
|
{
|
||||||
|
"slug": "reports-to",
|
||||||
|
"name": "Reports to",
|
||||||
|
"structure_slug": "administrative",
|
||||||
|
"source_unit_type_slug": "administrative-unit",
|
||||||
|
"target_unit_type_slug": "administrative-unit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"slug": "municipality",
|
||||||
|
"name": "Municipality",
|
||||||
|
"unit_type_slug": "administrative-unit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "office",
|
||||||
|
"name": "Office",
|
||||||
|
"unit_type_slug": "administrative-unit",
|
||||||
|
"parent_slug": "municipality",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"structure_slug": "administrative",
|
||||||
|
"relation_type_slug": "reports-to",
|
||||||
|
"source_unit_slug": "office",
|
||||||
|
"target_unit_slug": "municipality",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"function_types": [
|
||||||
|
{
|
||||||
|
"slug": "head",
|
||||||
|
"name": "Head",
|
||||||
|
"organization_unit_type_slug": "administrative-unit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"slug": "head",
|
||||||
|
"name": "Office head",
|
||||||
|
"function_type_slug": "head",
|
||||||
|
"organization_unit_slug": "office",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_organizations.backend.api.v1.routes import (
|
||||||
|
get_organization_settings,
|
||||||
|
update_organization_settings,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.api.v1.schemas import (
|
||||||
|
OrganizationSettingsUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db.models import OrganizationTenantSettings
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationSettingsRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
OrganizationTenantSettings.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
OrganizationTenantSettings.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _principal(tenant_id: str) -> object:
|
||||||
|
return SimpleNamespace(tenant_id=tenant_id)
|
||||||
|
|
||||||
|
def test_missing_settings_row_returns_tenant_defaults(self) -> None:
|
||||||
|
result = get_organization_settings(
|
||||||
|
session=self.session,
|
||||||
|
principal=self._principal("tenant-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.tenant_id, "tenant-1")
|
||||||
|
self.assertTrue(result.allow_tenant_model_customization)
|
||||||
|
self.assertFalse(result.require_model_change_requests)
|
||||||
|
self.assertEqual(result.audit_detail_level, "standard")
|
||||||
|
self.assertIsNone(result.change_retention_days)
|
||||||
|
self.assertEqual(result.settings, {})
|
||||||
|
self.assertEqual(self.session.query(OrganizationTenantSettings).count(), 0)
|
||||||
|
|
||||||
|
def test_first_update_creates_only_the_active_tenant_settings(self) -> None:
|
||||||
|
result = update_organization_settings(
|
||||||
|
OrganizationSettingsUpdateRequest(
|
||||||
|
allow_tenant_model_customization=False,
|
||||||
|
require_model_change_requests=True,
|
||||||
|
audit_detail_level="full",
|
||||||
|
change_retention_days=365,
|
||||||
|
settings={"template": {"id": "municipality", "version": "2"}},
|
||||||
|
),
|
||||||
|
session=self.session,
|
||||||
|
principal=self._principal("tenant-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.tenant_id, "tenant-1")
|
||||||
|
self.assertFalse(result.allow_tenant_model_customization)
|
||||||
|
self.assertTrue(result.require_model_change_requests)
|
||||||
|
self.assertEqual(result.change_retention_days, 365)
|
||||||
|
self.assertEqual(
|
||||||
|
result.settings,
|
||||||
|
{"template": {"id": "municipality", "version": "2"}},
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.session.query(OrganizationTenantSettings)
|
||||||
|
.filter(OrganizationTenantSettings.tenant_id == "tenant-2")
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_settings_reads_are_tenant_isolated(self) -> None:
|
||||||
|
self.session.add(
|
||||||
|
OrganizationTenantSettings(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
allow_tenant_model_customization=False,
|
||||||
|
require_model_change_requests=True,
|
||||||
|
audit_detail_level="detailed",
|
||||||
|
settings={"private": "tenant-1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
other = get_organization_settings(
|
||||||
|
session=self.session,
|
||||||
|
principal=self._principal("tenant-2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(other.tenant_id, "tenant-2")
|
||||||
|
self.assertTrue(other.allow_tenant_model_customization)
|
||||||
|
self.assertEqual(other.settings, {})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+12
-7
@@ -1,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/organizations-webui",
|
"name": "@govoplan/organizations-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs",
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||||
|
"test:template-upgrades": "node scripts/test-template-upgrades.mjs"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
@@ -14,14 +19,14 @@
|
|||||||
"./styles/organizations.css": "./src/styles/organizations.css"
|
"./styles/organizations.css": "./src/styles/organizations.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": ">=8.3.0 <9",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function source(path) {
|
||||||
|
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = source("../src/features/organizations/OrganizationsPage.tsx");
|
||||||
|
const settings = source("../src/features/organizations/OrganizationsAdminPanel.tsx");
|
||||||
|
const patterns = source("../src/features/organizations/interfacePatterns.ts");
|
||||||
|
const moduleSource = source("../src/module.ts");
|
||||||
|
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||||
|
|
||||||
|
assert(page.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Workspace and tenant settings expose contextual documentation");
|
||||||
|
assert(page.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only states identify action, actor, and destination");
|
||||||
|
assert(page.includes("disabledReason") && settings.includes("disabledReason"), "Unavailable organization actions explain their state");
|
||||||
|
assert(page.includes("<PageActionBar") && page.includes("reloadAction={{") && page.includes("onReload: () => void loadModel()") && settings.includes("requestDiscard(() => void load())"), "Reload uses the guarded semantic page action or the settings draft guard");
|
||||||
|
assert((page.match(/<Card bodyLayout="table"/g) ?? []).length === 7, "All seven organization collection tables use the shared edge-to-edge card body");
|
||||||
|
assert(page.includes("hasDirtyDraft ? requestDiscard(discardDrafts)"), "Editor close uses the shared unsaved-change guard");
|
||||||
|
assert(page.includes("ORGANIZATIONS_FIELD_DOCUMENTATION"), "Model fields link to stable consequence documentation");
|
||||||
|
assert(patterns.includes('topicId: "organizations.model"') && patterns.includes('topicId: "organizations.reference.fields-and-consequences"'), "Organizations uses manifest-backed help references");
|
||||||
|
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-organizations.organizations_administration"'), "WebUI metadata matches the module release and localizes its composed surface");
|
||||||
|
assert(translations.includes('"i18n:govoplan-organizations.read_only_summary"'), "Blocker explanations are present in the translation catalogue");
|
||||||
|
assert(!page.includes("window.confirm"), "Organizations does not use browser-native consequential confirmation");
|
||||||
|
|
||||||
|
console.log("Organizations surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../src/features/organizations/OrganizationsPage.tsx", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const styles = readFileSync(
|
||||||
|
new URL("../src/styles/organizations.css", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const moduleSource = readFileSync(
|
||||||
|
new URL("../src/module.ts", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const apiSource = readFileSync(
|
||||||
|
new URL("../src/api/organizations.ts", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(source.includes("ExplorerTree,"), "Organizations imports the central ExplorerTree");
|
||||||
|
assert(source.includes("<ExplorerTree"), "the organization hierarchy uses ExplorerTree");
|
||||||
|
assert(source.includes("collapsible={false}"), "the always-expanded hierarchy uses the non-collapsible contract");
|
||||||
|
assert(source.includes("renderNodeActions="), "per-unit controls use the sibling node-action slot");
|
||||||
|
assert(source.includes("<IconButton"), "per-unit controls use the central icon-only action primitive");
|
||||||
|
assert(!source.includes("renderUnitTreeNodes"), "the custom recursive renderer was removed");
|
||||||
|
assert(!source.includes("organizations-tree-row"), "the custom tree row was removed");
|
||||||
|
assert(!source.includes("organizations-tree-node"), "the custom tree node was removed");
|
||||||
|
assert(!styles.includes("organizations-tree-"), "Organizations no longer owns shared tree styling");
|
||||||
|
assert(
|
||||||
|
moduleSource.includes('"admin.sections": organizationAdminSections'),
|
||||||
|
"organization governance settings use the shared admin layout"
|
||||||
|
);
|
||||||
|
for (const operation of [
|
||||||
|
"createUnit",
|
||||||
|
"patchUnit",
|
||||||
|
"createFunction",
|
||||||
|
"patchFunction"
|
||||||
|
]) {
|
||||||
|
assert(
|
||||||
|
source.includes(operation),
|
||||||
|
`the organization editor exposes ${operation}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
apiSource.includes(`function ${operation}`),
|
||||||
|
`the organization API client defines ${operation}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
source.includes("is_active: draft.is_active"),
|
||||||
|
"organization editors persist active and inactive state"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes('id="organizations-units"')
|
||||||
|
&& source.includes('id="organizations-functions"'),
|
||||||
|
"unit and function lists use the shared DataGrid"
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const panel = fs.readFileSync(
|
||||||
|
path.join(root, "src/features/organizations/OrganizationTemplateUpgradePanel.tsx"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const api = fs.readFileSync(path.join(root, "src/api/organizations.ts"), "utf8");
|
||||||
|
const moduleSource = fs.readFileSync(path.join(root, "src/module.ts"), "utf8");
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[panel, "previewOrganizationModelUpgrade", "The panel must create a persisted preview."],
|
||||||
|
[panel, "applyOrganizationModelUpgrade", "The panel must explicitly apply a reviewed preview."],
|
||||||
|
[panel, "cancelOrganizationModelUpgrade", "The panel must support cancellation without mutation."],
|
||||||
|
[panel, "<ConfirmDialog open={applyConfirmation}", "Apply must use a separate confirmation step."],
|
||||||
|
[panel, "<DataGrid id={`organization-model-upgrade-diff-", "The comparison must use the shared DataGrid."],
|
||||||
|
[panel, "<DismissibleAlert", "Errors and outcomes must use the shared alert component."],
|
||||||
|
[api, '"/api/v1/organizations/model-upgrades/preview"', "The preview API must be declared."],
|
||||||
|
[api, "/apply`", "The apply API must be declared."],
|
||||||
|
[moduleSource, 'id: "organizations.admin.template-upgrades"', "Views must be able to target the upgrade surface."]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [source, marker, message] of expectations) {
|
||||||
|
if (!source.includes(marker)) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/useEffect\([^)]*applyOrganizationModelUpgrade/s.test(panel)) {
|
||||||
|
throw new Error("An organization template upgrade must never apply from an effect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Organization template upgrade interface contract passed.");
|
||||||
+126
-24
@@ -1,4 +1,4 @@
|
|||||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
import { apiFetch, apiPatchJson, apiPostJson, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type OrganizationUnitTypeItem = {
|
export type OrganizationUnitTypeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -135,6 +135,81 @@ export type OrganizationModel = {
|
|||||||
functions: OrganizationFunctionItem[];
|
functions: OrganizationFunctionItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrganizationTemplateVersion = {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
version: string;
|
||||||
|
status: string;
|
||||||
|
definition_sha256: string;
|
||||||
|
release_notes?: string | null;
|
||||||
|
published_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationTemplateCatalogItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
versions: OrganizationTemplateVersion[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationTemplateCatalog = {
|
||||||
|
templates: OrganizationTemplateCatalogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationModelInstantiation = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
template_id: string;
|
||||||
|
template_version_id: string;
|
||||||
|
source_definition_sha256: string;
|
||||||
|
status: string;
|
||||||
|
object_counts: Record<string, number>;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationUpgradeDecisionAction = "keep_local" | "use_target" | "map_to";
|
||||||
|
|
||||||
|
export type OrganizationUpgradeDiffEntry = {
|
||||||
|
id: string;
|
||||||
|
collection: string;
|
||||||
|
key: string;
|
||||||
|
classification: string;
|
||||||
|
requires_decision: boolean;
|
||||||
|
base?: Record<string, unknown> | null;
|
||||||
|
local?: Record<string, unknown> | null;
|
||||||
|
target?: Record<string, unknown> | null;
|
||||||
|
allowed_actions: OrganizationUpgradeDecisionAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationModelUpgrade = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
template_id: string;
|
||||||
|
source_instantiation_id: string;
|
||||||
|
source_template_version_id: string;
|
||||||
|
target_template_version_id: string;
|
||||||
|
status: string;
|
||||||
|
revision: number;
|
||||||
|
preview: {
|
||||||
|
entries: OrganizationUpgradeDiffEntry[];
|
||||||
|
counts: Record<string, number>;
|
||||||
|
requires_decisions: number;
|
||||||
|
blocking_invalid_references: number;
|
||||||
|
silent_mutation: boolean;
|
||||||
|
};
|
||||||
|
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string | null }>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationModelUpgradeList = {
|
||||||
|
current_instantiation?: OrganizationModelInstantiation | null;
|
||||||
|
upgrades: OrganizationModelUpgrade[];
|
||||||
|
};
|
||||||
|
|
||||||
export type OrganizationChangeRequestPayload = {
|
export type OrganizationChangeRequestPayload = {
|
||||||
change_request_id?: string | null;
|
change_request_id?: string | null;
|
||||||
};
|
};
|
||||||
@@ -186,78 +261,105 @@ export type FunctionCreatePayload = SluggedCreatePayload & {
|
|||||||
act_in_place_allowed?: boolean | null;
|
act_in_place_allowed?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
|
||||||
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
|
||||||
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
||||||
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOrganizationTemplateCatalog(settings: ApiSettings): Promise<OrganizationTemplateCatalog> {
|
||||||
|
return apiFetch<OrganizationTemplateCatalog>(settings, "/api/v1/organizations/model-templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationModelUpgrades(settings: ApiSettings): Promise<OrganizationModelUpgradeList> {
|
||||||
|
return apiFetch<OrganizationModelUpgradeList>(settings, "/api/v1/organizations/model-upgrades");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewOrganizationModelUpgrade(settings: ApiSettings, targetTemplateVersionId: string): Promise<OrganizationModelUpgrade> {
|
||||||
|
return apiPostJson(settings, "/api/v1/organizations/model-upgrades/preview", {
|
||||||
|
target_template_version_id: targetTemplateVersionId,
|
||||||
|
idempotency_key: crypto.randomUUID()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelOrganizationModelUpgrade(settings: ApiSettings, upgradeId: string, expectedRevision: number): Promise<OrganizationModelUpgrade> {
|
||||||
|
return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/cancel`, {
|
||||||
|
expected_revision: expectedRevision
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOrganizationModelUpgrade(
|
||||||
|
settings: ApiSettings,
|
||||||
|
upgradeId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string }>,
|
||||||
|
changeRequestId?: string
|
||||||
|
): Promise<{ upgrade: OrganizationModelUpgrade; instantiation: OrganizationModelInstantiation }> {
|
||||||
|
return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/apply`, {
|
||||||
|
expected_revision: expectedRevision,
|
||||||
|
decisions,
|
||||||
|
change_request_id: changeRequestId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
|
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
|
||||||
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchOrganizationSettings(settings: ApiSettings, payload: OrganizationSettingsUpdatePayload): Promise<OrganizationSettingsItem> {
|
export function patchOrganizationSettings(settings: ApiSettings, payload: OrganizationSettingsUpdatePayload): Promise<OrganizationSettingsItem> {
|
||||||
return patch(settings, "/api/v1/organizations/settings", payload);
|
return apiPatchJson(settings, "/api/v1/organizations/settings", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise<OrganizationUnitTypeItem> {
|
export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise<OrganizationUnitTypeItem> {
|
||||||
return post(settings, "/api/v1/organizations/unit-types", payload);
|
return apiPostJson(settings, "/api/v1/organizations/unit-types", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchUnitType(settings: ApiSettings, id: string, payload: Partial<SluggedCreatePayload>): Promise<OrganizationUnitTypeItem> {
|
export function patchUnitType(settings: ApiSettings, id: string, payload: Partial<SluggedCreatePayload>): Promise<OrganizationUnitTypeItem> {
|
||||||
return patch(settings, `/api/v1/organizations/unit-types/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/unit-types/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStructure(settings: ApiSettings, payload: StructureCreatePayload): Promise<OrganizationStructureItem> {
|
export function createStructure(settings: ApiSettings, payload: StructureCreatePayload): Promise<OrganizationStructureItem> {
|
||||||
return post(settings, "/api/v1/organizations/structures", payload);
|
return apiPostJson(settings, "/api/v1/organizations/structures", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchStructure(settings: ApiSettings, id: string, payload: Partial<StructureCreatePayload>): Promise<OrganizationStructureItem> {
|
export function patchStructure(settings: ApiSettings, id: string, payload: Partial<StructureCreatePayload>): Promise<OrganizationStructureItem> {
|
||||||
return patch(settings, `/api/v1/organizations/structures/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/structures/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRelationType(settings: ApiSettings, payload: RelationTypeCreatePayload): Promise<OrganizationRelationTypeItem> {
|
export function createRelationType(settings: ApiSettings, payload: RelationTypeCreatePayload): Promise<OrganizationRelationTypeItem> {
|
||||||
return post(settings, "/api/v1/organizations/relation-types", payload);
|
return apiPostJson(settings, "/api/v1/organizations/relation-types", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchRelationType(settings: ApiSettings, id: string, payload: Partial<RelationTypeCreatePayload>): Promise<OrganizationRelationTypeItem> {
|
export function patchRelationType(settings: ApiSettings, id: string, payload: Partial<RelationTypeCreatePayload>): Promise<OrganizationRelationTypeItem> {
|
||||||
return patch(settings, `/api/v1/organizations/relation-types/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/relation-types/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUnit(settings: ApiSettings, payload: UnitCreatePayload): Promise<OrganizationUnitItem> {
|
export function createUnit(settings: ApiSettings, payload: UnitCreatePayload): Promise<OrganizationUnitItem> {
|
||||||
return post(settings, "/api/v1/organizations/units", payload);
|
return apiPostJson(settings, "/api/v1/organizations/units", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchUnit(settings: ApiSettings, id: string, payload: Partial<UnitCreatePayload>): Promise<OrganizationUnitItem> {
|
export function patchUnit(settings: ApiSettings, id: string, payload: Partial<UnitCreatePayload>): Promise<OrganizationUnitItem> {
|
||||||
return patch(settings, `/api/v1/organizations/units/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/units/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRelation(settings: ApiSettings, payload: RelationCreatePayload): Promise<OrganizationRelationItem> {
|
export function createRelation(settings: ApiSettings, payload: RelationCreatePayload): Promise<OrganizationRelationItem> {
|
||||||
return post(settings, "/api/v1/organizations/relations", payload);
|
return apiPostJson(settings, "/api/v1/organizations/relations", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchRelation(settings: ApiSettings, id: string, payload: Partial<RelationCreatePayload>): Promise<OrganizationRelationItem> {
|
export function patchRelation(settings: ApiSettings, id: string, payload: Partial<RelationCreatePayload>): Promise<OrganizationRelationItem> {
|
||||||
return patch(settings, `/api/v1/organizations/relations/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/relations/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFunctionType(settings: ApiSettings, payload: FunctionTypeCreatePayload): Promise<OrganizationFunctionTypeItem> {
|
export function createFunctionType(settings: ApiSettings, payload: FunctionTypeCreatePayload): Promise<OrganizationFunctionTypeItem> {
|
||||||
return post(settings, "/api/v1/organizations/function-types", payload);
|
return apiPostJson(settings, "/api/v1/organizations/function-types", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchFunctionType(settings: ApiSettings, id: string, payload: Partial<FunctionTypeCreatePayload>): Promise<OrganizationFunctionTypeItem> {
|
export function patchFunctionType(settings: ApiSettings, id: string, payload: Partial<FunctionTypeCreatePayload>): Promise<OrganizationFunctionTypeItem> {
|
||||||
return patch(settings, `/api/v1/organizations/function-types/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/function-types/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFunction(settings: ApiSettings, payload: FunctionCreatePayload): Promise<OrganizationFunctionItem> {
|
export function createFunction(settings: ApiSettings, payload: FunctionCreatePayload): Promise<OrganizationFunctionItem> {
|
||||||
return post(settings, "/api/v1/organizations/functions", payload);
|
return apiPostJson(settings, "/api/v1/organizations/functions", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> {
|
export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> {
|
||||||
return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react";
|
||||||
|
import { ActionToolbar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
LoadingFrame,
|
||||||
|
MetricCard,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
adminErrorMessage,
|
||||||
|
hasAnyScope,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
applyOrganizationModelUpgrade,
|
||||||
|
cancelOrganizationModelUpgrade,
|
||||||
|
getOrganizationModelUpgrades,
|
||||||
|
getOrganizationTemplateCatalog,
|
||||||
|
previewOrganizationModelUpgrade,
|
||||||
|
type OrganizationModelInstantiation,
|
||||||
|
type OrganizationModelUpgrade,
|
||||||
|
type OrganizationTemplateCatalogItem,
|
||||||
|
type OrganizationTemplateVersion,
|
||||||
|
type OrganizationUpgradeDecisionAction,
|
||||||
|
type OrganizationUpgradeDiffEntry
|
||||||
|
} from "../../api/organizations";
|
||||||
|
|
||||||
|
type Decision = { action: OrganizationUpgradeDecisionAction; target_key?: string };
|
||||||
|
|
||||||
|
export default function OrganizationTemplateUpgradePanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||||
|
const canRead = hasAnyScope(auth, ["organizations:model:read", "admin:settings:read"]);
|
||||||
|
const canWrite = hasAnyScope(auth, ["organizations:model:write"]);
|
||||||
|
const [templates, setTemplates] = useState<OrganizationTemplateCatalogItem[]>([]);
|
||||||
|
const [current, setCurrent] = useState<OrganizationModelInstantiation | null>(null);
|
||||||
|
const [upgrades, setUpgrades] = useState<OrganizationModelUpgrade[]>([]);
|
||||||
|
const [targetVersionId, setTargetVersionId] = useState("");
|
||||||
|
const [selected, setSelected] = useState<OrganizationModelUpgrade | null>(null);
|
||||||
|
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
|
||||||
|
const [changeRequestId, setChangeRequestId] = useState("");
|
||||||
|
const [applyConfirmation, setApplyConfirmation] = useState(false);
|
||||||
|
const [cancelTarget, setCancelTarget] = useState<OrganizationModelUpgrade | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!canRead) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [catalog, state] = await Promise.all([
|
||||||
|
getOrganizationTemplateCatalog(settings),
|
||||||
|
getOrganizationModelUpgrades(settings)
|
||||||
|
]);
|
||||||
|
setTemplates(catalog.templates);
|
||||||
|
setCurrent(state.current_instantiation ?? null);
|
||||||
|
setUpgrades(state.upgrades);
|
||||||
|
const available = availableVersions(catalog.templates, state.current_instantiation ?? null);
|
||||||
|
setTargetVersionId((value) => available.some((item) => item.id === value) ? value : available[0]?.id ?? "");
|
||||||
|
setSelected((value) => value ? state.upgrades.find((item) => item.id === value.id) ?? null : null);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(adminErrorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canRead, settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const versions = useMemo(() => versionMap(templates), [templates]);
|
||||||
|
const targets = useMemo(() => availableVersions(templates, current), [templates, current]);
|
||||||
|
const currentVersion = current ? versions.get(current.template_version_id) : undefined;
|
||||||
|
const pending = upgrades.filter((item) => item.status === "previewed");
|
||||||
|
|
||||||
|
const upgradeColumns = useMemo<DataGridColumn<OrganizationModelUpgrade>[]>(() => [
|
||||||
|
{ id: "source", header: "Source version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id, value: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id },
|
||||||
|
{ id: "target", header: "Target version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id, value: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id },
|
||||||
|
{ id: "changes", header: "Changes", width: 105, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.entries.length, value: (row) => row.preview.entries.length },
|
||||||
|
{ id: "decisions", header: "Decisions", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.requires_decisions, value: (row) => row.preview.requires_decisions },
|
||||||
|
{ id: "invalid", header: "Invalid refs", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.blocking_invalid_references, value: (row) => row.preview.blocking_invalid_references },
|
||||||
|
{ id: "status", header: "Status", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
|
||||||
|
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
|
||||||
|
{ id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={2} actions={[
|
||||||
|
{ id: "inspect", label: "Inspect upgrade", icon: <Eye aria-hidden="true" />, onClick: () => openUpgrade(row) },
|
||||||
|
{ id: "cancel", label: "Cancel preview", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canWrite || row.status !== "previewed", onClick: () => setCancelTarget(row) }
|
||||||
|
]} /> }
|
||||||
|
], [canWrite, versions]);
|
||||||
|
|
||||||
|
const diffColumns = useMemo<DataGridColumn<OrganizationUpgradeDiffEntry>[]>(() => [
|
||||||
|
{ id: "collection", header: "Area", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.collection), value: (row) => row.collection },
|
||||||
|
{ id: "key", header: "Object", width: 210, sortable: true, filterable: true, render: (row) => row.key, value: (row) => row.key },
|
||||||
|
{ id: "classification", header: "Classification", width: 185, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.classification} />, value: (row) => row.classification },
|
||||||
|
{ id: "summary", header: "Comparison", width: 330, render: (row) => comparisonSummary(row), value: (row) => comparisonSummary(row) },
|
||||||
|
{ id: "decision", header: "Decision", width: 280, render: (row) => row.requires_decision ? <div className="organization-upgrade-decision"><select value={decisions[row.id]?.action ?? ""} disabled={!canWrite || busy || selected?.status !== "previewed"} onChange={(event) => setDecision(row, event.target.value as OrganizationUpgradeDecisionAction)}><option value="">Select decision</option>{row.allowed_actions.map((action) => <option key={action} value={action}>{decisionLabel(action)}</option>)}</select>{decisions[row.id]?.action === "map_to" && <input value={decisions[row.id]?.target_key ?? ""} placeholder="Target key" disabled={!canWrite || busy} onChange={(event) => setDecisions((value) => ({ ...value, [row.id]: { ...value[row.id], target_key: event.target.value } }))} />}</div> : "Automatic", value: (row) => decisions[row.id]?.action ?? "automatic" }
|
||||||
|
], [busy, canWrite, decisions, selected?.status]);
|
||||||
|
|
||||||
|
function setDecision(entry: OrganizationUpgradeDiffEntry, action: OrganizationUpgradeDecisionAction) {
|
||||||
|
if (!action) {
|
||||||
|
setDecisions((value) => { const next = { ...value }; delete next[entry.id]; return next; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDecisions((value) => ({ ...value, [entry.id]: { action } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpgrade(upgrade: OrganizationModelUpgrade) {
|
||||||
|
setSelected(upgrade);
|
||||||
|
setDecisions(upgrade.decisions ?? {});
|
||||||
|
setChangeRequestId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPreview() {
|
||||||
|
if (!targetVersionId || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const created = await previewOrganizationModelUpgrade(settings, targetVersionId);
|
||||||
|
setSuccess("The three-way comparison was recorded. No tenant model data was changed.");
|
||||||
|
await load();
|
||||||
|
openUpgrade(created);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(adminErrorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUpgrade() {
|
||||||
|
if (!selected || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
await applyOrganizationModelUpgrade(settings, selected.id, selected.revision, decisions, changeRequestId.trim() || undefined);
|
||||||
|
setApplyConfirmation(false);
|
||||||
|
setSelected(null);
|
||||||
|
setSuccess("The template upgrade was applied as a new tenant-owned model instantiation.");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(adminErrorMessage(caught));
|
||||||
|
setApplyConfirmation(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelUpgrade() {
|
||||||
|
if (!cancelTarget || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await cancelOrganizationModelUpgrade(settings, cancelTarget.id, cancelTarget.revision);
|
||||||
|
setCancelTarget(null);
|
||||||
|
if (selected?.id === cancelTarget.id) setSelected(null);
|
||||||
|
setSuccess("The upgrade preview was cancelled without changing the tenant model.");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(adminErrorMessage(caught));
|
||||||
|
setCancelTarget(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decisionsComplete = selected ? selected.preview.entries.every((entry) => !entry.requires_decision || Boolean(decisions[entry.id]?.action) && (decisions[entry.id].action !== "map_to" || Boolean(decisions[entry.id].target_key?.trim()))) : false;
|
||||||
|
const applyDisabled = !selected || !canWrite || busy || selected.status !== "previewed" || selected.preview.blocking_invalid_references > 0 || !decisionsComplete;
|
||||||
|
|
||||||
|
if (!canRead) return null;
|
||||||
|
return <>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||||
|
<Card bodyLayout="table" title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
|
||||||
|
<LoadingFrame loading={loading} label="Loading organization template upgrade state">
|
||||||
|
<div className="content-pad">
|
||||||
|
<MetricGrid density="compact">
|
||||||
|
<MetricCard label="Applied version" value={currentVersion?.version ?? "Custom model"} tone="info" />
|
||||||
|
<MetricCard label="Available upgrades" value={targets.length} tone={targets.length ? "info" : "good"} />
|
||||||
|
<MetricCard label="Open previews" value={pending.length} tone={pending.length ? "warning" : "good"} />
|
||||||
|
<MetricCard label="Copy semantics" value="Tenant-owned" tone="good" />
|
||||||
|
</MetricGrid>
|
||||||
|
<p className="muted small-note">Template versions are immutable sources. A tenant model never live-inherits changes: every upgrade is a recorded three-way comparison, explicit decision set, and confirmed new instantiation.</p>
|
||||||
|
<ActionToolbar className="organization-upgrade-toolbar">
|
||||||
|
<FormField label="Published target version"><select value={targetVersionId} disabled={!canWrite || busy || !targets.length} onChange={(event) => setTargetVersionId(event.target.value)}>{targets.length ? targets.map((version) => <option key={version.id} value={version.id}>{templateVersionLabel(templates, version)}</option>) : <option value="">No newer published version</option>}</select></FormField>
|
||||||
|
<Button variant="primary" onClick={() => void createPreview()} disabled={!canWrite || busy || !targetVersionId}><GitCompareArrows aria-hidden="true" /> Create preview</Button>
|
||||||
|
</ActionToolbar>
|
||||||
|
</div>
|
||||||
|
<div className="organization-upgrade-table"><DataGrid id="organization-model-upgrades" rows={upgrades} columns={upgradeColumns} initialFit="container" getRowKey={(row) => row.id} emptyText={current ? "No organization template upgrades have been previewed." : "This tenant model was not instantiated from a system template."} /></div>
|
||||||
|
</LoadingFrame>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(selected)} title="Organization model upgrade preview" className="organization-upgrade-dialog" onClose={() => !busy && setSelected(null)} closeDisabled={busy} footer={<><Button onClick={() => setSelected(null)} disabled={busy}>Close</Button>{selected?.status === "previewed" && <><Button variant="danger" onClick={() => setCancelTarget(selected)} disabled={!canWrite || busy}>Cancel preview</Button><Button variant="primary" onClick={() => setApplyConfirmation(true)} disabled={applyDisabled}><Play aria-hidden="true" /> Review and apply</Button></>}</>}>
|
||||||
|
{selected && <>
|
||||||
|
<MetricGrid density="compact">
|
||||||
|
<MetricCard label="Changes" value={selected.preview.entries.length} tone="info" />
|
||||||
|
<MetricCard label="Required decisions" value={selected.preview.requires_decisions} tone={selected.preview.requires_decisions ? "warning" : "good"} />
|
||||||
|
<MetricCard label="Invalid references" value={selected.preview.blocking_invalid_references} tone={selected.preview.blocking_invalid_references ? "danger" : "good"} />
|
||||||
|
<MetricCard label="Status" value={humanize(selected.status)} tone="info" />
|
||||||
|
</MetricGrid>
|
||||||
|
<p className="muted small-note">Compatible additions and non-conflicting changes apply automatically. Local-only divergence is preserved. Destructive remapping and competing edits require an explicit bounded decision.</p>
|
||||||
|
<div className="organization-upgrade-diff"><DataGrid id={`organization-model-upgrade-diff-${selected.id}`} rows={selected.preview.entries} columns={diffColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="The versions and tenant model are equivalent." /></div>
|
||||||
|
<FormField label="Approved change request (when required by tenant policy)"><input value={changeRequestId} disabled={!canWrite || busy || selected.status !== "previewed"} onChange={(event) => setChangeRequestId(event.target.value)} /></FormField>
|
||||||
|
</>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={applyConfirmation} title="Apply organization model upgrade" message="Apply this reviewed comparison and its explicit decisions? The current instantiation will be superseded, the resulting model remains tenant-owned, and the operation is recorded for audit and event consumers." confirmLabel="Apply upgrade" busy={busy} onConfirm={() => void applyUpgrade()} onCancel={() => !busy && setApplyConfirmation(false)} />
|
||||||
|
<ConfirmDialog open={Boolean(cancelTarget)} title="Cancel organization model upgrade" message="Cancel this preview? No tenant organization data will be changed and the cancellation remains recorded in the upgrade history." confirmLabel="Cancel preview" tone="danger" busy={busy} onConfirm={() => void cancelUpgrade()} onCancel={() => !busy && setCancelTarget(null)} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionMap(templates: OrganizationTemplateCatalogItem[]): Map<string, OrganizationTemplateVersion> {
|
||||||
|
return new Map(templates.flatMap((template) => template.versions.map((version) => [version.id, version] as const)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableVersions(templates: OrganizationTemplateCatalogItem[], current: OrganizationModelInstantiation | null): OrganizationTemplateVersion[] {
|
||||||
|
if (!current) return [];
|
||||||
|
const template = templates.find((item) => item.id === current.template_id);
|
||||||
|
return (template?.versions ?? []).filter((version) => version.id !== current.template_version_id && version.status === "published");
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateVersionLabel(templates: OrganizationTemplateCatalogItem[], version: OrganizationTemplateVersion): string {
|
||||||
|
const template = templates.find((item) => item.id === version.template_id);
|
||||||
|
return `${template?.name ?? "Template"} · ${version.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparisonSummary(entry: OrganizationUpgradeDiffEntry): string {
|
||||||
|
if (entry.classification === "compatible_addition") return "Added by target template";
|
||||||
|
if (entry.classification === "compatible_change") return "Target changed; tenant still matches source";
|
||||||
|
if (entry.classification === "destructive_remapping") return "Removal or reference remapping";
|
||||||
|
if (entry.classification === "invalid_reference") return String(entry.local?.diagnostic ?? "Invalid tenant reference");
|
||||||
|
return entry.requires_decision ? "Both tenant and target changed" : "Tenant-only customization is preserved";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionLabel(action: OrganizationUpgradeDecisionAction): string {
|
||||||
|
if (action === "keep_local") return "Keep tenant value";
|
||||||
|
if (action === "use_target") return "Use template value";
|
||||||
|
return "Map references to another key";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { ContentGrid, FormGrid,
|
||||||
|
ActionBlockerHint,
|
||||||
AdminPageLayout,
|
AdminPageLayout,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
DocumentationHelpLink,
|
||||||
FormField,
|
FormField,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
|
useUnsavedChanges,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
@@ -17,6 +20,13 @@ import {
|
|||||||
type OrganizationAuditDetailLevel,
|
type OrganizationAuditDetailLevel,
|
||||||
type OrganizationSettingsItem
|
type OrganizationSettingsItem
|
||||||
} from "../../api/organizations";
|
} from "../../api/organizations";
|
||||||
|
import {
|
||||||
|
ORGANIZATIONS_DOCUMENTATION,
|
||||||
|
ORGANIZATIONS_FIELD_DOCUMENTATION,
|
||||||
|
ORGANIZATIONS_INTERFACE_I18N,
|
||||||
|
organizationWriteReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
import OrganizationTemplateUpgradePanel from "./OrganizationTemplateUpgradePanel";
|
||||||
|
|
||||||
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
||||||
tenant_id: "",
|
tenant_id: "",
|
||||||
@@ -40,8 +50,16 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
|
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
|
||||||
const dirty = settingsKey(draft) !== settingsKey(savedDraft);
|
const dirty = settingsKey(draft) !== settingsKey(savedDraft);
|
||||||
|
const reloadDisabledReason = loading
|
||||||
|
? ORGANIZATIONS_INTERFACE_I18N.loading
|
||||||
|
: busy
|
||||||
|
? ORGANIZATIONS_INTERFACE_I18N.busy
|
||||||
|
: undefined;
|
||||||
|
const saveDisabledReason = organizationWriteReason(canWrite, busy, "settings")
|
||||||
|
?? (!dirty ? ORGANIZATIONS_INTERFACE_I18N.noChanges : undefined);
|
||||||
|
|
||||||
useUnsavedDraftGuard({
|
useUnsavedDraftGuard({
|
||||||
dirty,
|
dirty,
|
||||||
@@ -99,20 +117,57 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
|
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-organizations.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !dirty}>{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}</Button></>}
|
actions={(
|
||||||
|
<>
|
||||||
|
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
|
||||||
|
<Button
|
||||||
|
onClick={() => requestDiscard(() => void load())}
|
||||||
|
disabled={Boolean(reloadDisabledReason)}
|
||||||
|
disabledReason={reloadDisabledReason}
|
||||||
|
>
|
||||||
|
i18n:govoplan-organizations.reload.cce71553
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={Boolean(saveDisabledReason)}
|
||||||
|
disabledReason={saveDisabledReason}
|
||||||
|
>
|
||||||
|
{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="organizations-settings-grid">
|
{!canWrite && (
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: ORGANIZATIONS_INTERFACE_I18N.readOnlySummary,
|
||||||
|
requiredAction: ORGANIZATIONS_INTERFACE_I18N.permissionGuidance,
|
||||||
|
actor: ORGANIZATIONS_INTERFACE_I18N.administrator,
|
||||||
|
target: ORGANIZATIONS_INTERFACE_I18N.administrationTarget
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: ORGANIZATIONS_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: ORGANIZATIONS_INTERFACE_I18N.actor,
|
||||||
|
target: ORGANIZATIONS_INTERFACE_I18N.destination
|
||||||
|
}}
|
||||||
|
documentation={ORGANIZATIONS_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContentGrid columns={1}>
|
||||||
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
|
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
|
||||||
<div className="settings-list">
|
<div className="settings-list">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.allow_tenant_model_customization}
|
checked={draft.allow_tenant_model_customization}
|
||||||
disabled={!canWrite || busy}
|
disabled={!canWrite || busy}
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
|
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
|
||||||
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
|
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
|
||||||
/>
|
/>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.require_model_change_requests}
|
checked={draft.require_model_change_requests}
|
||||||
disabled={!canWrite || busy}
|
disabled={!canWrite || busy}
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
onChange={(checked) => setDraft({ ...draft, require_model_change_requests: checked })}
|
onChange={(checked) => setDraft({ ...draft, require_model_change_requests: checked })}
|
||||||
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
|
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
|
||||||
/>
|
/>
|
||||||
@@ -121,13 +176,23 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
||||||
<div className="organizations-form-grid">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.audit_detail_level.7397355d"
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
||||||
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.change_retention_days.71bbd140">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.change_retention_days.71bbd140"
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
helpContextId="organizations.field.audit-retention"
|
||||||
|
helpModuleId="organizations"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -137,10 +202,11 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
onChange={(event) => setDraft({ ...draft, change_retention_days: event.target.value === "" ? null : Math.max(0, Number(event.target.value)) })}
|
onChange={(event) => setDraft({ ...draft, change_retention_days: event.target.value === "" ? null : Math.max(0, Number(event.target.value)) })}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
|
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</ContentGrid>
|
||||||
|
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent, type JSX } from "react";
|
||||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
import { Edit3, Plus } from "lucide-react";
|
||||||
import {
|
import { FormLayout, ActionToolbar,
|
||||||
|
ActionBlockerHint,
|
||||||
AdminIconButton,
|
AdminIconButton,
|
||||||
ApiError,
|
ApiError,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DocumentationHelpLink,
|
||||||
|
ExplorerTree,
|
||||||
FormField,
|
FormField,
|
||||||
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
ModuleSubnav,
|
ModuleSubnav,
|
||||||
PageTitle,
|
PageActionBar,
|
||||||
|
PageLayout,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
useEffectiveView,
|
||||||
|
useUnsavedChanges,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
usePlatformUiCapabilities,
|
usePlatformUiCapabilities,
|
||||||
|
useViewSurfaces,
|
||||||
|
WorkspaceLayout,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type ModuleSubnavGroup,
|
type ModuleSubnavGroup,
|
||||||
type OrganizationFunctionActionContribution,
|
|
||||||
type OrganizationFunctionActionsUiCapability
|
type OrganizationFunctionActionsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +66,12 @@ import {
|
|||||||
type StructureCreatePayload,
|
type StructureCreatePayload,
|
||||||
type UnitCreatePayload
|
type UnitCreatePayload
|
||||||
} from "../../api/organizations";
|
} from "../../api/organizations";
|
||||||
|
import {
|
||||||
|
ORGANIZATIONS_DOCUMENTATION,
|
||||||
|
ORGANIZATIONS_FIELD_DOCUMENTATION,
|
||||||
|
ORGANIZATIONS_INTERFACE_I18N,
|
||||||
|
organizationWriteReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
export type OrganizationSection = "model" | "units" | "relations" | "functions";
|
export type OrganizationSection = "model" | "units" | "relations" | "functions";
|
||||||
type OrganizationsPageMode = "workspace" | "admin";
|
type OrganizationsPageMode = "workspace" | "admin";
|
||||||
@@ -267,7 +282,7 @@ function mapById<T extends { id: string }>(items: T[]): Map<string, T> {
|
|||||||
function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSubnavGroup<OrganizationSection>[] {
|
function sectionGroupsFor(sections: ReadonlySet<OrganizationSection>): ModuleSubnavGroup<OrganizationSection>[] {
|
||||||
return SECTION_GROUPS.map((group) => ({
|
return SECTION_GROUPS.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
items: group.items.filter((item) => sections.has(item.id))
|
items: group.items.filter((item) => "id" in item && sections.has(item.id))
|
||||||
})).filter((group) => group.items.length > 0);
|
})).filter((group) => group.items.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,34 +309,39 @@ function activeStatus(active: boolean): JSX.Element {
|
|||||||
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
|
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RowActions({ disabled, onEdit, extra }: { disabled: boolean; onEdit: () => void; extra?: JSX.Element[] }) {
|
function SluggedFields<T extends SluggedDraft>({
|
||||||
return (
|
|
||||||
<div className="organizations-row-actions">
|
|
||||||
<AdminIconButton label="i18n:govoplan-organizations.edit.7dce1220" icon={<Edit3 size={16} aria-hidden="true" />} disabled={disabled} onClick={onEdit} />
|
|
||||||
{extra}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SluggedFields({
|
|
||||||
draft,
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
disabled
|
disabled,
|
||||||
|
disabledReason
|
||||||
}: {
|
}: {
|
||||||
draft: SluggedDraft;
|
draft: T;
|
||||||
onChange: (next: SluggedDraft) => void;
|
onChange: (next: T) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField label="i18n:govoplan-organizations.name.709a2322">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.name.709a2322"
|
||||||
|
help={disabledReason}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<input value={draft.name} disabled={disabled} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
<input value={draft.name} disabled={disabled} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.slug.094da9b9">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.slug.094da9b9"
|
||||||
|
help={disabledReason}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<input value={draft.slug} disabled={disabled} onChange={(event) => onChange({ ...draft, slug: event.target.value })} />
|
<input value={draft.slug} disabled={disabled} onChange={(event) => onChange({ ...draft, slug: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="wide">
|
<div className="wide">
|
||||||
<FormField label="i18n:govoplan-organizations.description.55f8ebc8">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.description.55f8ebc8"
|
||||||
|
help={disabledReason}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
|
<textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +349,7 @@ function SluggedFields({
|
|||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.is_active}
|
checked={draft.is_active}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
help={disabledReason}
|
||||||
onChange={(checked) => onChange({ ...draft, is_active: checked })}
|
onChange={(checked) => onChange({ ...draft, is_active: checked })}
|
||||||
label="i18n:govoplan-organizations.active.a733b809"
|
label="i18n:govoplan-organizations.active.a733b809"
|
||||||
/>
|
/>
|
||||||
@@ -366,6 +387,7 @@ export default function OrganizationsPage({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
const [unitTypeDraft, setUnitTypeDraft] = useState<SluggedDraft>(() => emptySluggedDraft());
|
const [unitTypeDraft, setUnitTypeDraft] = useState<SluggedDraft>(() => emptySluggedDraft());
|
||||||
const [structureDraft, setStructureDraft] = useState<StructureDraft>(() => emptyStructureDraft());
|
const [structureDraft, setStructureDraft] = useState<StructureDraft>(() => emptyStructureDraft());
|
||||||
@@ -385,6 +407,8 @@ export default function OrganizationsPage({
|
|||||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||||
const [changeRequestId, setChangeRequestId] = useState("");
|
const [changeRequestId, setChangeRequestId] = useState("");
|
||||||
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
|
const viewSurfaces = useViewSurfaces();
|
||||||
|
|
||||||
const canWriteModel = hasScope(auth, "organizations:model:write");
|
const canWriteModel = hasScope(auth, "organizations:model:write");
|
||||||
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
||||||
@@ -400,8 +424,11 @@ export default function OrganizationsPage({
|
|||||||
() => functionActionCapabilities
|
() => functionActionCapabilities
|
||||||
.flatMap((capability) => capability.actions)
|
.flatMap((capability) => capability.actions)
|
||||||
.filter((contribution) => contributionVisible(auth, contribution))
|
.filter((contribution) => contributionVisible(auth, contribution))
|
||||||
|
.filter((contribution) =>
|
||||||
|
isViewSurfaceVisible(effectiveView, contribution.surfaceId, viewSurfaces)
|
||||||
|
)
|
||||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||||
[auth, functionActionCapabilities]
|
[auth, effectiveView, functionActionCapabilities, viewSurfaces]
|
||||||
);
|
);
|
||||||
const unitsByParentId = useMemo(() => {
|
const unitsByParentId = useMemo(() => {
|
||||||
const mapped = new Map<string, OrganizationUnitItem[]>();
|
const mapped = new Map<string, OrganizationUnitItem[]>();
|
||||||
@@ -870,7 +897,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
|
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
|
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editUnitType(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editUnitType(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
|
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
|
||||||
@@ -878,7 +905,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
|
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
|
||||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editStructure(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editStructure(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
|
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
|
||||||
@@ -888,7 +915,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
|
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
|
||||||
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
|
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editRelationType(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editRelationType(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
|
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
|
||||||
@@ -897,7 +924,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
|
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
|
||||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteUnits || busy} onEdit={() => editUnit(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, disabledReason: organizationWriteReason(canWriteUnits, busy, "unit"), onClick: () => editUnit(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
|
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
|
||||||
@@ -906,7 +933,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
|
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
|
||||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
|
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteUnits || busy} onEdit={() => editRelation(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, disabledReason: organizationWriteReason(canWriteUnits, busy, "unit"), onClick: () => editRelation(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
|
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
|
||||||
@@ -915,7 +942,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
|
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
|
||||||
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
|
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editFunctionType(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, disabledReason: organizationWriteReason(canWriteModel, busy, "model"), onClick: () => editFunctionType(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
|
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
|
||||||
@@ -926,42 +953,92 @@ export default function OrganizationsPage({
|
|||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "",
|
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
width: functionActionContributions.length ? 220 : 88,
|
width: 88 + (functionActionContributions.length * 40),
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => (
|
resizable: false,
|
||||||
<RowActions
|
align: "right",
|
||||||
disabled={!canWriteFunctions || busy}
|
render: (row) => <TableActionGroup actions={[
|
||||||
onEdit={() => editFunction(row)}
|
{
|
||||||
extra={functionActionContributions.map((contribution) => (
|
id: "edit",
|
||||||
<span className="organizations-contributed-action" key={contribution.id}>
|
label: "i18n:govoplan-organizations.edit.7dce1220",
|
||||||
{contribution.render({ settings, auth, function: row })}
|
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||||
</span>
|
disabled: !canWriteFunctions || busy,
|
||||||
))}
|
disabledReason: organizationWriteReason(canWriteFunctions, busy, "function"),
|
||||||
/>
|
onClick: () => editFunction(row)
|
||||||
)
|
},
|
||||||
|
...functionActionContributions.map((contribution) => ({
|
||||||
|
id: contribution.id,
|
||||||
|
label: contribution.label,
|
||||||
|
icon: contribution.icon,
|
||||||
|
onClick: () => contribution.onClick({ settings, auth, function: row })
|
||||||
|
}))
|
||||||
|
]} />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const canWriteActive = active === "model" ? canWriteModel : active === "units" || active === "relations" ? canWriteUnits : canWriteFunctions;
|
const activeWriteKind = active === "model"
|
||||||
|
? "model"
|
||||||
|
: active === "units" || active === "relations"
|
||||||
|
? "unit"
|
||||||
|
: "function";
|
||||||
|
const canWriteActive = activeWriteKind === "model"
|
||||||
|
? canWriteModel
|
||||||
|
: activeWriteKind === "unit"
|
||||||
|
? canWriteUnits
|
||||||
|
: canWriteFunctions;
|
||||||
|
const activeWriteReason = organizationWriteReason(
|
||||||
|
canWriteActive,
|
||||||
|
busy,
|
||||||
|
activeWriteKind
|
||||||
|
);
|
||||||
|
const reloadDisabledReason = loading
|
||||||
|
? ORGANIZATIONS_INTERFACE_I18N.loading
|
||||||
|
: busy
|
||||||
|
? ORGANIZATIONS_INTERFACE_I18N.busy
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
|
<>
|
||||||
<div className="page-heading split organizations-heading">
|
<PageLayout
|
||||||
<div>
|
archetype="workspace"
|
||||||
<PageTitle loading={loading}>{title}</PageTitle>
|
title={title}
|
||||||
<p>{description}</p>
|
description={description}
|
||||||
</div>
|
actions={<PageActionBar
|
||||||
<div className="organizations-toolbar">
|
variant="workspace"
|
||||||
<Button type="button" onClick={() => void loadModel()} disabled={loading || busy} title="i18n:govoplan-organizations.reload.cce71553">
|
refreshable
|
||||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
|
reloadAction={{
|
||||||
</Button>
|
onReload: () => void loadModel(),
|
||||||
</div>
|
disabled: Boolean(reloadDisabledReason),
|
||||||
</div>
|
disabledReason: reloadDisabledReason,
|
||||||
|
title: "i18n:govoplan-organizations.reload.cce71553"
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
}}
|
||||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
helpAction={<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />}
|
||||||
{!canWriteActive && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-organizations.write_permission_required.8b09fd67</DismissibleAlert>}
|
/>}
|
||||||
|
mode={mode === "workspace" ? "workspace" : "embedded"}
|
||||||
|
headerLoading={loading}
|
||||||
|
error={error}
|
||||||
|
success={error ? "" : success}
|
||||||
|
className={`organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}
|
||||||
|
headerClassName="organizations-heading"
|
||||||
|
>
|
||||||
|
{!canWriteActive && (
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: ORGANIZATIONS_INTERFACE_I18N.readOnlySummary,
|
||||||
|
details: activeWriteReason,
|
||||||
|
requiredAction: ORGANIZATIONS_INTERFACE_I18N.permissionGuidance,
|
||||||
|
actor: ORGANIZATIONS_INTERFACE_I18N.administrator,
|
||||||
|
target: ORGANIZATIONS_INTERFACE_I18N.administrationTarget
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: ORGANIZATIONS_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: ORGANIZATIONS_INTERFACE_I18N.actor,
|
||||||
|
target: ORGANIZATIONS_INTERFACE_I18N.destination
|
||||||
|
}}
|
||||||
|
documentation={ORGANIZATIONS_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoadingFrame loading={loading || busy} label="i18n:govoplan-organizations.loading_organization_model.846aa317">
|
<LoadingFrame loading={loading || busy} label="i18n:govoplan-organizations.loading_organization_model.846aa317">
|
||||||
{active === "model" && renderModelSection()}
|
{active === "model" && renderModelSection()}
|
||||||
@@ -969,77 +1046,90 @@ export default function OrganizationsPage({
|
|||||||
{active === "relations" && renderRelationsSection()}
|
{active === "relations" && renderRelationsSection()}
|
||||||
{active === "functions" && renderFunctionsSection()}
|
{active === "functions" && renderFunctionsSection()}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
</PageLayout>
|
||||||
{renderEditorDialog()}
|
{renderEditorDialog()}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mode === "admin") return content;
|
if (mode === "admin") return content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace organizations-workspace">
|
<WorkspaceLayout
|
||||||
<ModuleSubnav active={active} groups={visibleSectionGroups} onSelect={setActive} />
|
className="organizations-workspace"
|
||||||
<main className="workspace-content">
|
primarySize="wide"
|
||||||
{content}
|
primary={(
|
||||||
</main>
|
<ModuleSubnav
|
||||||
</div>
|
active={active}
|
||||||
|
groups={visibleSectionGroups}
|
||||||
|
onSelect={(section) => requestDiscard(() => setActive(section))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
primaryLabel="i18n:govoplan-organizations.organization_model.5945c48a"
|
||||||
|
contentLabel={title}
|
||||||
|
documentationType="user"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</WorkspaceLayout>
|
||||||
);
|
);
|
||||||
|
|
||||||
function renderModelSection() {
|
function renderModelSection() {
|
||||||
return (
|
return (
|
||||||
<div className="organizations-table-stack">
|
<div className="organizations-table-stack">
|
||||||
<Card title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openUnitTypeCreate} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.unit_types.c7afc174" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit_type.58f2c05a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openUnitTypeCreate} />}>
|
||||||
<DataGrid id="organizations-unit-types" rows={model.unit_types} columns={unitTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
|
<DataGrid id="organizations-unit-types" rows={model.unit_types} columns={unitTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_unit_types_found.c81fb2a7" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openStructureCreate} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.structures.f9b7f3b4" actions={<AdminIconButton label="i18n:govoplan-organizations.add_structure.b722042a" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openStructureCreate} />}>
|
||||||
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
|
<DataGrid id="organizations-structures" rows={model.structures} columns={structureColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_structures_found.20b382d0" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openRelationTypeCreate} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.relation_types.e5890528" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation_type.2ad19d03" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openRelationTypeCreate} />}>
|
||||||
<DataGrid id="organizations-relation-types" rows={model.relation_types} columns={relationTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
|
<DataGrid id="organizations-relation-types" rows={model.relation_types} columns={relationTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relation_types_found.6f90bb81" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} onClick={openFunctionTypeCreate} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.function_types.172c01fe" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function_type.90d793f1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} onClick={openFunctionTypeCreate} />}>
|
||||||
<DataGrid id="organizations-function-types" rows={model.function_types} columns={functionTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
|
<DataGrid id="organizations-function-types" rows={model.function_types} columns={functionTypeColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_function_types_found.5eef31b1" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUnitTreeNodes(parentId = "", depth = 0, seen: ReadonlySet<string> = new Set()): JSX.Element[] {
|
|
||||||
return (unitsByParentId.get(parentId) ?? []).flatMap((unit) => {
|
|
||||||
if (seen.has(unit.id)) return [];
|
|
||||||
const nextSeen = new Set(seen);
|
|
||||||
nextSeen.add(unit.id);
|
|
||||||
return [
|
|
||||||
<div className="organizations-tree-row" key={unit.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`organizations-tree-node ${selectedUnitId === unit.id ? "active" : ""}`.trim()}
|
|
||||||
style={{ paddingLeft: `${10 + depth * 18}px` }}
|
|
||||||
onClick={() => setSelectedUnitId(unit.id)}
|
|
||||||
>
|
|
||||||
<strong>{unit.name}</strong>
|
|
||||||
<span>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</span>
|
|
||||||
</button>
|
|
||||||
<Button type="button" variant="ghost" disabled={!canWriteUnits || busy} onClick={() => addSubunit(unit)} title="i18n:govoplan-organizations.add_subunit.8256a8f7">
|
|
||||||
<Plus size={16} aria-hidden="true" /> i18n:govoplan-organizations.add_subunit.8256a8f7
|
|
||||||
</Button>
|
|
||||||
</div>,
|
|
||||||
...renderUnitTreeNodes(unit.id, depth + 1, nextSeen)
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUnitsSection() {
|
function renderUnitsSection() {
|
||||||
return (
|
return (
|
||||||
<div className="organizations-table-stack">
|
<div className="organizations-table-stack">
|
||||||
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
|
||||||
<div className="organizations-tree-toolbar">
|
<ActionToolbar className="explorer-tree-toolbar">
|
||||||
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div className="organizations-tree-list">
|
{model.units.length ? (
|
||||||
{model.units.length ? renderUnitTreeNodes() : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
<ExplorerTree
|
||||||
</div>
|
nodes={unitsByParentId.get("") ?? []}
|
||||||
|
getNodeId={(unit) => unit.id}
|
||||||
|
getNodeLabel={(unit) => unit.name}
|
||||||
|
getNodeChildren={(unit) => unitsByParentId.get(unit.id) ?? []}
|
||||||
|
activeId={selectedUnitId}
|
||||||
|
collapsible={false}
|
||||||
|
depth={0}
|
||||||
|
className="explorer-tree-scroll-region"
|
||||||
|
getNodeWrapStyle={(_unit, context) => ({ paddingLeft: `${Math.min(context.depth * 18, 72)}px` })}
|
||||||
|
onOpen={(unit) => setSelectedUnitId(unit.id)}
|
||||||
|
renderNodeContent={(unit) => (
|
||||||
|
<span className="explorer-tree-node-content">
|
||||||
|
<strong>{unit.name}</strong>
|
||||||
|
<small>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</small>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
renderNodeActions={(unit) => (
|
||||||
|
<IconButton
|
||||||
|
label="i18n:govoplan-organizations.add_subunit.8256a8f7"
|
||||||
|
icon={<Plus size={16} aria-hidden="true" />}
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canWriteUnits || busy}
|
||||||
|
disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")}
|
||||||
|
onClick={() => addSubunit(unit)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={() => openUnitCreate()} />}>
|
||||||
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
|
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1049,7 +1139,7 @@ export default function OrganizationsPage({
|
|||||||
function renderRelationsSection() {
|
function renderRelationsSection() {
|
||||||
return (
|
return (
|
||||||
<div className="organizations-table-stack">
|
<div className="organizations-table-stack">
|
||||||
<Card title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={openRelationCreate} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.relations.1c796711" actions={<AdminIconButton label="i18n:govoplan-organizations.add_relation.6b6e67ea" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} onClick={openRelationCreate} />}>
|
||||||
<DataGrid id="organizations-relations" rows={model.relations} columns={relationColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
|
<DataGrid id="organizations-relations" rows={model.relations} columns={relationColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_relations_found.4e75b11d" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1059,7 +1149,7 @@ export default function OrganizationsPage({
|
|||||||
function renderFunctionsSection() {
|
function renderFunctionsSection() {
|
||||||
return (
|
return (
|
||||||
<div className="organizations-table-stack">
|
<div className="organizations-table-stack">
|
||||||
<Card title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} onClick={() => openFunctionCreate()} />}>
|
<Card bodyLayout="table" title="i18n:govoplan-organizations.functions.805dc49b" actions={<AdminIconButton label="i18n:govoplan-organizations.add_function.6abafee0" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} onClick={() => openFunctionCreate()} />}>
|
||||||
<DataGrid id="organizations-functions" rows={model.functions} columns={functionColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
|
<DataGrid id="organizations-functions" rows={model.functions} columns={functionColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_functions_found.54e759a0" initialFit="container" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1069,7 +1159,11 @@ export default function OrganizationsPage({
|
|||||||
function renderChangeRequestField() {
|
function renderChangeRequestField() {
|
||||||
return (
|
return (
|
||||||
<div className="wide organizations-dialog-change-request">
|
<div className="wide organizations-dialog-change-request">
|
||||||
<FormField label="i18n:govoplan-organizations.change_request_id.a6e7fd6b">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.change_request_id.a6e7fd6b"
|
||||||
|
help={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<p className="organizations-field-note">i18n:govoplan-organizations.change_request_id_help.0c119a5d</p>
|
<p className="organizations-field-note">i18n:govoplan-organizations.change_request_id_help.0c119a5d</p>
|
||||||
@@ -1091,18 +1185,32 @@ export default function OrganizationsPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editorSubmitDisabled(): boolean {
|
function editorSubmitDisabled(): boolean {
|
||||||
if (busy || !activeEditor) return true;
|
return Boolean(editorSubmitDisabledReason());
|
||||||
if ((activeEditor === "unitType" || activeEditor === "structure" || activeEditor === "relationType" || activeEditor === "functionType") && !canWriteModel) return true;
|
}
|
||||||
if ((activeEditor === "unit" || activeEditor === "relation") && !canWriteUnits) return true;
|
|
||||||
if (activeEditor === "function" && !canWriteFunctions) return true;
|
function editorSubmitDisabledReason(): string | undefined {
|
||||||
if (activeEditor === "unitType") return !unitTypeDraft.name.trim();
|
if (busy) return ORGANIZATIONS_INTERFACE_I18N.busy;
|
||||||
if (activeEditor === "structure") return !structureDraft.name.trim();
|
if (!activeEditor) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
if (activeEditor === "relationType") return !relationTypeDraft.name.trim();
|
if (activeEditor === "unitType" || activeEditor === "structure" || activeEditor === "relationType" || activeEditor === "functionType") {
|
||||||
if (activeEditor === "unit") return !unitDraft.name.trim();
|
const reason = organizationWriteReason(canWriteModel, busy, "model");
|
||||||
if (activeEditor === "relation") return !relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id;
|
if (reason) return reason;
|
||||||
if (activeEditor === "functionType") return !functionTypeDraft.name.trim();
|
}
|
||||||
if (activeEditor === "function") return !functionDraft.name.trim() || !functionDraft.organization_unit_id;
|
if (activeEditor === "unit" || activeEditor === "relation") {
|
||||||
return true;
|
const reason = organizationWriteReason(canWriteUnits, busy, "unit");
|
||||||
|
if (reason) return reason;
|
||||||
|
}
|
||||||
|
if (activeEditor === "function") {
|
||||||
|
const reason = organizationWriteReason(canWriteFunctions, busy, "function");
|
||||||
|
if (reason) return reason;
|
||||||
|
}
|
||||||
|
if (activeEditor === "unitType" && !unitTypeDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
if (activeEditor === "structure" && !structureDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
if (activeEditor === "relationType" && !relationTypeDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
if (activeEditor === "unit" && !unitDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
if (activeEditor === "relation" && (!relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id)) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
if (activeEditor === "functionType" && !functionTypeDraft.name.trim()) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
if (activeEditor === "function" && (!functionDraft.name.trim() || !functionDraft.organization_unit_id)) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditorDialog() {
|
function renderEditorDialog() {
|
||||||
@@ -1116,22 +1224,44 @@ export default function OrganizationsPage({
|
|||||||
activeEditor === "functionType" ? submitFunctionType :
|
activeEditor === "functionType" ? submitFunctionType :
|
||||||
submitFunction;
|
submitFunction;
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={Boolean(activeEditor)}
|
open={Boolean(activeEditor)}
|
||||||
title={editorTitle()}
|
title={editorTitle()}
|
||||||
onClose={() => !busy && discardDrafts()}
|
onClose={() => {
|
||||||
|
if (busy) return;
|
||||||
|
if (hasDirtyDraft) requestDiscard(discardDrafts);
|
||||||
|
else discardDrafts();
|
||||||
|
}}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
className="admin-dialog admin-dialog-wide organizations-editor-dialog"
|
className="organizations-editor-dialog"
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button type="button" onClick={discardDrafts} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>
|
<Button
|
||||||
<Button type="submit" form={formId} variant="primary" disabled={editorSubmitDisabled()}>{editorTitle()}</Button>
|
type="button"
|
||||||
|
onClick={() => hasDirtyDraft ? requestDiscard(discardDrafts) : discardDrafts()}
|
||||||
|
disabled={busy}
|
||||||
|
disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined}
|
||||||
|
>
|
||||||
|
i18n:govoplan-organizations.cancel_edit.309c2a6f
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
variant="primary"
|
||||||
|
disabled={editorSubmitDisabled()}
|
||||||
|
disabledReason={editorSubmitDisabledReason()}
|
||||||
|
>
|
||||||
|
{editorTitle()}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<form id={formId} className="organizations-form-grid" onSubmit={(event) => void submit(event)}>
|
<div className="button-row organizations-dialog-help">
|
||||||
|
<DocumentationHelpLink reference={ORGANIZATIONS_FIELD_DOCUMENTATION} />
|
||||||
|
</div>
|
||||||
|
<FormLayout columns={2} gap="small" collapseAt="workspace" id={formId} className="" onSubmit={(event) => void submit(event)}>
|
||||||
{renderEditorFields()}
|
{renderEditorFields()}
|
||||||
</form>
|
</FormLayout>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1140,7 +1270,7 @@ export default function OrganizationsPage({
|
|||||||
if (activeEditor === "unitType") {
|
if (activeEditor === "unitType") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} />
|
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||||
{renderChangeRequestField()}
|
{renderChangeRequestField()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1148,8 +1278,8 @@ export default function OrganizationsPage({
|
|||||||
if (activeEditor === "structure") {
|
if (activeEditor === "structure") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} />
|
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||||
<FormField label="i18n:govoplan-organizations.kind.794c9d9c">
|
<FormField label="i18n:govoplan-organizations.kind.794c9d9c" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={structureDraft.structure_kind} disabled={!canWriteModel || busy} onChange={(event) => setStructureDraft({ ...structureDraft, structure_kind: event.target.value as OrganizationStructureKind })}>
|
<select value={structureDraft.structure_kind} disabled={!canWriteModel || busy} onChange={(event) => setStructureDraft({ ...structureDraft, structure_kind: event.target.value as OrganizationStructureKind })}>
|
||||||
{STRUCTURE_KINDS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
{STRUCTURE_KINDS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -1161,28 +1291,28 @@ export default function OrganizationsPage({
|
|||||||
if (activeEditor === "relationType") {
|
if (activeEditor === "relationType") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} />
|
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||||
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
|
<FormField label="i18n:govoplan-organizations.structure.7732fb0b" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationTypeDraft.structure_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, structure_id: event.target.value })}>
|
<select value={relationTypeDraft.structure_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, structure_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa">
|
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationTypeDraft.source_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, source_unit_type_id: event.target.value })}>
|
<select value={relationTypeDraft.source_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, source_unit_type_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86">
|
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationTypeDraft.target_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, target_unit_type_id: event.target.value })}>
|
<select value={relationTypeDraft.target_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, target_unit_type_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="organizations-check-list">
|
<div className="organizations-check-list">
|
||||||
<ToggleSwitch checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: checked })} label="i18n:govoplan-organizations.hierarchical.8964f313" />
|
<ToggleSwitch checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: checked })} label="i18n:govoplan-organizations.hierarchical.8964f313" />
|
||||||
<ToggleSwitch checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: checked })} label="i18n:govoplan-organizations.allow_cycles.31327578" />
|
<ToggleSwitch checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: checked })} label="i18n:govoplan-organizations.allow_cycles.31327578" />
|
||||||
</div>
|
</div>
|
||||||
{renderChangeRequestField()}
|
{renderChangeRequestField()}
|
||||||
</>
|
</>
|
||||||
@@ -1191,14 +1321,14 @@ export default function OrganizationsPage({
|
|||||||
if (activeEditor === "unit") {
|
if (activeEditor === "unit") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} />
|
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} />
|
||||||
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
|
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={unitDraft.unit_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, unit_type_id: event.target.value })}>
|
<select value={unitDraft.unit_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, unit_type_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.parent_unit.1986c35e">
|
<FormField label="i18n:govoplan-organizations.parent_unit.1986c35e" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={unitDraft.parent_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, parent_id: event.target.value })}>
|
<select value={unitDraft.parent_id} disabled={!canWriteUnits || busy} onChange={(event) => setUnitDraft({ ...unitDraft, parent_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.units.filter((item) => item.id !== editingUnitId).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.units.filter((item) => item.id !== editingUnitId).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
@@ -1216,25 +1346,25 @@ export default function OrganizationsPage({
|
|||||||
if (activeEditor === "relation") {
|
if (activeEditor === "relation") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
|
<FormField label="i18n:govoplan-organizations.structure.7732fb0b" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationDraft.structure_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, structure_id: event.target.value })}>
|
<select value={relationDraft.structure_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, structure_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.select_structure.c10f551c</option>
|
<option value="">i18n:govoplan-organizations.select_structure.c10f551c</option>
|
||||||
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.relation_type.d0aee2e7">
|
<FormField label="i18n:govoplan-organizations.relation_type.d0aee2e7" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationDraft.relation_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, relation_type_id: event.target.value })}>
|
<select value={relationDraft.relation_type_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, relation_type_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.select_relation_type.73f92478</option>
|
<option value="">i18n:govoplan-organizations.select_relation_type.73f92478</option>
|
||||||
{model.relation_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.relation_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa">
|
<FormField label="i18n:govoplan-organizations.source_unit.2fbd8baa" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationDraft.source_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, source_unit_id: event.target.value })}>
|
<select value={relationDraft.source_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, source_unit_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.select_source_unit.9b5a29c8</option>
|
<option value="">i18n:govoplan-organizations.select_source_unit.9b5a29c8</option>
|
||||||
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86">
|
<FormField label="i18n:govoplan-organizations.target_unit.a1507d86" help={organizationWriteReason(canWriteUnits, busy, "unit")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={relationDraft.target_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, target_unit_id: event.target.value })}>
|
<select value={relationDraft.target_unit_id} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, target_unit_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.select_target_unit.8d607541</option>
|
<option value="">i18n:govoplan-organizations.select_target_unit.8d607541</option>
|
||||||
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
@@ -1244,6 +1374,7 @@ export default function OrganizationsPage({
|
|||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={relationDraft.is_active}
|
checked={relationDraft.is_active}
|
||||||
disabled={!canWriteUnits || busy}
|
disabled={!canWriteUnits || busy}
|
||||||
|
help={organizationWriteReason(canWriteUnits, busy, "unit")}
|
||||||
onChange={(checked) => setRelationDraft({ ...relationDraft, is_active: checked })}
|
onChange={(checked) => setRelationDraft({ ...relationDraft, is_active: checked })}
|
||||||
label="i18n:govoplan-organizations.active.a733b809"
|
label="i18n:govoplan-organizations.active.a733b809"
|
||||||
/>
|
/>
|
||||||
@@ -1255,16 +1386,16 @@ export default function OrganizationsPage({
|
|||||||
if (activeEditor === "functionType") {
|
if (activeEditor === "functionType") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} />
|
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||||
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
|
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d" help={organizationWriteReason(canWriteModel, busy, "model")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={functionTypeDraft.organization_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, organization_unit_type_id: event.target.value })}>
|
<select value={functionTypeDraft.organization_unit_type_id} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, organization_unit_type_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="organizations-check-list">
|
<div className="organizations-check-list">
|
||||||
<ToggleSwitch checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
|
<ToggleSwitch checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
|
||||||
<ToggleSwitch checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
|
<ToggleSwitch checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
|
||||||
</div>
|
</div>
|
||||||
{renderChangeRequestField()}
|
{renderChangeRequestField()}
|
||||||
</>
|
</>
|
||||||
@@ -1272,22 +1403,22 @@ export default function OrganizationsPage({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} />
|
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} />
|
||||||
<FormField label="i18n:govoplan-organizations.unit.8fe4d595">
|
<FormField label="i18n:govoplan-organizations.unit.8fe4d595" help={organizationWriteReason(canWriteFunctions, busy, "function")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={functionDraft.organization_unit_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, organization_unit_id: event.target.value })}>
|
<select value={functionDraft.organization_unit_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, organization_unit_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.select_unit.013bf13a</option>
|
<option value="">i18n:govoplan-organizations.select_unit.013bf13a</option>
|
||||||
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.function_type.501fe7c0">
|
<FormField label="i18n:govoplan-organizations.function_type.501fe7c0" help={organizationWriteReason(canWriteFunctions, busy, "function")} documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}>
|
||||||
<select value={functionDraft.function_type_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, function_type_id: event.target.value })}>
|
<select value={functionDraft.function_type_id} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, function_type_id: event.target.value })}>
|
||||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||||
{model.function_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
{model.function_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="organizations-check-list">
|
<div className="organizations-check-list">
|
||||||
<ToggleSwitch checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} onChange={(checked) => setFunctionDraft({ ...functionDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
|
<ToggleSwitch checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
|
||||||
<ToggleSwitch checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} onChange={(checked) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
|
<ToggleSwitch checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
|
||||||
</div>
|
</div>
|
||||||
{renderChangeRequestField()}
|
{renderChangeRequestField()}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const ORGANIZATIONS_DOCUMENTATION = {
|
||||||
|
topicId: "organizations.model",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ORGANIZATIONS_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "organizations.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ORGANIZATIONS_INTERFACE_I18N = {
|
||||||
|
loading: "i18n:govoplan-organizations.loading_reason",
|
||||||
|
busy: "i18n:govoplan-organizations.busy_reason",
|
||||||
|
noChanges: "i18n:govoplan-organizations.no_changes_reason",
|
||||||
|
modelWrite: "i18n:govoplan-organizations.model_write_reason",
|
||||||
|
unitWrite: "i18n:govoplan-organizations.unit_write_reason",
|
||||||
|
functionWrite: "i18n:govoplan-organizations.function_write_reason",
|
||||||
|
settingsWrite: "i18n:govoplan-organizations.settings_write_reason",
|
||||||
|
incomplete: "i18n:govoplan-organizations.incomplete_editor_reason",
|
||||||
|
readOnlySummary: "i18n:govoplan-organizations.read_only_summary",
|
||||||
|
requiredAction: "i18n:govoplan-organizations.required_action",
|
||||||
|
actor: "i18n:govoplan-organizations.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-organizations.destination",
|
||||||
|
permissionGuidance: "i18n:govoplan-organizations.request_write_permission",
|
||||||
|
administrator: "i18n:govoplan-organizations.organization_or_access_administrator",
|
||||||
|
administrationTarget: "i18n:govoplan-organizations.administration_roles_target"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function organizationWriteReason(
|
||||||
|
permitted: boolean,
|
||||||
|
busy: boolean,
|
||||||
|
kind: "model" | "unit" | "function" | "settings"
|
||||||
|
): string | undefined {
|
||||||
|
if (busy) return ORGANIZATIONS_INTERFACE_I18N.busy;
|
||||||
|
if (permitted) return undefined;
|
||||||
|
return {
|
||||||
|
model: ORGANIZATIONS_INTERFACE_I18N.modelWrite,
|
||||||
|
unit: ORGANIZATIONS_INTERFACE_I18N.unitWrite,
|
||||||
|
function: ORGANIZATIONS_INTERFACE_I18N.functionWrite,
|
||||||
|
settings: ORGANIZATIONS_INTERFACE_I18N.settingsWrite
|
||||||
|
}[kind];
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "No units found.",
|
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "No units found.",
|
||||||
"i18n:govoplan-organizations.none.334c4a4c": "None",
|
"i18n:govoplan-organizations.none.334c4a4c": "None",
|
||||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
|
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
|
||||||
|
"i18n:govoplan-organizations.organization_model.5945c48a": "Organization model",
|
||||||
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
|
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
|
||||||
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organizations",
|
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organizations",
|
||||||
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.",
|
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.",
|
||||||
@@ -122,7 +123,23 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.update_structure.b2e25446": "Update structure",
|
"i18n:govoplan-organizations.update_structure.b2e25446": "Update structure",
|
||||||
"i18n:govoplan-organizations.update_unit.67e2500f": "Update unit",
|
"i18n:govoplan-organizations.update_unit.67e2500f": "Update unit",
|
||||||
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Update unit type",
|
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Update unit type",
|
||||||
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section."
|
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section.",
|
||||||
|
"i18n:govoplan-organizations.organizations_administration": "Organizations administration",
|
||||||
|
"i18n:govoplan-organizations.loading_reason": "Organization data is loading.",
|
||||||
|
"i18n:govoplan-organizations.busy_reason": "An organization change is in progress.",
|
||||||
|
"i18n:govoplan-organizations.no_changes_reason": "Make a change before saving.",
|
||||||
|
"i18n:govoplan-organizations.model_write_reason": "Organization model write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.unit_write_reason": "Organization unit write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.function_write_reason": "Organization function write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.settings_write_reason": "Organization settings write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.incomplete_editor_reason": "Complete the required fields before saving.",
|
||||||
|
"i18n:govoplan-organizations.read_only_summary": "This organization section is read-only.",
|
||||||
|
"i18n:govoplan-organizations.required_action": "Required action",
|
||||||
|
"i18n:govoplan-organizations.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-organizations.destination": "Destination",
|
||||||
|
"i18n:govoplan-organizations.request_write_permission": "Request the matching organization model, unit, function, or settings write permission.",
|
||||||
|
"i18n:govoplan-organizations.organization_or_access_administrator": "An organization or Access administrator",
|
||||||
|
"i18n:govoplan-organizations.administration_roles_target": "Administration > Role templates and function mappings"
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
|
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
|
||||||
@@ -189,6 +206,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "Keine Einheiten gefunden.",
|
"i18n:govoplan-organizations.no_units_found.eea2dd0c": "Keine Einheiten gefunden.",
|
||||||
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
|
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
|
||||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
|
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
|
||||||
|
"i18n:govoplan-organizations.organization_model.5945c48a": "Organisationsmodell",
|
||||||
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
|
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
|
||||||
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationen",
|
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationen",
|
||||||
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.",
|
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.",
|
||||||
@@ -245,6 +263,22 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.update_structure.b2e25446": "Struktur aktualisieren",
|
"i18n:govoplan-organizations.update_structure.b2e25446": "Struktur aktualisieren",
|
||||||
"i18n:govoplan-organizations.update_unit.67e2500f": "Einheit aktualisieren",
|
"i18n:govoplan-organizations.update_unit.67e2500f": "Einheit aktualisieren",
|
||||||
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Einheitstyp aktualisieren",
|
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Einheitstyp aktualisieren",
|
||||||
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich."
|
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich.",
|
||||||
|
"i18n:govoplan-organizations.organizations_administration": "Organisationsverwaltung",
|
||||||
|
"i18n:govoplan-organizations.loading_reason": "Organisationsdaten werden geladen.",
|
||||||
|
"i18n:govoplan-organizations.busy_reason": "Eine Organisationsänderung wird verarbeitet.",
|
||||||
|
"i18n:govoplan-organizations.no_changes_reason": "Nehmen Sie vor dem Speichern eine Änderung vor.",
|
||||||
|
"i18n:govoplan-organizations.model_write_reason": "Eine Schreibberechtigung für das Organisationsmodell ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.unit_write_reason": "Eine Schreibberechtigung für Organisationseinheiten ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.function_write_reason": "Eine Schreibberechtigung für Organisationsfunktionen ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.settings_write_reason": "Eine Schreibberechtigung für Organisationseinstellungen ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.incomplete_editor_reason": "Füllen Sie vor dem Speichern die Pflichtfelder aus.",
|
||||||
|
"i18n:govoplan-organizations.read_only_summary": "Dieser Organisationsbereich ist schreibgeschützt.",
|
||||||
|
"i18n:govoplan-organizations.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-organizations.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-organizations.destination": "Ziel",
|
||||||
|
"i18n:govoplan-organizations.request_write_permission": "Fordern Sie die passende Schreibberechtigung für Organisationsmodell, Einheiten, Funktionen oder Einstellungen an.",
|
||||||
|
"i18n:govoplan-organizations.organization_or_access_administrator": "Eine Organisations- oder Access-Administration",
|
||||||
|
"i18n:govoplan-organizations.administration_roles_target": "Administration > Rollenvorlagen und Funktionszuordnungen"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+21
-1
@@ -23,6 +23,9 @@ const organizationAdminSections: AdminSectionsUiCapability = {
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "tenant-organization-settings",
|
id: "tenant-organization-settings",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "settings",
|
||||||
|
surfaceId: "organizations.admin.tenant",
|
||||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||||
group: "TENANT",
|
group: "TENANT",
|
||||||
order: 85,
|
order: 85,
|
||||||
@@ -41,10 +44,27 @@ const organizationFunctionPicker: OrganizationFunctionPickerUiCapability = {
|
|||||||
export const organizationsModule: PlatformWebModule = {
|
export const organizationsModule: PlatformWebModule = {
|
||||||
id: "organizations",
|
id: "organizations",
|
||||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||||
version: "1.0.0",
|
version: "0.1.8",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["admin"],
|
optionalDependencies: ["admin"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "organizations.admin.tenant",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "section",
|
||||||
|
label: "i18n:govoplan-organizations.organizations_administration",
|
||||||
|
order: 85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "organizations.admin.template-upgrades",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "section",
|
||||||
|
label: "Organization template upgrades",
|
||||||
|
parentId: "organizations.admin.tenant",
|
||||||
|
order: 86
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/organizations",
|
to: "/organizations",
|
||||||
|
|||||||
@@ -1,61 +1,3 @@
|
|||||||
.organizations-workspace {
|
|
||||||
grid-template-columns: 230px minmax(0, 1fr);
|
|
||||||
background: var(--bg, #f8f7f4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-page {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-admin-page {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-settings-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-heading {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-grid .wide {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-actions,
|
|
||||||
.organizations-row-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-row-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-contributed-action {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-check-list {
|
.organizations-check-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -76,51 +18,6 @@
|
|||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-tree-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 640px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-node {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-node:hover,
|
|
||||||
.organizations-tree-node.active {
|
|
||||||
border-color: var(--line);
|
|
||||||
background: var(--surface-muted, #f6f7f9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-node span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-field-note {
|
.organizations-field-note {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -143,12 +40,40 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.organization-upgrade-toolbar {
|
||||||
.organizations-workspace {
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(16rem, 1fr) auto;
|
||||||
}
|
align-items: end;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin: 0.9rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.organizations-form-grid {
|
.organization-upgrade-table {
|
||||||
grid-template-columns: 1fr;
|
min-height: 10rem;
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-upgrade-dialog {
|
||||||
|
width: min(92vw, 78rem);
|
||||||
|
height: min(88vh, 54rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-upgrade-diff {
|
||||||
|
min-height: 12rem;
|
||||||
|
max-height: 25rem;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-upgrade-decision {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.organization-upgrade-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user