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 | ||
|
|
213157aca1 | ||
|
|
2810b1c500 | ||
|
|
f1ae795917 |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Organizations Codex Guide
|
# GovOPlaN Organizations Codex Guide
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Organizations internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This repository owns the canonical GovOPlaN organizational model: tenant-local
|
This repository owns the canonical GovOPlaN organizational model: tenant-local
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# GovOPlaN Organizations
|
# GovOPlaN Organizations
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-organizations` is the canonical organizational model module for
|
`govoplan-organizations` is the canonical organizational model module for
|
||||||
GovOPlaN.
|
GovOPlaN.
|
||||||
|
|
||||||
@@ -36,10 +40,19 @@ organizations module is active. Those controls are limited to governance and
|
|||||||
policy settings such as tenant model customization, change-request
|
policy settings such as tenant model customization, change-request
|
||||||
requirements, audit detail, and retention behavior.
|
requirements, audit detail, and retention behavior.
|
||||||
|
|
||||||
|
The reviewed surface inventory, consequence classes, availability rules, and
|
||||||
|
accessibility evidence are recorded in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
## Module Contract
|
## Module Contract
|
||||||
|
|
||||||
The module registers the `organizations.directory` capability from
|
The module registers two capabilities from
|
||||||
`govoplan_core.core.organizations`.
|
`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
|
Feature modules should consume the capability instead of importing
|
||||||
organization ORM models.
|
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,
|
- Function: a named responsibility in an organization unit, such as clerk,
|
||||||
reviewer, approver, committee secretary, intake desk, or resource manager.
|
reviewer, approver, committee secretary, intake desk, or resource manager.
|
||||||
|
|
||||||
|
A function says what responsibility exists and where. It does not by itself
|
||||||
|
prove that the institution or function is legally or organizationally
|
||||||
|
competent for a subject, territory, population, decision type, signature, or
|
||||||
|
period. That effective mandate/jurisdiction belongs to a separate shared
|
||||||
|
Mandates contract. Organizations retains only stable references needed to
|
||||||
|
explain how a mandate attaches to a unit or function.
|
||||||
|
|
||||||
|
## Governance And Templates
|
||||||
|
|
||||||
|
Concrete organization models are tenant-owned. Units, structures, relation
|
||||||
|
types, relations, function types, and functions are never shared as one live
|
||||||
|
global hierarchy across tenants.
|
||||||
|
|
||||||
|
The system may provide versioned organization-model templates. A tenant
|
||||||
|
explicitly instantiates one template version and receives tenant-owned concrete
|
||||||
|
records. The template reference and version are provenance, not a live parent
|
||||||
|
model:
|
||||||
|
|
||||||
|
- tenants may run different template versions;
|
||||||
|
- a template update never silently mutates a tenant hierarchy;
|
||||||
|
- upgrading is an explicit diff and migration with preview, conflict
|
||||||
|
reporting, and recorded provenance;
|
||||||
|
- system policy may constrain permitted unit, relation, structure, and
|
||||||
|
function types;
|
||||||
|
- tenant administrators customize concrete records only within the effective
|
||||||
|
policy.
|
||||||
|
|
||||||
|
This avoids ambiguous inheritance when institutions model responsibility
|
||||||
|
differently. Template catalogue, instantiation, and explicit upgrades are
|
||||||
|
available through the Organizations API and administration surface. The
|
||||||
|
existing organization tables remain the canonical tenant-local state.
|
||||||
|
|
||||||
|
An upgrade starts by persisting a three-way comparison of the source template,
|
||||||
|
the current tenant-owned model, and a newer published template version. The
|
||||||
|
preview distinguishes compatible additions and changes from local divergence,
|
||||||
|
destructive remapping, and invalid references. Local-only changes are retained;
|
||||||
|
conflicts require an explicit keep, replace, or bounded mapping decision. The
|
||||||
|
apply operation rejects stale source, target, or local state and records a new
|
||||||
|
instantiation plus platform event. Cancelling a preview records the outcome but
|
||||||
|
does not change tenant data. Consumers of organization references must respond
|
||||||
|
to the applied event; Organizations does not rewrite another module's records.
|
||||||
|
|
||||||
## Boundary With Identity And IDM
|
## Boundary With Identity And IDM
|
||||||
|
|
||||||
This module does not own login accounts, identity lifecycle, account linking,
|
This module does not own login accounts, identity lifecycle, account linking,
|
||||||
@@ -36,6 +78,66 @@ delegation enforcement, and explain responses.
|
|||||||
This separation keeps the institution model stable even when authorization
|
This separation keeps the institution model stable even when authorization
|
||||||
policy changes.
|
policy changes.
|
||||||
|
|
||||||
|
## Access Projection Migration
|
||||||
|
|
||||||
|
`govoplan-access` now prefers `organizations.directory` for organization units
|
||||||
|
and function definitions when the Organizations module is installed. It uses
|
||||||
|
`govoplan-idm` for identity-to-function assignment facts and Access-owned
|
||||||
|
external function-role mappings to turn accepted function facts into rights.
|
||||||
|
|
||||||
|
The legacy Access tables `access_organization_units`, `access_functions`, and
|
||||||
|
`access_function_assignments` remain readable as compatibility/projection
|
||||||
|
tables during rollout. They are no longer the target model for new module
|
||||||
|
integrations.
|
||||||
|
|
||||||
|
Rollout plan:
|
||||||
|
|
||||||
|
- keep direct Access function/organization reads as a fallback when
|
||||||
|
Organizations or IDM are not installed;
|
||||||
|
- backfill Organizations units/functions from the Access projection for
|
||||||
|
existing installations that enable the Organizations module later;
|
||||||
|
- move new identity-to-function assignment workflows to IDM rather than adding
|
||||||
|
assignment ownership to Organizations;
|
||||||
|
- use Access external function-role mappings for authorization and check that
|
||||||
|
the referenced Organizations function is still active before granting
|
||||||
|
derived permissions;
|
||||||
|
- retire Access-owned organization/function tables only after a release-level
|
||||||
|
migration/backfill and rollback plan exists.
|
||||||
|
|
||||||
|
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
|
## UI Boundary
|
||||||
|
|
||||||
The organization workspace at `/organizations` is the primary module UI for
|
The organization workspace at `/organizations` is the primary module UI for
|
||||||
|
|||||||
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/organizations-webui",
|
"name": "@govoplan/organizations-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.15",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": ">=8.3.0 <9",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+3
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-organizations"
|
name = "govoplan-organizations"
|
||||||
version = "0.1.6"
|
version = "0.1.15"
|
||||||
description = "GovOPlaN organizational model module."
|
description = "GovOPlaN organizational model module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.6",
|
"govoplan-core>=0.1.15",
|
||||||
"govoplan-tenancy>=0.1.6",
|
"govoplan-tenancy>=0.1.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN organizations module."""
|
"""GovOPlaN organizations module."""
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.15"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
import re
|
import re
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,10 +16,26 @@ from govoplan_core.core.configuration_control import (
|
|||||||
ensure_configuration_change_allowed,
|
ensure_configuration_change_allowed,
|
||||||
record_configuration_change_applied,
|
record_configuration_change_applied,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION,
|
||||||
|
organization_lifecycle_event_type,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
OrganizationFunctionType,
|
OrganizationFunctionType,
|
||||||
|
OrganizationModelInstantiation,
|
||||||
|
OrganizationModelTemplate,
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
OrganizationRelation,
|
OrganizationRelation,
|
||||||
OrganizationRelationType,
|
OrganizationRelationType,
|
||||||
OrganizationTenantSettings,
|
OrganizationTenantSettings,
|
||||||
@@ -31,9 +49,22 @@ from .schemas import (
|
|||||||
FunctionTypeCreateRequest,
|
FunctionTypeCreateRequest,
|
||||||
FunctionTypeUpdateRequest,
|
FunctionTypeUpdateRequest,
|
||||||
FunctionUpdateRequest,
|
FunctionUpdateRequest,
|
||||||
|
OrganizationModelInstantiationItem,
|
||||||
|
OrganizationModelUpgradeApplyRequest,
|
||||||
|
OrganizationModelUpgradeApplyResponse,
|
||||||
|
OrganizationModelUpgradeCancelRequest,
|
||||||
|
OrganizationModelUpgradeItem,
|
||||||
|
OrganizationModelUpgradeListResponse,
|
||||||
|
OrganizationModelUpgradePreviewRequest,
|
||||||
OrganizationFunctionItem,
|
OrganizationFunctionItem,
|
||||||
OrganizationFunctionTypeItem,
|
OrganizationFunctionTypeItem,
|
||||||
OrganizationModelResponse,
|
OrganizationModelResponse,
|
||||||
|
OrganizationModelTemplateCatalogItem,
|
||||||
|
OrganizationModelTemplateCatalogResponse,
|
||||||
|
OrganizationModelTemplateCreateRequest,
|
||||||
|
OrganizationModelTemplateItem,
|
||||||
|
OrganizationModelTemplateVersionCreateRequest,
|
||||||
|
OrganizationModelTemplateVersionItem,
|
||||||
OrganizationRelationItem,
|
OrganizationRelationItem,
|
||||||
OrganizationRelationTypeItem,
|
OrganizationRelationTypeItem,
|
||||||
OrganizationSettingsItem,
|
OrganizationSettingsItem,
|
||||||
@@ -52,9 +83,24 @@ from .schemas import (
|
|||||||
UnitTypeUpdateRequest,
|
UnitTypeUpdateRequest,
|
||||||
UnitUpdateRequest,
|
UnitUpdateRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_organizations.backend.templates import (
|
||||||
|
OrganizationTemplateError,
|
||||||
|
canonical_template_definition,
|
||||||
|
instantiate_template_version,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.upgrades import (
|
||||||
|
OrganizationUpgradeError,
|
||||||
|
apply_model_upgrade,
|
||||||
|
cancel_model_upgrade,
|
||||||
|
create_model_upgrade_preview,
|
||||||
|
current_model_instantiation,
|
||||||
|
list_model_upgrades,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||||
|
ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000
|
||||||
|
ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000
|
||||||
|
|
||||||
ORG_READ_SCOPES = (
|
ORG_READ_SCOPES = (
|
||||||
"organizations:model:read",
|
"organizations:model:read",
|
||||||
@@ -69,6 +115,7 @@ ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:
|
|||||||
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
|
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
|
||||||
ORG_CHANGE_CONTROL_KEY = "organizations.model"
|
ORG_CHANGE_CONTROL_KEY = "organizations.model"
|
||||||
ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated"
|
ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated"
|
||||||
|
ORG_TEMPLATE_ADMIN_SCOPES = ("system:settings:write",)
|
||||||
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT")
|
ModelT = TypeVar("ModelT")
|
||||||
@@ -128,7 +175,29 @@ def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str
|
|||||||
|
|
||||||
|
|
||||||
def _commit(session: Session, item: ModelT) -> ModelT:
|
def _commit(session: Session, item: ModelT) -> ModelT:
|
||||||
|
lifecycle = _organization_lifecycle_change(item)
|
||||||
try:
|
try:
|
||||||
|
session.flush()
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=getattr(item, "tenant_id", None),
|
||||||
|
source_module="organizations",
|
||||||
|
resource_type=item.__class__.__name__,
|
||||||
|
resource_id=str(
|
||||||
|
getattr(
|
||||||
|
item,
|
||||||
|
"id",
|
||||||
|
getattr(item, "tenant_id", "system"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if lifecycle is not None:
|
||||||
|
_emit_organization_lifecycle_event(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
action=lifecycle[0],
|
||||||
|
changes=lifecycle[1],
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
@@ -137,6 +206,121 @@ def _commit(session: Session, item: ModelT) -> ModelT:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
_LIFECYCLE_RESOURCES = {
|
||||||
|
OrganizationUnitType: "unit_type",
|
||||||
|
OrganizationStructure: "structure",
|
||||||
|
OrganizationRelationType: "relation_type",
|
||||||
|
OrganizationUnit: "unit",
|
||||||
|
OrganizationRelation: "relation",
|
||||||
|
OrganizationFunctionType: "function_type",
|
||||||
|
OrganizationFunction: "function",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_lifecycle_change(
|
||||||
|
item: object,
|
||||||
|
) -> tuple[str, dict[str, dict[str, object | None]]] | None:
|
||||||
|
resource_type = _LIFECYCLE_RESOURCES.get(type(item))
|
||||||
|
if resource_type is None:
|
||||||
|
return None
|
||||||
|
state = sqlalchemy_inspect(item)
|
||||||
|
changes: dict[str, dict[str, object | None]] = {}
|
||||||
|
for attribute in state.mapper.column_attrs:
|
||||||
|
history = state.attrs[attribute.key].history
|
||||||
|
if not history.has_changes():
|
||||||
|
continue
|
||||||
|
changes[attribute.key] = {
|
||||||
|
"before": (
|
||||||
|
_organization_event_value(history.deleted[0])
|
||||||
|
if history.deleted
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"after": (
|
||||||
|
_organization_event_value(history.added[0])
|
||||||
|
if history.added
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if state.pending:
|
||||||
|
action = "created"
|
||||||
|
elif (
|
||||||
|
"is_active" in changes
|
||||||
|
and changes["is_active"]["after"] is False
|
||||||
|
):
|
||||||
|
action = "deactivated"
|
||||||
|
elif resource_type == "unit" and "parent_id" in changes:
|
||||||
|
action = "moved"
|
||||||
|
else:
|
||||||
|
action = "updated"
|
||||||
|
return action, changes
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_event_value(value: object) -> object:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_organization_lifecycle_event(
|
||||||
|
session: Session,
|
||||||
|
item: object,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
changes: dict[str, dict[str, object | None]],
|
||||||
|
) -> None:
|
||||||
|
resource_type = _LIFECYCLE_RESOURCES[type(item)]
|
||||||
|
tenant_id = str(getattr(item, "tenant_id"))
|
||||||
|
item_id = str(getattr(item, "id"))
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"schema_version": ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": item_id,
|
||||||
|
"status": (
|
||||||
|
"active"
|
||||||
|
if bool(getattr(item, "is_active", True))
|
||||||
|
else "inactive"
|
||||||
|
),
|
||||||
|
"changed_fields": sorted(changes),
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
for field in (
|
||||||
|
"slug",
|
||||||
|
"unit_type_id",
|
||||||
|
"organization_unit_id",
|
||||||
|
"function_type_id",
|
||||||
|
"structure_id",
|
||||||
|
"relation_type_id",
|
||||||
|
"source_unit_id",
|
||||||
|
"target_unit_id",
|
||||||
|
):
|
||||||
|
value = getattr(item, field, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[field] = str(value)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=organization_lifecycle_event_type(
|
||||||
|
resource_type, # type: ignore[arg-type]
|
||||||
|
action, # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
module_id="organizations",
|
||||||
|
tenant=EventTenantRef(id=tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type=f"organization_{resource_type}",
|
||||||
|
id=item_id,
|
||||||
|
label=str(getattr(item, "name", None) or "") or None,
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type=f"organization_{resource_type}",
|
||||||
|
id=item_id,
|
||||||
|
),
|
||||||
|
payload=payload,
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _requires_organization_change_request(session: Session, tenant_id: str) -> bool:
|
def _requires_organization_change_request(session: Session, tenant_id: str) -> bool:
|
||||||
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||||
return bool(item and item.require_model_change_requests)
|
return bool(item and item.require_model_change_requests)
|
||||||
@@ -257,6 +441,142 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
|
|||||||
return OrganizationFunctionItem(**_row_fields(item))
|
return OrganizationFunctionItem(**_row_fields(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _template_item(
|
||||||
|
item: OrganizationModelTemplate,
|
||||||
|
) -> OrganizationModelTemplateItem:
|
||||||
|
return OrganizationModelTemplateItem(
|
||||||
|
id=item.id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=dict(item.settings or {}),
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_version_item(
|
||||||
|
item: OrganizationModelTemplateVersion,
|
||||||
|
) -> OrganizationModelTemplateVersionItem:
|
||||||
|
return OrganizationModelTemplateVersionItem(
|
||||||
|
id=item.id,
|
||||||
|
template_id=item.template_id,
|
||||||
|
version=item.version,
|
||||||
|
schema_version=item.schema_version,
|
||||||
|
status=item.status,
|
||||||
|
definition=item.definition,
|
||||||
|
definition_sha256=item.definition_sha256,
|
||||||
|
release_notes=item.release_notes,
|
||||||
|
published_at=item.published_at,
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _instantiation_item(
|
||||||
|
item: OrganizationModelInstantiation,
|
||||||
|
) -> OrganizationModelInstantiationItem:
|
||||||
|
return OrganizationModelInstantiationItem(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
template_id=item.template_id,
|
||||||
|
template_version_id=item.template_version_id,
|
||||||
|
source_definition_sha256=item.source_definition_sha256,
|
||||||
|
status=item.status,
|
||||||
|
object_counts=dict(item.object_counts or {}),
|
||||||
|
provenance=dict(item.provenance or {}),
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upgrade_item(item: OrganizationModelUpgrade) -> OrganizationModelUpgradeItem:
|
||||||
|
return OrganizationModelUpgradeItem(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
template_id=item.template_id,
|
||||||
|
source_instantiation_id=item.source_instantiation_id,
|
||||||
|
source_template_version_id=item.source_template_version_id,
|
||||||
|
target_template_version_id=item.target_template_version_id,
|
||||||
|
status=item.status,
|
||||||
|
revision=item.revision,
|
||||||
|
base_definition_sha256=item.base_definition_sha256,
|
||||||
|
local_definition_sha256=item.local_definition_sha256,
|
||||||
|
target_definition_sha256=item.target_definition_sha256,
|
||||||
|
preview=item.preview,
|
||||||
|
decisions=item.decisions,
|
||||||
|
requested_by_account_id=item.requested_by_account_id,
|
||||||
|
applied_by_account_id=item.applied_by_account_id,
|
||||||
|
cancelled_by_account_id=item.cancelled_by_account_id,
|
||||||
|
applied_at=item.applied_at,
|
||||||
|
cancelled_at=item.cancelled_at,
|
||||||
|
provenance=dict(item.provenance or {}),
|
||||||
|
created_at=item.created_at,
|
||||||
|
updated_at=item.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_model_upgrade_event(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
item: OrganizationModelUpgrade,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=f"organizations.model_upgrade.{action}.v1",
|
||||||
|
module_id="organizations",
|
||||||
|
tenant=EventTenantRef(id=item.tenant_id),
|
||||||
|
actor=EventActorRef(type="account", id=principal.account_id),
|
||||||
|
subject=EventObjectRef(type="organization_model_upgrade", id=item.id),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="organization_model_template_version",
|
||||||
|
id=item.target_template_version_id,
|
||||||
|
),
|
||||||
|
payload={
|
||||||
|
"schema_version": "1",
|
||||||
|
"tenant_id": item.tenant_id,
|
||||||
|
"upgrade_id": item.id,
|
||||||
|
"status": item.status,
|
||||||
|
"revision": item.revision,
|
||||||
|
"source_template_version_id": item.source_template_version_id,
|
||||||
|
"target_template_version_id": item.target_template_version_id,
|
||||||
|
"requires_decisions": int(
|
||||||
|
dict(item.preview or {}).get("requires_decisions", 0)
|
||||||
|
),
|
||||||
|
"blocking_invalid_references": int(
|
||||||
|
dict(item.preview or {}).get(
|
||||||
|
"blocking_invalid_references", 0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"decision_count": len(dict(item.decisions or {})),
|
||||||
|
"silent_mutation": False,
|
||||||
|
},
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_version(
|
||||||
|
session: Session,
|
||||||
|
template_id: str,
|
||||||
|
version: str,
|
||||||
|
) -> OrganizationModelTemplateVersion:
|
||||||
|
item = (
|
||||||
|
session.query(OrganizationModelTemplateVersion)
|
||||||
|
.filter(
|
||||||
|
OrganizationModelTemplateVersion.template_id == template_id,
|
||||||
|
OrganizationModelTemplateVersion.version == version,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise _not_found("Organization model template version")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _row_fields(item: object) -> dict[str, Any]:
|
def _row_fields(item: object) -> dict[str, Any]:
|
||||||
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
||||||
return {key: getattr(item, key) for key in keys}
|
return {key: getattr(item, key) for key in keys}
|
||||||
@@ -329,21 +649,439 @@ def update_organization_settings(
|
|||||||
return _item_settings(_commit(session, item))
|
return _item_settings(_commit(session, item))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/model-templates",
|
||||||
|
response_model=OrganizationModelTemplateCatalogResponse,
|
||||||
|
)
|
||||||
|
def list_organization_model_templates(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
|
) -> OrganizationModelTemplateCatalogResponse:
|
||||||
|
del principal
|
||||||
|
templates = (
|
||||||
|
session.query(OrganizationModelTemplate)
|
||||||
|
.filter(OrganizationModelTemplate.is_active.is_(True))
|
||||||
|
.order_by(
|
||||||
|
OrganizationModelTemplate.name.asc(),
|
||||||
|
OrganizationModelTemplate.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
versions = (
|
||||||
|
session.query(OrganizationModelTemplateVersion)
|
||||||
|
.filter(
|
||||||
|
OrganizationModelTemplateVersion.template_id.in_(
|
||||||
|
[item.id for item in templates]
|
||||||
|
),
|
||||||
|
OrganizationModelTemplateVersion.status == "published",
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
OrganizationModelTemplateVersion.template_id.asc(),
|
||||||
|
OrganizationModelTemplateVersion.published_at.desc(),
|
||||||
|
OrganizationModelTemplateVersion.version.desc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if templates
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
by_template: dict[str, list[OrganizationModelTemplateVersion]] = {}
|
||||||
|
for version in versions:
|
||||||
|
by_template.setdefault(version.template_id, []).append(version)
|
||||||
|
return OrganizationModelTemplateCatalogResponse(
|
||||||
|
templates=[
|
||||||
|
OrganizationModelTemplateCatalogItem(
|
||||||
|
**_template_item(item).model_dump(),
|
||||||
|
versions=[
|
||||||
|
_template_version_item(version)
|
||||||
|
for version in by_template.get(item.id, ())
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for item in templates
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates",
|
||||||
|
response_model=OrganizationModelTemplateItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_organization_model_template(
|
||||||
|
payload: OrganizationModelTemplateCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelTemplateItem:
|
||||||
|
item = OrganizationModelTemplate(
|
||||||
|
slug=payload.slug,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
settings=payload.settings,
|
||||||
|
created_by_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict("An organization model template with this slug already exists") from exc
|
||||||
|
session.refresh(item)
|
||||||
|
return _template_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates/{template_id}/versions",
|
||||||
|
response_model=OrganizationModelTemplateVersionItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_organization_model_template_version(
|
||||||
|
template_id: str,
|
||||||
|
payload: OrganizationModelTemplateVersionCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelTemplateVersionItem:
|
||||||
|
template = session.get(OrganizationModelTemplate, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise _not_found("Organization model template")
|
||||||
|
try:
|
||||||
|
definition, definition_sha256 = canonical_template_definition(
|
||||||
|
payload.definition
|
||||||
|
)
|
||||||
|
except OrganizationTemplateError as exc:
|
||||||
|
raise _invalid(str(exc)) from exc
|
||||||
|
item = OrganizationModelTemplateVersion(
|
||||||
|
template_id=template.id,
|
||||||
|
version=payload.version,
|
||||||
|
definition=definition,
|
||||||
|
definition_sha256=definition_sha256,
|
||||||
|
release_notes=payload.release_notes,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict("This organization model template version already exists") from exc
|
||||||
|
session.refresh(item)
|
||||||
|
return _template_version_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates/{template_id}/versions/{version}/publish",
|
||||||
|
response_model=OrganizationModelTemplateVersionItem,
|
||||||
|
)
|
||||||
|
def publish_organization_model_template_version(
|
||||||
|
template_id: str,
|
||||||
|
version: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelTemplateVersionItem:
|
||||||
|
item = _template_version(session, template_id, version)
|
||||||
|
if item.status == "retired":
|
||||||
|
raise _conflict("A retired organization template version cannot be published")
|
||||||
|
if item.status == "draft":
|
||||||
|
item.status = "published"
|
||||||
|
item.published_at = datetime.now(UTC)
|
||||||
|
item.published_by_account_id = principal.account_id
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return _template_version_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-templates/{template_id}/versions/{version}/instantiate",
|
||||||
|
response_model=OrganizationModelInstantiationItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def instantiate_organization_model_template(
|
||||||
|
template_id: str,
|
||||||
|
version: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelInstantiationItem:
|
||||||
|
template = session.get(OrganizationModelTemplate, template_id)
|
||||||
|
if template is None or not template.is_active:
|
||||||
|
raise _not_found("Organization model template")
|
||||||
|
item = _template_version(session, template_id, version)
|
||||||
|
try:
|
||||||
|
instantiation = instantiate_template_version(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
template=template,
|
||||||
|
version=item,
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(instantiation)
|
||||||
|
except OrganizationTemplateError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return _instantiation_item(instantiation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/model-upgrades",
|
||||||
|
response_model=OrganizationModelUpgradeListResponse,
|
||||||
|
)
|
||||||
|
def list_organization_model_upgrades(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
|
) -> OrganizationModelUpgradeListResponse:
|
||||||
|
current = current_model_instantiation(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
)
|
||||||
|
return OrganizationModelUpgradeListResponse(
|
||||||
|
current_instantiation=(
|
||||||
|
_instantiation_item(current) if current is not None else None
|
||||||
|
),
|
||||||
|
upgrades=[
|
||||||
|
_upgrade_item(item)
|
||||||
|
for item in list_model_upgrades(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-upgrades/preview",
|
||||||
|
response_model=OrganizationModelUpgradeItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def preview_organization_model_upgrade(
|
||||||
|
payload: OrganizationModelUpgradePreviewRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelUpgradeItem:
|
||||||
|
target = session.get(
|
||||||
|
OrganizationModelTemplateVersion,
|
||||||
|
payload.target_template_version_id,
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
raise _not_found("Organization model template version")
|
||||||
|
try:
|
||||||
|
item = create_model_upgrade_preview(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
target_version=target,
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
_emit_model_upgrade_event(session, principal, item, "previewed")
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
except OrganizationUpgradeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return _upgrade_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-upgrades/{upgrade_id}/cancel",
|
||||||
|
response_model=OrganizationModelUpgradeItem,
|
||||||
|
)
|
||||||
|
def cancel_organization_model_upgrade(
|
||||||
|
upgrade_id: str,
|
||||||
|
payload: OrganizationModelUpgradeCancelRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelUpgradeItem:
|
||||||
|
try:
|
||||||
|
item = cancel_model_upgrade(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
upgrade_id=upgrade_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
_emit_model_upgrade_event(session, principal, item, "cancelled")
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
except OrganizationUpgradeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return _upgrade_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/model-upgrades/{upgrade_id}/apply",
|
||||||
|
response_model=OrganizationModelUpgradeApplyResponse,
|
||||||
|
)
|
||||||
|
def apply_organization_model_upgrade(
|
||||||
|
upgrade_id: str,
|
||||||
|
payload: OrganizationModelUpgradeApplyRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(*ORG_MODEL_WRITE_SCOPES)
|
||||||
|
),
|
||||||
|
) -> OrganizationModelUpgradeApplyResponse:
|
||||||
|
approval, control_target, _value = _ensure_organization_change_allowed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
resource_type="model_upgrade",
|
||||||
|
operation="applied",
|
||||||
|
payload=payload,
|
||||||
|
resource_id=upgrade_id,
|
||||||
|
)
|
||||||
|
before = _upgrade_item(
|
||||||
|
_get_tenant_row(
|
||||||
|
session,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
|
upgrade_id,
|
||||||
|
principal.tenant_id,
|
||||||
|
"Organization model upgrade",
|
||||||
|
)
|
||||||
|
).model_dump(mode="json")
|
||||||
|
try:
|
||||||
|
item, instantiation = apply_model_upgrade(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
upgrade_id=upgrade_id,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
decisions={
|
||||||
|
key: value.model_dump(exclude_none=True)
|
||||||
|
for key, value in payload.decisions.items()
|
||||||
|
},
|
||||||
|
actor_account_id=principal.account_id,
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_module="organizations",
|
||||||
|
resource_type="organization_model_upgrade",
|
||||||
|
resource_id=item.id,
|
||||||
|
)
|
||||||
|
_emit_model_upgrade_event(session, principal, item, "applied")
|
||||||
|
response = OrganizationModelUpgradeApplyResponse(
|
||||||
|
upgrade=_upgrade_item(item),
|
||||||
|
instantiation=_instantiation_item(instantiation),
|
||||||
|
)
|
||||||
|
if approval is None:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
_record_organization_change_applied(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
approval=approval,
|
||||||
|
target=control_target,
|
||||||
|
before=before,
|
||||||
|
after=response.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
session.refresh(item)
|
||||||
|
session.refresh(instantiation)
|
||||||
|
except OrganizationUpgradeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(str(exc)) from exc
|
||||||
|
return OrganizationModelUpgradeApplyResponse(
|
||||||
|
upgrade=_upgrade_item(item),
|
||||||
|
instantiation=_instantiation_item(instantiation),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/model", response_model=OrganizationModelResponse)
|
@router.get("/model", response_model=OrganizationModelResponse)
|
||||||
def get_organization_model(
|
def get_organization_model(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
) -> OrganizationModelResponse:
|
) -> OrganizationModelResponse:
|
||||||
tenant_id = _tenant_id(principal)
|
tenant_id = _tenant_id(principal)
|
||||||
return OrganizationModelResponse(
|
unit_types = _bounded_organization_rows(
|
||||||
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
|
session.query(OrganizationUnitType)
|
||||||
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
|
.filter(OrganizationUnitType.tenant_id == tenant_id)
|
||||||
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
|
.order_by(OrganizationUnitType.name.asc()),
|
||||||
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
|
"unit types",
|
||||||
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()],
|
|
||||||
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
|
|
||||||
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
|
|
||||||
)
|
)
|
||||||
|
structures = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationStructure)
|
||||||
|
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationStructure.name.asc()),
|
||||||
|
"structures",
|
||||||
|
)
|
||||||
|
relation_types = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationRelationType)
|
||||||
|
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationRelationType.name.asc()),
|
||||||
|
"relation types",
|
||||||
|
)
|
||||||
|
units = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationUnit)
|
||||||
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationUnit.name.asc()),
|
||||||
|
"units",
|
||||||
|
)
|
||||||
|
relations = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationRelation)
|
||||||
|
.filter(OrganizationRelation.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationRelation.created_at.asc()),
|
||||||
|
"relations",
|
||||||
|
)
|
||||||
|
function_types = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationFunctionType)
|
||||||
|
.filter(OrganizationFunctionType.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationFunctionType.name.asc()),
|
||||||
|
"function types",
|
||||||
|
)
|
||||||
|
functions = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationFunction)
|
||||||
|
.filter(OrganizationFunction.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationFunction.name.asc()),
|
||||||
|
"functions",
|
||||||
|
)
|
||||||
|
if sum(
|
||||||
|
len(items)
|
||||||
|
for items in (
|
||||||
|
unit_types,
|
||||||
|
structures,
|
||||||
|
relation_types,
|
||||||
|
units,
|
||||||
|
relations,
|
||||||
|
function_types,
|
||||||
|
functions,
|
||||||
|
)
|
||||||
|
) > ORGANIZATION_MODEL_TOTAL_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
"The organization model is too large for the aggregate "
|
||||||
|
"endpoint and cannot be returned as one response."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return OrganizationModelResponse(
|
||||||
|
unit_types=[_item_unit_type(item) for item in unit_types],
|
||||||
|
structures=[_item_structure(item) for item in structures],
|
||||||
|
relation_types=[_item_relation_type(item) for item in relation_types],
|
||||||
|
units=[_item_unit(item) for item in units],
|
||||||
|
relations=[_item_relation(item) for item in relations],
|
||||||
|
function_types=[_item_function_type(item) for item in function_types],
|
||||||
|
functions=[_item_function(item) for item in functions],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_organization_rows(query: Any, label: str) -> list[Any]:
|
||||||
|
rows = query.limit(ORGANIZATION_MODEL_COLLECTION_LIMIT + 1).all()
|
||||||
|
if len(rows) > ORGANIZATION_MODEL_COLLECTION_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
f"The organization model has more than "
|
||||||
|
f"{ORGANIZATION_MODEL_COLLECTION_LIMIT} {label} and cannot "
|
||||||
|
"be returned as one response."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -3,11 +3,277 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
||||||
AuditDetailLevel = Literal["summary", "standard", "full"]
|
AuditDetailLevel = Literal["summary", "standard", "full"]
|
||||||
|
TemplateVersionStatus = Literal["draft", "published", "retired"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateBaseDefinition(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
slug: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||||
|
)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateUnitTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateStructureDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
structure_kind: StructureKind = "hierarchy"
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateRelationTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
structure_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
source_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
target_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
is_hierarchical: bool = True
|
||||||
|
allow_cycles: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateUnitDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
parent_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateRelationDefinition(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
structure_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
relation_type_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
source_unit_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
target_unit_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateFunctionTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
organization_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
delegable: bool = False
|
||||||
|
act_in_place_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationTemplateFunctionDefinition(OrganizationTemplateBaseDefinition):
|
||||||
|
function_type_slug: str | None = Field(default=None, max_length=100)
|
||||||
|
organization_unit_slug: str = Field(min_length=1, max_length=100)
|
||||||
|
delegable: bool = False
|
||||||
|
act_in_place_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateDefinition(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
unit_types: list[OrganizationTemplateUnitTypeDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
structures: list[OrganizationTemplateStructureDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
relation_types: list[OrganizationTemplateRelationTypeDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
units: list[OrganizationTemplateUnitDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
relations: list[OrganizationTemplateRelationDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
function_types: list[OrganizationTemplateFunctionTypeDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
functions: list[OrganizationTemplateFunctionDefinition] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
slug: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||||
|
)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateVersionCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
version: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=50,
|
||||||
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._+-]*$",
|
||||||
|
)
|
||||||
|
definition: OrganizationModelTemplateDefinition
|
||||||
|
release_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
settings: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateVersionItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
template_id: str
|
||||||
|
version: str
|
||||||
|
schema_version: int
|
||||||
|
status: TemplateVersionStatus
|
||||||
|
definition: OrganizationModelTemplateDefinition
|
||||||
|
definition_sha256: str
|
||||||
|
release_notes: str | None = None
|
||||||
|
published_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateCatalogItem(OrganizationModelTemplateItem):
|
||||||
|
versions: list[OrganizationModelTemplateVersionItem] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateCatalogResponse(BaseModel):
|
||||||
|
templates: list[OrganizationModelTemplateCatalogItem] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelInstantiationItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
template_id: str
|
||||||
|
template_version_id: str
|
||||||
|
source_definition_sha256: str
|
||||||
|
status: str
|
||||||
|
object_counts: dict[str, int]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
UpgradeClassification = Literal[
|
||||||
|
"unchanged",
|
||||||
|
"compatible_addition",
|
||||||
|
"compatible_change",
|
||||||
|
"destructive_remapping",
|
||||||
|
"local_divergence",
|
||||||
|
"invalid_reference",
|
||||||
|
]
|
||||||
|
UpgradeDecisionAction = Literal["keep_local", "use_target", "map_to"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradePreviewRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
target_template_version_id: str = Field(min_length=1, max_length=36)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeDecision(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
action: UpgradeDecisionAction
|
||||||
|
target_key: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeApplyRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
decisions: dict[str, OrganizationModelUpgradeDecision] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
max_length=5000,
|
||||||
|
)
|
||||||
|
change_request_id: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeCancelRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeDiffEntry(BaseModel):
|
||||||
|
id: str
|
||||||
|
collection: str
|
||||||
|
key: str
|
||||||
|
classification: UpgradeClassification
|
||||||
|
requires_decision: bool
|
||||||
|
base: dict[str, Any] | None = None
|
||||||
|
local: dict[str, Any] | None = None
|
||||||
|
target: dict[str, Any] | None = None
|
||||||
|
allowed_actions: list[UpgradeDecisionAction] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradePreview(BaseModel):
|
||||||
|
entries: list[OrganizationModelUpgradeDiffEntry] = Field(default_factory=list)
|
||||||
|
counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
requires_decisions: int = 0
|
||||||
|
blocking_invalid_references: int = 0
|
||||||
|
silent_mutation: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
template_id: str
|
||||||
|
source_instantiation_id: str
|
||||||
|
source_template_version_id: str
|
||||||
|
target_template_version_id: str
|
||||||
|
status: str
|
||||||
|
revision: int
|
||||||
|
base_definition_sha256: str
|
||||||
|
local_definition_sha256: str
|
||||||
|
target_definition_sha256: str
|
||||||
|
preview: OrganizationModelUpgradePreview
|
||||||
|
decisions: dict[str, OrganizationModelUpgradeDecision] = Field(default_factory=dict)
|
||||||
|
requested_by_account_id: str | None = None
|
||||||
|
applied_by_account_id: str | None = None
|
||||||
|
cancelled_by_account_id: str | None = None
|
||||||
|
applied_at: datetime | None = None
|
||||||
|
cancelled_at: datetime | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeListResponse(BaseModel):
|
||||||
|
current_instantiation: OrganizationModelInstantiationItem | None = None
|
||||||
|
upgrades: list[OrganizationModelUpgradeItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgradeApplyResponse(BaseModel):
|
||||||
|
upgrade: OrganizationModelUpgradeItem
|
||||||
|
instantiation: OrganizationModelInstantiationItem
|
||||||
|
|
||||||
|
|
||||||
class OrganizationSettingsItem(BaseModel):
|
class OrganizationSettingsItem(BaseModel):
|
||||||
|
|||||||
@@ -55,6 +55,178 @@ class OrganizationTenantSettings(Base, TimestampMixin):
|
|||||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplate(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_templates"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
slug: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
|
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelTemplateVersion(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_template_versions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"template_id",
|
||||||
|
"version",
|
||||||
|
name="uq_organizations_model_template_versions",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_org_model_template_versions_template_status",
|
||||||
|
"template_id",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
template_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_templates.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False)
|
||||||
|
definition: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
release_notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
published_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelInstantiation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_instantiations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"template_version_id",
|
||||||
|
name="uq_organizations_model_instantiation_version",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_organizations_model_instantiations_tenant",
|
||||||
|
"tenant_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
template_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
template_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"organizations_model_template_versions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default="applied",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
instantiated_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
object_counts: Mapped[dict[str, int]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationModelUpgrade(Base, TimestampMixin):
|
||||||
|
__tablename__ = "organizations_model_upgrades"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_organizations_model_upgrade_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_organizations_model_upgrades_tenant_status",
|
||||||
|
"tenant_id",
|
||||||
|
"status",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
template_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_instantiation_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_model_instantiations.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
source_template_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"organizations_model_template_versions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
target_template_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"organizations_model_template_versions.id",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(30), default="previewed", nullable=False, index=True
|
||||||
|
)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
base_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
local_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
target_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
preview: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
decisions: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
requested_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
applied_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
cancelled_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationStructure(Base, TimestampMixin):
|
class OrganizationStructure(Base, TimestampMixin):
|
||||||
__tablename__ = "organizations_structures"
|
__tablename__ = "organizations_structures"
|
||||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
||||||
@@ -153,6 +325,10 @@ class OrganizationFunction(Base, TimestampMixin):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"OrganizationFunction",
|
"OrganizationFunction",
|
||||||
"OrganizationFunctionType",
|
"OrganizationFunctionType",
|
||||||
|
"OrganizationModelInstantiation",
|
||||||
|
"OrganizationModelTemplate",
|
||||||
|
"OrganizationModelTemplateVersion",
|
||||||
|
"OrganizationModelUpgrade",
|
||||||
"OrganizationRelation",
|
"OrganizationRelation",
|
||||||
"OrganizationRelationType",
|
"OrganizationRelationType",
|
||||||
"OrganizationStructure",
|
"OrganizationStructure",
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.organizations import (
|
from govoplan_core.core.organizations import (
|
||||||
OrganizationDirectory,
|
OrganizationDirectory,
|
||||||
OrganizationFunctionRef,
|
OrganizationFunctionRef,
|
||||||
|
OrganizationFunctionTypeRef,
|
||||||
|
OrganizationFunctionTypeResolution,
|
||||||
|
OrganizationHierarchyCatalogRef,
|
||||||
|
OrganizationHierarchyDirection,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
OrganizationHierarchyEdgeRef,
|
||||||
|
OrganizationHierarchyMatchRef,
|
||||||
|
OrganizationHierarchyPathResolution,
|
||||||
|
OrganizationHierarchyResolution,
|
||||||
|
OrganizationRelationTypeRef,
|
||||||
|
OrganizationResolutionStatus,
|
||||||
|
OrganizationStructureRef,
|
||||||
OrganizationUnitRef,
|
OrganizationUnitRef,
|
||||||
|
OrganizationUnitTypeRef,
|
||||||
|
OrganizationUnitTypeResolution,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
OrganizationUnit,
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_DIRECTORY_BATCH = 500
|
||||||
|
MAX_HIERARCHY_DEPTH = 100
|
||||||
|
|
||||||
|
|
||||||
def _status(active: bool) -> str:
|
def _status(active: bool) -> str:
|
||||||
return "active" if active else "inactive"
|
return "active" if active else "inactive"
|
||||||
|
|
||||||
@@ -29,6 +61,17 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_type_ref(item: OrganizationUnitType) -> OrganizationUnitTypeRef:
|
||||||
|
return OrganizationUnitTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||||
return OrganizationFunctionRef(
|
return OrganizationFunctionRef(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -41,27 +84,148 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
|||||||
delegable=item.delegable,
|
delegable=item.delegable,
|
||||||
act_in_place_allowed=item.act_in_place_allowed,
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
status=_status(item.is_active), # type: ignore[arg-type]
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
settings=dict(item.settings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SqlOrganizationDirectory(OrganizationDirectory):
|
def _function_type_ref(
|
||||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
item: OrganizationFunctionType,
|
||||||
with get_database().session() as session:
|
) -> OrganizationFunctionTypeRef:
|
||||||
|
return OrganizationFunctionTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
organization_unit_type_id=item.organization_unit_type_id,
|
||||||
|
description=item.description,
|
||||||
|
delegable=item.delegable,
|
||||||
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
settings=dict(item.settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _structure_ref(item: OrganizationStructure) -> OrganizationStructureRef:
|
||||||
|
return OrganizationStructureRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
structure_kind=item.structure_kind,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relation_type_ref(
|
||||||
|
item: OrganizationRelationType,
|
||||||
|
) -> OrganizationRelationTypeRef:
|
||||||
|
return OrganizationRelationTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
structure_id=item.structure_id,
|
||||||
|
source_unit_type_id=item.source_unit_type_id,
|
||||||
|
target_unit_type_id=item.target_unit_type_id,
|
||||||
|
is_hierarchical=item.is_hierarchical,
|
||||||
|
allow_cycles=item.allow_cycles,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_ids(values: Sequence[str], *, label: str) -> tuple[str, ...]:
|
||||||
|
result = tuple(dict.fromkeys(str(value).strip() for value in values))
|
||||||
|
if any(not value for value in result):
|
||||||
|
raise ValueError(f"{label} must not contain empty IDs.")
|
||||||
|
if len(result) > MAX_DIRECTORY_BATCH:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} is limited to {MAX_DIRECTORY_BATCH} entries."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_direction(
|
||||||
|
value: OrganizationHierarchyDirection,
|
||||||
|
) -> OrganizationHierarchyDirection:
|
||||||
|
if value not in {"ancestors", "descendants"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Hierarchy direction must be ancestors or descendants."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_depth(value: int) -> int:
|
||||||
|
depth = int(value)
|
||||||
|
if depth < 1 or depth > MAX_HIERARCHY_DEPTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Hierarchy depth must be between 1 and {MAX_HIERARCHY_DEPTH}."
|
||||||
|
)
|
||||||
|
return depth
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _HierarchyGraph:
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
structure_id: str
|
||||||
|
structure: OrganizationStructureRef | None
|
||||||
|
relation_type_ids: tuple[str, ...]
|
||||||
|
units: dict[str, OrganizationUnitRef]
|
||||||
|
outgoing: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||||
|
incoming: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||||
|
diagnostics: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class SqlOrganizationDirectory(
|
||||||
|
OrganizationDirectory,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_factory: Callable[[], Session] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _session(self) -> Iterator[Session]:
|
||||||
|
if self._session_factory is None:
|
||||||
|
with get_database().session() as session:
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
session = self._session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_organization_unit(
|
||||||
|
self,
|
||||||
|
organization_unit_id: str,
|
||||||
|
) -> OrganizationUnitRef | None:
|
||||||
|
with self._session() as session:
|
||||||
item = session.get(OrganizationUnit, organization_unit_id)
|
item = session.get(OrganizationUnit, organization_unit_id)
|
||||||
return _unit_ref(item) if item is not None else None
|
return _unit_ref(item) if item is not None else None
|
||||||
|
|
||||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
def organization_units_for_tenant(
|
||||||
with get_database().session() as session:
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> tuple[OrganizationUnitRef, ...]:
|
||||||
|
with self._session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
session.query(OrganizationUnit)
|
session.query(OrganizationUnit)
|
||||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||||
.order_by(OrganizationUnit.name.asc())
|
.order_by(OrganizationUnit.name.asc(), OrganizationUnit.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_unit_ref(item) for item in rows)
|
return tuple(_unit_ref(item) for item in rows)
|
||||||
|
|
||||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
def get_function(
|
||||||
with get_database().session() as session:
|
self,
|
||||||
|
function_id: str,
|
||||||
|
) -> OrganizationFunctionRef | None:
|
||||||
|
with self._session() as session:
|
||||||
item = session.get(OrganizationFunction, function_id)
|
item = session.get(OrganizationFunction, function_id)
|
||||||
return _function_ref(item) if item is not None else None
|
return _function_ref(item) if item is not None else None
|
||||||
|
|
||||||
@@ -71,7 +235,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
*,
|
*,
|
||||||
include_subunits: bool = False,
|
include_subunits: bool = False,
|
||||||
) -> tuple[OrganizationFunctionRef, ...]:
|
) -> tuple[OrganizationFunctionRef, ...]:
|
||||||
with get_database().session() as session:
|
with self._session() as session:
|
||||||
|
unit = session.get(OrganizationUnit, organization_unit_id)
|
||||||
|
if unit is None:
|
||||||
|
return ()
|
||||||
unit_ids = {organization_unit_id}
|
unit_ids = {organization_unit_id}
|
||||||
if include_subunits:
|
if include_subunits:
|
||||||
pending = [organization_unit_id]
|
pending = [organization_unit_id]
|
||||||
@@ -80,7 +247,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
child_ids = [
|
child_ids = [
|
||||||
row[0]
|
row[0]
|
||||||
for row in session.query(OrganizationUnit.id)
|
for row in session.query(OrganizationUnit.id)
|
||||||
.filter(OrganizationUnit.parent_id == parent_id)
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == unit.tenant_id,
|
||||||
|
OrganizationUnit.parent_id == parent_id,
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
for child_id in child_ids:
|
for child_id in child_ids:
|
||||||
@@ -89,8 +259,688 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
pending.append(child_id)
|
pending.append(child_id)
|
||||||
rows = (
|
rows = (
|
||||||
session.query(OrganizationFunction)
|
session.query(OrganizationFunction)
|
||||||
.filter(OrganizationFunction.organization_unit_id.in_(unit_ids))
|
.filter(
|
||||||
.order_by(OrganizationFunction.name.asc())
|
OrganizationFunction.tenant_id == unit.tenant_id,
|
||||||
|
OrganizationFunction.organization_unit_id.in_(unit_ids),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
OrganizationFunction.name.asc(),
|
||||||
|
OrganizationFunction.id,
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_function_ref(item) for item in rows)
|
return tuple(_function_ref(item) for item in rows)
|
||||||
|
|
||||||
|
def get_unit_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
) -> OrganizationUnitTypeRef | None:
|
||||||
|
with self._session() as session:
|
||||||
|
item = session.get(OrganizationUnitType, unit_type_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return _unit_type_ref(item)
|
||||||
|
|
||||||
|
def hierarchy_catalog(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> OrganizationHierarchyCatalogRef:
|
||||||
|
with self._session() as session:
|
||||||
|
structures = (
|
||||||
|
session.query(OrganizationStructure)
|
||||||
|
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationStructure.name, OrganizationStructure.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
relation_types = (
|
||||||
|
session.query(OrganizationRelationType)
|
||||||
|
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||||
|
.order_by(
|
||||||
|
OrganizationRelationType.structure_id,
|
||||||
|
OrganizationRelationType.name,
|
||||||
|
OrganizationRelationType.id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return OrganizationHierarchyCatalogRef(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structures=tuple(_structure_ref(item) for item in structures),
|
||||||
|
relation_types=tuple(
|
||||||
|
_relation_type_ref(item) for item in relation_types
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_function_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
) -> OrganizationFunctionTypeRef | None:
|
||||||
|
with self._session() as session:
|
||||||
|
item = session.get(OrganizationFunctionType, function_type_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return _function_type_ref(item)
|
||||||
|
|
||||||
|
def resolve_functions_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
*,
|
||||||
|
organization_unit_ids: Sequence[str] = (),
|
||||||
|
) -> OrganizationFunctionTypeResolution:
|
||||||
|
requested_ids = _unique_ids(
|
||||||
|
organization_unit_ids,
|
||||||
|
label="Organization unit scope",
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
function_type = session.get(
|
||||||
|
OrganizationFunctionType,
|
||||||
|
function_type_id,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
function_type is None
|
||||||
|
or function_type.tenant_id != tenant_id
|
||||||
|
):
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=requested_ids,
|
||||||
|
status="missing",
|
||||||
|
diagnostics=("function_type_missing",),
|
||||||
|
)
|
||||||
|
units: dict[str, OrganizationUnit] = {}
|
||||||
|
if requested_ids:
|
||||||
|
units = {
|
||||||
|
item.id: item
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(requested_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
query = session.query(OrganizationFunction).filter(
|
||||||
|
OrganizationFunction.tenant_id == tenant_id,
|
||||||
|
OrganizationFunction.function_type_id == function_type_id,
|
||||||
|
)
|
||||||
|
if requested_ids:
|
||||||
|
query = query.filter(
|
||||||
|
OrganizationFunction.organization_unit_id.in_(
|
||||||
|
tuple(units)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = query.order_by(
|
||||||
|
OrganizationFunction.organization_unit_id,
|
||||||
|
OrganizationFunction.name,
|
||||||
|
OrganizationFunction.id,
|
||||||
|
).all()
|
||||||
|
relevant_unit_ids = (
|
||||||
|
requested_ids
|
||||||
|
if requested_ids
|
||||||
|
else tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.organization_unit_id for item in rows
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not requested_ids and relevant_unit_ids:
|
||||||
|
units = {
|
||||||
|
item.id: item
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(relevant_unit_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
missing = tuple(
|
||||||
|
item_id
|
||||||
|
for item_id in relevant_unit_ids
|
||||||
|
if item_id not in units
|
||||||
|
)
|
||||||
|
inactive = tuple(
|
||||||
|
item_id
|
||||||
|
for item_id in relevant_unit_ids
|
||||||
|
if item_id in units and not units[item_id].is_active
|
||||||
|
)
|
||||||
|
diagnostics = []
|
||||||
|
if missing:
|
||||||
|
diagnostics.append("organization_units_missing")
|
||||||
|
if inactive:
|
||||||
|
diagnostics.append("organization_units_inactive")
|
||||||
|
if not rows:
|
||||||
|
diagnostics.append("no_matching_functions")
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=requested_ids,
|
||||||
|
status=(
|
||||||
|
"active" if function_type.is_active else "inactive"
|
||||||
|
),
|
||||||
|
function_type=_function_type_ref(function_type),
|
||||||
|
matches=tuple(_function_ref(item) for item in rows),
|
||||||
|
missing_unit_ids=missing,
|
||||||
|
inactive_unit_ids=inactive,
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_units_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
*,
|
||||||
|
structure_id: str | None = None,
|
||||||
|
root_unit_id: str | None = None,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "descendants",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> OrganizationUnitTypeResolution:
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
if bool(structure_id) != bool(root_unit_id):
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="invalid",
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
diagnostics=(
|
||||||
|
"structure_and_root_must_be_supplied_together",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
unit_type = session.get(OrganizationUnitType, unit_type_id)
|
||||||
|
if unit_type is None or unit_type.tenant_id != tenant_id:
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="missing",
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
diagnostics=("unit_type_missing",),
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.unit_type_id == unit_type_id,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationUnit.name, OrganizationUnit.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
status: OrganizationResolutionStatus = (
|
||||||
|
"active" if unit_type.is_active else "inactive"
|
||||||
|
)
|
||||||
|
if structure_id and root_unit_id:
|
||||||
|
hierarchy = self.resolve_hierarchy_relatives(
|
||||||
|
tenant_id,
|
||||||
|
(root_unit_id,),
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)[0]
|
||||||
|
scope_ids = {
|
||||||
|
root_unit_id,
|
||||||
|
*(match.unit.id for match in hierarchy.matches),
|
||||||
|
}
|
||||||
|
rows = [item for item in rows if item.id in scope_ids]
|
||||||
|
diagnostics = hierarchy.diagnostics
|
||||||
|
if hierarchy.status in {"missing", "invalid"}:
|
||||||
|
status = hierarchy.status
|
||||||
|
elif hierarchy.status == "inactive" and status == "active":
|
||||||
|
status = "inactive"
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status=status,
|
||||||
|
unit_type=_unit_type_ref(unit_type),
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
matches=tuple(_unit_ref(item) for item in rows),
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_relatives(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
organization_unit_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "ancestors",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> tuple[OrganizationHierarchyResolution, ...]:
|
||||||
|
root_ids = _unique_ids(
|
||||||
|
organization_unit_ids,
|
||||||
|
label="Organization hierarchy roots",
|
||||||
|
)
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
graph = self._hierarchy_graph(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
required_unit_ids=root_ids,
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
self._relative_resolution(
|
||||||
|
graph,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_id,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
for root_id in root_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_paths(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_pairs: Sequence[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "descendants",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> tuple[OrganizationHierarchyPathResolution, ...]:
|
||||||
|
if len(unit_pairs) > MAX_DIRECTORY_BATCH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Organization path batches are limited to "
|
||||||
|
f"{MAX_DIRECTORY_BATCH} entries."
|
||||||
|
)
|
||||||
|
pairs = tuple(
|
||||||
|
(str(source).strip(), str(target).strip())
|
||||||
|
for source, target in unit_pairs
|
||||||
|
)
|
||||||
|
if any(not source or not target for source, target in pairs):
|
||||||
|
raise ValueError("Organization paths require source and target IDs.")
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
required_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item
|
||||||
|
for pair in pairs
|
||||||
|
for item in pair
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
graph = self._hierarchy_graph(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
required_unit_ids=required_ids,
|
||||||
|
)
|
||||||
|
cache: dict[str, OrganizationHierarchyResolution] = {}
|
||||||
|
results: list[OrganizationHierarchyPathResolution] = []
|
||||||
|
for source_id, target_id in pairs:
|
||||||
|
if source_id not in cache:
|
||||||
|
cache[source_id] = self._relative_resolution(
|
||||||
|
graph,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=source_id,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
relative = cache[source_id]
|
||||||
|
target = graph.units.get(target_id)
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in relative.matches
|
||||||
|
if item.unit.id == target_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if source_id == target_id and relative.root is not None:
|
||||||
|
status: OrganizationResolutionStatus = (
|
||||||
|
"active"
|
||||||
|
if relative.root.status == "active"
|
||||||
|
else "inactive"
|
||||||
|
)
|
||||||
|
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
|
||||||
|
elif relative.root is None or target is None:
|
||||||
|
status = "missing"
|
||||||
|
path = ()
|
||||||
|
elif match is None:
|
||||||
|
status = (
|
||||||
|
"invalid"
|
||||||
|
if relative.status == "invalid"
|
||||||
|
else "unreachable"
|
||||||
|
)
|
||||||
|
path = ()
|
||||||
|
else:
|
||||||
|
status = (
|
||||||
|
"active"
|
||||||
|
if (
|
||||||
|
relative.root.status == "active"
|
||||||
|
and target.status == "active"
|
||||||
|
)
|
||||||
|
else "inactive"
|
||||||
|
)
|
||||||
|
path = match.path
|
||||||
|
results.append(
|
||||||
|
OrganizationHierarchyPathResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_unit_id=source_id,
|
||||||
|
target_unit_id=target_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=depth,
|
||||||
|
status=status,
|
||||||
|
source=relative.root,
|
||||||
|
target=target,
|
||||||
|
path=path,
|
||||||
|
cycle_detected=relative.cycle_detected,
|
||||||
|
depth_limited=relative.depth_limited,
|
||||||
|
diagnostics=relative.diagnostics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
def _hierarchy_graph(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: tuple[str, ...],
|
||||||
|
required_unit_ids: tuple[str, ...],
|
||||||
|
) -> _HierarchyGraph:
|
||||||
|
structure = session.get(OrganizationStructure, structure_id)
|
||||||
|
if structure is None or structure.tenant_id != tenant_id:
|
||||||
|
return _HierarchyGraph(
|
||||||
|
status="missing",
|
||||||
|
structure_id=structure_id,
|
||||||
|
structure=None,
|
||||||
|
relation_type_ids=relation_type_ids,
|
||||||
|
units={},
|
||||||
|
outgoing={},
|
||||||
|
incoming={},
|
||||||
|
diagnostics=("structure_missing",),
|
||||||
|
)
|
||||||
|
relation_query = session.query(OrganizationRelationType).filter(
|
||||||
|
OrganizationRelationType.tenant_id == tenant_id,
|
||||||
|
OrganizationRelationType.is_hierarchical.is_(True),
|
||||||
|
or_(
|
||||||
|
OrganizationRelationType.structure_id.is_(None),
|
||||||
|
OrganizationRelationType.structure_id == structure_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if relation_type_ids:
|
||||||
|
relation_query = relation_query.filter(
|
||||||
|
OrganizationRelationType.id.in_(relation_type_ids)
|
||||||
|
)
|
||||||
|
relation_types = relation_query.all()
|
||||||
|
relation_type_by_id = {
|
||||||
|
item.id: item
|
||||||
|
for item in relation_types
|
||||||
|
if item.is_active
|
||||||
|
}
|
||||||
|
diagnostics: list[str] = []
|
||||||
|
invalid_relation_filter = False
|
||||||
|
if relation_type_ids:
|
||||||
|
missing_relation_ids = tuple(
|
||||||
|
item
|
||||||
|
for item in relation_type_ids
|
||||||
|
if item not in {row.id for row in relation_types}
|
||||||
|
)
|
||||||
|
inactive_relation_ids = tuple(
|
||||||
|
item.id for item in relation_types if not item.is_active
|
||||||
|
)
|
||||||
|
if missing_relation_ids:
|
||||||
|
diagnostics.append("relation_types_missing_or_invalid")
|
||||||
|
invalid_relation_filter = True
|
||||||
|
if inactive_relation_ids:
|
||||||
|
diagnostics.append("relation_types_inactive")
|
||||||
|
invalid_relation_filter = True
|
||||||
|
selected_ids = tuple(sorted(relation_type_by_id))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
relations = []
|
||||||
|
if selected_ids:
|
||||||
|
relations = (
|
||||||
|
session.query(OrganizationRelation)
|
||||||
|
.filter(
|
||||||
|
OrganizationRelation.tenant_id == tenant_id,
|
||||||
|
OrganizationRelation.structure_id == structure_id,
|
||||||
|
OrganizationRelation.relation_type_id.in_(selected_ids),
|
||||||
|
OrganizationRelation.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(OrganizationRelation.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
relations = [
|
||||||
|
item
|
||||||
|
for item in relations
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
item.valid_from is None
|
||||||
|
or _utc_datetime(item.valid_from) <= now
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
item.valid_until is None
|
||||||
|
or _utc_datetime(item.valid_until) > now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
unit_ids = {
|
||||||
|
*required_unit_ids,
|
||||||
|
*(item.source_unit_id for item in relations),
|
||||||
|
*(item.target_unit_id for item in relations),
|
||||||
|
}
|
||||||
|
units = {
|
||||||
|
item.id: _unit_ref(item)
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(unit_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
} if unit_ids else {}
|
||||||
|
structure_ref = _structure_ref(structure)
|
||||||
|
outgoing_lists: dict[
|
||||||
|
str,
|
||||||
|
list[OrganizationHierarchyEdgeRef],
|
||||||
|
] = {}
|
||||||
|
incoming_lists: dict[
|
||||||
|
str,
|
||||||
|
list[OrganizationHierarchyEdgeRef],
|
||||||
|
] = {}
|
||||||
|
for relation in relations:
|
||||||
|
relation_type = relation_type_by_id[relation.relation_type_id]
|
||||||
|
if (
|
||||||
|
relation.source_unit_id not in units
|
||||||
|
or relation.target_unit_id not in units
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
f"relation_unit_missing:{relation.id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
edge = OrganizationHierarchyEdgeRef(
|
||||||
|
id=relation.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure=structure_ref,
|
||||||
|
relation_type=_relation_type_ref(relation_type),
|
||||||
|
source_unit_id=relation.source_unit_id,
|
||||||
|
target_unit_id=relation.target_unit_id,
|
||||||
|
valid_from=relation.valid_from,
|
||||||
|
valid_until=relation.valid_until,
|
||||||
|
)
|
||||||
|
outgoing_lists.setdefault(edge.source_unit_id, []).append(edge)
|
||||||
|
incoming_lists.setdefault(edge.target_unit_id, []).append(edge)
|
||||||
|
return _HierarchyGraph(
|
||||||
|
status=(
|
||||||
|
"inactive"
|
||||||
|
if not structure.is_active
|
||||||
|
else "invalid"
|
||||||
|
if invalid_relation_filter
|
||||||
|
else "active"
|
||||||
|
),
|
||||||
|
structure_id=structure_id,
|
||||||
|
structure=structure_ref,
|
||||||
|
relation_type_ids=selected_ids,
|
||||||
|
units=units,
|
||||||
|
outgoing={
|
||||||
|
key: tuple(sorted(value, key=lambda item: item.id))
|
||||||
|
for key, value in outgoing_lists.items()
|
||||||
|
},
|
||||||
|
incoming={
|
||||||
|
key: tuple(sorted(value, key=lambda item: item.id))
|
||||||
|
for key, value in incoming_lists.items()
|
||||||
|
},
|
||||||
|
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _relative_resolution(
|
||||||
|
self,
|
||||||
|
graph: _HierarchyGraph,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
root_unit_id: str,
|
||||||
|
direction: OrganizationHierarchyDirection,
|
||||||
|
max_depth: int,
|
||||||
|
) -> OrganizationHierarchyResolution:
|
||||||
|
root = graph.units.get(root_unit_id)
|
||||||
|
if root is None:
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=(
|
||||||
|
graph.structure_id
|
||||||
|
),
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status="missing",
|
||||||
|
diagnostics=tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
(*graph.diagnostics, "root_unit_missing")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if graph.status in {"missing", "invalid"}:
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=(
|
||||||
|
graph.structure_id
|
||||||
|
),
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status=graph.status,
|
||||||
|
root=root,
|
||||||
|
diagnostics=graph.diagnostics,
|
||||||
|
)
|
||||||
|
adjacency = (
|
||||||
|
graph.incoming
|
||||||
|
if direction == "ancestors"
|
||||||
|
else graph.outgoing
|
||||||
|
)
|
||||||
|
pending = deque(
|
||||||
|
[(root_unit_id, 0, (), frozenset({root_unit_id}))]
|
||||||
|
)
|
||||||
|
best_depth = {root_unit_id: 0}
|
||||||
|
matches: dict[str, OrganizationHierarchyMatchRef] = {}
|
||||||
|
diagnostics = list(graph.diagnostics)
|
||||||
|
cycle_detected = False
|
||||||
|
depth_limited = False
|
||||||
|
while pending:
|
||||||
|
current_id, current_depth, path, path_units = pending.popleft()
|
||||||
|
edges = adjacency.get(current_id, ())
|
||||||
|
if current_depth >= max_depth:
|
||||||
|
if edges:
|
||||||
|
depth_limited = True
|
||||||
|
continue
|
||||||
|
for edge in edges:
|
||||||
|
next_id = (
|
||||||
|
edge.source_unit_id
|
||||||
|
if direction == "ancestors"
|
||||||
|
else edge.target_unit_id
|
||||||
|
)
|
||||||
|
if next_id in path_units:
|
||||||
|
cycle_detected = True
|
||||||
|
diagnostics.append(f"cycle_detected:{edge.id}")
|
||||||
|
continue
|
||||||
|
unit = graph.units.get(next_id)
|
||||||
|
if unit is None:
|
||||||
|
diagnostics.append(f"unit_missing:{next_id}")
|
||||||
|
continue
|
||||||
|
next_depth = current_depth + 1
|
||||||
|
previous_depth = best_depth.get(next_id)
|
||||||
|
if (
|
||||||
|
previous_depth is not None
|
||||||
|
and previous_depth <= next_depth
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
next_path = (*path, edge)
|
||||||
|
best_depth[next_id] = next_depth
|
||||||
|
matches[next_id] = OrganizationHierarchyMatchRef(
|
||||||
|
unit=unit,
|
||||||
|
depth=next_depth,
|
||||||
|
path=next_path,
|
||||||
|
)
|
||||||
|
pending.append(
|
||||||
|
(
|
||||||
|
next_id,
|
||||||
|
next_depth,
|
||||||
|
next_path,
|
||||||
|
path_units | {next_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if depth_limited:
|
||||||
|
diagnostics.append("maximum_depth_reached")
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=graph.structure_id,
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status=(
|
||||||
|
"active"
|
||||||
|
if root.status == "active" and graph.status == "active"
|
||||||
|
else "inactive"
|
||||||
|
),
|
||||||
|
root=root,
|
||||||
|
matches=tuple(
|
||||||
|
sorted(
|
||||||
|
matches.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
item.depth,
|
||||||
|
item.unit.name.casefold(),
|
||||||
|
item.unit.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cycle_detected=cycle_detected,
|
||||||
|
depth_limited=depth_limited,
|
||||||
|
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SqlOrganizationDirectory"]
|
||||||
|
|||||||
@@ -2,20 +2,30 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||||
|
|
||||||
@@ -43,14 +53,46 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission("organizations:model:read", "View organization model", "Read organization meta-model definitions such as unit types, structures, and relation types."),
|
_permission(
|
||||||
_permission("organizations:model:write", "Manage organization model", "Create and edit organization meta-model definitions."),
|
"organizations:model:read",
|
||||||
_permission("organizations:settings:read", "View organization settings", "Read organization governance, audit, and retention settings."),
|
"View organization model",
|
||||||
_permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."),
|
"Read organization meta-model definitions such as unit types, structures, and relation types.",
|
||||||
_permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."),
|
),
|
||||||
_permission("organizations:unit:write", "Manage organization units", "Create and edit concrete organization units and relations."),
|
_permission(
|
||||||
_permission("organizations:function:read", "View organization functions", "Read function definitions."),
|
"organizations:model:write",
|
||||||
_permission("organizations:function:write", "Manage organization functions", "Create and edit function definitions."),
|
"Manage organization model",
|
||||||
|
"Create and edit organization meta-model definitions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:settings:read",
|
||||||
|
"View organization settings",
|
||||||
|
"Read organization governance, audit, and retention settings.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:settings:write",
|
||||||
|
"Manage organization settings",
|
||||||
|
"Edit organization governance, audit, and retention settings.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:unit:read",
|
||||||
|
"View organization units",
|
||||||
|
"Read concrete organization units and relations.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:unit:write",
|
||||||
|
"Manage organization units",
|
||||||
|
"Create and edit concrete organization units and relations.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:function:read",
|
||||||
|
"View organization functions",
|
||||||
|
"Read function definitions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"organizations:function:write",
|
||||||
|
"Manage organization functions",
|
||||||
|
"Create and edit function definitions.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -64,7 +106,12 @@ ROLE_TEMPLATES = (
|
|||||||
slug="organization_viewer",
|
slug="organization_viewer",
|
||||||
name="Organization viewer",
|
name="Organization viewer",
|
||||||
description="Read organization model, organization units, and functions.",
|
description="Read organization model, organization units, and functions.",
|
||||||
permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"),
|
permissions=(
|
||||||
|
"organizations:model:read",
|
||||||
|
"organizations:settings:read",
|
||||||
|
"organizations:unit:read",
|
||||||
|
"organizations:function:read",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,18 +133,71 @@ def _organization_directory(context: ModuleContext) -> object:
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="organizations",
|
id="organizations",
|
||||||
name="Organizations",
|
name="Organizations",
|
||||||
version="0.1.6",
|
version="0.1.15",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="organizations.directory",
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="organizations.hierarchy_directory",
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/organizations",
|
||||||
|
label="Organizations",
|
||||||
|
icon="building-2",
|
||||||
|
required_any=ORGANIZATIONS_READ_SCOPES,
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="organizations",
|
module_id="organizations",
|
||||||
package_name="@govoplan/organizations-webui",
|
package_name="@govoplan/organizations-webui",
|
||||||
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
routes=(
|
||||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
FrontendRoute(
|
||||||
|
path="/organizations",
|
||||||
|
component="OrganizationsPage",
|
||||||
|
required_any=ORGANIZATIONS_READ_SCOPES,
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/organizations",
|
||||||
|
label="Organizations",
|
||||||
|
icon="building-2",
|
||||||
|
required_any=ORGANIZATIONS_READ_SCOPES,
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="organizations.admin.tenant",
|
||||||
|
module_id="organizations",
|
||||||
|
kind="section",
|
||||||
|
label="Organizations administration",
|
||||||
|
order=85,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="organizations.admin.template-upgrades",
|
||||||
|
module_id="organizations",
|
||||||
|
kind="section",
|
||||||
|
label="Organization template upgrades",
|
||||||
|
parent_id="organizations.admin.tenant",
|
||||||
|
order=86,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="organizations",
|
module_id="organizations",
|
||||||
@@ -108,6 +208,10 @@ manifest = ModuleManifest(
|
|||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
organization_models.OrganizationUnitType,
|
organization_models.OrganizationUnitType,
|
||||||
organization_models.OrganizationTenantSettings,
|
organization_models.OrganizationTenantSettings,
|
||||||
|
organization_models.OrganizationModelTemplate,
|
||||||
|
organization_models.OrganizationModelTemplateVersion,
|
||||||
|
organization_models.OrganizationModelInstantiation,
|
||||||
|
organization_models.OrganizationModelUpgrade,
|
||||||
organization_models.OrganizationStructure,
|
organization_models.OrganizationStructure,
|
||||||
organization_models.OrganizationRelationType,
|
organization_models.OrganizationRelationType,
|
||||||
organization_models.OrganizationRelation,
|
organization_models.OrganizationRelation,
|
||||||
@@ -119,8 +223,56 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
|
||||||
},
|
},
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="organizations.model",
|
id="organizations.model",
|
||||||
title="Organization model",
|
title="Organization model",
|
||||||
@@ -128,13 +280,109 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
|
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
|
||||||
"A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
|
"A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
|
||||||
"Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights."
|
"Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights. "
|
||||||
|
"A function does not itself prove mandate, jurisdiction, decision authority, or signature authority; those effective institutional facts belong to a separate provider contract."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "access_admin", "operator"),
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
|
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,
|
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",
|
"name": "@govoplan/organizations-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs",
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||||
|
"test:template-upgrades": "node scripts/test-template-upgrades.mjs"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
@@ -14,14 +19,14 @@
|
|||||||
"./styles/organizations.css": "./src/styles/organizations.css"
|
"./styles/organizations.css": "./src/styles/organizations.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.15",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": ">=8.3.0 <9",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -0,0 +1,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 = {
|
export type OrganizationUnitTypeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -135,6 +135,81 @@ export type OrganizationModel = {
|
|||||||
functions: OrganizationFunctionItem[];
|
functions: OrganizationFunctionItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrganizationTemplateVersion = {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
version: string;
|
||||||
|
status: string;
|
||||||
|
definition_sha256: string;
|
||||||
|
release_notes?: string | null;
|
||||||
|
published_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationTemplateCatalogItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
versions: OrganizationTemplateVersion[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationTemplateCatalog = {
|
||||||
|
templates: OrganizationTemplateCatalogItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationModelInstantiation = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
template_id: string;
|
||||||
|
template_version_id: string;
|
||||||
|
source_definition_sha256: string;
|
||||||
|
status: string;
|
||||||
|
object_counts: Record<string, number>;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationUpgradeDecisionAction = "keep_local" | "use_target" | "map_to";
|
||||||
|
|
||||||
|
export type OrganizationUpgradeDiffEntry = {
|
||||||
|
id: string;
|
||||||
|
collection: string;
|
||||||
|
key: string;
|
||||||
|
classification: string;
|
||||||
|
requires_decision: boolean;
|
||||||
|
base?: Record<string, unknown> | null;
|
||||||
|
local?: Record<string, unknown> | null;
|
||||||
|
target?: Record<string, unknown> | null;
|
||||||
|
allowed_actions: OrganizationUpgradeDecisionAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationModelUpgrade = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
template_id: string;
|
||||||
|
source_instantiation_id: string;
|
||||||
|
source_template_version_id: string;
|
||||||
|
target_template_version_id: string;
|
||||||
|
status: string;
|
||||||
|
revision: number;
|
||||||
|
preview: {
|
||||||
|
entries: OrganizationUpgradeDiffEntry[];
|
||||||
|
counts: Record<string, number>;
|
||||||
|
requires_decisions: number;
|
||||||
|
blocking_invalid_references: number;
|
||||||
|
silent_mutation: boolean;
|
||||||
|
};
|
||||||
|
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string | null }>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationModelUpgradeList = {
|
||||||
|
current_instantiation?: OrganizationModelInstantiation | null;
|
||||||
|
upgrades: OrganizationModelUpgrade[];
|
||||||
|
};
|
||||||
|
|
||||||
export type OrganizationChangeRequestPayload = {
|
export type OrganizationChangeRequestPayload = {
|
||||||
change_request_id?: string | null;
|
change_request_id?: string | null;
|
||||||
};
|
};
|
||||||
@@ -186,78 +261,105 @@ export type FunctionCreatePayload = SluggedCreatePayload & {
|
|||||||
act_in_place_allowed?: boolean | null;
|
act_in_place_allowed?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
|
||||||
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
|
||||||
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
||||||
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOrganizationTemplateCatalog(settings: ApiSettings): Promise<OrganizationTemplateCatalog> {
|
||||||
|
return apiFetch<OrganizationTemplateCatalog>(settings, "/api/v1/organizations/model-templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationModelUpgrades(settings: ApiSettings): Promise<OrganizationModelUpgradeList> {
|
||||||
|
return apiFetch<OrganizationModelUpgradeList>(settings, "/api/v1/organizations/model-upgrades");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewOrganizationModelUpgrade(settings: ApiSettings, targetTemplateVersionId: string): Promise<OrganizationModelUpgrade> {
|
||||||
|
return apiPostJson(settings, "/api/v1/organizations/model-upgrades/preview", {
|
||||||
|
target_template_version_id: targetTemplateVersionId,
|
||||||
|
idempotency_key: crypto.randomUUID()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelOrganizationModelUpgrade(settings: ApiSettings, upgradeId: string, expectedRevision: number): Promise<OrganizationModelUpgrade> {
|
||||||
|
return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/cancel`, {
|
||||||
|
expected_revision: expectedRevision
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOrganizationModelUpgrade(
|
||||||
|
settings: ApiSettings,
|
||||||
|
upgradeId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string }>,
|
||||||
|
changeRequestId?: string
|
||||||
|
): Promise<{ upgrade: OrganizationModelUpgrade; instantiation: OrganizationModelInstantiation }> {
|
||||||
|
return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/apply`, {
|
||||||
|
expected_revision: expectedRevision,
|
||||||
|
decisions,
|
||||||
|
change_request_id: changeRequestId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
|
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
|
||||||
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchOrganizationSettings(settings: ApiSettings, payload: OrganizationSettingsUpdatePayload): Promise<OrganizationSettingsItem> {
|
export function patchOrganizationSettings(settings: ApiSettings, payload: OrganizationSettingsUpdatePayload): Promise<OrganizationSettingsItem> {
|
||||||
return patch(settings, "/api/v1/organizations/settings", payload);
|
return apiPatchJson(settings, "/api/v1/organizations/settings", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise<OrganizationUnitTypeItem> {
|
export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise<OrganizationUnitTypeItem> {
|
||||||
return post(settings, "/api/v1/organizations/unit-types", payload);
|
return apiPostJson(settings, "/api/v1/organizations/unit-types", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchUnitType(settings: ApiSettings, id: string, payload: Partial<SluggedCreatePayload>): Promise<OrganizationUnitTypeItem> {
|
export function patchUnitType(settings: ApiSettings, id: string, payload: Partial<SluggedCreatePayload>): Promise<OrganizationUnitTypeItem> {
|
||||||
return patch(settings, `/api/v1/organizations/unit-types/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/unit-types/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStructure(settings: ApiSettings, payload: StructureCreatePayload): Promise<OrganizationStructureItem> {
|
export function createStructure(settings: ApiSettings, payload: StructureCreatePayload): Promise<OrganizationStructureItem> {
|
||||||
return post(settings, "/api/v1/organizations/structures", payload);
|
return apiPostJson(settings, "/api/v1/organizations/structures", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchStructure(settings: ApiSettings, id: string, payload: Partial<StructureCreatePayload>): Promise<OrganizationStructureItem> {
|
export function patchStructure(settings: ApiSettings, id: string, payload: Partial<StructureCreatePayload>): Promise<OrganizationStructureItem> {
|
||||||
return patch(settings, `/api/v1/organizations/structures/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/structures/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRelationType(settings: ApiSettings, payload: RelationTypeCreatePayload): Promise<OrganizationRelationTypeItem> {
|
export function createRelationType(settings: ApiSettings, payload: RelationTypeCreatePayload): Promise<OrganizationRelationTypeItem> {
|
||||||
return post(settings, "/api/v1/organizations/relation-types", payload);
|
return apiPostJson(settings, "/api/v1/organizations/relation-types", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchRelationType(settings: ApiSettings, id: string, payload: Partial<RelationTypeCreatePayload>): Promise<OrganizationRelationTypeItem> {
|
export function patchRelationType(settings: ApiSettings, id: string, payload: Partial<RelationTypeCreatePayload>): Promise<OrganizationRelationTypeItem> {
|
||||||
return patch(settings, `/api/v1/organizations/relation-types/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/relation-types/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUnit(settings: ApiSettings, payload: UnitCreatePayload): Promise<OrganizationUnitItem> {
|
export function createUnit(settings: ApiSettings, payload: UnitCreatePayload): Promise<OrganizationUnitItem> {
|
||||||
return post(settings, "/api/v1/organizations/units", payload);
|
return apiPostJson(settings, "/api/v1/organizations/units", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchUnit(settings: ApiSettings, id: string, payload: Partial<UnitCreatePayload>): Promise<OrganizationUnitItem> {
|
export function patchUnit(settings: ApiSettings, id: string, payload: Partial<UnitCreatePayload>): Promise<OrganizationUnitItem> {
|
||||||
return patch(settings, `/api/v1/organizations/units/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/units/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRelation(settings: ApiSettings, payload: RelationCreatePayload): Promise<OrganizationRelationItem> {
|
export function createRelation(settings: ApiSettings, payload: RelationCreatePayload): Promise<OrganizationRelationItem> {
|
||||||
return post(settings, "/api/v1/organizations/relations", payload);
|
return apiPostJson(settings, "/api/v1/organizations/relations", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchRelation(settings: ApiSettings, id: string, payload: Partial<RelationCreatePayload>): Promise<OrganizationRelationItem> {
|
export function patchRelation(settings: ApiSettings, id: string, payload: Partial<RelationCreatePayload>): Promise<OrganizationRelationItem> {
|
||||||
return patch(settings, `/api/v1/organizations/relations/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/relations/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFunctionType(settings: ApiSettings, payload: FunctionTypeCreatePayload): Promise<OrganizationFunctionTypeItem> {
|
export function createFunctionType(settings: ApiSettings, payload: FunctionTypeCreatePayload): Promise<OrganizationFunctionTypeItem> {
|
||||||
return post(settings, "/api/v1/organizations/function-types", payload);
|
return apiPostJson(settings, "/api/v1/organizations/function-types", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchFunctionType(settings: ApiSettings, id: string, payload: Partial<FunctionTypeCreatePayload>): Promise<OrganizationFunctionTypeItem> {
|
export function patchFunctionType(settings: ApiSettings, id: string, payload: Partial<FunctionTypeCreatePayload>): Promise<OrganizationFunctionTypeItem> {
|
||||||
return patch(settings, `/api/v1/organizations/function-types/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/function-types/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFunction(settings: ApiSettings, payload: FunctionCreatePayload): Promise<OrganizationFunctionItem> {
|
export function createFunction(settings: ApiSettings, payload: FunctionCreatePayload): Promise<OrganizationFunctionItem> {
|
||||||
return post(settings, "/api/v1/organizations/functions", payload);
|
return apiPostJson(settings, "/api/v1/organizations/functions", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> {
|
export function patchFunction(settings: ApiSettings, id: string, payload: Partial<FunctionCreatePayload>): Promise<OrganizationFunctionItem> {
|
||||||
return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
|
return apiPatchJson(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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 { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
AdminPageLayout,
|
AdminPageLayout,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
DocumentationHelpLink,
|
||||||
FormField,
|
FormField,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
|
useUnsavedChanges,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
@@ -17,6 +20,13 @@ import {
|
|||||||
type OrganizationAuditDetailLevel,
|
type OrganizationAuditDetailLevel,
|
||||||
type OrganizationSettingsItem
|
type OrganizationSettingsItem
|
||||||
} from "../../api/organizations";
|
} from "../../api/organizations";
|
||||||
|
import {
|
||||||
|
ORGANIZATIONS_DOCUMENTATION,
|
||||||
|
ORGANIZATIONS_FIELD_DOCUMENTATION,
|
||||||
|
ORGANIZATIONS_INTERFACE_I18N,
|
||||||
|
organizationWriteReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
import OrganizationTemplateUpgradePanel from "./OrganizationTemplateUpgradePanel";
|
||||||
|
|
||||||
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
||||||
tenant_id: "",
|
tenant_id: "",
|
||||||
@@ -40,8 +50,16 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
|
const canWrite = hasAnyScope(auth, ["organizations:settings:write", "admin:settings:write"]);
|
||||||
const dirty = settingsKey(draft) !== settingsKey(savedDraft);
|
const dirty = settingsKey(draft) !== settingsKey(savedDraft);
|
||||||
|
const reloadDisabledReason = loading
|
||||||
|
? ORGANIZATIONS_INTERFACE_I18N.loading
|
||||||
|
: busy
|
||||||
|
? ORGANIZATIONS_INTERFACE_I18N.busy
|
||||||
|
: undefined;
|
||||||
|
const saveDisabledReason = organizationWriteReason(canWrite, busy, "settings")
|
||||||
|
?? (!dirty ? ORGANIZATIONS_INTERFACE_I18N.noChanges : undefined);
|
||||||
|
|
||||||
useUnsavedDraftGuard({
|
useUnsavedDraftGuard({
|
||||||
dirty,
|
dirty,
|
||||||
@@ -99,20 +117,57 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
|
loadingLabel="i18n:govoplan-organizations.loading_organization_settings.c6008db8"
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading || busy}>i18n:govoplan-organizations.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !dirty}>{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}</Button></>}
|
actions={(
|
||||||
|
<>
|
||||||
|
<DocumentationHelpLink reference={ORGANIZATIONS_DOCUMENTATION} />
|
||||||
|
<Button
|
||||||
|
onClick={() => requestDiscard(() => void load())}
|
||||||
|
disabled={Boolean(reloadDisabledReason)}
|
||||||
|
disabledReason={reloadDisabledReason}
|
||||||
|
>
|
||||||
|
i18n:govoplan-organizations.reload.cce71553
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={Boolean(saveDisabledReason)}
|
||||||
|
disabledReason={saveDisabledReason}
|
||||||
|
>
|
||||||
|
{busy ? "i18n:govoplan-organizations.saving.56a2285c" : "i18n:govoplan-organizations.save_settings.913aba9f"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{!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">
|
<div className="organizations-settings-grid">
|
||||||
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
|
<Card title="i18n:govoplan-organizations.model_governance.6aa18fd0">
|
||||||
<div className="settings-list">
|
<div className="settings-list">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.allow_tenant_model_customization}
|
checked={draft.allow_tenant_model_customization}
|
||||||
disabled={!canWrite || busy}
|
disabled={!canWrite || busy}
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
|
onChange={(checked) => setDraft({ ...draft, allow_tenant_model_customization: checked })}
|
||||||
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
|
label="i18n:govoplan-organizations.allow_tenant_model_customization.2425d751"
|
||||||
/>
|
/>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.require_model_change_requests}
|
checked={draft.require_model_change_requests}
|
||||||
disabled={!canWrite || busy}
|
disabled={!canWrite || busy}
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
onChange={(checked) => setDraft({ ...draft, require_model_change_requests: checked })}
|
onChange={(checked) => setDraft({ ...draft, require_model_change_requests: checked })}
|
||||||
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
|
label="i18n:govoplan-organizations.require_model_change_requests.83454cad"
|
||||||
/>
|
/>
|
||||||
@@ -121,13 +176,21 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
||||||
<div className="organizations-form-grid">
|
<div className="admin-form-grid two-columns">
|
||||||
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.audit_detail_level.7397355d"
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
||||||
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="i18n:govoplan-organizations.change_retention_days.71bbd140">
|
<FormField
|
||||||
|
label="i18n:govoplan-organizations.change_retention_days.71bbd140"
|
||||||
|
help={organizationWriteReason(canWrite, busy, "settings")}
|
||||||
|
documentation={ORGANIZATIONS_FIELD_DOCUMENTATION}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
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>
|
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Tenant admins may customize the organization meta-model",
|
"i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Tenant admins may customize the organization meta-model",
|
||||||
"i18n:govoplan-organizations.change_retention_days.71bbd140": "Retain organization change detail for days",
|
"i18n:govoplan-organizations.change_retention_days.71bbd140": "Retain organization change detail for days",
|
||||||
"i18n:govoplan-organizations.change_request_id.a6e7fd6b": "Change request ID",
|
"i18n:govoplan-organizations.change_request_id.a6e7fd6b": "Change request ID",
|
||||||
|
"i18n:govoplan-organizations.change_request_id_help.0c119a5d": "Only required when organization model governance requires an approved recorded change request.",
|
||||||
"i18n:govoplan-organizations.cancel_edit.309c2a6f": "Cancel edit",
|
"i18n:govoplan-organizations.cancel_edit.309c2a6f": "Cancel edit",
|
||||||
"i18n:govoplan-organizations.concrete_model.4e9c531a": "Concrete model",
|
"i18n:govoplan-organizations.concrete_model.4e9c531a": "Concrete model",
|
||||||
"i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate",
|
"i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate",
|
||||||
@@ -66,9 +67,9 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.none.334c4a4c": "None",
|
"i18n:govoplan-organizations.none.334c4a4c": "None",
|
||||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
|
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organization model",
|
||||||
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
|
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Configure tenant-local unit types, parallel structures, relation types, and function types used by the organization module.",
|
||||||
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organization settings",
|
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organizations",
|
||||||
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.",
|
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Configure tenant-level governance, audit, and retention behavior for organization changes.",
|
||||||
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organization settings saved.",
|
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organizations saved.",
|
||||||
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree",
|
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organization tree",
|
||||||
"i18n:govoplan-organizations.organizations.220edf64": "Organizations",
|
"i18n:govoplan-organizations.organizations.220edf64": "Organizations",
|
||||||
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.",
|
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Model organization types, concrete units, structures, and functions.",
|
||||||
@@ -121,7 +122,23 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.update_structure.b2e25446": "Update structure",
|
"i18n:govoplan-organizations.update_structure.b2e25446": "Update structure",
|
||||||
"i18n:govoplan-organizations.update_unit.67e2500f": "Update unit",
|
"i18n:govoplan-organizations.update_unit.67e2500f": "Update unit",
|
||||||
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Update unit type",
|
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Update unit type",
|
||||||
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section."
|
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "You do not have write permission for this organization section.",
|
||||||
|
"i18n:govoplan-organizations.organizations_administration": "Organizations administration",
|
||||||
|
"i18n:govoplan-organizations.loading_reason": "Organization data is loading.",
|
||||||
|
"i18n:govoplan-organizations.busy_reason": "An organization change is in progress.",
|
||||||
|
"i18n:govoplan-organizations.no_changes_reason": "Make a change before saving.",
|
||||||
|
"i18n:govoplan-organizations.model_write_reason": "Organization model write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.unit_write_reason": "Organization unit write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.function_write_reason": "Organization function write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.settings_write_reason": "Organization settings write permission is required.",
|
||||||
|
"i18n:govoplan-organizations.incomplete_editor_reason": "Complete the required fields before saving.",
|
||||||
|
"i18n:govoplan-organizations.read_only_summary": "This organization section is read-only.",
|
||||||
|
"i18n:govoplan-organizations.required_action": "Required action",
|
||||||
|
"i18n:govoplan-organizations.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-organizations.destination": "Destination",
|
||||||
|
"i18n:govoplan-organizations.request_write_permission": "Request the matching organization model, unit, function, or settings write permission.",
|
||||||
|
"i18n:govoplan-organizations.organization_or_access_administrator": "An organization or Access administrator",
|
||||||
|
"i18n:govoplan-organizations.administration_roles_target": "Administration > Role templates and function mappings"
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
|
"i18n:govoplan-organizations.act_in_place.49b942bd": "In Vertretung handeln",
|
||||||
@@ -146,6 +163,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Mandantenadmins dürfen das Organisations-Metamodell anpassen",
|
"i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Mandantenadmins dürfen das Organisations-Metamodell anpassen",
|
||||||
"i18n:govoplan-organizations.change_retention_days.71bbd140": "Änderungsdetails zur Organisation in Tagen aufbewahren",
|
"i18n:govoplan-organizations.change_retention_days.71bbd140": "Änderungsdetails zur Organisation in Tagen aufbewahren",
|
||||||
"i18n:govoplan-organizations.change_request_id.a6e7fd6b": "Änderungsantrags-ID",
|
"i18n:govoplan-organizations.change_request_id.a6e7fd6b": "Änderungsantrags-ID",
|
||||||
|
"i18n:govoplan-organizations.change_request_id_help.0c119a5d": "Nur erforderlich, wenn die Organisationsmodell-Governance einen genehmigten erfassten Änderungsantrag verlangt.",
|
||||||
"i18n:govoplan-organizations.cancel_edit.309c2a6f": "Bearbeitung abbrechen",
|
"i18n:govoplan-organizations.cancel_edit.309c2a6f": "Bearbeitung abbrechen",
|
||||||
"i18n:govoplan-organizations.concrete_model.4e9c531a": "Konkretes Modell",
|
"i18n:govoplan-organizations.concrete_model.4e9c531a": "Konkretes Modell",
|
||||||
"i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren",
|
"i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren",
|
||||||
@@ -188,9 +206,9 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
|
"i18n:govoplan-organizations.none.334c4a4c": "Keine",
|
||||||
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
|
"i18n:govoplan-organizations.organization_model.4f924c0e": "Organisationsmodell",
|
||||||
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
|
"i18n:govoplan-organizations.organization_model_admin_description.35dc9f10": "Konfiguriere mandantenbezogene Einheitstypen, parallele Strukturen, Beziehungstypen und Funktionstypen des Organisationsmoduls.",
|
||||||
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationseinstellungen",
|
"i18n:govoplan-organizations.organization_settings.c9ab9829": "Organisationen",
|
||||||
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.",
|
"i18n:govoplan-organizations.organization_settings_description.35dc9f10": "Konfiguriere Governance, Audit und Aufbewahrung für Organisationsänderungen auf Mandantenebene.",
|
||||||
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationseinstellungen gespeichert.",
|
"i18n:govoplan-organizations.organization_settings_saved.bfbcfdfa": "Organisationen gespeichert.",
|
||||||
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum",
|
"i18n:govoplan-organizations.organization_tree.e5bfb195": "Organisationsbaum",
|
||||||
"i18n:govoplan-organizations.organizations.220edf64": "Organisationen",
|
"i18n:govoplan-organizations.organizations.220edf64": "Organisationen",
|
||||||
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.",
|
"i18n:govoplan-organizations.organizations_intro.4e67c4bb": "Modelliere Organisationstypen, konkrete Einheiten, Strukturen und Funktionen.",
|
||||||
@@ -243,6 +261,22 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-organizations.update_structure.b2e25446": "Struktur aktualisieren",
|
"i18n:govoplan-organizations.update_structure.b2e25446": "Struktur aktualisieren",
|
||||||
"i18n:govoplan-organizations.update_unit.67e2500f": "Einheit aktualisieren",
|
"i18n:govoplan-organizations.update_unit.67e2500f": "Einheit aktualisieren",
|
||||||
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Einheitstyp aktualisieren",
|
"i18n:govoplan-organizations.update_unit_type.b96fc0ad": "Einheitstyp aktualisieren",
|
||||||
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich."
|
"i18n:govoplan-organizations.write_permission_required.8b09fd67": "Du hast keine Schreibberechtigung für diesen Organisationsbereich.",
|
||||||
|
"i18n:govoplan-organizations.organizations_administration": "Organisationsverwaltung",
|
||||||
|
"i18n:govoplan-organizations.loading_reason": "Organisationsdaten werden geladen.",
|
||||||
|
"i18n:govoplan-organizations.busy_reason": "Eine Organisationsänderung wird verarbeitet.",
|
||||||
|
"i18n:govoplan-organizations.no_changes_reason": "Nehmen Sie vor dem Speichern eine Änderung vor.",
|
||||||
|
"i18n:govoplan-organizations.model_write_reason": "Eine Schreibberechtigung für das Organisationsmodell ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.unit_write_reason": "Eine Schreibberechtigung für Organisationseinheiten ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.function_write_reason": "Eine Schreibberechtigung für Organisationsfunktionen ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.settings_write_reason": "Eine Schreibberechtigung für Organisationseinstellungen ist erforderlich.",
|
||||||
|
"i18n:govoplan-organizations.incomplete_editor_reason": "Füllen Sie vor dem Speichern die Pflichtfelder aus.",
|
||||||
|
"i18n:govoplan-organizations.read_only_summary": "Dieser Organisationsbereich ist schreibgeschützt.",
|
||||||
|
"i18n:govoplan-organizations.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-organizations.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-organizations.destination": "Ziel",
|
||||||
|
"i18n:govoplan-organizations.request_write_permission": "Fordern Sie die passende Schreibberechtigung für Organisationsmodell, Einheiten, Funktionen oder Einstellungen an.",
|
||||||
|
"i18n:govoplan-organizations.organization_or_access_administrator": "Eine Organisations- oder Access-Administration",
|
||||||
|
"i18n:govoplan-organizations.administration_roles_target": "Administration > Rollenvorlagen und Funktionszuordnungen"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-3
@@ -23,7 +23,10 @@ const organizationAdminSections: AdminSectionsUiCapability = {
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "tenant-organization-settings",
|
id: "tenant-organization-settings",
|
||||||
label: "i18n:govoplan-organizations.organization_settings.c9ab9829",
|
moduleId: "organizations",
|
||||||
|
kind: "settings",
|
||||||
|
surfaceId: "organizations.admin.tenant",
|
||||||
|
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||||
group: "TENANT",
|
group: "TENANT",
|
||||||
order: 85,
|
order: 85,
|
||||||
anyOf: ["organizations:settings:read", "admin:settings:read"],
|
anyOf: ["organizations:settings:read", "admin:settings:read"],
|
||||||
@@ -41,15 +44,32 @@ const organizationFunctionPicker: OrganizationFunctionPickerUiCapability = {
|
|||||||
export const organizationsModule: PlatformWebModule = {
|
export const organizationsModule: PlatformWebModule = {
|
||||||
id: "organizations",
|
id: "organizations",
|
||||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||||
version: "1.0.0",
|
version: "0.1.8",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["admin"],
|
optionalDependencies: ["admin"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "organizations.admin.tenant",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "section",
|
||||||
|
label: "i18n:govoplan-organizations.organizations_administration",
|
||||||
|
order: 85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "organizations.admin.template-upgrades",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "section",
|
||||||
|
label: "Organization template upgrades",
|
||||||
|
parentId: "organizations.admin.tenant",
|
||||||
|
order: 86
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/organizations",
|
to: "/organizations",
|
||||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||||
iconName: "users",
|
iconName: "building-2",
|
||||||
anyOf: organizationReadScopes,
|
anyOf: organizationReadScopes,
|
||||||
order: 70
|
order: 70
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
.organizations-workspace {
|
.organizations-workspace {
|
||||||
grid-template-columns: 230px minmax(0, 1fr);
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
background: var(--bg, #f8f7f4);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-page {
|
.organizations-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1480px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-admin-page {
|
.organizations-admin-page {
|
||||||
@@ -26,137 +25,29 @@
|
|||||||
.organizations-toolbar {
|
.organizations-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-change-request {
|
|
||||||
display: inline-grid;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 220px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-change-request input {
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-section-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
|
||||||
gap: 18px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-editor-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
|
||||||
gap: 18px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-panel,
|
|
||||||
.organizations-table-panel {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-panel .card-body,
|
|
||||||
.organizations-table-panel .card-body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-row-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-contributed-action {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-check-list {
|
.organizations-check-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-check-list label,
|
|
||||||
.organizations-inline-check {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-table-stack {
|
.organizations-table-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
|
||||||
|
|
||||||
.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%;
|
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-table-stack > .card {
|
||||||
.organizations-tree-node.active {
|
width: 100%;
|
||||||
border-color: var(--line);
|
|
||||||
background: var(--surface-muted, #f6f7f9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-tree-node span {
|
.organizations-dialog-change-request {
|
||||||
color: var(--muted);
|
padding-top: 2px;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-field-note {
|
.organizations-field-note {
|
||||||
@@ -181,19 +72,45 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.organizations-section-grid,
|
|
||||||
.organizations-editor-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.organizations-workspace {
|
.organizations-workspace {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.organization-upgrade-table {
|
||||||
grid-template-columns: 1fr;
|
min-height: 10rem;
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-upgrade-dialog {
|
||||||
|
width: min(92vw, 78rem);
|
||||||
|
height: min(88vh, 54rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-upgrade-diff {
|
||||||
|
min-height: 12rem;
|
||||||
|
max-height: 25rem;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-upgrade-decision {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.organization-upgrade-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user