From 2defb89bc165edc709d40432a9cb1391b3925308 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 7 Jul 2026 15:49:06 +0200 Subject: [PATCH] Release v0.1.5 --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 + .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 + .gitea/ISSUE_TEMPLATE/feature_request.md | 32 + .gitea/ISSUE_TEMPLATE/task.md | 28 + .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 + .gitea/PULL_REQUEST_TEMPLATE.md | 15 + README.md | 25 + package.json | 32 + pyproject.toml | 25 + src/govoplan_admin.egg-info/PKG-INFO | 35 + src/govoplan_admin.egg-info/SOURCES.txt | 19 + .../dependency_links.txt | 1 + src/govoplan_admin.egg-info/entry_points.txt | 2 + src/govoplan_admin.egg-info/requires.txt | 2 + src/govoplan_admin.egg-info/top_level.txt | 1 + src/govoplan_admin/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 210 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 199 bytes src/govoplan_admin/backend/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 229 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 218 bytes .../__pycache__/governance.cpython-312.pyc | Bin 0 -> 7773 bytes .../__pycache__/governance.cpython-313.pyc | Bin 0 -> 7769 bytes .../__pycache__/manifest.cpython-312.pyc | Bin 0 -> 1317 bytes .../__pycache__/manifest.cpython-313.pyc | Bin 0 -> 1292 bytes src/govoplan_admin/backend/api/__init__.py | 0 .../api/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 168 bytes .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 157 bytes src/govoplan_admin/backend/api/v1/__init__.py | 0 .../v1/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 171 bytes .../v1/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 160 bytes .../api/v1/__pycache__/routes.cpython-312.pyc | Bin 0 -> 47717 bytes .../api/v1/__pycache__/routes.cpython-313.pyc | Bin 0 -> 48150 bytes .../v1/__pycache__/schemas.cpython-312.pyc | Bin 0 -> 18516 bytes .../v1/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 18925 bytes src/govoplan_admin/backend/api/v1/routes.py | 840 ++++++++++++++++++ src/govoplan_admin/backend/api/v1/schemas.py | 360 ++++++++ src/govoplan_admin/backend/db/__init__.py | 2 + .../db/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 225 bytes .../db/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 214 bytes .../db/__pycache__/models.cpython-312.pyc | Bin 0 -> 2606 bytes .../db/__pycache__/models.cpython-313.pyc | Bin 0 -> 2601 bytes src/govoplan_admin/backend/db/models.py | 39 + src/govoplan_admin/backend/governance.py | 169 ++++ src/govoplan_admin/backend/manifest.py | 34 + src/govoplan_admin/py.typed | 1 + webui/package.json | 30 + webui/src/api/admin.ts | 403 +++++++++ .../src/features/admin/AdminOverviewPanel.tsx | 93 ++ .../admin/GovernanceTemplatesPanel.tsx | 229 +++++ .../features/admin/ModuleManagementPanel.tsx | 638 +++++++++++++ .../features/admin/SystemSettingsPanel.tsx | 120 +++ webui/src/index.ts | 7 + webui/src/module.ts | 87 ++ 55 files changed, 3361 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/govoplan_admin.egg-info/PKG-INFO create mode 100644 src/govoplan_admin.egg-info/SOURCES.txt create mode 100644 src/govoplan_admin.egg-info/dependency_links.txt create mode 100644 src/govoplan_admin.egg-info/entry_points.txt create mode 100644 src/govoplan_admin.egg-info/requires.txt create mode 100644 src/govoplan_admin.egg-info/top_level.txt create mode 100644 src/govoplan_admin/__init__.py create mode 100644 src/govoplan_admin/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_admin/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/__init__.py create mode 100644 src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/api/__init__.py create mode 100644 src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/api/v1/__init__.py create mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/api/v1/routes.py create mode 100644 src/govoplan_admin/backend/api/v1/schemas.py create mode 100644 src/govoplan_admin/backend/db/__init__.py create mode 100644 src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/db/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc create mode 100644 src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc create mode 100644 src/govoplan_admin/backend/db/models.py create mode 100644 src/govoplan_admin/backend/governance.py create mode 100644 src/govoplan_admin/backend/manifest.py create mode 100644 src/govoplan_admin/py.typed create mode 100644 webui/package.json create mode 100644 webui/src/api/admin.ts create mode 100644 webui/src/features/admin/AdminOverviewPanel.tsx create mode 100644 webui/src/features/admin/GovernanceTemplatesPanel.tsx create mode 100644 webui/src/features/admin/ModuleManagementPanel.tsx create mode 100644 webui/src/features/admin/SystemSettingsPanel.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1be3329 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/admin +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..166732a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/admin + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..37397b0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/admin +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..9e68c67 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/admin +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..3e13ddd --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/admin +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d503aa --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# GovOPlaN Admin + +`govoplan-admin` owns generic system administration API and WebUI contributions +during the GovOPlaN module split. + +This repository owns the live `governance_templates` and +`governance_template_assignments` tables while preserving their historical +table names. It contributes the stable +`/api/v1/admin/system/governance-templates` routes and owns governance-template +CRUD plus materialization into access-owned tenant groups and roles. + +## WebUI Package + +The repository root and `webui/` directory both expose the package +`@govoplan/admin-webui`. The package does not contribute the `/admin` route +itself; `govoplan-access` owns the route shell. Instead, admin contributes +section entries through core's `admin.sections` UI capability: + +- overview +- system settings +- governance template tenant roles +- governance template groups + +The route shell in access collects those sections at runtime, applies their +scope requirements, and renders them without importing admin package internals. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e6b94ac --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@govoplan/admin-webui", + "version": "0.1.5", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + } + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.5", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5e9755a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-admin" +version = "0.1.5" +description = "GovOPlaN generic administration module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.5", + "govoplan-access>=0.1.5", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_admin = ["py.typed"] + +[project.entry-points."govoplan.modules"] +admin = "govoplan_admin.backend.manifest:get_manifest" + diff --git a/src/govoplan_admin.egg-info/PKG-INFO b/src/govoplan_admin.egg-info/PKG-INFO new file mode 100644 index 0000000..420a4a8 --- /dev/null +++ b/src/govoplan_admin.egg-info/PKG-INFO @@ -0,0 +1,35 @@ +Metadata-Version: 2.4 +Name: govoplan-admin +Version: 0.1.4 +Summary: GovOPlaN generic administration module. +Author: GovOPlaN +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: govoplan-core>=0.1.4 +Requires-Dist: govoplan-access>=0.1.4 + +# GovOPlaN Admin + +`govoplan-admin` owns generic system administration API and WebUI contributions +during the GovOPlaN module split. + +This repository owns the live `governance_templates` and +`governance_template_assignments` tables while preserving their historical +table names. It contributes the stable +`/api/v1/admin/system/governance-templates` routes and owns governance-template +CRUD plus materialization into access-owned tenant groups and roles. + +## WebUI Package + +The repository root and `webui/` directory both expose the package +`@govoplan/admin-webui`. The package does not contribute the `/admin` route +itself; `govoplan-access` owns the route shell. Instead, admin contributes +section entries through core's `admin.sections` UI capability: + +- overview +- system settings +- governance template tenant roles +- governance template groups + +The route shell in access collects those sections at runtime, applies their +scope requirements, and renders them without importing admin package internals. diff --git a/src/govoplan_admin.egg-info/SOURCES.txt b/src/govoplan_admin.egg-info/SOURCES.txt new file mode 100644 index 0000000..80cf3b6 --- /dev/null +++ b/src/govoplan_admin.egg-info/SOURCES.txt @@ -0,0 +1,19 @@ +README.md +pyproject.toml +src/govoplan_admin/__init__.py +src/govoplan_admin/py.typed +src/govoplan_admin.egg-info/PKG-INFO +src/govoplan_admin.egg-info/SOURCES.txt +src/govoplan_admin.egg-info/dependency_links.txt +src/govoplan_admin.egg-info/entry_points.txt +src/govoplan_admin.egg-info/requires.txt +src/govoplan_admin.egg-info/top_level.txt +src/govoplan_admin/backend/__init__.py +src/govoplan_admin/backend/governance.py +src/govoplan_admin/backend/manifest.py +src/govoplan_admin/backend/api/__init__.py +src/govoplan_admin/backend/api/v1/__init__.py +src/govoplan_admin/backend/api/v1/routes.py +src/govoplan_admin/backend/api/v1/schemas.py +src/govoplan_admin/backend/db/__init__.py +src/govoplan_admin/backend/db/models.py \ No newline at end of file diff --git a/src/govoplan_admin.egg-info/dependency_links.txt b/src/govoplan_admin.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_admin.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_admin.egg-info/entry_points.txt b/src/govoplan_admin.egg-info/entry_points.txt new file mode 100644 index 0000000..49119b2 --- /dev/null +++ b/src/govoplan_admin.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[govoplan.modules] +admin = govoplan_admin.backend.manifest:get_manifest diff --git a/src/govoplan_admin.egg-info/requires.txt b/src/govoplan_admin.egg-info/requires.txt new file mode 100644 index 0000000..58ec5d6 --- /dev/null +++ b/src/govoplan_admin.egg-info/requires.txt @@ -0,0 +1,2 @@ +govoplan-core>=0.1.4 +govoplan-access>=0.1.4 diff --git a/src/govoplan_admin.egg-info/top_level.txt b/src/govoplan_admin.egg-info/top_level.txt new file mode 100644 index 0000000..ae9ef12 --- /dev/null +++ b/src/govoplan_admin.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_admin diff --git a/src/govoplan_admin/__init__.py b/src/govoplan_admin/__init__.py new file mode 100644 index 0000000..33f4211 --- /dev/null +++ b/src/govoplan_admin/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN generic administration module.""" + diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c179ca8b01530c986efd75766ce2b45a0fedbde GIT binary patch literal 210 zcmX@j%ge<81V1{xv-E-VV-N=h7@>^M96-i&h7^V`)wo?n(Z5gX7fkn@T` SPW!;j$jEq$LAQtn$N>PmYdXLH literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f9aeb4e009caa77c10ca99cd5b528f4a438e73f GIT binary patch literal 199 zcmey&%ge<81V1{xv-E-VV-N=h7@>^M96-iYhG2#whIB?vrYd#!{4)Q5oJ2o`^whl6 zqReE4#FX63yv*W~qQsKS{5*x+{FKt1R6RdU##`+1@hSPq@$oAeK7&lVWuT|0pPpZq zUyzfSrwi7mUtE+77m9}n#m57UD~XTSE2zB1VFNMFu80k20LV?nAoqM=W@Kc%#h_cn H0^|Sy9=$fQ literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/__init__.py b/src/govoplan_admin/backend/__init__.py new file mode 100644 index 0000000..c7ceb0d --- /dev/null +++ b/src/govoplan_admin/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN admin module.""" + diff --git a/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70ba3c42996197b44ec35a3cf908d343756f2508 GIT binary patch literal 229 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 z zYFpp@V1T8uRA(;6Fkv}^f5g%uih%yh$`pNAja zv2Vb0iKNM#yimHKKZffBjDWP07P+UbPukw$a7{OVCV%tH={^{5vR-; zvRSKUQDuQ?7DZA|P@l_R;_N~4kWbmt5JZgwTIcuzKZR~`Q!4Q9*&c*gGUo!{XYzHy zHD$81)xoeWV@9x(X9@Whto|Ga`niJr7Lh%;HrTnqC(Ic?XjQiwdJHX77@KZN5Y+V= zMd*J&bQ)MNa_U#M8*Qw-z#F4t$b<8+EB3!M0~k33DfN-V{uW%f7=?M@fxY~SrPGia zPoK?pg!)F_;DCm4yhhQn%g|%x#u|VZ#8{f9jeIc!`^La;j=v27{RoZCHKMWEyeP?G z=rDWO3?(ghB4D){Rhq|q8d=s4`u(%PH*(i=`P;dAMEDKi1BR-HU=M?OD$B2f6m8Y) zaNVEE z%`WF}YdrO+>D;7(Mrruc`tgR6t^R;Y<*O^#%Ypq1ED&I&ZCA;&i-xq{E{BsvZ?YO{U+Q|?^`pxx zSIVKi3y!KMv~U6&M8Ez~^rNoguAy@HRMC5?y0v$~`N;J#-&qU6*J|w~(zSH_@$pLU z@p8+FPhEkm*@W@Al|1$iK!=>Or*){m7%e&a}ZtK6hxS-=RZ=hx;F7HF% zqVJJX0#$Y>oj+Y8kih}1t7rMnPwxKkZn^>{JhNIP&+lh|IQ@&6oa#S}*0DKm<&t-T*fC2VkywyO+nF#8>ykN_%3zK3(y?@kh??a@RQMTJzRX`Mrx9qfr=`72E9o z|A0TB={dV~i)HM1vy&ts%)}i<7Yu_-nSsau5mpSRG7D}4NgF6~ za&`P>Y5Zp8nEaeq@ODCFK7%vP$i0zA2cEO-6h|X)t?Q5_bz08px4gU_)}uA()7*m+ zT8PF@Kxz117oDy**%E{T4&pzV9{l}df4grU?+ zH*^;Y!bm<>zZOI#`mtgRCG@y2BlJTe{9y#Ywp+kY*1w4Zj|LmQNlz(sIvc+vM1|EgW2r=1wH8PQ#K*iG&P(18(M( zo0()Lbxq37^urNc#=aW-XjT_51REaiSkXtBgW#4QRllb((Ecd0FbG7wrA1 zd^VfU(c1(4UJd<}*0Hgv-k$y~NdJR?2O8;@wwsjFmhyB?O=l(j4+?!7RVh+xT86(| z^fz1w>UVioLmCUEW|~ZfhH$Uq$A|DiEsh!+J(c1KN5%uA9v&ev%V4ZE1RltQrc^jSpeTMmj^!=Xn{eg^sO5Q4wxBg^f8QbRs9)ENb=D;W9>?h>l zXW@?JL&Z?vydSO!RyvCPMC*4d? literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b29a00e8e7c72fb1aa78a92a76016051866f685 GIT binary patch literal 7769 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 JTszzR{{|jx@y7rF literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a62eabae98c42a7ded24c8312a0636cf73713c42 GIT binary patch literal 1317 zcmZ`&PjBNy6rXV%+ew_XO?D-^s@SkXh^X3RS7OD16;fFw4v=zaPnK5J#50Xt>~S@bafWgnVQjFIIe9H|Ev{wt+>1Pm-OSH}D99U8BX34c z>#Jw2Jd8rX>(u+ojoJ=kP3qHtHfWO>E1*6L54WnLZ{BCEQ>U~b%VX~0t2eOP*4!c(}DSqJ#6ed?Du+MpY>11!47YlH6J#yP;cMK~`MQE9;?m|k8`mKoHaF(Ff_ z7+0|(lZ?f4o^q*3mc`RK5p+<~?QuF4_S?M~OTaAXJY!D^uGptacS>_iczVF3f|~Ao zM6v{zeC%%S2wd^SEdVFzOXxLp7`puhbo+C>hX%D>VUi3tQOp$+14q9;Esly=MtDqU zp7P;@B%d%&hh&xxj~)(fo)j(fnWO!riI2zqd9P;|p*gUANVB zeaDdgto~s5aQK$F7fBK(B~WcZ_o0DtN_CJkMJQ1O66zV5ux#LK2Ldi9In(};QK#TK zq-+LPXFN%n)a@4t=}unh6H6n|=1UUBGf^C+6iOwAV6}`uR+J8a6Qn9X0y*$55DKa; ze1pU~M1@Jz;gqSkiip?1Wgcn9Z-IVo^%fu7gJ<6|C=U-2MLy(rKE4a zm`W!wZjuQjv{hW(HU+yM6d(yFJYoARhuKt2Z~fP|BV{_}Is8=Ot|?$TB>HB%?&I^| zaY^`Hu?+zuhRk4lR>K&t(7nIV;}shJhW5_U-U_{O;kmf`PX~o}mVRg1SYL*{<;K>s z+yAB4|N8B3_rHJo-P0d?AN~=%xeQPM*If5hcc^JX$f36^o8J3oyoyOm2Q>QLn>U}ndEe-=*{l;BgJ14F zT2=^op@MR>R=~J?ir|cdBsBV@XMo`=Z1&Aw1uTs#{c6t!TVt#5^jvUzHK_IKP}jL? zztQu+L%bQ>&I|TKAzQ0(KzVw?M1h>vmsGIW zSSutZV(hqac@4oCIl}H@PqDw;@X5EvP13C#gDO(mAfFWsx~9B2&QJ4cMtMY&Ny>u} zjlX9+3FtHpPHzRePC!F#Go=%+^ypY9F5j1*1;If{^06j45D>pft#}Wvcn>C5M+l~m zWh>J3(Fu)<93I0464vk$O`PUr;chIg&!086=7UR25LwmivU;SH{#gAaxE0)1hoN!I zgg|{YY&mu_J@w|-3DHF2!mLS=h zl%_;Nxw8F^Z_yO;(=8h*xstTy8O*{WvCcwr5Elx|4*WWHpMtH{r17);!9Ih+E z_SZRD!YPm012(}6SunZ#-`tT9>6lOOPl6kYKy?UwHLd&T-dzd)0`_r1nISTqUR4ak zSdzV$ebc_?5{ literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/api/__init__.py b/src/govoplan_admin/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0035353f879ce9164dc2c99bb574b573a7ace65a GIT binary patch literal 168 zcmX@j%ge<81ULJ=vq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a@WtzE75mx3~|&? z&n(eT&o9d_$Vtr8O-#wn%+oI}N`?!?LxhqNle1IvQuGrGGWFx*GxIV_;^XxSDt~d< e#Fy1ma>4<0CU8BV!RWkOctO=_+ji literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8da047330d5f0420f826e26cab88b3d644816d52 GIT binary patch literal 157 zcmey&%ge<81ULJ=vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i2o}PYsep!A& zPGX*JVoGjio_=vrGF&JgB9xSvoSmANqMulhsUIJonU`4-AFo$Xd5gm)H$SB`C)KWq V6=)d9vSJY9BQql-V-Yiu1pw$}CRqRg literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/api/v1/__init__.py b/src/govoplan_admin/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..19cfd533cb987fe3426fd2757df510714093ef15 GIT binary patch literal 171 zcmX@j%ge<81ULJ=vq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3u=EE75mx3~|&? z&n(eT&o9d_$Vtr8O-#wn%+oI}N`?!?LxhqNle1IvQuGrGGWE+0_2c6+^D;}~>P{wB#AY&>+I)f&o-%5reCLr%KNa~ioo}PYsep!A& zPGX*JVoGjio_=vrGF&JgB9xSvoSmANqMulhsb6NOA0MBYmst`YuUAlci^C>2KczG$ Y)vkyYXdcMEVi4maGb1Bo5i^hl0A!~pG5`Po literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..550bec9e407c12b0117590ee819c523a0222375b GIT binary patch literal 47717 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 literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1ceb0c8f93d7015cfe2f1bf0a2f915cbea084fc GIT binary patch literal 48150 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

(1vv+u!dvGeHK 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 o>#xu|b(i}j=^#BFm$KI`4Qht8lQ34| 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 AccessAdministration: + registry = get_registry() + if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is not configured") + capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION) + if not isinstance(capability, AccessAdministration): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is invalid") + return capability + + +def _request_registry(request: Request) -> PlatformRegistry: + registry = getattr(request.app.state, "govoplan_registry", None) + if not isinstance(registry, PlatformRegistry): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured") + return registry + + +def _request_lifecycle(request: Request) -> ModuleLifecycleManager: + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + if not isinstance(lifecycle, ModuleLifecycleManager): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module lifecycle manager is not configured") + return lifecycle + + +def _http_admin_error(exc: Exception) -> HTTPException: + if isinstance(exc, AdminConflictError): + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + if isinstance(exc, AdminValidationError): + return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) + + +def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all() + return GovernanceTemplateItem( + id=item.id, + kind=item.kind, + slug=item.slug, + name=item.name, + description=item.description, + permissions=item.permissions or [], + effective_permission_count=effective_permission_count(item.permissions or [], level="tenant") if item.kind == "role" else 0, + is_active=item.is_active, + assignments=[{"tenant_id": row.tenant_id, "mode": row.mode} for row in assignments], + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse: + registry = _request_registry(request) + current_enabled = [manifest.id for manifest in registry.manifests()] + configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules)) + desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled)) + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True) + dependents = module_dependents(available) + current_set = set(current_enabled) + desired_set = set(desired_enabled) + protected_set = set(PROTECTED_MODULES) + items: list[ModuleCatalogItem] = [] + for module_id, manifest in sorted(available.items(), key=lambda item: item[0]): + migration = manifest.migration_spec + frontend = manifest.frontend + items.append(ModuleCatalogItem( + id=manifest.id, + name=manifest.name, + version=manifest.version, + installed=True, + current_enabled=manifest.id in current_set, + desired_enabled=manifest.id in desired_set, + protected=manifest.id in protected_set, + restart_required=(manifest.id in current_set) != (manifest.id in desired_set), + dependencies=list(manifest.dependencies), + optional_dependencies=list(manifest.optional_dependencies), + dependents=list(dependents.get(manifest.id, ())), + permissions_count=len(manifest.permissions), + role_templates_count=len(manifest.role_templates), + route_contributed=manifest.route_factory is not None, + nav_count=len(manifest.nav_items), + frontend_package=frontend.package_name if frontend else None, + migration_module_id=migration.module_id if migration else None, + migration_script_location=migration.script_location if migration else None, + runtime_toggle_supported=True, + install_uninstall_supported=manifest.id not in protected_set, + )) + restart_required = current_set != desired_set + maintenance_mode = saved_maintenance_mode(session) + return ModuleCatalogResponse( + modules=items, + current_enabled=current_enabled, + desired_enabled=desired_enabled, + configured_enabled=configured_enabled, + protected_modules=list(PROTECTED_MODULES), + restart_required=restart_required, + runtime_toggle_supported=True, + install_uninstall_supported=True, + install_plan_supported=True, + package_mutation_supported=False, + maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), + notes=notes or [ + "Enable/disable changes are applied to the running server and saved as startup state.", + "Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.", + ], + ) + + +def _module_install_plan_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleInstallPlanResponse: + plan = saved_module_install_plan(session) + maintenance_mode = saved_maintenance_mode(session) + registry = _request_registry(request) + current_enabled = [manifest.id for manifest in registry.manifests()] + configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules)) + desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled)) + lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) + available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True) + preflight = module_install_preflight( + plan=plan, + available=available, + current_enabled=current_enabled, + desired_enabled=desired_enabled, + maintenance_mode=maintenance_mode.enabled, + session=session, + webui_root=_webui_root(), + runtime_dir=default_installer_runtime_dir(core_settings.database_url), + ) + default_notes = [ + "The running server does not install or remove packages inside the FastAPI request.", + "Use govoplan-module-installer from an operator shell while maintenance mode is active.", + ] + if not maintenance_mode.enabled: + default_notes.append("Maintenance mode is currently off.") + return ModuleInstallPlanResponse( + items=[item.as_dict() for item in plan.items], + updated_at=plan.updated_at, + commands=list(module_install_plan_commands(plan)), + maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), + preflight=ModuleInstallPreflightResponse.model_validate(preflight.as_dict()), + notes=notes or default_notes, + ) + + +def _webui_root() -> Path | None: + configured = os.getenv("GOVOPLAN_WEBUI_ROOT") + if configured: + return Path(configured).expanduser().resolve() + candidate = Path.cwd() / "webui" + return candidate if candidate.exists() else None + + +def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem): + existing = saved_module_install_plan(session) + retained = [ + current + for current in existing.items + if not (current.status == "planned" and current.module_id == item.module_id) + ] + return save_module_install_plan(session, [*retained, item]) + + +def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, object]]: + result = validate_module_package_catalog() + if not result.get("valid"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(result.get("error") or "Module package catalog is invalid."), + ) + for raw_item in result.get("modules", []): + if not isinstance(raw_item, dict): + continue + if raw_item.get("module_id") != module_id or raw_item.get("action") != "install": + continue + license_decision = module_license_decision(_catalog_license_features(raw_item)) + if not license_decision.get("allowed"): + missing = license_decision.get("missing_features") + missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature" + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"License does not allow installing {module_id}: {missing_text}.", + ) + notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None + if license_decision.get("reason") and license_decision.get("missing_features"): + prefix = f"{notes}\n" if notes else "" + notes = f"{prefix}License warning: {license_decision['reason']}" + return ModuleInstallPlanItem( + module_id=str(raw_item["module_id"]), + action="install", + python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None, + python_ref=raw_item.get("python_ref") if isinstance(raw_item.get("python_ref"), str) else None, + webui_package=raw_item.get("webui_package") if isinstance(raw_item.get("webui_package"), str) else None, + webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None, + notes=notes, + ), result + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install entry not found: {module_id}") + + +def _catalog_license_features(raw_item: dict[str, object]) -> list[str]: + value = raw_item.get("license_features") + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem: + payload = dict(raw_item) + decision = module_license_decision(_catalog_license_features(raw_item)) + payload["license_allowed"] = bool(decision.get("allowed")) + payload["license_enforced"] = bool(decision.get("enforced")) + missing = decision.get("missing_features") + payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else [] + reason = decision.get("reason") + payload["license_reason"] = reason if isinstance(reason, str) else None + return ModulePackageCatalogItem.model_validate(payload) + + +@router.get("/overview", response_model=AdminOverviewResponse) +def admin_overview( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope( + "admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read", + "admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read", + "system:settings:read", "system:governance:read", "system:audit:read", + )), +): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + counts = tenant_counts(session, tenant.id) + access_admin = _access_administration() + capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)] + return AdminOverviewResponse( + active_tenant_id=tenant.id, + active_tenant_name=tenant.name, + tenant_count=session.query(Tenant).count() if has_scope(principal, "system:tenants:read") else None, + system_account_count=access_admin.system_account_count(session) if has_scope(principal, "system:accounts:read") or has_scope(principal, "system:access:read") else None, + system_group_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "group").count() if has_scope(principal, "system:governance:read") else None, + system_role_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "role").count() if has_scope(principal, "system:governance:read") else None, + user_count=counts["users"], + active_user_count=counts["active_users"], + group_count=counts["groups"], + role_count=access_admin.role_count_for_tenant(session, tenant.id), + active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id), + capabilities=capabilities, + ) + + +@router.get("/system/modules", response_model=ModuleCatalogResponse) +def list_system_modules( + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + return _module_catalog_response(session, request) + + +@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) +def read_module_install_plan( + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + return _module_install_plan_response(session, request) + + +@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse) +def list_module_install_runs( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + return ModuleInstallerRunListResponse( + runs=[ + ModuleInstallerRunSummary.model_validate(item) + for item in list_module_installer_runs(runtime_dir=runtime_dir) + ], + lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)), + ) + + +@router.get("/system/modules/install-runs/{run_id}") +def read_module_install_run_detail( + run_id: str, + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + return read_module_installer_run(runtime_dir=runtime_dir, run_id=run_id) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse) +def list_module_install_requests( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + return ModuleInstallerRequestListResponse( + requests=[ + ModuleInstallerRequestItem.model_validate(item) + for item in list_module_installer_requests(runtime_dir=runtime_dir) + ], + daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)), + ) + + +@router.get("/system/modules/install-requests/{request_id}") +def read_module_install_request_detail( + request_id: str, + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + return read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem) +def create_module_install_request( + payload: ModuleInstallerRequestCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + maintenance_mode = saved_maintenance_mode(session) + if not maintenance_mode.enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before queuing installer requests.") + if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Queuing installer requests requires maintenance access.") + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + request = queue_module_installer_request( + runtime_dir=runtime_dir, + requested_by=principal.user.id, + options=payload.options.model_dump(exclude_none=True), + ) + audit_from_principal( + session, + principal, + action="system_modules.install_request_queued", + scope="system", + object_type="module_install_request", + object_id=str(request["request_id"]), + details={"options": payload.options.model_dump(exclude_none=True)}, + ) + session.commit() + return ModuleInstallerRequestItem.model_validate(request) + + +@router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem) +def cancel_module_install_request( + request_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + maintenance_mode = saved_maintenance_mode(session) + if not maintenance_mode.enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.") + if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.") + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + request = cancel_module_installer_request( + runtime_dir=runtime_dir, + request_id=request_id, + cancelled_by=principal.user.id, + ) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.install_request_cancelled", + scope="system", + object_type="module_install_request", + object_id=request_id, + details={}, + ) + session.commit() + return ModuleInstallerRequestItem.model_validate(request) + + +@router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem) +def retry_module_install_request( + request_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + maintenance_mode = saved_maintenance_mode(session) + if not maintenance_mode.enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.") + if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.") + runtime_dir = default_installer_runtime_dir(core_settings.database_url) + try: + request = retry_module_installer_request( + runtime_dir=runtime_dir, + request_id=request_id, + requested_by=principal.user.id, + ) + except ModuleInstallerError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.install_request_retried", + scope="system", + object_type="module_install_request", + object_id=request_id, + details={"new_request_id": request["request_id"]}, + ) + session.commit() + return ModuleInstallerRequestItem.model_validate(request) + + +@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse) +def read_module_package_catalog( + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + result = validate_module_package_catalog() + return ModulePackageCatalogResponse( + modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)], + configured=bool(result["configured"]), + valid=bool(result["valid"]), + path=result["path"] if isinstance(result["path"], str) else None, + source=result["source"] if isinstance(result.get("source"), str) else None, + source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None, + channel=result["channel"] if isinstance(result["channel"], str) else None, + sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None, + generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None, + expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None, + signed=bool(result["signed"]), + trusted=bool(result["trusted"]), + key_id=result["key_id"] if isinstance(result["key_id"], str) else None, + warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [], + error=result["error"] if isinstance(result["error"], str) else None, + ) + + +@router.post("/system/modules/install-plan/catalog/{module_id}", response_model=ModuleInstallPlanResponse) +def plan_module_install_from_catalog( + module_id: str, + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + try: + item, validation = _catalog_plan_item(module_id) + plan = _upsert_install_plan_item(session, item) + record_module_package_catalog_acceptance(validation) + except ModuleManagementError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.install_plan_catalog_added", + scope="system", + object_type="module_install_plan", + object_id=module_id, + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."]) + + +@router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse) +def plan_module_uninstall( + module_id: str, + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + registry = _request_registry(request) + lifecycle = _request_lifecycle(request) + current_enabled = {manifest.id for manifest in registry.manifests()} + configured_enabled = configured_enabled_modules(core_settings.enabled_modules) + desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled)) + if module_id in PROTECTED_MODULES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.") + if module_id in current_enabled or module_id in desired_enabled: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.") + try: + item = module_uninstall_plan_item(module_id, lifecycle.available_modules) + plan = _upsert_install_plan_item(session, item) + except ModuleManagementError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.uninstall_plan_added", + scope="system", + object_type="module_install_plan", + object_id=module_id, + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."]) + + +@router.put("/system/modules", response_model=ModuleCatalogResponse) +def update_system_modules( + request: Request, + payload: ModuleStateUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + lifecycle = _request_lifecycle(request) + available = lifecycle.available_modules + try: + plan = plan_desired_enabled_modules(payload.enabled_modules, available) + except ModuleManagementError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + try: + result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + desired = save_desired_enabled_modules(session, result.enabled_modules) + audit_from_principal( + session, + principal, + action="system_modules.desired_state_updated", + scope="system", + object_type="module_state", + object_id="global", + details={ + "desired_enabled": list(desired), + "added_dependencies": list(plan.added_dependencies), + "activated": list(result.activated_modules), + "deactivated": list(result.deactivated_modules), + "mounted": list(result.mounted_modules), + }, + ) + session.commit() + notes = ["Module state saved and applied to the running server."] + if plan.added_dependencies: + notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies)) + if result.activated_modules: + notes.append("Activated: " + ", ".join(result.activated_modules)) + if result.deactivated_modules: + notes.append("Deactivated: " + ", ".join(result.deactivated_modules)) + return _module_catalog_response(session, request, notes=notes) + + +@router.put("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) +def update_module_install_plan( + request: Request, + payload: ModuleInstallPlanUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + try: + plan = save_module_install_plan(session, [item.model_dump() for item in payload.items]) + except ModuleManagementError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_from_principal( + session, + principal, + action="system_modules.install_plan_updated", + scope="system", + object_type="module_install_plan", + object_id="global", + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=["Module package install plan saved."]) + + +@router.delete("/system/modules/install-plan", response_model=ModuleInstallPlanResponse) +def clear_module_install_plan( + request: Request, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + plan = save_module_install_plan(session, ()) + audit_from_principal( + session, + principal, + action="system_modules.install_plan_cleared", + scope="system", + object_type="module_install_plan", + object_id="global", + details={"items": [item.as_dict() for item in plan.items]}, + ) + session.commit() + return _module_install_plan_response(session, request, notes=["Module package install plan cleared."]) + + +@router.get("/system/settings", response_model=SystemSettingsItem) +def read_system_settings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + item = get_system_settings(session) + session.commit() + policy = privacy_policy_from_settings(item) + maintenance_mode = saved_maintenance_mode(session) + return SystemSettingsItem( + default_locale=item.default_locale, + allow_tenant_custom_groups=item.allow_tenant_custom_groups, + allow_tenant_custom_roles=item.allow_tenant_custom_roles, + allow_tenant_api_keys=item.allow_tenant_api_keys, + privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")), + maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()), + settings=item.settings or {}, + ) + + +@router.patch("/system/settings", response_model=SystemSettingsItem) +def write_system_settings( + payload: SystemSettingsUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:write")), +): + item = get_system_settings(session) + item.default_locale = payload.default_locale.strip() or "en" + item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups + item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles + item.allow_tenant_api_keys = payload.allow_tenant_api_keys + if payload.privacy_retention_policy is not None: + set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json")) + if payload.maintenance_mode is not None: + current = saved_maintenance_mode(session) + next_mode = MaintenanceMode( + enabled=payload.maintenance_mode.enabled, + message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None, + ) + if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.", + ) + save_maintenance_mode(session, next_mode) + session.add(item) + audit_from_principal( + session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id, + details=payload.model_dump(), + ) + session.commit() + return read_system_settings(session=session, principal=principal) + + +@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) +def list_governance_templates( + kind: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:read")), +): + del principal + query = session.query(GovernanceTemplate) + if kind: + query = query.filter(GovernanceTemplate.kind == kind) + items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) + + +@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED) +def create_governance_template( + payload: GovernanceTemplateCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + try: + item = create_template( + session, + kind=payload.kind, + slug=payload.slug, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + is_active=payload.is_active, + assignments=[assignment.model_dump() for assignment in payload.assignments], + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, + principal, + action="governance_template.created", + scope="system", + object_type=f"{payload.kind}_template", + object_id=item.id, + details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, + ) + session.commit() + return _governance_template_item(session, item) + + +@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem) +def update_governance_template( + template_id: str, + payload: GovernanceTemplateUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + item = session.get(GovernanceTemplate, template_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + try: + update_template( + session, + item, + name=payload.name, + description=payload.description, + permissions=payload.permissions, + is_active=payload.is_active, + assignments=[assignment.model_dump() for assignment in payload.assignments], + ) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, + principal, + action="governance_template.updated", + scope="system", + object_type=f"{item.kind}_template", + object_id=item.id, + details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, + ) + session.commit() + return _governance_template_item(session, item) + + +@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def remove_governance_template( + template_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:write")), +): + item = session.get(GovernanceTemplate, template_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + kind = item.kind + try: + delete_template(session, item) + except (AdminConflictError, AdminValidationError) as exc: + raise _http_admin_error(exc) from exc + audit_from_principal( + session, + principal, + action="governance_template.deleted", + scope="system", + object_type=f"{kind}_template", + object_id=template_id, + details={}, + ) + session.commit() + return None diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py new file mode 100644 index 0000000..69abf62 --- /dev/null +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +RETENTION_DAY_KEYS = ( + "raw_campaign_json_retention_days", + "generated_eml_retention_days", + "stored_report_detail_retention_days", + "mock_mailbox_retention_days", + "audit_detail_retention_days", +) + + +RETENTION_POLICY_FIELD_KEYS = ( + "store_raw_campaign_json", + *RETENTION_DAY_KEYS, + "audit_detail_level", +) + + +def default_allow_lower_level_limits() -> dict[str, bool]: + return {key: True for key in RETENTION_POLICY_FIELD_KEYS} + + +def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None: + if value in (None, ""): + return default_allow_lower_level_limits() if fill_defaults else None + if not isinstance(value, dict): + raise ValueError("allow_lower_level_limits must be an object") + normalized = default_allow_lower_level_limits() if fill_defaults else {} + for key, allowed in value.items(): + clean_key = str(key) + if clean_key not in RETENTION_POLICY_FIELD_KEYS: + raise ValueError(f"Unknown retention policy field: {clean_key}") + normalized[clean_key] = bool(allowed) + return normalized + + +class PrivacyRetentionPolicyItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + store_raw_campaign_json: bool = True + raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) + generated_eml_retention_days: int | None = Field(default=None, ge=0) + stored_report_detail_retention_days: int | None = Field(default=None, ge=0) + mock_mailbox_retention_days: int | None = Field(default=None, ge=0) + audit_detail_retention_days: int | None = Field(default=None, ge=0) + audit_detail_level: Literal["full", "redacted", "minimal"] = "full" + allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits) + + @field_validator("allow_lower_level_limits", mode="before") + @classmethod + def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: + return normalize_allow_lower_level_limits(value, fill_defaults=True) + + +class MaintenanceModeItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool = False + message: str | None = Field(default=None, max_length=500) + + +class AdminOverviewResponse(BaseModel): + active_tenant_id: str + active_tenant_name: str + tenant_count: int | None = None + system_account_count: int | None = None + system_group_template_count: int | None = None + system_role_template_count: int | None = None + user_count: int + active_user_count: int + group_count: int + role_count: int + active_api_key_count: int + capabilities: list[str] = Field(default_factory=list) + + +class SystemSettingsItem(BaseModel): + default_locale: str = "en" + allow_tenant_custom_groups: bool = True + allow_tenant_custom_roles: bool = True + allow_tenant_api_keys: bool = True + privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) + maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + settings: dict[str, Any] = Field(default_factory=dict) + + +class SystemSettingsUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + default_locale: str = Field(min_length=1, max_length=20) + allow_tenant_custom_groups: bool + allow_tenant_custom_roles: bool + allow_tenant_api_keys: bool + privacy_retention_policy: PrivacyRetentionPolicyItem | None = None + maintenance_mode: MaintenanceModeItem | None = None + + +class ModuleCatalogItem(BaseModel): + id: str + name: str + version: str + installed: bool = True + current_enabled: bool = False + desired_enabled: bool = False + protected: bool = False + restart_required: bool = False + dependencies: list[str] = Field(default_factory=list) + optional_dependencies: list[str] = Field(default_factory=list) + dependents: list[str] = Field(default_factory=list) + permissions_count: int = 0 + role_templates_count: int = 0 + route_contributed: bool = False + nav_count: int = 0 + frontend_package: str | None = None + migration_module_id: str | None = None + migration_script_location: str | None = None + runtime_toggle_supported: bool = False + install_uninstall_supported: bool = False + + +class ModuleCatalogResponse(BaseModel): + modules: list[ModuleCatalogItem] + current_enabled: list[str] + desired_enabled: list[str] + configured_enabled: list[str] + protected_modules: list[str] + restart_required: bool = False + runtime_toggle_supported: bool = False + install_uninstall_supported: bool = False + install_plan_supported: bool = False + package_mutation_supported: bool = False + maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + notes: list[str] = Field(default_factory=list) + + +class ModuleStateUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled_modules: list[str] = Field(default_factory=list) + + +class ModuleInstallPlanItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + module_id: str = Field(min_length=1, max_length=120) + action: Literal["install", "uninstall"] + python_package: str | None = Field(default=None, max_length=200) + python_ref: str | None = Field(default=None, max_length=1000) + webui_package: str | None = Field(default=None, max_length=200) + webui_ref: str | None = Field(default=None, max_length=1000) + destroy_data: bool = False + status: Literal["planned", "applied", "blocked"] = "planned" + notes: str | None = Field(default=None, max_length=1000) + + +class ModuleInstallPreflightIssue(BaseModel): + severity: Literal["blocker", "warning", "info"] + code: str + message: str + module_id: str | None = None + + +class ModuleInstallChecklistItem(BaseModel): + id: str + label: str + status: Literal["done", "pending", "blocked", "warning"] + detail: str | None = None + + +class ModuleInstallPreflightResponse(BaseModel): + allowed: bool + maintenance_mode: bool + frontend_rebuild_required: bool + restart_required: bool + commands: list[str] = Field(default_factory=list) + rollback_commands: list[str] = Field(default_factory=list) + issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list) + checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list) + + +class ModuleInstallPlanResponse(BaseModel): + items: list[ModuleInstallPlanItem] = Field(default_factory=list) + updated_at: str | None = None + commands: list[str] = Field(default_factory=list) + maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + preflight: ModuleInstallPreflightResponse | None = None + notes: list[str] = Field(default_factory=list) + + +class ModuleInstallerLockStatus(BaseModel): + model_config = ConfigDict(extra="allow") + + locked: bool = False + path: str + + +class ModuleInstallerDaemonStatus(BaseModel): + model_config = ConfigDict(extra="allow") + + running: bool = False + status: str = "unknown" + path: str + + +class ModuleInstallerRunSummary(BaseModel): + run_id: str + status: str + started_at: str | None = None + finished_at: str | None = None + rollback_status: str | None = None + supervisor_status: str | None = None + error: str | None = None + record_path: str + commands_count: int = 0 + planned_modules: list[str] = Field(default_factory=list) + + +class ModuleInstallerRunListResponse(BaseModel): + runs: list[ModuleInstallerRunSummary] = Field(default_factory=list) + lock: ModuleInstallerLockStatus + + +class ModuleInstallerRequestOptions(BaseModel): + model_config = ConfigDict(extra="forbid") + + build_webui: bool = False + migrate_database: bool = True + webui_root: str | None = Field(default=None, max_length=1000) + npm_bin: str | None = Field(default=None, max_length=500) + database_backup_command: str | None = Field(default=None, max_length=4000) + database_restore_command: str | None = Field(default=None, max_length=4000) + database_restore_check_command: str | None = Field(default=None, max_length=4000) + activate_installed_modules: bool = True + remove_uninstalled_modules_from_desired: bool = True + restart_commands: list[str] = Field(default_factory=list) + health_urls: list[str] = Field(default_factory=list) + health_timeout_seconds: float = Field(default=60.0, ge=1, le=3600) + health_interval_seconds: float = Field(default=2.0, ge=0.2, le=120) + + +class ModuleInstallerRequestItem(BaseModel): + model_config = ConfigDict(extra="allow") + + request_id: str + status: str + created_at: str + requested_by: str | None = None + started_at: str | None = None + finished_at: str | None = None + cancelled_at: str | None = None + cancelled_by: str | None = None + retry_of: str | None = None + error: str | None = None + record_path: str | None = None + options: dict[str, Any] = Field(default_factory=dict) + + +class ModuleInstallerRequestListResponse(BaseModel): + requests: list[ModuleInstallerRequestItem] = Field(default_factory=list) + daemon: ModuleInstallerDaemonStatus + + +class ModuleInstallerRequestCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions) + + +class ModulePackageCatalogItem(BaseModel): + module_id: str + name: str + description: str | None = None + version: str | None = None + action: Literal["install", "uninstall"] = "install" + python_package: str | None = None + python_ref: str | None = None + webui_package: str | None = None + webui_ref: str | None = None + license_features: list[str] = Field(default_factory=list) + license_allowed: bool = True + license_enforced: bool = False + license_missing_features: list[str] = Field(default_factory=list) + license_reason: str | None = None + notes: str | None = None + tags: list[str] = Field(default_factory=list) + + +class ModulePackageCatalogResponse(BaseModel): + modules: list[ModulePackageCatalogItem] = Field(default_factory=list) + configured: bool = False + valid: bool = True + path: str | None = None + source: str | None = None + source_type: str | None = None + channel: str | None = None + sequence: int | None = None + generated_at: str | None = None + expires_at: str | None = None + signed: bool = False + trusted: bool = False + key_id: str | None = None + warnings: list[str] = Field(default_factory=list) + error: str | None = None + + +class ModuleInstallPlanUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + items: list[ModuleInstallPlanItem] = Field(default_factory=list) + + +class GovernanceAssignment(BaseModel): + tenant_id: str + mode: Literal["available", "required"] = "available" + + +class GovernanceTemplateItem(BaseModel): + id: str + kind: Literal["group", "role"] + slug: str + name: str + description: str | None = None + permissions: list[str] = Field(default_factory=list) + effective_permission_count: int = 0 + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class GovernanceTemplateListResponse(BaseModel): + templates: list[GovernanceTemplateItem] + + +class GovernanceTemplateCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["group", "role"] + slug: str + name: str = Field(min_length=1, max_length=255) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) + + +class GovernanceTemplateUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + is_active: bool = True + assignments: list[GovernanceAssignment] = Field(default_factory=list) diff --git a/src/govoplan_admin/backend/db/__init__.py b/src/govoplan_admin/backend/db/__init__.py new file mode 100644 index 0000000..d10e58b --- /dev/null +++ b/src/govoplan_admin/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Admin-owned SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a119ababb18df0f22616e8264c55d0d7c92d216 GIT binary patch literal 225 zcmX@j%ge<81izSkvW$WBV-N=h7@>^M96-i&h7^V6ilEjq6l0=2XycC7p{FKz3Vm&`i##`+1@hSPq@$oAeK7&mB<))vT zSEBFY7~-g(o>`)wo?n(U|7Tg^M96-iYhG2#whIB?vrYdd6l-$fb-Td;r)D(r_ zKp)4P6ilEjq6l0=2XycC7p{FKz3Vm&`i##`+1@hSPq@$oAeK7&lWWv!>D zpPpZqUyzfSr<({eOTV}%87>qL5lTu-&Q8rs(N9UzkB`sH%PfhH*DI*J#bE<9xilx$ bu80k27RZUkAm@ExW@Kc%#b8*(0^|SyAU!(( literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7b632f107befc1de98e77b97f91268b4a866bac GIT binary patch literal 2606 zcmcImO>ERg6drrMzuuqxY(k(3zXf#(B$Pivg^-dg&_a_^5xE>`h5Z z+fuYR9yg9oOPMPq7lVs;PETOZwxmm9o=X+RkVh7$<4umZCMtVTZG0~nq?Lg z|HVXW^zCu5CpOrd3WTcPOIq`^oJ*}DIVTC#aGauNcxKUY2XYY+?JX89W;h~ow#b=T za4xWEp`0IlZ$Lx_J#IP$p$xGbo@lu2n3E;e2hVxjFdZ)^i%7pQF~O)v+5TN0DOx4l z0f*9SxJ;ynOq;o$VNdj%H%wN3k?*!0uRGr})YDxsz3xJBsyJa8&Ow9Prqk{6k-A9_ zOvA>=ICE$>9qzV^lv!@q#I%Sx>=Pa5FKSZJ>eP(C_vtd(a%4as#n5;gZ zmZ41$S##afz#3_CFAlb;M4jz zNFYkts8O=K7iy5>A3}m%6S7HHvp_!!^q5n!EMwSWA`DZq8>E-h~Z|)o@I_#QIDRW1-Ie{vim-#Lv<6|?8RkCHep8rPRPB3npu8V@5Nml*CAlhpp zZo0ZL;+a!yAlD*dx{k}yb&=3@NZpc!{iLo>mJF-vN$I+WAqPVqhj)r})utP~;OaWZ z?B-Df%yqs8IT>AlrEUOeE4sEJ;39bpVLt+vL!n^&`~Z$o!~7sZ69TG(;|}sIHON56 zod@`e{FL24+rQGf@BY->RQZ%%Y5lm|GCVu*TkD<&XXg$-JpU;F`H*fVu`1rmN$>?&>e4}KuzjgoJyOTLMw7n|#Ir+v|9eQCDjT1ERm zrP%|ST5xRUcw)yU`qPcN-xX?2x;m8YxCdG3c-ZaOT)UujWbk&_UcMb*i0{DeZUjuz znJt^V(d~j6qkTPteLeXz@B@q$L97<@*jc_8pGgF>B8_7u5OHJ5FnwY3P8=)v8yLuC zco&R#H^L!=HxW=l{4l~Mvf)S2f%TuCKsbqT8sTqRIF2?1T$+0a0JU%|(86a+Nz1_NPY4;Av^Q(?1k@MTZ8jUUS8?gKmXQ} z_2h#}=hgCXpQcs;Ifwn8Cor-cLNOue`7YbzS$BWeg&j6fxCGK#P zaW0FsXS4(cs68D2XS@MGB-}~Mutu&kdpf8H{-&T{f=c7pkDwa8WOfy~&GF#n@2zNo zkL&-t;h4c$7ai^zhC>EVA|Zv~mn*-lV0z6S zNs{yn+5MEL&&b6e$;DsEp=adyT11jM)?*22P`a1B_xU=(-ukwvbXB^4bZ+}P!QLNP MqV`K)M*W@o3pviEV*mgE literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..613e4bb42d934c1d44de2b2af595ec53727181d0 GIT binary patch literal 2601 zcmb_eOHdqD6n#C@^Z)S~J`x~E0y-gqn1G5EtqKrg2El|Tq%@@3o|$IS(9;9AdoW;B zT$$2T=_XYvS4m|9JC-c7WI0n*MR}!VSy`mmMPcPe@9iFjkl@a)nlta+d)|G&dv5nm zBobu!c-$Y&JoYm7lsbD4_URFJenPy*Bqj+XEGdA{Ms3%K3taf`9&sl{5R)G8B)#BG z`oNd;gFhL7fXx#}g2@nsl3@tjea}cF8HH#v1~Kf1C2td(5uvJ)wKK`r#3cVzbSl;- z(#(uOwQUceJ$S%g)5E0D6#;6qu0&*;s0o3G6vN0{ilyZZb1dQIzJYvRR~3T?hx4Fn zSz|<9J6#jCCw+I&GB#BmlVnqehe+&ikw&8Zn4$=UyMIw0Jii+y~gW$F2)|j_av zUwOoF+|^jZ&E2>xE*1(}hIUib-}%DJ4Ct z8kwF<2bj z$&4s2^d6gHAV>_eK>&9LW(J5;9=uJrBuXC1+wblbz&qt@XH$N<qz3fnCA0aok=RHJ7lZuF}XOa85u5`dLheSFU-q_k!0e?L|GNMXec>#`#46NSatEx9B4TO7Ufw? zZeK>7RtmcHcgV@!>zFt%+=C(2TiY3QBm|%xZ2yA*tq4ms z`{Ubgw~gfu^#%`RR5K0Q9O-M-1?@zpVN2CzvaXqyJtK1GRLE(jNm5)2m;EV};|(6r zOj$`=8XiRrjvh-iaK9|mo@AK^Wf@bnpi?_6%ku?AFMDFLJgtFg>6)P$d2~l)*`kd& zeh6SJ9xdBs1+u0r1En?i2q?*c&WvNA7wQC(n#gs8fVK{Pf-V9|b1u@Z;1u;mf^IU_ z$uer+($cbGVIfHsES09C{n%;&I&Ns#1G;^472#X$0=vIB_gN2oa;yJlg|5>pv zwLJDm{jrC`D`y@h*M~OVEY(ZP19w83qO{f6@o;)&{L##M--cLf99$8;`+qK zg;MMI@})bspNQkmE@%S>gil9_kFfI?@qZV77|TN#g%$00NimdI6-v*M^uFwgQ~eUh zk@P{?*HmO*i?QMuDP{}5jxtuNhpKEtqLDYhD%JCtv{@sk8rF6*Qu)zC^e8J8lF37R zQkJni2Q17-!=h%(;aW!J69ZZ>I2=WofHrC#BcKFbYB*#@vl}D&2G2`_=Z7xftInTC z@#?Cj4nrr62|7`U1|4YN0cAnaY@x$R>WftQ%Yv(h9yGyeg0~3H5S%3-6+th-A<_YQ zKY@qf9Krhp0|f67yp;X@WFw$sH18r%_V+p2|K-|j@xyGX{${am=D)N5=*iVji|<}3 zPN}6+)5ZCP=Zt+V47&bgtZm4(Jo4j_9sG0|a&5JoSbc9z-}t1|b*-4n7V~DQ#ll4X zVpFvC71TvZ-#AHXc_L0a2~NvPxE-O|>!mV5FMm+20vJHTO~k1xO&8FBRSEFL&Y%i` z2hDk1(bG58+@ez&?31CQ;glS^kT_*&pV^&<9MCtHeZag9@7jKQgy(KS5PoCrPg&?0yZke|{5w1S ujJ>_<6@->ue^8Kw`|ZBC_ literal 0 HcmV?d00001 diff --git a/src/govoplan_admin/backend/db/models.py b/src/govoplan_admin/backend/db/models.py new file mode 100644 index 0000000..0b2f941 --- /dev/null +++ b/src/govoplan_admin/backend/db/models.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class GovernanceTemplate(Base, TimestampMixin): + __tablename__ = "governance_templates" + __table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + +class GovernanceTemplateAssignment(Base, TimestampMixin): + __tablename__ = "governance_template_assignments" + __table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False) + + +__all__ = ["GovernanceTemplate", "GovernanceTemplateAssignment", "new_uuid"] + diff --git a/src/govoplan_admin/backend/governance.py b/src/govoplan_admin/backend/governance.py new file mode 100644 index 0000000..6c2ed0f --- /dev/null +++ b/src/govoplan_admin/backend/governance.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment +from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify +from govoplan_core.core.access import ( + CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER, + AccessGovernanceMaterializer, + GovernanceTemplateMaterialization, +) +from govoplan_core.core.runtime import get_registry +from govoplan_core.security.permissions import validate_tenant_permissions +from govoplan_tenancy.backend.db.models import Tenant + +TEMPLATE_KINDS = {"group", "role"} +ASSIGNMENT_MODES = {"available", "required"} + + +def _governance_materializer() -> AccessGovernanceMaterializer: + registry = get_registry() + if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER): + raise AdminValidationError("Access governance materializer capability is not configured.") + capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER) + if not isinstance(capability, AccessGovernanceMaterializer): + raise AdminValidationError("Access governance materializer capability is invalid.") + return capability + + +def _materialization( + item: GovernanceTemplate, + *, + tenant_id: str, + required: bool = False, +) -> GovernanceTemplateMaterialization: + return GovernanceTemplateMaterialization( + template_id=item.id, + kind=item.kind, # type: ignore[arg-type] + tenant_id=tenant_id, + slug=item.slug, + name=item.name, + description=item.description, + permissions=tuple(item.permissions or []), + is_active=item.is_active, + required=required, + ) + + +def validate_template(kind: str, permissions: list[str]) -> list[str]: + if kind not in TEMPLATE_KINDS: + raise AdminValidationError("Template kind must be group or role.") + if kind == "group": + if permissions: + raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.") + return [] + try: + return validate_tenant_permissions(permissions) + except ValueError as exc: + raise AdminValidationError(str(exc)) from exc + + +def create_template( + session: Session, + *, + kind: str, + slug: str, + name: str, + description: str | None, + permissions: list[str], + is_active: bool, + assignments: list[dict[str, str]], +) -> GovernanceTemplate: + normalized_slug = slugify(slug) + permissions = validate_template(kind, permissions) + exists = session.query(GovernanceTemplate).filter( + GovernanceTemplate.kind == kind, + GovernanceTemplate.slug == normalized_slug, + ).first() + if exists: + raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.") + item = GovernanceTemplate( + kind=kind, + slug=normalized_slug, + name=name.strip(), + description=description, + permissions=permissions, + is_active=is_active, + ) + session.add(item) + session.flush() + set_template_assignments(session, item, assignments) + return item + + +def update_template( + session: Session, + item: GovernanceTemplate, + *, + name: str, + description: str | None, + permissions: list[str], + is_active: bool, + assignments: list[dict[str, str]], +) -> GovernanceTemplate: + item.name = name.strip() + item.description = description + item.permissions = validate_template(item.kind, permissions) + item.is_active = is_active + set_template_assignments(session, item, assignments) + sync_template(session, item) + return item + + +def set_template_assignments( + session: Session, + item: GovernanceTemplate, + assignments: list[dict[str, str]], +) -> None: + desired: dict[str, str] = {} + for assignment in assignments: + tenant_id = assignment.get("tenant_id", "") + mode = assignment.get("mode", "available") + if mode not in ASSIGNMENT_MODES: + raise AdminValidationError("Template assignment mode must be available or required.") + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise AdminValidationError(f"Unknown tenant: {tenant_id}") + desired[tenant_id] = mode + + existing = { + row.tenant_id: row + for row in session.query(GovernanceTemplateAssignment) + .filter(GovernanceTemplateAssignment.template_id == item.id) + .all() + } + for tenant_id, row in list(existing.items()): + if tenant_id in desired: + row.mode = desired[tenant_id] + continue + _governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id)) + session.delete(row) + + for tenant_id, mode in desired.items(): + if tenant_id not in existing: + session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode)) + session.flush() + sync_template(session, item) + + +def sync_template(session: Session, item: GovernanceTemplate) -> None: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).all() + for assignment in assignments: + required = assignment.mode == "required" + _governance_materializer().sync_template( + session, + _materialization(item, tenant_id=assignment.tenant_id, required=required), + ) + session.flush() + + +def delete_template(session: Session, item: GovernanceTemplate) -> None: + assignments = session.query(GovernanceTemplateAssignment).filter( + GovernanceTemplateAssignment.template_id == item.id + ).all() + for assignment in assignments: + _governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id)) + session.delete(item) diff --git a/src/govoplan_admin/backend/manifest.py b/src/govoplan_admin/backend/manifest.py new file mode 100644 index 0000000..d678232 --- /dev/null +++ b/src/govoplan_admin/backend/manifest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata +from govoplan_core.core.module_guards import persistent_table_uninstall_guard +from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.db.base import Base + + +def _route_factory(context: ModuleContext): + del context + from govoplan_admin.backend.api.v1.routes import router + + return router + + +manifest = ModuleManifest( + id="admin", + name="Admin", + version="0.1.5", + dependencies=("access",), + route_factory=_route_factory, + migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + admin_models.GovernanceTemplate, + admin_models.GovernanceTemplateAssignment, + label="Admin", + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_admin/py.typed b/src/govoplan_admin/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_admin/py.typed @@ -0,0 +1 @@ + diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..5dbf5cb --- /dev/null +++ b/webui/package.json @@ -0,0 +1,30 @@ +{ + "name": "@govoplan/admin-webui", + "version": "0.1.5", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.5", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts new file mode 100644 index 0000000..e1b2dfa --- /dev/null +++ b/webui/src/api/admin.ts @@ -0,0 +1,403 @@ +import type { ApiSettings } from "@govoplan/core-webui"; +import { apiFetch } from "@govoplan/core-webui"; + +export type PermissionItem = { + scope: string; + label: string; + description: string; + category: string; + level: "tenant" | "system"; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type AdminOverview = { + active_tenant_id: string; + active_tenant_name: string; + tenant_count?: number | null; + system_account_count?: number | null; + system_group_template_count?: number | null; + system_role_template_count?: number | null; + user_count: number; + active_user_count: number; + group_count: number; + role_count: number; + active_api_key_count: number; + capabilities: string[]; +}; + +export type TenantAdminItem = { + id: string; + slug: string; + name: string; + description?: string | null; + default_locale: string; + settings: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + effective_governance: Record; + is_active: boolean; + counts: Record; + created_at: string; + updated_at: string; +}; + +export type PrivacyRetentionPolicyFieldKey = + | "store_raw_campaign_json" + | "raw_campaign_json_retention_days" + | "generated_eml_retention_days" + | "stored_report_detail_retention_days" + | "mock_mailbox_retention_days" + | "audit_detail_retention_days" + | "audit_detail_level"; + +export type PrivacyRetentionLimitPermissions = Record; +export type PrivacyRetentionLimitPermissionPatch = Partial; + +export type PrivacyRetentionPolicy = { + store_raw_campaign_json: boolean; + raw_campaign_json_retention_days?: number | null; + generated_eml_retention_days?: number | null; + stored_report_detail_retention_days?: number | null; + mock_mailbox_retention_days?: number | null; + audit_detail_retention_days?: number | null; + audit_detail_level: "full" | "redacted" | "minimal"; + allow_lower_level_limits: PrivacyRetentionLimitPermissions; +}; + +export type PrivacyRetentionPolicyPatch = Partial> & { + allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch; +}; + +export type MaintenanceMode = { + enabled: boolean; + message?: string | null; +}; + +export type SystemSettingsItem = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy: PrivacyRetentionPolicy; + maintenance_mode: MaintenanceMode; + settings: Record; +}; + +export type SystemSettingsUpdatePayload = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy?: PrivacyRetentionPolicy | null; + maintenance_mode?: MaintenanceMode | null; +}; + +export type ModuleCatalogItem = { + id: string; + name: string; + version: string; + installed: boolean; + current_enabled: boolean; + desired_enabled: boolean; + protected: boolean; + restart_required: boolean; + dependencies: string[]; + optional_dependencies: string[]; + dependents: string[]; + permissions_count: number; + role_templates_count: number; + route_contributed: boolean; + nav_count: number; + frontend_package?: string | null; + migration_module_id?: string | null; + migration_script_location?: string | null; + runtime_toggle_supported: boolean; + install_uninstall_supported: boolean; +}; + +export type ModuleCatalogResponse = { + modules: ModuleCatalogItem[]; + current_enabled: string[]; + desired_enabled: string[]; + configured_enabled: string[]; + protected_modules: string[]; + restart_required: boolean; + runtime_toggle_supported: boolean; + install_uninstall_supported: boolean; + install_plan_supported: boolean; + package_mutation_supported: boolean; + maintenance_mode: MaintenanceMode; + notes: string[]; +}; + +export type ModuleInstallPlanItem = { + module_id: string; + action: "install" | "uninstall"; + python_package?: string | null; + python_ref?: string | null; + webui_package?: string | null; + webui_ref?: string | null; + destroy_data: boolean; + status: "planned" | "applied" | "blocked"; + notes?: string | null; +}; + +export type ModuleInstallPreflightIssue = { + severity: "blocker" | "warning" | "info"; + code: string; + message: string; + module_id?: string | null; +}; + +export type ModuleInstallChecklistItem = { + id: string; + label: string; + status: "done" | "pending" | "blocked" | "warning"; + detail?: string | null; +}; + +export type ModuleInstallPreflight = { + allowed: boolean; + maintenance_mode: boolean; + frontend_rebuild_required: boolean; + restart_required: boolean; + commands: string[]; + rollback_commands: string[]; + issues: ModuleInstallPreflightIssue[]; + checklist: ModuleInstallChecklistItem[]; +}; + +export type ModuleInstallPlanResponse = { + items: ModuleInstallPlanItem[]; + updated_at?: string | null; + commands: string[]; + maintenance_mode: MaintenanceMode; + preflight?: ModuleInstallPreflight | null; + notes: string[]; +}; + +export type ModuleInstallerLockStatus = { + locked: boolean; + path: string; + [key: string]: unknown; +}; + +export type ModuleInstallerDaemonStatus = { + running: boolean; + status: string; + path: string; + [key: string]: unknown; +}; + +export type ModuleInstallerRunSummary = { + run_id: string; + status: string; + started_at?: string | null; + finished_at?: string | null; + rollback_status?: string | null; + supervisor_status?: string | null; + error?: string | null; + record_path: string; + commands_count: number; + planned_modules: string[]; +}; + +export type ModuleInstallerRunListResponse = { + runs: ModuleInstallerRunSummary[]; + lock: ModuleInstallerLockStatus; +}; + +export type ModuleInstallerRequestOptions = { + build_webui: boolean; + migrate_database: boolean; + webui_root?: string | null; + npm_bin?: string | null; + database_backup_command?: string | null; + database_restore_command?: string | null; + database_restore_check_command?: string | null; + activate_installed_modules: boolean; + remove_uninstalled_modules_from_desired: boolean; + restart_commands: string[]; + health_urls: string[]; + health_timeout_seconds: number; + health_interval_seconds: number; +}; + +export type ModuleInstallerRequestItem = { + request_id: string; + status: string; + created_at: string; + requested_by?: string | null; + started_at?: string | null; + finished_at?: string | null; + cancelled_at?: string | null; + cancelled_by?: string | null; + retry_of?: string | null; + error?: string | null; + record_path?: string | null; + options: Record; +}; + +export type ModuleInstallerRequestListResponse = { + requests: ModuleInstallerRequestItem[]; + daemon: ModuleInstallerDaemonStatus; +}; + +export type ModulePackageCatalogItem = { + module_id: string; + name: string; + description?: string | null; + version?: string | null; + action: "install" | "uninstall"; + python_package?: string | null; + python_ref?: string | null; + webui_package?: string | null; + webui_ref?: string | null; + license_features: string[]; + license_allowed: boolean; + license_enforced: boolean; + license_missing_features: string[]; + license_reason?: string | null; + notes?: string | null; + tags: string[]; +}; + +export type ModulePackageCatalogResponse = { + modules: ModulePackageCatalogItem[]; + configured: boolean; + valid: boolean; + path?: string | null; + source?: string | null; + source_type?: string | null; + channel?: string | null; + sequence?: number | null; + generated_at?: string | null; + expires_at?: string | null; + signed: boolean; + trusted: boolean; + key_id?: string | null; + warnings: string[]; + error?: string | null; +}; + +export type GovernanceAssignment = { + tenant_id: string; + mode: "available" | "required"; +}; + +export type GovernanceTemplateItem = { + id: string; + kind: "group" | "role"; + slug: string; + name: string; + description?: string | null; + permissions: string[]; + effective_permission_count: number; + is_active: boolean; + assignments: GovernanceAssignment[]; + created_at: string; + updated_at: string; +}; + +export function fetchAdminOverview(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/overview"); +} + +export async function fetchPermissionCatalog(settings: ApiSettings): Promise { + const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); + return response.permissions; +} + +export async function fetchTenants(settings: ApiSettings): Promise { + const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); + return response.tenants; +} + +export function fetchSystemSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings"); +} + +export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function fetchModuleCatalog(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules"); +} + +export function updateModuleState(settings: ApiSettings, enabledModules: string[]): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules", { + method: "PUT", + body: JSON.stringify({ enabled_modules: enabledModules }) + }); +} + +export function fetchModuleInstallPlan(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-plan"); +} + +export function fetchModuleInstallerRuns(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-runs"); +} + +export function fetchModuleInstallerRequests(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-requests"); +} + +export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-requests", { + method: "POST", + body: JSON.stringify({ options }) + }); +} + +export function cancelModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/cancel`, { method: "POST" }); +} + +export function retryModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/retry`, { method: "POST" }); +} + +export function fetchModulePackageCatalog(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/package-catalog"); +} + +export function planModuleInstallFromCatalog(settings: ApiSettings, moduleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/install-plan/catalog/${encodeURIComponent(moduleId)}`, { method: "POST" }); +} + +export function planModuleUninstall(settings: ApiSettings, moduleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" }); +} + +export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { + method: "PUT", + body: JSON.stringify({ items }) + }); +} + +export function clearModuleInstallPlan(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { method: "DELETE" }); +} + +export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise { + const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; + const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); + return response.templates; +} + +export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { + return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit): Promise { + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" }); +} diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx new file mode 100644 index 0000000..6935319 --- /dev/null +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { fetchAdminOverview, type AdminOverview } from "../../api/admin"; +import { Card } from "@govoplan/core-webui"; +import { Button } from "@govoplan/core-webui"; +import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui"; + +export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet }) { + const [overview, setOverview] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + setError(""); + try { setOverview(await fetchAdminOverview(settings)); } + catch (err) { setError(adminErrorMessage(err)); } + finally { setLoading(false); } + } + + useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); + + const hasSystemMetrics = Boolean(overview && [ + overview.tenant_count, + overview.system_account_count, + overview.system_group_template_count, + overview.system_role_template_count + ].some((value) => value !== null && value !== undefined)); + + return ( + void load()} disabled={loading}>Reload}> + {overview && <> + {hasSystemMetrics && <> +

System
+
+ + + + +
+ } + + {hasSystemArea(availableSections) && +
+ {availableSections.has("system-settings") && onSelect("system-settings")} />} + {availableSections.has("system-tenants") && onSelect("system-tenants")} />} + {availableSections.has("system-roles") && onSelect("system-roles")} />} + {availableSections.has("system-role-templates") && onSelect("system-role-templates")} />} + {availableSections.has("system-groups") && onSelect("system-groups")} />} + {availableSections.has("system-users") && onSelect("system-users")} />} + {availableSections.has("system-mail-servers") && onSelect("system-mail-servers")} />} + {availableSections.has("system-retention") && onSelect("system-retention")} />} + {availableSections.has("system-modules") && onSelect("system-modules")} />} + {availableSections.has("system-audit") && onSelect("system-audit")} />} +
+
} + +
Active tenant: {overview.active_tenant_name}
+
+ + + + +
+ + +
+ {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} + {availableSections.has("tenant-roles") && onSelect("tenant-roles")} />} + {availableSections.has("tenant-groups") && onSelect("tenant-groups")} />} + {availableSections.has("tenant-users") && onSelect("tenant-users")} />} + {availableSections.has("tenant-mail-servers") && onSelect("tenant-mail-servers")} />} + {availableSections.has("tenant-retention") && onSelect("tenant-retention")} />} + {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} + {availableSections.has("tenant-audit") && onSelect("tenant-audit")} />} +
+
+ } + + ); +} + +function hasSystemArea(sections: ReadonlySet): boolean { + return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-modules", "system-audit"].some((section) => sections.has(section)); +} + +function Metric({ title, value, text }: { title: string; value: string | number; text: string }) { + return {value}

{text}

; +} + +function AreaLink({ title, text, onClick }: { title: string; text: string; onClick: () => void }) { + return ; +} diff --git a/webui/src/features/admin/GovernanceTemplatesPanel.tsx b/webui/src/features/admin/GovernanceTemplatesPanel.tsx new file mode 100644 index 0000000..d5a28dc --- /dev/null +++ b/webui/src/features/admin/GovernanceTemplatesPanel.tsx @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useState } from "react"; +import { Search, Pencil, Plus, Trash2 } from "lucide-react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { Button } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; +import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; +import { Dialog } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; +import { StatusBadge } from "@govoplan/core-webui"; +import { + createGovernanceTemplate, + deleteGovernanceTemplate, + fetchGovernanceTemplates, + fetchPermissionCatalog, + fetchTenants, + updateGovernanceTemplate, + type GovernanceAssignment, + type GovernanceTemplateItem, + type PermissionItem, + type TenantAdminItem +} from "../../api/admin"; +import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui"; + +const emptyDraft = { + slug: "", + name: "", + description: "", + isActive: true, + permissions: [] as string[], + assignments: [] as GovernanceAssignment[] +}; + +export default function GovernanceTemplatesPanel({ + settings, + kind, + canWrite, + onAuthRefresh +}: { + settings: ApiSettings; + kind: "group" | "role"; + canWrite: boolean; + onAuthRefresh: () => Promise; +}) { + const [items, setItems] = useState([]); + const [tenants, setTenants] = useState([]); + const [permissions, setPermissions] = useState([]); + const [editing, setEditing] = useState(null); + const [viewing, setViewing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + const [nextItems, nextTenants, nextPermissions] = await Promise.all([ + fetchGovernanceTemplates(settings, kind), + fetchTenants(settings), + kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([]) + ]); + setItems(nextItems); + setTenants(nextTenants); + setPermissions(nextPermissions.filter((item) => item.level === "tenant")); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setLoading(false); } + } + + useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]); + + function openCreate() { + setDraft(emptyDraft); + setEditing("new"); + } + + function openEdit(item: GovernanceTemplateItem) { + setDraft({ + slug: item.slug, + name: item.name, + description: item.description || "", + isActive: item.is_active, + permissions: item.permissions, + assignments: item.assignments + }); + setEditing(item); + } + + function assignmentMode(tenantId: string): "none" | "available" | "required" { + return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none"; + } + + function setAssignment(tenantId: string, mode: "none" | "available" | "required") { + setDraft((current) => ({ + ...current, + assignments: mode === "none" + ? current.assignments.filter((item) => item.tenant_id !== tenantId) + : [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }] + })); + } + + async function save() { + setBusy(true); + setError(""); + try { + if (editing === "new") { + await createGovernanceTemplate(settings, { + kind, + slug: draft.slug, + name: draft.name, + description: draft.description || null, + permissions: kind === "role" ? draft.permissions : [], + is_active: draft.isActive, + assignments: draft.assignments + }); + setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`); + } else if (editing) { + await updateGovernanceTemplate(settings, editing.id, { + name: draft.name, + description: draft.description || null, + permissions: kind === "role" ? draft.permissions : [], + is_active: draft.isActive, + assignments: draft.assignments + }); + setSuccess(`${draft.name} updated and synchronized to assigned tenants.`); + } + setEditing(null); + await load(); + await onAuthRefresh(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + async function remove() { + if (!deleting) return; + setBusy(true); + setError(""); + try { + await deleteGovernanceTemplate(settings, deleting.id); + setSuccess(`${deleting.name} deleted.`); + setDeleting(null); + await load(); + await onAuthRefresh(); + } catch (err) { setError(adminErrorMessage(err)); } + finally { setBusy(false); } + } + + const columns = useMemo[]>(() => [ + { + id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, + value: (row) => `${row.name} ${row.slug}`, + render: (row) =>
{row.name}
{row.slug}
+ }, + { + id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, + value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—", + render: (row) => row.assignments.length ? row.assignments.map((assignment) => { + const tenant = tenants.find((item) => item.id === assignment.tenant_id); + return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`; + }).join(", ") : "—" + }, + ...(kind === "role" ? [{ + id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const, + value: (row: GovernanceTemplateItem) => row.effective_permission_count + }] : []), + { id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, + { + id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", + render: (row) =>
+ } onClick={() => setViewing(row)} /> + } onClick={() => openEdit(row)} disabled={!canWrite} /> + } variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} /> +
+ } + ], [canWrite, kind, tenants]); + + const title = kind === "group" ? "Central groups" : "Tenant roles"; + const description = kind === "group" + ? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local." + : "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions."; + + return ( + <> + } variant="primary" onClick={openCreate} disabled={!canWrite} />} + > +
+ row.id} emptyText={`No central ${kind} templates found.`} /> +
+
+ + !busy && setEditing(null)} + className="admin-dialog admin-dialog-wide" + footer={<>} + > +
+ setDraft({ ...draft, name: event.target.value })} /> + setDraft({ ...draft, slug: event.target.value })} /> + +