41 Commits
Author SHA1 Message Date
zemion 58ebf2c7e6 Release govoplan-organizations v0.1.21: unify interface contracts and documentation
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 01:32:48 +02:00
zemion b23e6e4eee fix(webui): bind organization retention to help
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 11:36:41 +02:00
zemion 4408a9486f docs(organizations): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 13s
2026-08-23 19:31:24 +02:00
zemion 9f17de7a3f fix(i18n): complete organization model translations 2026-08-21 17:36:17 +02:00
zemion aa4ed0b7c1 feat(organizations): add governed DSAR coverage 2026-08-21 01:26:05 +02:00
zemion d1e3b1cbfd docs: define function delegation modes 2026-08-19 22:16:18 +02:00
zemion f34a6b17a9 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 8b67f28ace Adopt semantic organization action layout 2026-08-19 14:26:26 +02:00
zemion bc0eb60064 feat: classify organizations product area 2026-08-18 21:32:50 +02:00
zemion 5bae5bbc3e Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 1a7b9fbf16 Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 8a3724bf8b Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion 30fcdbe832 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:45 +02:00
zemion 3ebe8c2c99 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:07 +02:00
zemion 18e4df348a Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:12 +02:00
zemion aab7f25e86 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:12 +02:00
zemion 5d58742928 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion a33c7abd31 Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 8940c4ed9e Add protected package release workflow 2026-08-04 04:14:06 +02:00
zemion fcfd67b0b5 Add governed organization template upgrades 2026-08-04 01:46:36 +02:00
zemion 97acfcba7d Migrate Organizations interface patterns 2026-08-03 12:09:33 +02:00
zemion 3f0f38d226 refactor: use shared JSON mutation clients 2026-08-02 05:29:53 +02:00
zemion 7dd013dd7a docs: declare institutional architecture boundary 2026-08-01 17:48:37 +02:00
zemion daeaa4bcb8 Expose function governance settings through directory 2026-07-31 19:40:16 +02:00
zemion 17ab9c9c8d test: cover organization administration workflows 2026-07-31 04:21:34 +02:00
zemion 06bd1c9003 Align Organizations WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion 326bf3f56e feat: add versioned organization model templates 2026-07-30 17:42:07 +02:00
zemion 5ebdffc0ec feat: expose organization hierarchy catalog 2026-07-30 03:59:58 +02:00
zemion 84ca4f39ae feat: expose bounded organization hierarchy routing 2026-07-30 03:26:48 +02:00
zemion 58857654e9 fix: invalidate auth context after organization changes 2026-07-29 19:24:12 +02:00
zemion 00212ea331 fix: bound organization aggregate responses 2026-07-29 14:32:54 +02:00
zemion 35aebe8759 Filter organization surfaces by View 2026-07-28 21:04:54 +02:00
zemion 28f799e426 refactor(webui): use shared organization explorer tree 2026-07-21 13:59:57 +02:00
zemion df91c70491 refactor(webui): host-render function row actions 2026-07-21 13:52:38 +02:00
zemion 81e532fd54 fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion 025067eb87 refactor(webui): use central form breakpoint 2026-07-21 13:33:13 +02:00
zemion 6b0b2d2d0b fix(webui): restore organization header actions 2026-07-21 13:32:43 +02:00
zemion f09fdf2ef7 refactor(webui): use central forms and table actions 2026-07-21 12:06:44 +02:00
zemion c240778ad2 fix(release): align package version metadata 2026-07-21 12:06:39 +02:00
zemion 45cda1a33f intermittent commit 2026-07-14 13:22:12 +02:00
zemion 39c081c4fb Release v0.1.8 2026-07-11 16:49:03 +02:00
41 changed files with 8017 additions and 377 deletions
+270
View File
@@ -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
+6
View File
@@ -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
+23 -2
View File
@@ -1,5 +1,9 @@
# GovOPlaN Organizations # GovOPlaN Organizations
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-organizations` is the canonical organizational model module for `govoplan-organizations` is the canonical organizational model module for
GovOPlaN. GovOPlaN.
@@ -36,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.
+54
View File
@@ -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.
+72
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/organizations-webui", "name": "@govoplan/organizations-webui",
"version": "0.1.7", "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.7", "@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
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-organizations" name = "govoplan-organizations"
version = "0.1.7" 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.7", "govoplan-core>=0.1.45",
"govoplan-tenancy>=0.1.7", "govoplan-tenancy>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+1 -1
View File
@@ -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",
+862 -12
View File
@@ -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"]
+462 -17
View File
@@ -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.7", 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",),
), ),
) )
@@ -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()
@@ -0,0 +1 @@
"""Organizations Alembic revisions."""
@@ -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,
)
@@ -0,0 +1,187 @@
"""v0.1.7 organizations baseline
Revision ID: 6d7e8f9a0b1c
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '6d7e8f9a0b1c'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('organizations_structures',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_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('structure_kind', sa.String(length=30), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
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_structures')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organizations_structures_tenant_slug')
)
op.create_index(op.f('ix_organizations_structures_tenant_id'), 'organizations_structures', ['tenant_id'], unique=False)
op.create_table('organizations_tenant_settings',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('allow_tenant_model_customization', sa.Boolean(), nullable=False),
sa.Column('require_model_change_requests', sa.Boolean(), nullable=False),
sa.Column('audit_detail_level', sa.String(length=30), nullable=False),
sa.Column('change_retention_days', sa.Integer(), 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_tenant_settings')),
sa.UniqueConstraint('tenant_id', name='uq_organizations_tenant_settings_tenant')
)
op.create_index(op.f('ix_organizations_tenant_settings_tenant_id'), 'organizations_tenant_settings', ['tenant_id'], unique=False)
op.create_table('organizations_unit_types',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_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('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_unit_types')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organizations_unit_types_tenant_slug')
)
op.create_index(op.f('ix_organizations_unit_types_tenant_id'), 'organizations_unit_types', ['tenant_id'], unique=False)
op.create_table('organizations_function_types',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_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('organization_unit_type_id', sa.String(length=36), nullable=True),
sa.Column('delegable', sa.Boolean(), nullable=False),
sa.Column('act_in_place_allowed', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
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.ForeignKeyConstraint(['organization_unit_type_id'], ['organizations_unit_types.id'], name=op.f('fk_organizations_function_types_organization_unit_type_id_organizations_unit_types'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_organizations_function_types')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organizations_function_types_tenant_slug')
)
op.create_index(op.f('ix_organizations_function_types_organization_unit_type_id'), 'organizations_function_types', ['organization_unit_type_id'], unique=False)
op.create_index(op.f('ix_organizations_function_types_tenant_id'), 'organizations_function_types', ['tenant_id'], unique=False)
op.create_table('organizations_relation_types',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('structure_id', sa.String(length=36), nullable=True),
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('source_unit_type_id', sa.String(length=36), nullable=True),
sa.Column('target_unit_type_id', sa.String(length=36), nullable=True),
sa.Column('is_hierarchical', sa.Boolean(), nullable=False),
sa.Column('allow_cycles', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
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.ForeignKeyConstraint(['source_unit_type_id'], ['organizations_unit_types.id'], name=op.f('fk_organizations_relation_types_source_unit_type_id_organizations_unit_types'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['structure_id'], ['organizations_structures.id'], name=op.f('fk_organizations_relation_types_structure_id_organizations_structures'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['target_unit_type_id'], ['organizations_unit_types.id'], name=op.f('fk_organizations_relation_types_target_unit_type_id_organizations_unit_types'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_organizations_relation_types')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organizations_relation_types_tenant_slug')
)
op.create_index(op.f('ix_organizations_relation_types_source_unit_type_id'), 'organizations_relation_types', ['source_unit_type_id'], unique=False)
op.create_index('ix_organizations_relation_types_structure', 'organizations_relation_types', ['tenant_id', 'structure_id'], unique=False)
op.create_index(op.f('ix_organizations_relation_types_structure_id'), 'organizations_relation_types', ['structure_id'], unique=False)
op.create_index(op.f('ix_organizations_relation_types_target_unit_type_id'), 'organizations_relation_types', ['target_unit_type_id'], unique=False)
op.create_index(op.f('ix_organizations_relation_types_tenant_id'), 'organizations_relation_types', ['tenant_id'], unique=False)
op.create_table('organizations_units',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('unit_type_id', sa.String(length=36), nullable=True),
sa.Column('parent_id', sa.String(length=36), nullable=True),
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('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.ForeignKeyConstraint(['parent_id'], ['organizations_units.id'], name=op.f('fk_organizations_units_parent_id_organizations_units'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['unit_type_id'], ['organizations_unit_types.id'], name=op.f('fk_organizations_units_unit_type_id_organizations_unit_types'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_organizations_units')),
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organizations_units_tenant_slug')
)
op.create_index(op.f('ix_organizations_units_parent_id'), 'organizations_units', ['parent_id'], unique=False)
op.create_index(op.f('ix_organizations_units_tenant_id'), 'organizations_units', ['tenant_id'], unique=False)
op.create_index(op.f('ix_organizations_units_unit_type_id'), 'organizations_units', ['unit_type_id'], unique=False)
op.create_table('organizations_functions',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('function_type_id', sa.String(length=36), nullable=True),
sa.Column('organization_unit_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('delegable', sa.Boolean(), nullable=False),
sa.Column('act_in_place_allowed', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
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.ForeignKeyConstraint(['function_type_id'], ['organizations_function_types.id'], name=op.f('fk_organizations_functions_function_type_id_organizations_function_types'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_unit_id'], ['organizations_units.id'], name=op.f('fk_organizations_functions_organization_unit_id_organizations_units'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_organizations_functions')),
sa.UniqueConstraint('tenant_id', 'organization_unit_id', 'slug', name='uq_organizations_functions_tenant_unit_slug')
)
op.create_index(op.f('ix_organizations_functions_function_type_id'), 'organizations_functions', ['function_type_id'], unique=False)
op.create_index(op.f('ix_organizations_functions_organization_unit_id'), 'organizations_functions', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_organizations_functions_tenant_id'), 'organizations_functions', ['tenant_id'], unique=False)
op.create_table('organizations_relations',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('structure_id', sa.String(length=36), nullable=False),
sa.Column('relation_type_id', sa.String(length=36), nullable=False),
sa.Column('source_unit_id', sa.String(length=36), nullable=False),
sa.Column('target_unit_id', sa.String(length=36), nullable=False),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
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.ForeignKeyConstraint(['relation_type_id'], ['organizations_relation_types.id'], name=op.f('fk_organizations_relations_relation_type_id_organizations_relation_types'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_unit_id'], ['organizations_units.id'], name=op.f('fk_organizations_relations_source_unit_id_organizations_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['structure_id'], ['organizations_structures.id'], name=op.f('fk_organizations_relations_structure_id_organizations_structures'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['target_unit_id'], ['organizations_units.id'], name=op.f('fk_organizations_relations_target_unit_id_organizations_units'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_organizations_relations')),
sa.UniqueConstraint('tenant_id', 'structure_id', 'relation_type_id', 'source_unit_id', 'target_unit_id', name='uq_organizations_relations_edge')
)
op.create_index(op.f('ix_organizations_relations_relation_type_id'), 'organizations_relations', ['relation_type_id'], unique=False)
op.create_index(op.f('ix_organizations_relations_source_unit_id'), 'organizations_relations', ['source_unit_id'], unique=False)
op.create_index(op.f('ix_organizations_relations_structure_id'), 'organizations_relations', ['structure_id'], unique=False)
op.create_index('ix_organizations_relations_structure_source', 'organizations_relations', ['tenant_id', 'structure_id', 'source_unit_id'], unique=False)
op.create_index('ix_organizations_relations_structure_target', 'organizations_relations', ['tenant_id', 'structure_id', 'target_unit_id'], unique=False)
op.create_index(op.f('ix_organizations_relations_target_unit_id'), 'organizations_relations', ['target_unit_id'], unique=False)
op.create_index(op.f('ix_organizations_relations_tenant_id'), 'organizations_relations', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('organizations_relations')
op.drop_table('organizations_functions')
op.drop_table('organizations_units')
op.drop_table('organizations_relation_types')
op.drop_table('organizations_function_types')
op.drop_table('organizations_unit_types')
op.drop_table('organizations_tenant_settings')
op.drop_table('organizations_structures')
@@ -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")
@@ -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
+166
View File
@@ -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()
+394
View File
@@ -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()
+444
View File
@@ -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()
+536
View File
@@ -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()
+113
View File
@@ -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
View File
@@ -1,11 +1,16 @@
{ {
"name": "@govoplan/organizations-webui", "name": "@govoplan/organizations-webui",
"version": "0.1.7", "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.7", "@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"
);
+35
View File
@@ -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
View File
@@ -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,26 +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,
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 {
@@ -56,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";
@@ -266,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);
} }
@@ -293,41 +309,51 @@ 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>
<label className="organizations-inline-check wide"> <div className="wide">
<input type="checkbox" checked={draft.is_active} disabled={disabled} onChange={(event) => onChange({ ...draft, is_active: event.target.checked })} /> <ToggleSwitch
<span>i18n:govoplan-organizations.active.a733b809</span> checked={draft.is_active}
</label> disabled={disabled}
help={disabledReason}
onChange={(checked) => onChange({ ...draft, is_active: checked })}
label="i18n:govoplan-organizations.active.a733b809"
/>
</div>
</> </>
); );
} }
@@ -361,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());
@@ -380,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");
@@ -395,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[]>();
@@ -865,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>[] = [
@@ -873,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>[] = [
@@ -883,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>[] = [
@@ -892,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>[] = [
@@ -901,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>[] = [
@@ -910,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>[] = [
@@ -921,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()}
@@ -964,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" collapsible collapseKey="organizations.unit-types" 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" collapsible collapseKey="organizations.structures" 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" collapsible collapseKey="organizations.relation-types" 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" collapsible collapseKey="organizations.function-types" 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" collapsible collapseKey="organizations.tree" 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" collapsible collapseKey="organizations.units" 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>
@@ -1044,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" collapsible collapseKey="organizations.relations" 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>
@@ -1054,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" collapsible collapseKey="organizations.functions" 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>
@@ -1064,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>
@@ -1086,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() {
@@ -1111,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>
); );
} }
@@ -1135,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()}
</> </>
); );
@@ -1143,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>
@@ -1156,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">
<label><input type="checkbox" checked={relationTypeDraft.is_hierarchical} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: event.target.checked })} /> i18n:govoplan-organizations.hierarchical.8964f313</label> <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" />
<label><input type="checkbox" checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} onChange={(event) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: event.target.checked })} /> i18n:govoplan-organizations.allow_cycles.31327578</label> <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()}
</> </>
@@ -1186,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>)}
@@ -1211,34 +1346,39 @@ 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>)}
</select> </select>
</FormField> </FormField>
<label className="organizations-inline-check wide"> <div className="wide">
<input type="checkbox" checked={relationDraft.is_active} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, is_active: event.target.checked })} /> <ToggleSwitch
<span>i18n:govoplan-organizations.active.a733b809</span> checked={relationDraft.is_active}
</label> disabled={!canWriteUnits || busy}
help={organizationWriteReason(canWriteUnits, busy, "unit")}
onChange={(checked) => setRelationDraft({ ...relationDraft, is_active: checked })}
label="i18n:govoplan-organizations.active.a733b809"
/>
</div>
{renderChangeRequestField()} {renderChangeRequestField()}
</> </>
); );
@@ -1246,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">
<label><input type="checkbox" checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: event.target.checked })} /> i18n:govoplan-organizations.delegable.b4f0137d</label> <ToggleSwitch checked={functionTypeDraft.delegable} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
<label><input type="checkbox" checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} onChange={(event) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: event.target.checked })} /> i18n:govoplan-organizations.act_in_place.49b942bd</label> <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()}
</> </>
@@ -1263,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">
<label><input type="checkbox" checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, delegable: event.target.checked })} /> i18n:govoplan-organizations.delegable.b4f0137d</label> <ToggleSwitch checked={functionDraft.delegable} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
<label><input type="checkbox" checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} onChange={(event) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: event.target.checked })} /> i18n:govoplan-organizations.act_in_place.49b942bd</label> <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];
}
+40 -6
View File
@@ -66,10 +66,11 @@ 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": "Organization settings", "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.",
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organization settings saved.", "i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organizations saved.",
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree", "i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree",
"i18n:govoplan-organizations.organizations.220edf64": "Organizations", "i18n:govoplan-organizations.organizations.220edf64": "Organizations",
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.", "i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.",
@@ -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,10 +206,11 @@ 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": "Organisationseinstellungen", "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.",
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.", "i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationen gespeichert.",
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum", "i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum",
"i18n:govoplan-organizations.organizations.220edf64": "Organisationen", "i18n:govoplan-organizations.organizations.220edf64": "Organisationen",
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.", "i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.",
@@ -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"
} }
}; };
+23 -3
View File
@@ -23,7 +23,10 @@ const organizationAdminSections: AdminSectionsUiCapability = {
sections: [ sections: [
{ {
id: "tenant-organization-settings", id: "tenant-organization-settings",
label: "i18n:govoplan-organizations.organization_settings.c9ab9829", moduleId: "organizations",
kind: "settings",
surfaceId: "organizations.admin.tenant",
label: "i18n:govoplan-organizations.organizations.220edf64",
group: "TENANT", group: "TENANT",
order: 85, order: 85,
anyOf: ["organizations:settings:read", "admin:settings:read"], anyOf: ["organizations:settings:read", "admin:settings:read"],
@@ -41,15 +44,32 @@ 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",
label: "i18n:govoplan-organizations.organizations.220edf64", label: "i18n:govoplan-organizations.organizations.220edf64",
iconName: "users", iconName: "building-2",
anyOf: organizationReadScopes, anyOf: organizationReadScopes,
order: 70 order: 70
} }
+34 -119
View File
@@ -1,77 +1,9 @@
.organizations-workspace {
grid-template-columns: 230px minmax(0, 1fr);
background: var(--bg, #f8f7f4);
}
.organizations-page {
display: grid;
gap: 18px;
width: 100%;
max-width: 1480px;
}
.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;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.organizations-check-list label,
.organizations-inline-check {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 600;
}
.organizations-table-stack { .organizations-table-stack {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -86,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);
@@ -153,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);
} }
} }