Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab7f25e86 | ||
|
|
5d58742928 | ||
|
|
a33c7abd31 | ||
|
|
8940c4ed9e | ||
|
|
fcfd67b0b5 | ||
|
|
97acfcba7d | ||
|
|
3f0f38d226 | ||
|
|
7dd013dd7a | ||
|
|
daeaa4bcb8 | ||
|
|
17ab9c9c8d | ||
|
|
06bd1c9003 | ||
|
|
326bf3f56e | ||
|
|
5ebdffc0ec | ||
|
|
84ca4f39ae | ||
|
|
58857654e9 | ||
|
|
00212ea331 | ||
|
|
35aebe8759 | ||
|
|
28f799e426 | ||
|
|
df91c70491 | ||
|
|
81e532fd54 | ||
|
|
025067eb87 | ||
|
|
6b0b2d2d0b | ||
|
|
f09fdf2ef7 | ||
|
|
c240778ad2 | ||
|
|
45cda1a33f | ||
|
|
39c081c4fb |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Organizations Codex Guide
|
||||
|
||||
## 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
|
||||
|
||||
This repository owns the canonical GovOPlaN organizational model: tenant-local
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# GovOPlaN Organizations
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-organizations` is the canonical organizational model module for
|
||||
GovOPlaN.
|
||||
|
||||
@@ -36,10 +40,19 @@ organizations module is active. Those controls are limited to governance and
|
||||
policy settings such as tenant model customization, change-request
|
||||
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
|
||||
|
||||
The module registers the `organizations.directory` capability from
|
||||
`govoplan_core.core.organizations`.
|
||||
The module registers two capabilities from
|
||||
`govoplan_core.core.organizations`:
|
||||
|
||||
- `organizations.directory` for backward-compatible direct unit/function
|
||||
lookup;
|
||||
- `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly
|
||||
structure-scoped hierarchy and path resolution.
|
||||
|
||||
Feature modules should consume the capability instead of importing
|
||||
organization ORM models.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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 page, subnavigation, grid, tree, card, dialog, loading,
|
||||
alert, status, blocker, field-help, and disabled-action controls. Stable table
|
||||
action slots remain keyboard reachable; dialogs retain shared focus containment
|
||||
and return. Responsive behavior stays owned by the existing workspace and admin
|
||||
shell CSS. Labels and accessible properties use the English/German module
|
||||
catalogue, and API errors are bounded to the current tenant operation.
|
||||
|
||||
The manifest contributes stable help contexts for the workspace, sections,
|
||||
settings, fields, lifecycle changes, and governed change references. Backend
|
||||
and WebUI contract tests prevent those boundaries and explanations from
|
||||
silently regressing.
|
||||
@@ -16,6 +16,48 @@ The organization model answers where responsibility lives.
|
||||
- Function: a named responsibility in an organization unit, such as clerk,
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
The organization workspace at `/organizations` is the primary module UI for
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,14 +19,14 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+3
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-organizations"
|
||||
version = "0.1.7"
|
||||
version = "0.1.15"
|
||||
description = "GovOPlaN organizational model module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.7",
|
||||
"govoplan-tenancy>=0.1.7",
|
||||
"govoplan-core>=0.1.15",
|
||||
"govoplan-tenancy>=0.1.15",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN organizations module."""
|
||||
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.15"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import re
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -14,10 +16,26 @@ from govoplan_core.core.configuration_control import (
|
||||
ensure_configuration_change_allowed,
|
||||
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_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelTemplate,
|
||||
OrganizationModelTemplateVersion,
|
||||
OrganizationModelUpgrade,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationTenantSettings,
|
||||
@@ -31,9 +49,22 @@ from .schemas import (
|
||||
FunctionTypeCreateRequest,
|
||||
FunctionTypeUpdateRequest,
|
||||
FunctionUpdateRequest,
|
||||
OrganizationModelInstantiationItem,
|
||||
OrganizationModelUpgradeApplyRequest,
|
||||
OrganizationModelUpgradeApplyResponse,
|
||||
OrganizationModelUpgradeCancelRequest,
|
||||
OrganizationModelUpgradeItem,
|
||||
OrganizationModelUpgradeListResponse,
|
||||
OrganizationModelUpgradePreviewRequest,
|
||||
OrganizationFunctionItem,
|
||||
OrganizationFunctionTypeItem,
|
||||
OrganizationModelResponse,
|
||||
OrganizationModelTemplateCatalogItem,
|
||||
OrganizationModelTemplateCatalogResponse,
|
||||
OrganizationModelTemplateCreateRequest,
|
||||
OrganizationModelTemplateItem,
|
||||
OrganizationModelTemplateVersionCreateRequest,
|
||||
OrganizationModelTemplateVersionItem,
|
||||
OrganizationRelationItem,
|
||||
OrganizationRelationTypeItem,
|
||||
OrganizationSettingsItem,
|
||||
@@ -52,9 +83,24 @@ from .schemas import (
|
||||
UnitTypeUpdateRequest,
|
||||
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"])
|
||||
ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000
|
||||
ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000
|
||||
|
||||
ORG_READ_SCOPES = (
|
||||
"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_CHANGE_CONTROL_KEY = "organizations.model"
|
||||
ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated"
|
||||
ORG_TEMPLATE_ADMIN_SCOPES = ("system:settings:write",)
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
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:
|
||||
lifecycle = _organization_lifecycle_change(item)
|
||||
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()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
@@ -137,6 +206,121 @@ def _commit(session: Session, item: ModelT) -> ModelT:
|
||||
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:
|
||||
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||
return bool(item and item.require_model_change_requests)
|
||||
@@ -257,6 +441,142 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
|
||||
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]:
|
||||
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
||||
return {key: getattr(item, key) for key in keys}
|
||||
@@ -329,21 +649,439 @@ def update_organization_settings(
|
||||
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)
|
||||
def get_organization_model(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||
) -> OrganizationModelResponse:
|
||||
tenant_id = _tenant_id(principal)
|
||||
return OrganizationModelResponse(
|
||||
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
|
||||
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
|
||||
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
|
||||
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
|
||||
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()],
|
||||
unit_types = _bounded_organization_rows(
|
||||
session.query(OrganizationUnitType)
|
||||
.filter(OrganizationUnitType.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnitType.name.asc()),
|
||||
"unit types",
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -3,11 +3,277 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
||||
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):
|
||||
|
||||
@@ -55,6 +55,178 @@ class OrganizationTenantSettings(Base, TimestampMixin):
|
||||
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):
|
||||
__tablename__ = "organizations_structures"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
||||
@@ -153,6 +325,10 @@ class OrganizationFunction(Base, TimestampMixin):
|
||||
__all__ = [
|
||||
"OrganizationFunction",
|
||||
"OrganizationFunctionType",
|
||||
"OrganizationModelInstantiation",
|
||||
"OrganizationModelTemplate",
|
||||
"OrganizationModelTemplateVersion",
|
||||
"OrganizationModelUpgrade",
|
||||
"OrganizationRelation",
|
||||
"OrganizationRelationType",
|
||||
"OrganizationStructure",
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
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 (
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef,
|
||||
OrganizationFunctionTypeRef,
|
||||
OrganizationFunctionTypeResolution,
|
||||
OrganizationHierarchyCatalogRef,
|
||||
OrganizationHierarchyDirection,
|
||||
OrganizationHierarchyDirectory,
|
||||
OrganizationHierarchyEdgeRef,
|
||||
OrganizationHierarchyMatchRef,
|
||||
OrganizationHierarchyPathResolution,
|
||||
OrganizationHierarchyResolution,
|
||||
OrganizationRelationTypeRef,
|
||||
OrganizationResolutionStatus,
|
||||
OrganizationStructureRef,
|
||||
OrganizationUnitRef,
|
||||
OrganizationUnitTypeRef,
|
||||
OrganizationUnitTypeResolution,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
MAX_DIRECTORY_BATCH = 500
|
||||
MAX_HIERARCHY_DEPTH = 100
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
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:
|
||||
return OrganizationFunctionRef(
|
||||
id=item.id,
|
||||
@@ -41,27 +84,148 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
with get_database().session() as session:
|
||||
def _function_type_ref(
|
||||
item: OrganizationFunctionType,
|
||||
) -> 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)
|
||||
return _unit_ref(item) if item is not None else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
||||
with get_database().session() as session:
|
||||
def organization_units_for_tenant(
|
||||
self,
|
||||
tenant_id: str,
|
||||
) -> tuple[OrganizationUnitRef, ...]:
|
||||
with self._session() as session:
|
||||
rows = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.order_by(OrganizationUnit.name.asc(), OrganizationUnit.id)
|
||||
.all()
|
||||
)
|
||||
return tuple(_unit_ref(item) for item in rows)
|
||||
|
||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||
with get_database().session() as session:
|
||||
def get_function(
|
||||
self,
|
||||
function_id: str,
|
||||
) -> OrganizationFunctionRef | None:
|
||||
with self._session() as session:
|
||||
item = session.get(OrganizationFunction, function_id)
|
||||
return _function_ref(item) if item is not None else None
|
||||
|
||||
@@ -71,7 +235,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> 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}
|
||||
if include_subunits:
|
||||
pending = [organization_unit_id]
|
||||
@@ -80,7 +247,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
child_ids = [
|
||||
row[0]
|
||||
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()
|
||||
]
|
||||
for child_id in child_ids:
|
||||
@@ -89,8 +259,688 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
pending.append(child_id)
|
||||
rows = (
|
||||
session.query(OrganizationFunction)
|
||||
.filter(OrganizationFunction.organization_unit_id.in_(unit_ids))
|
||||
.order_by(OrganizationFunction.name.asc())
|
||||
.filter(
|
||||
OrganizationFunction.tenant_id == unit.tenant_id,
|
||||
OrganizationFunction.organization_unit_id.in_(unit_ids),
|
||||
)
|
||||
.order_by(
|
||||
OrganizationFunction.name.asc(),
|
||||
OrganizationFunction.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
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"]
|
||||
|
||||
@@ -2,20 +2,30 @@ from __future__ import annotations
|
||||
|
||||
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.modules import (
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
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_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||
|
||||
@@ -43,14 +53,46 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("organizations:model:read", "View organization model", "Read organization meta-model definitions such as unit types, structures, and relation types."),
|
||||
_permission("organizations:model:write", "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."),
|
||||
_permission(
|
||||
"organizations:model:read",
|
||||
"View organization model",
|
||||
"Read organization meta-model definitions such as unit types, structures, and relation types.",
|
||||
),
|
||||
_permission(
|
||||
"organizations:model:write",
|
||||
"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 = (
|
||||
@@ -64,7 +106,12 @@ ROLE_TEMPLATES = (
|
||||
slug="organization_viewer",
|
||||
name="Organization viewer",
|
||||
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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -86,18 +133,71 @@ def _organization_directory(context: ModuleContext) -> object:
|
||||
manifest = ModuleManifest(
|
||||
id="organizations",
|
||||
name="Organizations",
|
||||
version="0.1.7",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
version="0.1.15",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
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",
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
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(
|
||||
module_id="organizations",
|
||||
package_name="@govoplan/organizations-webui",
|
||||
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||
routes=(
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(
|
||||
module_id="organizations",
|
||||
@@ -108,6 +208,10 @@ manifest = ModuleManifest(
|
||||
persistent_table_uninstall_guard(
|
||||
organization_models.OrganizationUnitType,
|
||||
organization_models.OrganizationTenantSettings,
|
||||
organization_models.OrganizationModelTemplate,
|
||||
organization_models.OrganizationModelTemplateVersion,
|
||||
organization_models.OrganizationModelInstantiation,
|
||||
organization_models.OrganizationModelUpgrade,
|
||||
organization_models.OrganizationStructure,
|
||||
organization_models.OrganizationRelationType,
|
||||
organization_models.OrganizationRelation,
|
||||
@@ -119,8 +223,56 @@ manifest = ModuleManifest(
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
|
||||
},
|
||||
documentation=(
|
||||
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,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="organizations.model",
|
||||
title="Organization model",
|
||||
@@ -128,13 +280,109 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"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. "
|
||||
"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",
|
||||
documentation_types=("admin", "user"),
|
||||
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 model API",
|
||||
href="/api/v1/organizations/model",
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": [
|
||||
"organizations.workspace",
|
||||
"organizations.model",
|
||||
"organizations.units",
|
||||
"organizations.relations",
|
||||
"organizations.functions",
|
||||
"organizations.admin.tenant",
|
||||
"organizations.blocked",
|
||||
],
|
||||
},
|
||||
order=25,
|
||||
),
|
||||
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. 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,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="institutional_foundation",
|
||||
kind="foundation",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/ORGANIZATION_MODEL.md",
|
||||
test_ref="tests/test_model_templates.py",
|
||||
known_limits=(
|
||||
"Downstream modules must reconcile organization-reference changes from the emitted upgrade event; cross-module records are not rewritten directly.",
|
||||
),
|
||||
owned_concepts=("organization unit", "organization structure", "organization relation", "organization function"),
|
||||
non_owned_concepts=("function incumbency", "identity", "application role", "mandate"),
|
||||
recovery_docs=("docs/ORGANIZATION_MODEL.md",),
|
||||
security_docs=("docs/ORGANIZATION_MODEL.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"""organization model templates
|
||||
|
||||
Revision ID: 7e8f9a0b1c2d
|
||||
Revises: 6d7e8f9a0b1c
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
revision = "7e8f9a0b1c2d"
|
||||
down_revision = "6d7e8f9a0b1c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_release = import_module(
|
||||
"govoplan_organizations.backend.migrations.versions."
|
||||
"7e8f9a0b1c2d_organization_model_templates"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_release.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_release.downgrade()
|
||||
@@ -0,0 +1 @@
|
||||
"""Organizations Alembic revisions."""
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"""Development mirror for the organization template upgrade ledger."""
|
||||
|
||||
from govoplan_organizations.backend.migrations.versions.a61e4d9c72b8_organization_template_upgrades import ( # noqa: F401
|
||||
branch_labels,
|
||||
depends_on,
|
||||
downgrade,
|
||||
down_revision,
|
||||
revision,
|
||||
upgrade,
|
||||
)
|
||||
+187
@@ -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')
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
"""organization model templates
|
||||
|
||||
Revision ID: 7e8f9a0b1c2d
|
||||
Revises: 6d7e8f9a0b1c
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "7e8f9a0b1c2d"
|
||||
down_revision = "6d7e8f9a0b1c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"organizations_model_templates",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_organizations_model_templates"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"slug",
|
||||
name=op.f("uq_organizations_model_templates_slug"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_templates_slug"),
|
||||
"organizations_model_templates",
|
||||
["slug"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_templates_created_by_account_id"),
|
||||
"organizations_model_templates",
|
||||
["created_by_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"organizations_model_template_versions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("version", sa.String(length=50), nullable=False),
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("definition", sa.JSON(), nullable=False),
|
||||
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("release_notes", sa.Text(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("published_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["organizations_model_templates.id"],
|
||||
name=op.f(
|
||||
"fk_organizations_model_template_versions_template_id_organizations_model_templates"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_organizations_model_template_versions"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"template_id",
|
||||
"version",
|
||||
name="uq_organizations_model_template_versions",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_template_versions_template_id"),
|
||||
"organizations_model_template_versions",
|
||||
["template_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f(
|
||||
"ix_organizations_model_template_versions_published_by_account_id"
|
||||
),
|
||||
"organizations_model_template_versions",
|
||||
["published_by_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_org_model_template_versions_template_status",
|
||||
"organizations_model_template_versions",
|
||||
["template_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"organizations_model_instantiations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("instantiated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("object_counts", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["organizations_model_templates.id"],
|
||||
name=op.f(
|
||||
"fk_organizations_model_instantiations_template_id_organizations_model_templates"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_version_id"],
|
||||
["organizations_model_template_versions.id"],
|
||||
name=op.f(
|
||||
"fk_organizations_model_instantiations_template_version_id_organizations_model_template_versions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_organizations_model_instantiations"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"template_version_id",
|
||||
name="uq_organizations_model_instantiation_version",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_tenant_id"),
|
||||
"organizations_model_instantiations",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_template_id"),
|
||||
"organizations_model_instantiations",
|
||||
["template_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_template_version_id"),
|
||||
"organizations_model_instantiations",
|
||||
["template_version_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_status"),
|
||||
"organizations_model_instantiations",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f(
|
||||
"ix_organizations_model_instantiations_instantiated_by_account_id"
|
||||
),
|
||||
"organizations_model_instantiations",
|
||||
["instantiated_by_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_organizations_model_instantiations_tenant",
|
||||
"organizations_model_instantiations",
|
||||
["tenant_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("organizations_model_instantiations")
|
||||
op.drop_table("organizations_model_template_versions")
|
||||
op.drop_table("organizations_model_templates")
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""organization template upgrade ledger
|
||||
|
||||
Revision ID: a61e4d9c72b8
|
||||
Revises: 7e8f9a0b1c2d
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a61e4d9c72b8"
|
||||
down_revision = "7e8f9a0b1c2d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"organizations_model_upgrades",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_instantiation_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_template_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("target_template_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("base_definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("local_definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("target_definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("preview", sa.JSON(), nullable=False),
|
||||
sa.Column("decisions", sa.JSON(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_digest", sa.String(length=64), nullable=False),
|
||||
sa.Column("requested_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("applied_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("cancelled_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["organizations_model_templates.id"],
|
||||
name=op.f("fk_organizations_model_upgrades_template_id_organizations_model_templates"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_instantiation_id"],
|
||||
["organizations_model_instantiations.id"],
|
||||
name=op.f("fk_organizations_model_upgrades_source_instantiation_id_organizations_model_instantiations"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_template_version_id"],
|
||||
["organizations_model_template_versions.id"],
|
||||
name=op.f("fk_organizations_model_upgrades_source_template_version_id_organizations_model_template_versions"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_template_version_id"],
|
||||
["organizations_model_template_versions.id"],
|
||||
name=op.f("fk_organizations_model_upgrades_target_template_version_id_organizations_model_template_versions"),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_model_upgrades")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_organizations_model_upgrade_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"template_id",
|
||||
"source_instantiation_id",
|
||||
"source_template_version_id",
|
||||
"target_template_version_id",
|
||||
"status",
|
||||
"requested_by_account_id",
|
||||
"applied_by_account_id",
|
||||
"cancelled_by_account_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_organizations_model_upgrades_{column}"),
|
||||
"organizations_model_upgrades",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_organizations_model_upgrades_tenant_status",
|
||||
"organizations_model_upgrades",
|
||||
["tenant_id", "status", "updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("organizations_model_upgrades")
|
||||
@@ -0,0 +1,442 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
OrganizationModelTemplateDefinition,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelTemplate,
|
||||
OrganizationModelTemplateVersion,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationTemplateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
TENANT_MODEL_TYPES = (
|
||||
OrganizationRelation,
|
||||
OrganizationFunction,
|
||||
OrganizationUnit,
|
||||
OrganizationRelationType,
|
||||
OrganizationFunctionType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
def canonical_template_definition(
|
||||
definition: OrganizationModelTemplateDefinition,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
_validate_definition_references(definition)
|
||||
payload = definition.model_dump(mode="json")
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return payload, hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def instantiate_template_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
template: OrganizationModelTemplate,
|
||||
version: OrganizationModelTemplateVersion,
|
||||
actor_account_id: str | None,
|
||||
) -> OrganizationModelInstantiation:
|
||||
if version.template_id != template.id or version.status != "published":
|
||||
raise OrganizationTemplateError(
|
||||
"Only a published version of the selected template can be instantiated"
|
||||
)
|
||||
if any(
|
||||
session.query(model).filter(model.tenant_id == tenant_id).first()
|
||||
is not None
|
||||
for model in TENANT_MODEL_TYPES
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
"Organization templates can currently be instantiated only into an empty tenant model"
|
||||
)
|
||||
existing = (
|
||||
session.query(OrganizationModelInstantiation)
|
||||
.filter(
|
||||
OrganizationModelInstantiation.tenant_id == tenant_id,
|
||||
OrganizationModelInstantiation.template_version_id == version.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
definition = OrganizationModelTemplateDefinition.model_validate(
|
||||
version.definition
|
||||
)
|
||||
_validate_definition_references(definition)
|
||||
provenance_base = {
|
||||
"template_id": template.id,
|
||||
"template_slug": template.slug,
|
||||
"template_version_id": version.id,
|
||||
"template_version": version.version,
|
||||
"definition_sha256": version.definition_sha256,
|
||||
}
|
||||
|
||||
unit_types = {
|
||||
item.slug: OrganizationUnitType(
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.unit_types
|
||||
}
|
||||
structures = {
|
||||
item.slug: OrganizationStructure(
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
structure_kind=item.structure_kind,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.structures
|
||||
}
|
||||
session.add_all([*unit_types.values(), *structures.values()])
|
||||
session.flush()
|
||||
|
||||
relation_types = {
|
||||
item.slug: OrganizationRelationType(
|
||||
tenant_id=tenant_id,
|
||||
structure_id=_ref_id(structures, item.structure_slug),
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
source_unit_type_id=_ref_id(
|
||||
unit_types,
|
||||
item.source_unit_type_slug,
|
||||
),
|
||||
target_unit_type_id=_ref_id(
|
||||
unit_types,
|
||||
item.target_unit_type_slug,
|
||||
),
|
||||
is_hierarchical=item.is_hierarchical,
|
||||
allow_cycles=item.allow_cycles,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.relation_types
|
||||
}
|
||||
function_types = {
|
||||
item.slug: OrganizationFunctionType(
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
organization_unit_type_id=_ref_id(
|
||||
unit_types,
|
||||
item.organization_unit_type_slug,
|
||||
),
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.function_types
|
||||
}
|
||||
session.add_all([*relation_types.values(), *function_types.values()])
|
||||
session.flush()
|
||||
|
||||
units = {
|
||||
item.slug: OrganizationUnit(
|
||||
tenant_id=tenant_id,
|
||||
unit_type_id=_ref_id(unit_types, item.unit_type_slug),
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.units
|
||||
}
|
||||
session.add_all(list(units.values()))
|
||||
session.flush()
|
||||
for item in definition.units:
|
||||
units[item.slug].parent_id = _ref_id(units, item.parent_slug)
|
||||
|
||||
relations = [
|
||||
OrganizationRelation(
|
||||
tenant_id=tenant_id,
|
||||
structure_id=structures[item.structure_slug].id,
|
||||
relation_type_id=relation_types[item.relation_type_slug].id,
|
||||
source_unit_id=units[item.source_unit_slug].id,
|
||||
target_unit_id=units[item.target_unit_slug].id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(
|
||||
item.settings,
|
||||
provenance_base,
|
||||
(
|
||||
f"{item.structure_slug}:{item.relation_type_slug}:"
|
||||
f"{item.source_unit_slug}:{item.target_unit_slug}"
|
||||
),
|
||||
),
|
||||
)
|
||||
for item in definition.relations
|
||||
]
|
||||
functions = [
|
||||
OrganizationFunction(
|
||||
tenant_id=tenant_id,
|
||||
function_type_id=_ref_id(
|
||||
function_types,
|
||||
item.function_type_slug,
|
||||
),
|
||||
organization_unit_id=units[item.organization_unit_slug].id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.functions
|
||||
]
|
||||
session.add_all([*relations, *functions])
|
||||
|
||||
counts = {
|
||||
"unit_types": len(unit_types),
|
||||
"structures": len(structures),
|
||||
"relation_types": len(relation_types),
|
||||
"units": len(units),
|
||||
"relations": len(relations),
|
||||
"function_types": len(function_types),
|
||||
"functions": len(functions),
|
||||
}
|
||||
instantiation = OrganizationModelInstantiation(
|
||||
tenant_id=tenant_id,
|
||||
template_id=template.id,
|
||||
template_version_id=version.id,
|
||||
source_definition_sha256=version.definition_sha256,
|
||||
instantiated_by_account_id=actor_account_id,
|
||||
object_counts=counts,
|
||||
provenance={
|
||||
**provenance_base,
|
||||
"copy_semantics": "tenant_owned_no_live_inheritance",
|
||||
},
|
||||
)
|
||||
session.add(instantiation)
|
||||
session.flush()
|
||||
return instantiation
|
||||
|
||||
|
||||
def _validate_definition_references(
|
||||
definition: OrganizationModelTemplateDefinition,
|
||||
) -> None:
|
||||
collections = {
|
||||
"unit type": [item.slug for item in definition.unit_types],
|
||||
"structure": [item.slug for item in definition.structures],
|
||||
"relation type": [item.slug for item in definition.relation_types],
|
||||
"unit": [item.slug for item in definition.units],
|
||||
"function type": [item.slug for item in definition.function_types],
|
||||
"function": [item.slug for item in definition.functions],
|
||||
}
|
||||
for label, slugs in collections.items():
|
||||
if len(slugs) != len(set(slugs)):
|
||||
raise OrganizationTemplateError(
|
||||
f"Template contains duplicate {label} slugs"
|
||||
)
|
||||
unit_types = set(collections["unit type"])
|
||||
structures = set(collections["structure"])
|
||||
relation_types = set(collections["relation type"])
|
||||
units = set(collections["unit"])
|
||||
function_types = set(collections["function type"])
|
||||
unit_by_slug = {item.slug: item for item in definition.units}
|
||||
relation_type_by_slug = {
|
||||
item.slug: item for item in definition.relation_types
|
||||
}
|
||||
function_type_by_slug = {
|
||||
item.slug: item for item in definition.function_types
|
||||
}
|
||||
for item in definition.relation_types:
|
||||
_require_ref(structures, item.structure_slug, "structure")
|
||||
_require_ref(unit_types, item.source_unit_type_slug, "source unit type")
|
||||
_require_ref(unit_types, item.target_unit_type_slug, "target unit type")
|
||||
for item in definition.units:
|
||||
_require_ref(unit_types, item.unit_type_slug, "unit type")
|
||||
_require_ref(units, item.parent_slug, "parent unit")
|
||||
if item.parent_slug == item.slug:
|
||||
raise OrganizationTemplateError("A unit cannot be its own parent")
|
||||
_require_acyclic_graph(
|
||||
{
|
||||
item.slug: {item.parent_slug}
|
||||
for item in definition.units
|
||||
if item.parent_slug is not None
|
||||
},
|
||||
label="unit parent hierarchy",
|
||||
)
|
||||
relation_edges: dict[str, dict[str, set[str]]] = {}
|
||||
relation_keys: set[tuple[str, str, str, str]] = set()
|
||||
for item in definition.relations:
|
||||
_require_ref(structures, item.structure_slug, "structure")
|
||||
_require_ref(relation_types, item.relation_type_slug, "relation type")
|
||||
_require_ref(units, item.source_unit_slug, "source unit")
|
||||
_require_ref(units, item.target_unit_slug, "target unit")
|
||||
if (
|
||||
item.valid_from is not None
|
||||
and item.valid_until is not None
|
||||
and item.valid_until < item.valid_from
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
"A relation validity end cannot precede its start"
|
||||
)
|
||||
relation_type = relation_type_by_slug[item.relation_type_slug]
|
||||
if (
|
||||
relation_type.structure_slug is not None
|
||||
and relation_type.structure_slug != item.structure_slug
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Relation {item.relation_type_slug!r} belongs to structure "
|
||||
f"{relation_type.structure_slug!r}, not {item.structure_slug!r}"
|
||||
)
|
||||
source_unit_type = unit_by_slug[item.source_unit_slug].unit_type_slug
|
||||
target_unit_type = unit_by_slug[item.target_unit_slug].unit_type_slug
|
||||
if (
|
||||
relation_type.source_unit_type_slug is not None
|
||||
and relation_type.source_unit_type_slug != source_unit_type
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Relation {item.relation_type_slug!r} does not allow source "
|
||||
f"unit {item.source_unit_slug!r}"
|
||||
)
|
||||
if (
|
||||
relation_type.target_unit_type_slug is not None
|
||||
and relation_type.target_unit_type_slug != target_unit_type
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Relation {item.relation_type_slug!r} does not allow target "
|
||||
f"unit {item.target_unit_slug!r}"
|
||||
)
|
||||
relation_key = (
|
||||
item.structure_slug,
|
||||
item.relation_type_slug,
|
||||
item.source_unit_slug,
|
||||
item.target_unit_slug,
|
||||
)
|
||||
if relation_key in relation_keys:
|
||||
raise OrganizationTemplateError(
|
||||
"Template contains a duplicate organization relation"
|
||||
)
|
||||
relation_keys.add(relation_key)
|
||||
if relation_type.is_hierarchical and not relation_type.allow_cycles:
|
||||
targets = relation_edges.setdefault(item.relation_type_slug, {})
|
||||
targets.setdefault(item.source_unit_slug, set()).add(
|
||||
item.target_unit_slug
|
||||
)
|
||||
for relation_type_slug, edges in relation_edges.items():
|
||||
_require_acyclic_graph(
|
||||
edges,
|
||||
label=f"relation hierarchy {relation_type_slug!r}",
|
||||
)
|
||||
for item in definition.function_types:
|
||||
_require_ref(
|
||||
unit_types,
|
||||
item.organization_unit_type_slug,
|
||||
"organization unit type",
|
||||
)
|
||||
for item in definition.functions:
|
||||
_require_ref(function_types, item.function_type_slug, "function type")
|
||||
_require_ref(units, item.organization_unit_slug, "organization unit")
|
||||
if item.function_type_slug is None:
|
||||
continue
|
||||
expected_unit_type = function_type_by_slug[
|
||||
item.function_type_slug
|
||||
].organization_unit_type_slug
|
||||
actual_unit_type = unit_by_slug[
|
||||
item.organization_unit_slug
|
||||
].unit_type_slug
|
||||
if (
|
||||
expected_unit_type is not None
|
||||
and expected_unit_type != actual_unit_type
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Function type {item.function_type_slug!r} does not apply to "
|
||||
f"unit {item.organization_unit_slug!r}"
|
||||
)
|
||||
|
||||
|
||||
def _require_ref(
|
||||
values: set[str],
|
||||
value: str | None,
|
||||
label: str,
|
||||
) -> None:
|
||||
if value is not None and value not in values:
|
||||
raise OrganizationTemplateError(
|
||||
f"Template references an unknown {label}: {value}"
|
||||
)
|
||||
|
||||
|
||||
def _ref_id(rows: dict[str, Any], slug: str | None) -> str | None:
|
||||
return rows[slug].id if slug is not None else None
|
||||
|
||||
|
||||
def _require_acyclic_graph(
|
||||
edges: dict[str, set[str]],
|
||||
*,
|
||||
label: str,
|
||||
) -> None:
|
||||
complete: set[str] = set()
|
||||
active: set[str] = set()
|
||||
|
||||
def visit(node: str) -> None:
|
||||
if node in complete:
|
||||
return
|
||||
if node in active:
|
||||
raise OrganizationTemplateError(
|
||||
f"Template contains a cycle in {label}"
|
||||
)
|
||||
active.add(node)
|
||||
for target in edges.get(node, ()):
|
||||
visit(target)
|
||||
active.remove(node)
|
||||
complete.add(node)
|
||||
|
||||
for start in edges:
|
||||
visit(start)
|
||||
|
||||
|
||||
def _settings(
|
||||
settings: dict[str, Any],
|
||||
provenance_base: dict[str, Any],
|
||||
source_key: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**dict(settings),
|
||||
"template_provenance": {
|
||||
**provenance_base,
|
||||
"source_key": source_key,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrganizationTemplateError",
|
||||
"canonical_template_definition",
|
||||
"instantiate_template_version",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.api.v1.routes import (
|
||||
create_function,
|
||||
create_unit,
|
||||
get_organization_model,
|
||||
update_function,
|
||||
update_unit,
|
||||
)
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
FunctionCreateRequest,
|
||||
FunctionUpdateRequest,
|
||||
UnitCreateRequest,
|
||||
UnitUpdateRequest,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationTenantSettings,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationAdministrationRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
self.tables = [
|
||||
ChangeSequenceEntry.__table__,
|
||||
OrganizationUnitType.__table__,
|
||||
OrganizationStructure.__table__,
|
||||
OrganizationRelationType.__table__,
|
||||
OrganizationUnit.__table__,
|
||||
OrganizationRelation.__table__,
|
||||
OrganizationFunctionType.__table__,
|
||||
OrganizationFunction.__table__,
|
||||
OrganizationTenantSettings.__table__,
|
||||
]
|
||||
Base.metadata.create_all(self.engine, tables=self.tables)
|
||||
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
self.session: Session = self.Session()
|
||||
self.principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
scopes=frozenset(),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine, tables=list(reversed(self.tables)))
|
||||
self.engine.dispose()
|
||||
|
||||
def test_unit_tree_and_function_create_update_list_deactivate(self) -> None:
|
||||
events: list[object] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("*", events.append)
|
||||
|
||||
with event_bus_context(bus):
|
||||
root = create_unit(
|
||||
UnitCreateRequest(name="Central administration", slug="central"),
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
child = create_unit(
|
||||
UnitCreateRequest(
|
||||
name="Procurement",
|
||||
slug="procurement",
|
||||
parent_id=root.id,
|
||||
),
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
function = create_function(
|
||||
FunctionCreateRequest(
|
||||
name="Procurement lead",
|
||||
slug="lead",
|
||||
organization_unit_id=child.id,
|
||||
delegable=True,
|
||||
),
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
|
||||
updated_child = update_unit(
|
||||
child.id,
|
||||
UnitUpdateRequest(
|
||||
name="Strategic procurement",
|
||||
is_active=False,
|
||||
),
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
updated_function = update_function(
|
||||
function.id,
|
||||
FunctionUpdateRequest(is_active=False),
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
|
||||
model = get_organization_model(
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(root.id, next(item for item in model.units if item.id == child.id).parent_id)
|
||||
self.assertEqual("Strategic procurement", updated_child.name)
|
||||
self.assertFalse(updated_child.is_active)
|
||||
self.assertFalse(updated_function.is_active)
|
||||
self.assertEqual(child.id, updated_function.organization_unit_id)
|
||||
self.assertEqual(2, len(model.units))
|
||||
self.assertEqual(1, len(model.functions))
|
||||
self.assertEqual(
|
||||
[
|
||||
"organizations.unit.created.v1",
|
||||
"organizations.unit.created.v1",
|
||||
"organizations.function.created.v1",
|
||||
"organizations.unit.deactivated.v1",
|
||||
"organizations.function.deactivated.v1",
|
||||
],
|
||||
[
|
||||
event.type
|
||||
for event in events
|
||||
if event.module_id == "organizations"
|
||||
],
|
||||
)
|
||||
|
||||
def test_model_listing_is_tenant_scoped(self) -> None:
|
||||
create_unit(
|
||||
UnitCreateRequest(name="Visible unit", slug="visible"),
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
create_unit(
|
||||
UnitCreateRequest(name="Other unit", slug="other"),
|
||||
session=self.session,
|
||||
principal=SimpleNamespace(
|
||||
tenant_id="tenant-2",
|
||||
account_id="account-2",
|
||||
membership_id="membership-2",
|
||||
scopes=frozenset(),
|
||||
),
|
||||
)
|
||||
|
||||
model = get_organization_model(
|
||||
session=self.session,
|
||||
principal=self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(["Visible unit"], [item.name for item in model.units])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,536 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
OrganizationModelTemplateDefinition,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelTemplate,
|
||||
OrganizationModelTemplateVersion,
|
||||
OrganizationModelUpgrade,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
from govoplan_organizations.backend.templates import (
|
||||
OrganizationTemplateError,
|
||||
canonical_template_definition,
|
||||
instantiate_template_version,
|
||||
)
|
||||
from govoplan_organizations.backend.upgrades import (
|
||||
OrganizationUpgradeError,
|
||||
apply_model_upgrade,
|
||||
cancel_model_upgrade,
|
||||
create_model_upgrade_preview,
|
||||
current_model_instantiation,
|
||||
)
|
||||
|
||||
|
||||
TABLES = [
|
||||
OrganizationModelTemplate.__table__,
|
||||
OrganizationModelTemplateVersion.__table__,
|
||||
OrganizationUnitType.__table__,
|
||||
OrganizationStructure.__table__,
|
||||
OrganizationRelationType.__table__,
|
||||
OrganizationFunctionType.__table__,
|
||||
OrganizationUnit.__table__,
|
||||
OrganizationFunction.__table__,
|
||||
OrganizationRelation.__table__,
|
||||
OrganizationModelInstantiation.__table__,
|
||||
OrganizationModelUpgrade.__table__,
|
||||
]
|
||||
|
||||
|
||||
class OrganizationModelTemplateTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=TABLES)
|
||||
self.session: Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
expire_on_commit=False,
|
||||
)()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine, tables=reversed(TABLES))
|
||||
self.engine.dispose()
|
||||
|
||||
def test_published_template_is_copied_into_tenant_owned_records(self) -> None:
|
||||
definition, fingerprint = canonical_template_definition(_definition())
|
||||
template = OrganizationModelTemplate(
|
||||
id="template-1",
|
||||
slug="municipality",
|
||||
name="Municipality",
|
||||
)
|
||||
version = OrganizationModelTemplateVersion(
|
||||
id="template-version-1",
|
||||
template_id=template.id,
|
||||
version="1.0.0",
|
||||
status="published",
|
||||
definition=definition,
|
||||
definition_sha256=fingerprint,
|
||||
)
|
||||
self.session.add_all([template, version])
|
||||
self.session.commit()
|
||||
|
||||
result = instantiate_template_version(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
template=template,
|
||||
version=version,
|
||||
actor_account_id="account-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"unit_types": 1,
|
||||
"structures": 1,
|
||||
"relation_types": 1,
|
||||
"units": 2,
|
||||
"relations": 1,
|
||||
"function_types": 1,
|
||||
"functions": 1,
|
||||
},
|
||||
result.object_counts,
|
||||
)
|
||||
office = (
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == "tenant-1",
|
||||
OrganizationUnit.slug == "office",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
root = (
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == "tenant-1",
|
||||
OrganizationUnit.slug == "municipality",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(root.id, office.parent_id)
|
||||
self.assertEqual(
|
||||
"tenant_owned_no_live_inheritance",
|
||||
result.provenance["copy_semantics"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"1.0.0",
|
||||
office.settings["template_provenance"]["template_version"],
|
||||
)
|
||||
|
||||
version.definition = {}
|
||||
self.session.commit()
|
||||
self.assertEqual("Office", office.name)
|
||||
|
||||
def test_instantiation_refuses_implicit_merge_into_existing_model(self) -> None:
|
||||
definition, fingerprint = canonical_template_definition(_definition())
|
||||
template = OrganizationModelTemplate(
|
||||
id="template-2",
|
||||
slug="municipality-2",
|
||||
name="Municipality",
|
||||
)
|
||||
version = OrganizationModelTemplateVersion(
|
||||
id="template-version-2",
|
||||
template_id=template.id,
|
||||
version="1",
|
||||
status="published",
|
||||
definition=definition,
|
||||
definition_sha256=fingerprint,
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
template,
|
||||
version,
|
||||
OrganizationUnitType(
|
||||
tenant_id="tenant-1",
|
||||
slug="existing",
|
||||
name="Existing",
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"only into an empty tenant model",
|
||||
):
|
||||
instantiate_template_version(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
template=template,
|
||||
version=version,
|
||||
actor_account_id="account-1",
|
||||
)
|
||||
|
||||
def test_unknown_template_reference_is_rejected_before_storage(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.units[1].unit_type_slug = "unknown"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"unknown unit type",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_parent_cycles_are_rejected_before_storage(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.units[0].parent_slug = "office"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"cycle in unit parent hierarchy",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_relation_unit_type_mismatch_is_rejected(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.unit_types.append(
|
||||
definition.unit_types[0].model_copy(
|
||||
update={"slug": "other", "name": "Other"}
|
||||
)
|
||||
)
|
||||
definition.units[1].unit_type_slug = "other"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"does not allow source unit",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_function_unit_type_mismatch_is_rejected(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.unit_types.append(
|
||||
definition.unit_types[0].model_copy(
|
||||
update={"slug": "other", "name": "Other"}
|
||||
)
|
||||
)
|
||||
definition.units[1].unit_type_slug = "other"
|
||||
definition.relations = []
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"does not apply to unit",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_three_way_upgrade_previews_additions_and_local_divergence(self) -> None:
|
||||
template, source, target = self._instantiated_upgrade_fixture(
|
||||
mutate_target=lambda definition: definition.units.append(
|
||||
definition.units[1].model_copy(
|
||||
update={
|
||||
"slug": "service-office",
|
||||
"name": "Service office",
|
||||
"parent_slug": "municipality",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
office = (
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter_by(tenant_id="tenant-1", slug="office")
|
||||
.one()
|
||||
)
|
||||
office.name = "Tenant-specific office"
|
||||
self.session.commit()
|
||||
|
||||
preview = create_model_upgrade_preview(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
target_version=target,
|
||||
actor_account_id="account-1",
|
||||
idempotency_key="preview-additive",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(1, preview.preview["counts"]["compatible_addition"])
|
||||
self.assertEqual(1, preview.preview["counts"]["local_divergence"])
|
||||
self.assertEqual(0, preview.preview["requires_decisions"])
|
||||
applied, instantiation = apply_model_upgrade(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
upgrade_id=preview.id,
|
||||
expected_revision=1,
|
||||
decisions={},
|
||||
actor_account_id="account-2",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual("applied", applied.status)
|
||||
self.assertEqual(target.id, instantiation.template_version_id)
|
||||
self.assertEqual(
|
||||
"Tenant-specific office",
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter_by(tenant_id="tenant-1", slug="office")
|
||||
.one()
|
||||
.name,
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter_by(tenant_id="tenant-1", slug="service-office")
|
||||
.one_or_none()
|
||||
)
|
||||
self.assertEqual("superseded", source.status)
|
||||
self.assertEqual(instantiation.id, current_model_instantiation(self.session, tenant_id="tenant-1").id)
|
||||
self.assertEqual(template.id, instantiation.template_id)
|
||||
|
||||
def test_divergent_upgrade_requires_bounded_decision_and_detects_stale_state(self) -> None:
|
||||
_template, _source, target = self._instantiated_upgrade_fixture(
|
||||
mutate_target=lambda definition: setattr(
|
||||
definition.units[1], "name", "Template office"
|
||||
)
|
||||
)
|
||||
office = self.session.query(OrganizationUnit).filter_by(
|
||||
tenant_id="tenant-1", slug="office"
|
||||
).one()
|
||||
office.name = "Local office"
|
||||
self.session.commit()
|
||||
preview = create_model_upgrade_preview(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
target_version=target,
|
||||
actor_account_id="account-1",
|
||||
idempotency_key="preview-divergent",
|
||||
)
|
||||
self.session.commit()
|
||||
conflict = next(
|
||||
entry
|
||||
for entry in preview.preview["entries"]
|
||||
if entry["id"] == "units:office"
|
||||
)
|
||||
self.assertTrue(conflict["requires_decision"])
|
||||
with self.assertRaisesRegex(OrganizationUpgradeError, "decision is required"):
|
||||
apply_model_upgrade(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
upgrade_id=preview.id,
|
||||
expected_revision=1,
|
||||
decisions={},
|
||||
actor_account_id="account-2",
|
||||
)
|
||||
self.session.rollback()
|
||||
|
||||
office = self.session.query(OrganizationUnit).filter_by(
|
||||
tenant_id="tenant-1", slug="office"
|
||||
).one()
|
||||
office.description = "Changed after preview"
|
||||
self.session.commit()
|
||||
with self.assertRaisesRegex(OrganizationUpgradeError, "changed after this preview"):
|
||||
apply_model_upgrade(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
upgrade_id=preview.id,
|
||||
expected_revision=1,
|
||||
decisions={"units:office": {"action": "use_target"}},
|
||||
actor_account_id="account-2",
|
||||
)
|
||||
|
||||
def test_divergent_upgrade_applies_explicit_target_decision(self) -> None:
|
||||
_template, _source, target = self._instantiated_upgrade_fixture(
|
||||
mutate_target=lambda definition: setattr(
|
||||
definition.units[1], "name", "Template office"
|
||||
)
|
||||
)
|
||||
office = self.session.query(OrganizationUnit).filter_by(
|
||||
tenant_id="tenant-1", slug="office"
|
||||
).one()
|
||||
office.name = "Local office"
|
||||
self.session.commit()
|
||||
preview = create_model_upgrade_preview(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
target_version=target,
|
||||
actor_account_id="account-1",
|
||||
idempotency_key="preview-explicit-decision",
|
||||
)
|
||||
self.session.commit()
|
||||
applied, _instantiation = apply_model_upgrade(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
upgrade_id=preview.id,
|
||||
expected_revision=1,
|
||||
decisions={"units:office": {"action": "use_target"}},
|
||||
actor_account_id="account-2",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual("applied", applied.status)
|
||||
self.assertEqual(
|
||||
"Template office",
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter_by(tenant_id="tenant-1", slug="office")
|
||||
.one()
|
||||
.name,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"action": "use_target"},
|
||||
applied.decisions["units:office"],
|
||||
)
|
||||
|
||||
def test_invalid_local_references_block_and_preview_can_be_cancelled(self) -> None:
|
||||
_template, _source, target = self._instantiated_upgrade_fixture()
|
||||
office = self.session.query(OrganizationUnit).filter_by(
|
||||
tenant_id="tenant-1", slug="office"
|
||||
).one()
|
||||
office.unit_type_id = "missing-unit-type"
|
||||
self.session.commit()
|
||||
preview = create_model_upgrade_preview(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
target_version=target,
|
||||
actor_account_id="account-1",
|
||||
idempotency_key="preview-invalid",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertGreater(preview.preview["blocking_invalid_references"], 0)
|
||||
with self.assertRaisesRegex(OrganizationUpgradeError, "Invalid tenant references"):
|
||||
apply_model_upgrade(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
upgrade_id=preview.id,
|
||||
expected_revision=1,
|
||||
decisions={},
|
||||
actor_account_id="account-2",
|
||||
)
|
||||
self.session.rollback()
|
||||
cancelled = cancel_model_upgrade(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
upgrade_id=preview.id,
|
||||
expected_revision=1,
|
||||
actor_account_id="account-1",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual("cancelled", cancelled.status)
|
||||
self.assertEqual(2, cancelled.revision)
|
||||
self.assertIsNone(cancelled.applied_at)
|
||||
|
||||
def test_unchanged_upgrade_preview_has_no_changes(self) -> None:
|
||||
_template, _source, target = self._instantiated_upgrade_fixture()
|
||||
preview = create_model_upgrade_preview(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
target_version=target,
|
||||
actor_account_id="account-1",
|
||||
idempotency_key="preview-unchanged",
|
||||
)
|
||||
self.assertEqual([], preview.preview["entries"])
|
||||
self.assertEqual(0, preview.preview["requires_decisions"])
|
||||
|
||||
def _instantiated_upgrade_fixture(self, mutate_target=None):
|
||||
source_definition = _definition()
|
||||
source_payload, source_hash = canonical_template_definition(source_definition)
|
||||
target_definition = source_definition.model_copy(deep=True)
|
||||
if mutate_target is not None:
|
||||
mutate_target(target_definition)
|
||||
target_payload, target_hash = canonical_template_definition(target_definition)
|
||||
template = OrganizationModelTemplate(
|
||||
id="template-upgrade",
|
||||
slug="upgrade-template",
|
||||
name="Upgrade template",
|
||||
)
|
||||
source_version = OrganizationModelTemplateVersion(
|
||||
id="template-upgrade-v1",
|
||||
template_id=template.id,
|
||||
version="1.0.0",
|
||||
status="published",
|
||||
definition=source_payload,
|
||||
definition_sha256=source_hash,
|
||||
)
|
||||
target_version = OrganizationModelTemplateVersion(
|
||||
id="template-upgrade-v2",
|
||||
template_id=template.id,
|
||||
version="2.0.0",
|
||||
status="published",
|
||||
definition=target_payload,
|
||||
definition_sha256=target_hash,
|
||||
)
|
||||
self.session.add_all([template, source_version, target_version])
|
||||
self.session.flush()
|
||||
source = instantiate_template_version(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
template=template,
|
||||
version=source_version,
|
||||
actor_account_id="account-1",
|
||||
)
|
||||
self.session.commit()
|
||||
return template, source, target_version
|
||||
|
||||
|
||||
def _definition() -> OrganizationModelTemplateDefinition:
|
||||
return OrganizationModelTemplateDefinition.model_validate(
|
||||
{
|
||||
"unit_types": [
|
||||
{
|
||||
"slug": "administrative-unit",
|
||||
"name": "Administrative unit",
|
||||
}
|
||||
],
|
||||
"structures": [
|
||||
{
|
||||
"slug": "administrative",
|
||||
"name": "Administrative hierarchy",
|
||||
}
|
||||
],
|
||||
"relation_types": [
|
||||
{
|
||||
"slug": "reports-to",
|
||||
"name": "Reports to",
|
||||
"structure_slug": "administrative",
|
||||
"source_unit_type_slug": "administrative-unit",
|
||||
"target_unit_type_slug": "administrative-unit",
|
||||
}
|
||||
],
|
||||
"units": [
|
||||
{
|
||||
"slug": "municipality",
|
||||
"name": "Municipality",
|
||||
"unit_type_slug": "administrative-unit",
|
||||
},
|
||||
{
|
||||
"slug": "office",
|
||||
"name": "Office",
|
||||
"unit_type_slug": "administrative-unit",
|
||||
"parent_slug": "municipality",
|
||||
},
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"structure_slug": "administrative",
|
||||
"relation_type_slug": "reports-to",
|
||||
"source_unit_slug": "office",
|
||||
"target_unit_slug": "municipality",
|
||||
}
|
||||
],
|
||||
"function_types": [
|
||||
{
|
||||
"slug": "head",
|
||||
"name": "Head",
|
||||
"organization_unit_type_slug": "administrative-unit",
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"slug": "head",
|
||||
"name": "Office head",
|
||||
"function_type_slug": "head",
|
||||
"organization_unit_slug": "office",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.api.v1.routes import (
|
||||
get_organization_settings,
|
||||
update_organization_settings,
|
||||
)
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
OrganizationSettingsUpdateRequest,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import OrganizationTenantSettings
|
||||
|
||||
|
||||
class OrganizationSettingsRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
OrganizationTenantSettings.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
OrganizationTenantSettings.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
def _principal(tenant_id: str) -> object:
|
||||
return SimpleNamespace(tenant_id=tenant_id)
|
||||
|
||||
def test_missing_settings_row_returns_tenant_defaults(self) -> None:
|
||||
result = get_organization_settings(
|
||||
session=self.session,
|
||||
principal=self._principal("tenant-1"),
|
||||
)
|
||||
|
||||
self.assertEqual(result.tenant_id, "tenant-1")
|
||||
self.assertTrue(result.allow_tenant_model_customization)
|
||||
self.assertFalse(result.require_model_change_requests)
|
||||
self.assertEqual(result.audit_detail_level, "standard")
|
||||
self.assertIsNone(result.change_retention_days)
|
||||
self.assertEqual(result.settings, {})
|
||||
self.assertEqual(self.session.query(OrganizationTenantSettings).count(), 0)
|
||||
|
||||
def test_first_update_creates_only_the_active_tenant_settings(self) -> None:
|
||||
result = update_organization_settings(
|
||||
OrganizationSettingsUpdateRequest(
|
||||
allow_tenant_model_customization=False,
|
||||
require_model_change_requests=True,
|
||||
audit_detail_level="full",
|
||||
change_retention_days=365,
|
||||
settings={"template": {"id": "municipality", "version": "2"}},
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal("tenant-1"),
|
||||
)
|
||||
|
||||
self.assertEqual(result.tenant_id, "tenant-1")
|
||||
self.assertFalse(result.allow_tenant_model_customization)
|
||||
self.assertTrue(result.require_model_change_requests)
|
||||
self.assertEqual(result.change_retention_days, 365)
|
||||
self.assertEqual(
|
||||
result.settings,
|
||||
{"template": {"id": "municipality", "version": "2"}},
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.session.query(OrganizationTenantSettings)
|
||||
.filter(OrganizationTenantSettings.tenant_id == "tenant-2")
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
def test_settings_reads_are_tenant_isolated(self) -> None:
|
||||
self.session.add(
|
||||
OrganizationTenantSettings(
|
||||
tenant_id="tenant-1",
|
||||
allow_tenant_model_customization=False,
|
||||
require_model_change_requests=True,
|
||||
audit_detail_level="detailed",
|
||||
settings={"private": "tenant-1"},
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
other = get_organization_settings(
|
||||
session=self.session,
|
||||
principal=self._principal("tenant-2"),
|
||||
)
|
||||
|
||||
self.assertEqual(other.tenant_id, "tenant-2")
|
||||
self.assertTrue(other.allow_tenant_model_customization)
|
||||
self.assertEqual(other.settings, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+12
-7
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"name": "@govoplan/organizations-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "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": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
@@ -14,14 +19,14 @@
|
||||
"./styles/organizations.css": "./src/styles/organizations.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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("requestDiscard(() => void loadModel())") && settings.includes("requestDiscard(() => void load())"), "Reload preserves dirty organization drafts and settings");
|
||||
assert(page.includes("hasDirtyDraft ? requestDiscard(discardDrafts)"), "Editor close uses the shared unsaved-change guard");
|
||||
assert(page.includes("ORGANIZATIONS_FIELD_DOCUMENTATION"), "Model fields link to stable consequence documentation");
|
||||
assert(patterns.includes('topicId: "organizations.model"') && patterns.includes('topicId: "organizations.reference.fields-and-consequences"'), "Organizations uses manifest-backed help references");
|
||||
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-organizations.organizations_administration"'), "WebUI metadata matches the module release and localizes its composed surface");
|
||||
assert(translations.includes('"i18n:govoplan-organizations.read_only_summary"'), "Blocker explanations are present in the translation catalogue");
|
||||
assert(!page.includes("window.confirm"), "Organizations does not use browser-native consequential confirmation");
|
||||
|
||||
console.log("Organizations surfaces satisfy the recorded interface pattern-language contract.");
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = readFileSync(
|
||||
new URL("../src/features/organizations/OrganizationsPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const styles = readFileSync(
|
||||
new URL("../src/styles/organizations.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const moduleSource = readFileSync(
|
||||
new URL("../src/module.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const apiSource = readFileSync(
|
||||
new URL("../src/api/organizations.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert(source.includes("ExplorerTree,"), "Organizations imports the central ExplorerTree");
|
||||
assert(source.includes("<ExplorerTree"), "the organization hierarchy uses ExplorerTree");
|
||||
assert(source.includes("collapsible={false}"), "the always-expanded hierarchy uses the non-collapsible contract");
|
||||
assert(source.includes("renderNodeActions="), "per-unit controls use the sibling node-action slot");
|
||||
assert(source.includes("<IconButton"), "per-unit controls use the central icon-only action primitive");
|
||||
assert(!source.includes("renderUnitTreeNodes"), "the custom recursive renderer was removed");
|
||||
assert(!source.includes("organizations-tree-row"), "the custom tree row was removed");
|
||||
assert(!source.includes("organizations-tree-node"), "the custom tree node was removed");
|
||||
assert(!styles.includes("organizations-tree-"), "Organizations no longer owns shared tree styling");
|
||||
assert(
|
||||
moduleSource.includes('"admin.sections": organizationAdminSections'),
|
||||
"organization governance settings use the shared admin layout"
|
||||
);
|
||||
for (const operation of [
|
||||
"createUnit",
|
||||
"patchUnit",
|
||||
"createFunction",
|
||||
"patchFunction"
|
||||
]) {
|
||||
assert(
|
||||
source.includes(operation),
|
||||
`the organization editor exposes ${operation}`
|
||||
);
|
||||
assert(
|
||||
apiSource.includes(`function ${operation}`),
|
||||
`the organization API client defines ${operation}`
|
||||
);
|
||||
}
|
||||
assert(
|
||||
source.includes("is_active: draft.is_active"),
|
||||
"organization editors persist active and inactive state"
|
||||
);
|
||||
assert(
|
||||
source.includes('id="organizations-units"')
|
||||
&& source.includes('id="organizations-functions"'),
|
||||
"unit and function lists use the shared DataGrid"
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = process.cwd();
|
||||
const panel = fs.readFileSync(
|
||||
path.join(root, "src/features/organizations/OrganizationTemplateUpgradePanel.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
const api = fs.readFileSync(path.join(root, "src/api/organizations.ts"), "utf8");
|
||||
const moduleSource = fs.readFileSync(path.join(root, "src/module.ts"), "utf8");
|
||||
|
||||
const expectations = [
|
||||
[panel, "previewOrganizationModelUpgrade", "The panel must create a persisted preview."],
|
||||
[panel, "applyOrganizationModelUpgrade", "The panel must explicitly apply a reviewed preview."],
|
||||
[panel, "cancelOrganizationModelUpgrade", "The panel must support cancellation without mutation."],
|
||||
[panel, "<ConfirmDialog open={applyConfirmation}", "Apply must use a separate confirmation step."],
|
||||
[panel, "<DataGrid id={`organization-model-upgrade-diff-", "The comparison must use the shared DataGrid."],
|
||||
[panel, "<DismissibleAlert", "Errors and outcomes must use the shared alert component."],
|
||||
[api, '"/api/v1/organizations/model-upgrades/preview"', "The preview API must be declared."],
|
||||
[api, "/apply`", "The apply API must be declared."],
|
||||
[moduleSource, 'id: "organizations.admin.template-upgrades"', "Views must be able to target the upgrade surface."]
|
||||
];
|
||||
|
||||
for (const [source, marker, message] of expectations) {
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (/useEffect\([^)]*applyOrganizationModelUpgrade/s.test(panel)) {
|
||||
throw new Error("An organization template upgrade must never apply from an effect.");
|
||||
}
|
||||
|
||||
console.log("Organization template upgrade interface contract passed.");
|
||||
+126
-24
@@ -1,4 +1,4 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiPatchJson, apiPostJson, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type OrganizationUnitTypeItem = {
|
||||
id: string;
|
||||
@@ -135,6 +135,81 @@ export type OrganizationModel = {
|
||||
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 = {
|
||||
change_request_id?: string | null;
|
||||
};
|
||||
@@ -186,78 +261,105 @@ export type FunctionCreatePayload = SluggedCreatePayload & {
|
||||
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> {
|
||||
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> {
|
||||
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
|
||||
return apiPatchJson(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react";
|
||||
import {
|
||||
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 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="metric-grid 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" />
|
||||
</div>
|
||||
<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>
|
||||
<div 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>
|
||||
</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 && <>
|
||||
<div className="metric-grid 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" />
|
||||
</div>
|
||||
<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 {
|
||||
ActionBlockerHint,
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
ToggleSwitch,
|
||||
adminErrorMessage,
|
||||
hasAnyScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
@@ -17,6 +20,13 @@ import {
|
||||
type OrganizationAuditDetailLevel,
|
||||
type OrganizationSettingsItem
|
||||
} from "../../api/organizations";
|
||||
import {
|
||||
ORGANIZATIONS_DOCUMENTATION,
|
||||
ORGANIZATIONS_FIELD_DOCUMENTATION,
|
||||
ORGANIZATIONS_INTERFACE_I18N,
|
||||
organizationWriteReason
|
||||
} from "./interfacePatterns";
|
||||
import OrganizationTemplateUpgradePanel from "./OrganizationTemplateUpgradePanel";
|
||||
|
||||
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
||||
tenant_id: "",
|
||||
@@ -40,8 +50,16 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
|
||||
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({
|
||||
dirty,
|
||||
@@ -99,20 +117,57 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
|
||||
error={error}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{!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}
|
||||
/>
|
||||
)}
|
||||
<div className="organizations-settings-grid">
|
||||
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
|
||||
<div className="settings-list">
|
||||
<ToggleSwitch
|
||||
checked={draft.allow_tenant_model_customization}
|
||||
disabled={!canWrite || busy}
|
||||
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
|
||||
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={draft.require_model_change_requests}
|
||||
disabled={!canWrite || busy}
|
||||
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||
onChange={(checked) => setDraft({ ...draft, require_model_change_requests: checked })}
|
||||
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
|
||||
/>
|
||||
@@ -121,13 +176,21 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
||||
<div className="organizations-form-grid">
|
||||
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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 })}>
|
||||
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</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}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -141,6 +204,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
||||
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
|
||||
</Card>
|
||||
</div>
|
||||
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
@@ -8,19 +9,27 @@ import {
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
ExplorerTree,
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
ModuleSubnav,
|
||||
PageTitle,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
isViewSurfaceVisible,
|
||||
useEffectiveView,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
usePlatformUiCapabilities,
|
||||
useViewSurfaces,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
type ModuleSubnavGroup,
|
||||
type OrganizationFunctionActionContribution,
|
||||
type OrganizationFunctionActionsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -56,6 +65,12 @@ import {
|
||||
type StructureCreatePayload,
|
||||
type UnitCreatePayload
|
||||
} from "../../api/organizations";
|
||||
import {
|
||||
ORGANIZATIONS_DOCUMENTATION,
|
||||
ORGANIZATIONS_FIELD_DOCUMENTATION,
|
||||
ORGANIZATIONS_INTERFACE_I18N,
|
||||
organizationWriteReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
export type OrganizationSection = "model" | "units" | "relations" | "functions";
|
||||
type OrganizationsPageMode = "workspace" | "admin";
|
||||
@@ -293,41 +308,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"} />;
|
||||
}
|
||||
|
||||
function RowActions({ disabled, onEdit, extra }: { disabled: boolean; onEdit: () => void; extra?: JSX.Element[] }) {
|
||||
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,
|
||||
onChange,
|
||||
disabled
|
||||
disabled,
|
||||
disabledReason
|
||||
}: {
|
||||
draft: SluggedDraft;
|
||||
onChange: (next: SluggedDraft) => void;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
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 })} />
|
||||
</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 })} />
|
||||
</FormField>
|
||||
<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 })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<label className="organizations-inline-check wide">
|
||||
<input type="checkbox" checked={draft.is_active} disabled={disabled} onChange={(event) => onChange({ ...draft, is_active: event.target.checked })} />
|
||||
<span>i18n:govoplan-organizations.active.a733b809</span>
|
||||
</label>
|
||||
<div className="wide">
|
||||
<ToggleSwitch
|
||||
checked={draft.is_active}
|
||||
disabled={disabled}
|
||||
help={disabledReason}
|
||||
onChange={(checked) => onChange({ ...draft, is_active: checked })}
|
||||
label="i18n:govoplan-organizations.active.a733b809"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -361,6 +386,7 @@ export default function OrganizationsPage({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const [unitTypeDraft, setUnitTypeDraft] = useState<SluggedDraft>(() => emptySluggedDraft());
|
||||
const [structureDraft, setStructureDraft] = useState<StructureDraft>(() => emptyStructureDraft());
|
||||
@@ -380,6 +406,8 @@ export default function OrganizationsPage({
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const [changeRequestId, setChangeRequestId] = useState("");
|
||||
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
|
||||
const canWriteModel = hasScope(auth, "organizations:model:write");
|
||||
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
||||
@@ -395,8 +423,11 @@ export default function OrganizationsPage({
|
||||
() => functionActionCapabilities
|
||||
.flatMap((capability) => capability.actions)
|
||||
.filter((contribution) => contributionVisible(auth, contribution))
|
||||
.filter((contribution) =>
|
||||
isViewSurfaceVisible(effectiveView, contribution.surfaceId, viewSurfaces)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[auth, functionActionCapabilities]
|
||||
[auth, effectiveView, functionActionCapabilities, viewSurfaces]
|
||||
);
|
||||
const unitsByParentId = useMemo(() => {
|
||||
const mapped = new Map<string, OrganizationUnitItem[]>();
|
||||
@@ -865,7 +896,7 @@ export default function OrganizationsPage({
|
||||
{ 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: "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>[] = [
|
||||
@@ -873,7 +904,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: "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: "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>[] = [
|
||||
@@ -883,7 +914,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: "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: "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>[] = [
|
||||
@@ -892,7 +923,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: "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: "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>[] = [
|
||||
@@ -901,7 +932,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: "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: "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>[] = [
|
||||
@@ -910,7 +941,7 @@ export default function OrganizationsPage({
|
||||
{ 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: "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>[] = [
|
||||
@@ -921,24 +952,50 @@ export default function OrganizationsPage({
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
width: functionActionContributions.length ? 220 : 88,
|
||||
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||
width: 88 + (functionActionContributions.length * 40),
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
<RowActions
|
||||
disabled={!canWriteFunctions || busy}
|
||||
onEdit={() => editFunction(row)}
|
||||
extra={functionActionContributions.map((contribution) => (
|
||||
<span className="organizations-contributed-action" key={contribution.id}>
|
||||
{contribution.render({ settings, auth, function: row })}
|
||||
</span>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "i18n:govoplan-organizations.edit.7dce1220",
|
||||
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||
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 = (
|
||||
<div className={`${mode === "workspace" ? "content-pad " : ""}organizations-page ${mode === "admin" ? "organizations-admin-page" : ""}`.trim()}>
|
||||
@@ -948,7 +1005,14 @@ export default function OrganizationsPage({
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="organizations-toolbar">
|
||||
<Button type="button" onClick={() => void loadModel()} disabled={loading || busy} title="i18n:govoplan-organizations.reload.cce71553">
|
||||
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => requestDiscard(() => void loadModel())}
|
||||
disabled={Boolean(reloadDisabledReason)}
|
||||
disabledReason={reloadDisabledReason}
|
||||
title="i18n:govoplan-organizations.reload.cce71553"
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
|
||||
</Button>
|
||||
</div>
|
||||
@@ -956,7 +1020,23 @@ export default function OrganizationsPage({
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
{!canWriteActive && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-organizations.write_permission_required.8b09fd67</DismissibleAlert>}
|
||||
{!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">
|
||||
{active === "model" && renderModelSection()}
|
||||
@@ -972,7 +1052,11 @@ export default function OrganizationsPage({
|
||||
|
||||
return (
|
||||
<div className="workspace organizations-workspace">
|
||||
<ModuleSubnav active={active} groups={visibleSectionGroups} onSelect={setActive} />
|
||||
<ModuleSubnav
|
||||
active={active}
|
||||
groups={visibleSectionGroups}
|
||||
onSelect={(section) => requestDiscard(() => setActive(section))}
|
||||
/>
|
||||
<main className="workspace-content">
|
||||
{content}
|
||||
</main>
|
||||
@@ -982,59 +1066,61 @@ export default function OrganizationsPage({
|
||||
function renderModelSection() {
|
||||
return (
|
||||
<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 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" />
|
||||
</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 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" />
|
||||
</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 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" />
|
||||
</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 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" />
|
||||
</Card>
|
||||
</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() {
|
||||
return (
|
||||
<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()} />}>
|
||||
<div className="organizations-tree-toolbar">
|
||||
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
||||
</div>
|
||||
<div className="organizations-tree-list">
|
||||
{model.units.length ? renderUnitTreeNodes() : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
||||
<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="explorer-tree-toolbar">
|
||||
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} disabledReason={busy ? ORGANIZATIONS_INTERFACE_I18N.busy : undefined} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
||||
</div>
|
||||
{model.units.length ? (
|
||||
<ExplorerTree
|
||||
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 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 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" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1044,7 +1130,7 @@ export default function OrganizationsPage({
|
||||
function renderRelationsSection() {
|
||||
return (
|
||||
<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 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" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1054,7 +1140,7 @@ export default function OrganizationsPage({
|
||||
function renderFunctionsSection() {
|
||||
return (
|
||||
<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 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" />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1064,7 +1150,11 @@ export default function OrganizationsPage({
|
||||
function renderChangeRequestField() {
|
||||
return (
|
||||
<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} />
|
||||
</FormField>
|
||||
<p className="organizations-field-note">i18n:govoplan-organizations.change_request_id_help.0c119a5d</p>
|
||||
@@ -1086,18 +1176,32 @@ export default function OrganizationsPage({
|
||||
}
|
||||
|
||||
function editorSubmitDisabled(): boolean {
|
||||
if (busy || !activeEditor) return true;
|
||||
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;
|
||||
if (activeEditor === "unitType") return !unitTypeDraft.name.trim();
|
||||
if (activeEditor === "structure") return !structureDraft.name.trim();
|
||||
if (activeEditor === "relationType") return !relationTypeDraft.name.trim();
|
||||
if (activeEditor === "unit") return !unitDraft.name.trim();
|
||||
if (activeEditor === "relation") return !relationDraft.structure_id || !relationDraft.relation_type_id || !relationDraft.source_unit_id || !relationDraft.target_unit_id;
|
||||
if (activeEditor === "functionType") return !functionTypeDraft.name.trim();
|
||||
if (activeEditor === "function") return !functionDraft.name.trim() || !functionDraft.organization_unit_id;
|
||||
return true;
|
||||
return Boolean(editorSubmitDisabledReason());
|
||||
}
|
||||
|
||||
function editorSubmitDisabledReason(): string | undefined {
|
||||
if (busy) return ORGANIZATIONS_INTERFACE_I18N.busy;
|
||||
if (!activeEditor) return ORGANIZATIONS_INTERFACE_I18N.incomplete;
|
||||
if (activeEditor === "unitType" || activeEditor === "structure" || activeEditor === "relationType" || activeEditor === "functionType") {
|
||||
const reason = organizationWriteReason(canWriteModel, busy, "model");
|
||||
if (reason) return reason;
|
||||
}
|
||||
if (activeEditor === "unit" || activeEditor === "relation") {
|
||||
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() {
|
||||
@@ -1114,17 +1218,39 @@ export default function OrganizationsPage({
|
||||
<Dialog
|
||||
open={Boolean(activeEditor)}
|
||||
title={editorTitle()}
|
||||
onClose={() => !busy && discardDrafts()}
|
||||
onClose={() => {
|
||||
if (busy) return;
|
||||
if (hasDirtyDraft) requestDiscard(discardDrafts);
|
||||
else discardDrafts();
|
||||
}}
|
||||
closeDisabled={busy}
|
||||
className="admin-dialog admin-dialog-wide organizations-editor-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={discardDrafts} disabled={busy}>i18n:govoplan-organizations.cancel_edit.309c2a6f</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={editorSubmitDisabled()}>{editorTitle()}</Button>
|
||||
<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>
|
||||
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||
{renderEditorFields()}
|
||||
</form>
|
||||
</Dialog>
|
||||
@@ -1135,7 +1261,7 @@ export default function OrganizationsPage({
|
||||
if (activeEditor === "unitType") {
|
||||
return (
|
||||
<>
|
||||
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} />
|
||||
<SluggedFields draft={unitTypeDraft} onChange={setUnitTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||
{renderChangeRequestField()}
|
||||
</>
|
||||
);
|
||||
@@ -1143,8 +1269,8 @@ export default function OrganizationsPage({
|
||||
if (activeEditor === "structure") {
|
||||
return (
|
||||
<>
|
||||
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} />
|
||||
<FormField label="i18n:govoplan-organizations.kind.794c9d9c">
|
||||
<SluggedFields draft={structureDraft} onChange={setStructureDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||
<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 })}>
|
||||
{STRUCTURE_KINDS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
@@ -1156,28 +1282,28 @@ export default function OrganizationsPage({
|
||||
if (activeEditor === "relationType") {
|
||||
return (
|
||||
<>
|
||||
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} />
|
||||
<FormField label="i18n:govoplan-organizations.structure.7732fb0b">
|
||||
<SluggedFields draft={relationTypeDraft} onChange={setRelationTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||
<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 })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<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>
|
||||
<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.is_hierarchical} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, is_hierarchical: checked })} label="i18n:govoplan-organizations.hierarchical.8964f313" />
|
||||
<ToggleSwitch checked={relationTypeDraft.allow_cycles} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setRelationTypeDraft({ ...relationTypeDraft, allow_cycles: checked })} label="i18n:govoplan-organizations.allow_cycles.31327578" />
|
||||
</div>
|
||||
{renderChangeRequestField()}
|
||||
</>
|
||||
@@ -1186,14 +1312,14 @@ export default function OrganizationsPage({
|
||||
if (activeEditor === "unit") {
|
||||
return (
|
||||
<>
|
||||
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} />
|
||||
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
|
||||
<SluggedFields draft={unitDraft} onChange={setUnitDraft} disabled={!canWriteUnits || busy} disabledReason={organizationWriteReason(canWriteUnits, busy, "unit")} />
|
||||
<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 })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<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>)}
|
||||
@@ -1211,34 +1337,39 @@ export default function OrganizationsPage({
|
||||
if (activeEditor === "relation") {
|
||||
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 })}>
|
||||
<option value="">i18n:govoplan-organizations.select_structure.c10f551c</option>
|
||||
{model.structures.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<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>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<option value="">i18n:govoplan-organizations.select_source_unit.9b5a29c8</option>
|
||||
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<option value="">i18n:govoplan-organizations.select_target_unit.8d607541</option>
|
||||
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<label className="organizations-inline-check wide">
|
||||
<input type="checkbox" checked={relationDraft.is_active} disabled={!canWriteUnits || busy} onChange={(event) => setRelationDraft({ ...relationDraft, is_active: event.target.checked })} />
|
||||
<span>i18n:govoplan-organizations.active.a733b809</span>
|
||||
</label>
|
||||
<div className="wide">
|
||||
<ToggleSwitch
|
||||
checked={relationDraft.is_active}
|
||||
disabled={!canWriteUnits || busy}
|
||||
help={organizationWriteReason(canWriteUnits, busy, "unit")}
|
||||
onChange={(checked) => setRelationDraft({ ...relationDraft, is_active: checked })}
|
||||
label="i18n:govoplan-organizations.active.a733b809"
|
||||
/>
|
||||
</div>
|
||||
{renderChangeRequestField()}
|
||||
</>
|
||||
);
|
||||
@@ -1246,16 +1377,16 @@ export default function OrganizationsPage({
|
||||
if (activeEditor === "functionType") {
|
||||
return (
|
||||
<>
|
||||
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} />
|
||||
<FormField label="i18n:govoplan-organizations.unit_type.9e62810d">
|
||||
<SluggedFields draft={functionTypeDraft} onChange={setFunctionTypeDraft} disabled={!canWriteModel || busy} disabledReason={organizationWriteReason(canWriteModel, busy, "model")} />
|
||||
<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 })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{model.unit_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<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>
|
||||
<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.delegable} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
|
||||
<ToggleSwitch checked={functionTypeDraft.act_in_place_allowed} disabled={!canWriteModel || busy} help={organizationWriteReason(canWriteModel, busy, "model")} onChange={(checked) => setFunctionTypeDraft({ ...functionTypeDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
|
||||
</div>
|
||||
{renderChangeRequestField()}
|
||||
</>
|
||||
@@ -1263,22 +1394,22 @@ export default function OrganizationsPage({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} />
|
||||
<FormField label="i18n:govoplan-organizations.unit.8fe4d595">
|
||||
<SluggedFields draft={functionDraft} onChange={setFunctionDraft} disabled={!canWriteFunctions || busy} disabledReason={organizationWriteReason(canWriteFunctions, busy, "function")} />
|
||||
<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 })}>
|
||||
<option value="">i18n:govoplan-organizations.select_unit.013bf13a</option>
|
||||
{model.units.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</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 })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{model.function_types.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<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>
|
||||
<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.delegable} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, delegable: checked })} label="i18n:govoplan-organizations.delegable.b4f0137d" />
|
||||
<ToggleSwitch checked={functionDraft.act_in_place_allowed} disabled={!canWriteFunctions || busy} help={organizationWriteReason(canWriteFunctions, busy, "function")} onChange={(checked) => setFunctionDraft({ ...functionDraft, act_in_place_allowed: checked })} label="i18n:govoplan-organizations.act_in_place.49b942bd" />
|
||||
</div>
|
||||
{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];
|
||||
}
|
||||
@@ -67,9 +67,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.none.334c4a4c": "None",
|
||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "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_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_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.organizations.220edf64": "Organizations",
|
||||
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.",
|
||||
@@ -122,7 +122,23 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.update_structure.b2e25446": "Update structure",
|
||||
"i18n:govoplan-organizations.update_unit.67e2500f": "Update unit",
|
||||
"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: {
|
||||
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
|
||||
@@ -190,9 +206,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
|
||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
|
||||
"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_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.",
|
||||
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationen gespeichert.",
|
||||
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum",
|
||||
"i18n:govoplan-organizations.organizations.220edf64": "Organisationen",
|
||||
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.",
|
||||
@@ -245,6 +261,22 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.update_structure.b2e25446": "Struktur aktualisieren",
|
||||
"i18n:govoplan-organizations.update_unit.67e2500f": "Einheit 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
@@ -23,7 +23,10 @@ const organizationAdminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
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",
|
||||
order: 85,
|
||||
anyOf: ["organizations:settings:read", "admin:settings:read"],
|
||||
@@ -41,15 +44,32 @@ const organizationFunctionPicker: OrganizationFunctionPickerUiCapability = {
|
||||
export const organizationsModule: PlatformWebModule = {
|
||||
id: "organizations",
|
||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||
version: "1.0.0",
|
||||
version: "0.1.8",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["admin"],
|
||||
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: [
|
||||
{
|
||||
to: "/organizations",
|
||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||
iconName: "users",
|
||||
iconName: "building-2",
|
||||
anyOf: organizationReadScopes,
|
||||
order: 70
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
.organizations-workspace {
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
background: var(--bg, #f8f7f4);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.organizations-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
}
|
||||
|
||||
.organizations-admin-page {
|
||||
@@ -31,47 +30,12 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -86,51 +50,6 @@
|
||||
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 {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
@@ -157,8 +76,41 @@
|
||||
.organizations-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.organization-upgrade-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(16rem, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.8rem;
|
||||
margin: 0.9rem 0;
|
||||
}
|
||||
|
||||
.organizations-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.organization-upgrade-table {
|
||||
min-height: 10rem;
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.organization-upgrade-dialog {
|
||||
width: min(92vw, 78rem);
|
||||
height: min(88vh, 54rem);
|
||||
}
|
||||
|
||||
.organization-upgrade-diff {
|
||||
min-height: 12rem;
|
||||
max-height: 25rem;
|
||||
overflow: auto;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.organization-upgrade-decision {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.organization-upgrade-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user