From 970625edff4ea65a90e1af18eb54a943bfd74512 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 12:51:16 +0200 Subject: [PATCH] chore: sync GovOPlaN module split state --- .gitignore | 23 + README.md | 26 +- package.json | 2 +- 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 - .../__pycache__/__init__.cpython-312.pyc | Bin 210 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 199 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 229 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 218 -> 0 bytes .../__pycache__/governance.cpython-312.pyc | Bin 7773 -> 0 bytes .../__pycache__/governance.cpython-313.pyc | Bin 7769 -> 0 bytes .../__pycache__/manifest.cpython-312.pyc | Bin 1317 -> 0 bytes .../__pycache__/manifest.cpython-313.pyc | Bin 1292 -> 0 bytes .../api/__pycache__/__init__.cpython-312.pyc | Bin 168 -> 0 bytes .../api/__pycache__/__init__.cpython-313.pyc | Bin 157 -> 0 bytes .../v1/__pycache__/__init__.cpython-312.pyc | Bin 171 -> 0 bytes .../v1/__pycache__/__init__.cpython-313.pyc | Bin 160 -> 0 bytes .../api/v1/__pycache__/routes.cpython-312.pyc | Bin 47717 -> 0 bytes .../api/v1/__pycache__/routes.cpython-313.pyc | Bin 48150 -> 0 bytes .../v1/__pycache__/schemas.cpython-312.pyc | Bin 18516 -> 0 bytes .../v1/__pycache__/schemas.cpython-313.pyc | Bin 18925 -> 0 bytes src/govoplan_admin/backend/api/v1/routes.py | 613 +++++++++++++-- src/govoplan_admin/backend/api/v1/schemas.py | 72 ++ .../db/__pycache__/__init__.cpython-312.pyc | Bin 225 -> 0 bytes .../db/__pycache__/__init__.cpython-313.pyc | Bin 214 -> 0 bytes .../db/__pycache__/models.cpython-312.pyc | Bin 2606 -> 0 bytes .../db/__pycache__/models.cpython-313.pyc | Bin 2601 -> 0 bytes src/govoplan_admin/backend/db/models.py | 8 +- webui/package.json | 2 +- webui/src/api/admin.ts | 263 ++++++- .../src/features/admin/AdminOverviewPanel.tsx | 127 ++-- .../admin/ConfigurationChangesPanel.tsx | 170 +++++ .../admin/ConfigurationPackagesPanel.tsx | 374 +++++++++ .../admin/GovernanceTemplatesPanel.tsx | 178 +++-- .../features/admin/ModuleManagementPanel.tsx | 481 +++++++----- .../features/admin/SystemSettingsPanel.tsx | 206 ++++- webui/src/i18n/generatedTranslations.ts | 710 ++++++++++++++++++ webui/src/index.ts | 1 + webui/src/module.ts | 161 ++-- 43 files changed, 2959 insertions(+), 518 deletions(-) create mode 100644 .gitignore delete mode 100644 src/govoplan_admin.egg-info/PKG-INFO delete mode 100644 src/govoplan_admin.egg-info/SOURCES.txt delete mode 100644 src/govoplan_admin.egg-info/dependency_links.txt delete mode 100644 src/govoplan_admin.egg-info/entry_points.txt delete mode 100644 src/govoplan_admin.egg-info/requires.txt delete mode 100644 src/govoplan_admin.egg-info/top_level.txt delete mode 100644 src/govoplan_admin/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_admin/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/__pycache__/governance.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/routes.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/api/v1/__pycache__/schemas.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/db/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc delete mode 100644 src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc create mode 100644 webui/src/features/admin/ConfigurationChangesPanel.tsx create mode 100644 webui/src/features/admin/ConfigurationPackagesPanel.tsx create mode 100644 webui/src/i18n/generatedTranslations.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fd4061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +build/ +dist/ +node_modules/ +webui/node_modules/ +webui/dist/ +*.tsbuildinfo +.component-test-build/ +.module-test-build/ +.policy-test-build/ +.template-preview-test-build/ +.import-test-build/ +webui/.component-test-build/ +webui/.module-test-build/ +webui/.policy-test-build/ +webui/.template-preview-test-build/ +webui/.import-test-build/ diff --git a/README.md b/README.md index 3d503aa..be309e8 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ `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 +This repository owns the live `admin_governance_templates` and +`admin_governance_template_assignments` tables. Core migrations rename the +historical unprefixed governance tables for existing development databases. 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. @@ -23,3 +24,22 @@ section entries through core's `admin.sections` UI capability: The route shell in access collects those sections at runtime, applies their scope requirements, and renders them without importing admin package internals. + +## Module Lifecycle Administration + +The admin module owns the operator surfaces for module lifecycle management: + +- installed/enabled/desired module state +- runtime activation and deactivation of installed modules +- signed catalog install planning +- non-destructive uninstall planning, with explicit `destroy_data` retirement + options where a module provides a retirement provider +- installer preflight status, maintenance-mode blockers, migration/restart + checklist entries, and rendered operator commands +- installer daemon request queue, cancellation/retry, recent run summaries, + rollback status, run IDs, request IDs, and trace IDs + +Package mutation is intentionally not executed inside the FastAPI request. The +admin UI records operator intent and queues or renders commands for the trusted +installer process described in +`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. diff --git a/package.json b/package.json index 95d7649..e2199f5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ ], "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/src/govoplan_admin.egg-info/PKG-INFO b/src/govoplan_admin.egg-info/PKG-INFO deleted file mode 100644 index 420a4a8..0000000 --- a/src/govoplan_admin.egg-info/PKG-INFO +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 80cf3b6..0000000 --- a/src/govoplan_admin.egg-info/SOURCES.txt +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 8b13789..0000000 --- a/src/govoplan_admin.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/govoplan_admin.egg-info/entry_points.txt b/src/govoplan_admin.egg-info/entry_points.txt deleted file mode 100644 index 49119b2..0000000 --- a/src/govoplan_admin.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[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 deleted file mode 100644 index 58ec5d6..0000000 --- a/src/govoplan_admin.egg-info/requires.txt +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index ae9ef12..0000000 --- a/src/govoplan_admin.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -govoplan_admin diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 1c179ca8b01530c986efd75766ce2b45a0fedbde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8f9aeb4e009caa77c10ca99cd5b528f4a438e73f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 70ba3c42996197b44ec35a3cf908d343756f2508..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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? diff --git a/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/governance.cpython-313.pyc deleted file mode 100644 index 6b29a00e8e7c72fb1aa78a92a76016051866f685..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7769 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 JTszzR{{|jx@y7rF diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc deleted file mode 100644 index fe11c0388ca75e6e209711ffb02c2fdc351825c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1317 zcmZ`&PjBNy6rXV%+ew_XO?D-^s@SkXh^QiA4;2SiNM(^Ypp-*GJ_zwLFp!Ng6i4VYqN(jP;2oU1HZBcvL6K}?Kt6j7sdHj2S{N^|B_xwY<-9)f{ ztc{OWHH7{$Mzz`{z?VM*I7JjuoS_^?7#r+lPF{;#i)&du_ae_?H}mr#3i3wO$eU5q z`s!IL52Fz9I`zJCqqc)sllnBE4ccVJ3aHP*qpj-b+xL0v%qcC%@|b({>TRq-;O$rV z_DzHH@C@!`)*(J`pZO(@Hs}WJ0E_O)+MxT7aSpI<5zY%mR9bKerk59#Wd`+UOvqF! z##OAyBxCWMr(7zMW$|=Q1Rd0Ldz?;%{dRxG5-JA=4d}D`B+<;TK)dB$D^F9(FeN+yQ68UMpeDHY`u{b z$&IT#Vsw4PD8IhVaCV|Qu{|k1B#A1-iCBZ$4S1xPpaoh6Yb*ENKUy1$@g+EruG{Lm zzH3N-R)2f=aJXacMUsR`2~-=5lR$+gnC9MEF1XRfq=_N&a{7Q)G4?Q zDVxF78BbCsb^AF&x|3J>#L@_~`GSP;Occi{g;I$jSS=%v6{Q2<6sgLOKn}bIgo3II z-ypFLQDG8wIAtoXBH}f0nMa!OTcF=sy~W2id2iqd(;#62aA{94H5Xh2#_Z{493RfX z!Qxn$Db|~nI4_7zC&D%H&~`O02UquRvXervq1~>#YNu9;y7}ndOVgzAAi&vjW+}K)n z`@i=3U%&J1!S_$Td-7B7qd$W;m*FeFgxeS4wgo#E;m)$VdF8u}=F^&Y-@97tgw7(k KdVujA%jQ3u%4wzm diff --git a/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc deleted file mode 100644 index 2ba156d795f6b1864e8e56b52c952b07b782067b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1292 zcmZ`&L5tf)6duX4B-^rg*G{0jERDK^QnOUsKtj7T6t+DS?05@Nda8pMOS49)JThPM*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{ diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 0035353f879ce9164dc2c99bb574b573a7ace65a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_admin/backend/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 8da047330d5f0420f826e26cab88b3d644816d52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 deleted file mode 100644 index 19cfd533cb987fe3426fd2757df510714093ef15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 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 deleted file mode 100644 index 550bec9e407c12b0117590ee819c523a0222375b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47717 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 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 deleted file mode 100644 index f1ceb0c8f93d7015cfe2f1bf0a2f915cbea084fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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() @@ -113,10 +156,136 @@ 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_422_UNPROCESSABLE_CONTENT, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) +def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": exc.code, "message": str(exc), "plan": exc.plan}, + ) + + +def _admin_delta_watermark(session: Session, collections: tuple[str, ...]) -> str: + return encode_sequence_watermark(max_sequence_id(session, module_id=ADMIN_MODULE_ID, collections=collections)) + + +def _admin_delta_entries(session: Session, *, collections: tuple[str, ...], since: str, limit: int): + try: + since_sequence = decode_sequence_watermark(since) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + if sequence_watermark_is_expired( + session, + since=since_sequence, + module_id=ADMIN_MODULE_ID, + collections=collections, + ): + return None, False + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + module_id=ADMIN_MODULE_ID, + collections=collections, + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + return entries_plus_one[:limit], has_more + + +def _admin_delta_response_watermark(session: Session, *, collections: tuple[str, ...], entries, has_more: bool) -> str: + return encode_sequence_watermark(entries[-1].id) if has_more and entries else _admin_delta_watermark(session, collections) + + +def _history_cursor_fingerprint(scope: str, *, page_size: int) -> str: + return keyset_query_fingerprint(scope, {"page_size": page_size, "sort": "id.desc"}) + + +def _window_history_records(records: list[dict[str, object]], *, id_key: str, scope: str, page_size: int, cursor: str | None): + start = 0 + full = False + fingerprint = _history_cursor_fingerprint(scope, page_size=page_size) + if cursor: + try: + values = decode_keyset_cursor(scope, cursor, fingerprint=fingerprint) + except KeysetCursorError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + after_id = values.get(id_key) if values else None + if not isinstance(after_id, str): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor") + matching_index = next((index for index, item in enumerate(records) if str(item.get(id_key) or "") == after_id), None) + if matching_index is None: + full = True + else: + start = matching_index + 1 + page = records[start:start + page_size] + next_cursor = None + if start + page_size < len(records) and page: + next_cursor = encode_keyset_cursor(scope, fingerprint=fingerprint, values={id_key: str(page[-1].get(id_key) or ""), "page_size": page_size}) + return page, next_cursor, full + + +def _system_settings_item(session: Session) -> SystemSettingsItem: + item = get_system_settings(session) + policy = privacy_policy_from_settings(item) + maintenance_mode = saved_maintenance_mode(session) + i18n_payload = system_i18n_payload(item) + 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()), + available_languages=i18n_payload["available_languages"], + enabled_language_codes=i18n_payload["enabled_languages"], + settings=item.settings or {}, + ) + + +def _system_settings_sections(item: SystemSettingsItem) -> dict[str, Any]: + payload = item.model_dump(mode="json") + return { + "defaults": {"default_locale": payload["default_locale"]}, + "tenant_capabilities": { + "allow_tenant_custom_groups": payload["allow_tenant_custom_groups"], + "allow_tenant_custom_roles": payload["allow_tenant_custom_roles"], + "allow_tenant_api_keys": payload["allow_tenant_api_keys"], + }, + "languages": { + "available_languages": payload["available_languages"], + "enabled_language_codes": payload["enabled_language_codes"], + }, + "privacy_retention_policy": payload["privacy_retention_policy"], + "maintenance_mode": payload["maintenance_mode"], + "settings": payload["settings"], + } + + +def _record_system_settings_section_changes( + session: Session, + *, + before: dict[str, Any], + after: dict[str, Any], + principal: ApiPrincipal, +) -> None: + for section in SYSTEM_SETTINGS_SECTIONS: + if before.get(section) == after.get(section): + continue + record_change( + session, + module_id=ADMIN_MODULE_ID, + collection=SYSTEM_SETTINGS_COLLECTION, + resource_type=SYSTEM_SETTINGS_RESOURCE, + resource_id=section, + operation="updated", + actor_type="user", + actor_id=principal.user.id, + payload={"section": section}, + ) + + def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem: assignments = session.query(GovernanceTemplateAssignment).filter( GovernanceTemplateAssignment.template_id == item.id @@ -136,6 +305,28 @@ def _governance_template_item(session: Session, item: GovernanceTemplate) -> Gov ) +def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None: + record_change( + session, + module_id=ADMIN_MODULE_ID, + collection=GOVERNANCE_TEMPLATES_COLLECTION, + resource_type=GOVERNANCE_TEMPLATE_RESOURCE, + resource_id=item.id, + operation=operation, + actor_type="user", + actor_id=principal.user.id, + payload={"kind": item.kind, "slug": item.slug, "name": item.name, "is_active": item.is_active}, + ) + + +def _governance_template_deleted_entries(entries, visible_template_ids: set[str]): + return [ + {"id": entry.resource_id, "resource_type": entry.resource_type or GOVERNANCE_TEMPLATE_RESOURCE} + for entry in entries + if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_template_ids) + ] + + 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()] @@ -251,7 +442,7 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, result = validate_module_package_catalog() if not result.get("valid"): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(result.get("error") or "Module package catalog is invalid."), ) for raw_item in result.get("modules", []): @@ -302,6 +493,14 @@ def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCa return ModulePackageCatalogItem.model_validate(payload) +def _catalog_required_license_features(result: dict[str, object]) -> list[str]: + required: list[str] = [] + for raw_item in result.get("modules", []): + if isinstance(raw_item, dict): + required.extend(_catalog_license_features(raw_item)) + return list(dict.fromkeys(required)) + + @router.get("/overview", response_model=AdminOverviewResponse) def admin_overview( session: Session = Depends(get_session), @@ -355,16 +554,23 @@ def read_module_install_plan( @router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse) def list_module_install_runs( + page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100), + cursor: str | None = None, principal: ApiPrincipal = Depends(require_scope("system:settings:read")), ): del principal runtime_dir = default_installer_runtime_dir(core_settings.database_url) + records = list(list_module_installer_runs(runtime_dir=runtime_dir, limit=1000)) + page, next_cursor, full = _window_history_records(records, id_key="run_id", scope=INSTALLER_RUNS_CURSOR_SCOPE, page_size=page_size, cursor=cursor) return ModuleInstallerRunListResponse( runs=[ ModuleInstallerRunSummary.model_validate(item) - for item in list_module_installer_runs(runtime_dir=runtime_dir) + for item in page ], lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)), + cursor=cursor, + next_cursor=next_cursor, + full=full, ) @@ -383,16 +589,23 @@ def read_module_install_run_detail( @router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse) def list_module_install_requests( + page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100), + cursor: str | None = None, principal: ApiPrincipal = Depends(require_scope("system:settings:read")), ): del principal runtime_dir = default_installer_runtime_dir(core_settings.database_url) + records = list(list_module_installer_requests(runtime_dir=runtime_dir, limit=1000)) + page, next_cursor, full = _window_history_records(records, id_key="request_id", scope=INSTALLER_REQUESTS_CURSOR_SCOPE, page_size=page_size, cursor=cursor) return ModuleInstallerRequestListResponse( requests=[ ModuleInstallerRequestItem.model_validate(item) - for item in list_module_installer_requests(runtime_dir=runtime_dir) + for item in page ], daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)), + cursor=cursor, + next_cursor=next_cursor, + full=full, ) @@ -433,7 +646,12 @@ def create_module_install_request( scope="system", object_type="module_install_request", object_id=str(request["request_id"]), - details={"options": payload.options.model_dump(exclude_none=True)}, + details=audit_operation_context( + request_id=request.get("request_id"), + outcome=request.get("status"), + trace=request.get("trace") if isinstance(request.get("trace"), dict) else None, + options=payload.options.model_dump(exclude_none=True), + ), ) session.commit() return ModuleInstallerRequestItem.model_validate(request) @@ -458,7 +676,7 @@ def cancel_module_install_request( cancelled_by=principal.user.id, ) except ModuleInstallerError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -466,7 +684,12 @@ def cancel_module_install_request( scope="system", object_type="module_install_request", object_id=request_id, - details={}, + details=audit_operation_context( + request_id=request_id, + outcome=request.get("status"), + trace=request.get("trace") if isinstance(request.get("trace"), dict) else None, + cancelled_by=request.get("cancelled_by"), + ), ) session.commit() return ModuleInstallerRequestItem.model_validate(request) @@ -491,7 +714,7 @@ def retry_module_install_request( requested_by=principal.user.id, ) except ModuleInstallerError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -499,7 +722,13 @@ def retry_module_install_request( scope="system", object_type="module_install_request", object_id=request_id, - details={"new_request_id": request["request_id"]}, + details=audit_operation_context( + request_id=request_id, + outcome=request.get("status"), + trace=request.get("trace") if isinstance(request.get("trace"), dict) else None, + retry_of=request.get("retry_of"), + new_request_id=request.get("request_id"), + ), ) session.commit() return ModuleInstallerRequestItem.model_validate(request) @@ -511,6 +740,7 @@ def read_module_package_catalog( ): del principal result = validate_module_package_catalog() + license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result)) return ModulePackageCatalogResponse( modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)], configured=bool(result["configured"]), @@ -518,13 +748,17 @@ def read_module_package_catalog( 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, + cache_used=bool(result.get("cache_used")), + cache_path=result["cache_path"] if isinstance(result.get("cache_path"), 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, + not_before=result["not_before"] if isinstance(result.get("not_before"), 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, + license=ModuleLicenseDiagnostics.model_validate(license_diagnostics), warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [], error=result["error"] if isinstance(result["error"], str) else None, ) @@ -542,7 +776,7 @@ def plan_module_install_from_catalog( 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 + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -550,7 +784,16 @@ def plan_module_install_from_catalog( scope="system", object_type="module_install_plan", object_id=module_id, - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + module_id=module_id, + outcome="planned", + items=[item.as_dict() for item in plan.items], + catalog_validation={ + "valid": validation.get("valid"), + "channel": validation.get("channel"), + "key_id": validation.get("key_id"), + }, + ), ) session.commit() return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."]) @@ -569,14 +812,14 @@ def plan_module_uninstall( 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.") + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 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 + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -584,7 +827,11 @@ def plan_module_uninstall( scope="system", object_type="module_install_plan", object_id=module_id, - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + module_id=module_id, + outcome="planned", + 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}."]) @@ -599,14 +846,28 @@ def update_system_modules( ): lifecycle = _request_lifecycle(request) available = lifecycle.available_modules + before_value = {"enabled_modules": list(saved_desired_enabled_modules(session, configured_enabled_modules(core_settings.enabled_modules)))} + apply_value = {"enabled_modules": list(payload.enabled_modules)} + try: + approval = ensure_configuration_change_allowed( + session, + key="module_management.desired_enabled", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc 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 + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 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 + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc desired = save_desired_enabled_modules(session, result.enabled_modules) audit_from_principal( session, @@ -615,13 +876,24 @@ def update_system_modules( 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), - }, + details=audit_operation_context( + outcome="applied", + 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), + ), + ) + record_configuration_change_applied( + session, + key="module_management.desired_enabled", + before_value=before_value, + after_value={"enabled_modules": list(desired)}, + actor_user_id=principal.user.id, + approval=approval, + target={"scope": "system"}, + audit_event="module_management.updated", ) session.commit() notes = ["Module state saved and applied to the running server."] @@ -641,10 +913,24 @@ def update_module_install_plan( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): + before_value = {"items": [item.as_dict() for item in saved_module_install_plan(session).items]} + apply_value = {"items": [item.model_dump() for item in payload.items]} + try: + approval = ensure_configuration_change_allowed( + session, + key="module_management.install_plan", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc 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 + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc audit_from_principal( session, principal, @@ -652,7 +938,21 @@ def update_module_install_plan( scope="system", object_type="module_install_plan", object_id="global", - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + outcome="saved", + items=[item.as_dict() for item in plan.items], + item_count=len(plan.items), + ), + ) + record_configuration_change_applied( + session, + key="module_management.install_plan", + before_value=before_value, + after_value={"items": [item.as_dict() for item in plan.items]}, + actor_user_id=principal.user.id, + approval=approval, + target={"scope": "system"}, + audit_event="module_install.requested", ) session.commit() return _module_install_plan_response(session, request, notes=["Module package install plan saved."]) @@ -672,7 +972,11 @@ def clear_module_install_plan( scope="system", object_type="module_install_plan", object_id="global", - details={"items": [item.as_dict() for item in plan.items]}, + details=audit_operation_context( + outcome="cleared", + items=[item.as_dict() for item in plan.items], + item_count=len(plan.items), + ), ) session.commit() return _module_install_plan_response(session, request, notes=["Module package install plan cleared."]) @@ -683,18 +987,59 @@ def read_system_settings( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:read")), ): - item = get_system_settings(session) + 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 {}, + return _system_settings_item(session) + + +def _full_system_settings_delta_response(session: Session) -> SystemSettingsDeltaResponse: + item = _system_settings_item(session) + return SystemSettingsDeltaResponse( + item=item, + sections=_system_settings_sections(item), + changed_sections=list(SYSTEM_SETTINGS_SECTIONS), + deleted=[], + watermark=_admin_delta_watermark(session, (SYSTEM_SETTINGS_COLLECTION,)), + has_more=False, + full=True, + ) + + +@router.get("/system/settings/delta", response_model=SystemSettingsDeltaResponse) +def read_system_settings_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:settings:read")), +): + del principal + get_system_settings(session) + session.commit() + if since is None: + return _full_system_settings_delta_response(session) + entries, has_more = _admin_delta_entries(session, collections=(SYSTEM_SETTINGS_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_system_settings_delta_response(session) + changed_sections = [ + section + for section in SYSTEM_SETTINGS_SECTIONS + if section in {entry.resource_id for entry in entries if entry.resource_type == SYSTEM_SETTINGS_RESOURCE} + ] + item = _system_settings_item(session) + sections = _system_settings_sections(item) + return SystemSettingsDeltaResponse( + item=None, + sections={section: sections[section] for section in changed_sections}, + changed_sections=changed_sections, + deleted=[], + watermark=_admin_delta_response_watermark( + session, + collections=(SYSTEM_SETTINGS_COLLECTION,), + entries=entries, + has_more=has_more, + ), + has_more=has_more, + full=False, ) @@ -705,10 +1050,45 @@ def write_system_settings( principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): item = get_system_settings(session) + privacy_approval: ConfigurationChangeApproval | None = None + maintenance_approval: ConfigurationChangeApproval | None = None + before_sections = _system_settings_sections(_system_settings_item(session)) + before_privacy = privacy_policy_from_settings(item).model_dump(mode="json") + before_maintenance = saved_maintenance_mode(session).as_dict() + if payload.privacy_retention_policy is not None: + privacy_value = payload.privacy_retention_policy.model_dump(mode="json") + try: + privacy_approval = ensure_configuration_change_allowed( + session, + key="privacy_retention_policy", + value=privacy_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + current_i18n = i18n_settings(item.settings) + raw_available = payload.available_languages if "available_languages" in payload.model_fields_set else current_i18n.get("available_languages") + raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes", current_i18n.get("enabled_languages")) + available_languages = normalize_language_packages( + [item.model_dump() for item in raw_available] if payload.available_languages is not None else raw_available + ) + enabled_language_codes = normalize_enabled_language_codes( + raw_enabled, + available_languages, + default_locale=payload.default_locale, + ) 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 + item.settings = update_i18n_settings( + item.settings, + available_languages=available_languages, + enabled_language_codes=enabled_language_codes, + ) 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: @@ -717,9 +1097,21 @@ def write_system_settings( enabled=payload.maintenance_mode.enabled, message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None, ) + try: + maintenance_approval = ensure_configuration_change_allowed( + session, + key="maintenance_mode", + value=next_mode.as_dict(), + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=None, + target={"scope": "system"}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.", ) save_maintenance_mode(session, next_mode) @@ -728,8 +1120,32 @@ def write_system_settings( session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id, details=payload.model_dump(), ) + if payload.privacy_retention_policy is not None: + record_configuration_change_applied( + session, + key="privacy_retention_policy", + before_value=before_privacy, + after_value=privacy_policy_from_settings(item).model_dump(mode="json"), + actor_user_id=principal.user.id, + approval=privacy_approval, + target={"scope": "system"}, + audit_event="privacy_retention.policy_updated", + ) + if payload.maintenance_mode is not None: + record_configuration_change_applied( + session, + key="maintenance_mode", + before_value=before_maintenance, + after_value=saved_maintenance_mode(session).as_dict(), + actor_user_id=principal.user.id, + approval=maintenance_approval, + target={"scope": "system"}, + audit_event="system_settings.updated", + ) + after_sections = _system_settings_sections(_system_settings_item(session)) + _record_system_settings_section_changes(session, before=before_sections, after=after_sections, principal=principal) session.commit() - return read_system_settings(session=session, principal=principal) + return _system_settings_item(session) @router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse) @@ -746,12 +1162,63 @@ def list_governance_templates( return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items]) +def _full_governance_template_delta_response(session: Session) -> GovernanceTemplateListDeltaResponse: + items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + return GovernanceTemplateListDeltaResponse( + templates=[_governance_template_item(session, item) for item in items], + deleted=[], + watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)), + has_more=False, + full=True, + ) + + +@router.get("/system/governance-templates/delta", response_model=GovernanceTemplateListDeltaResponse) +def list_governance_templates_delta( + since: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:governance:read")), +): + del principal + if since is None: + return _full_governance_template_delta_response(session) + entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit) + if entries is None: + return _full_governance_template_delta_response(session) + changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"] + items = [] + if changed_ids: + items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all() + visible_template_ids = {item.id for item in items} + return GovernanceTemplateListDeltaResponse( + templates=[_governance_template_item(session, item) for item in items], + deleted=_governance_template_deleted_entries(entries, visible_template_ids), + watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more), + has_more=has_more, + full=False, + ) + + @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")), ): + apply_value = payload.model_dump(exclude={"change_request_id"}) + try: + approval = ensure_configuration_change_allowed( + session, + key="governance_templates", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"kind": payload.kind, "slug": payload.slug}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: item = create_template( session, @@ -774,6 +1241,17 @@ def create_governance_template( object_id=item.id, details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, ) + record_configuration_change_applied( + session, + key="governance_templates", + before_value=None, + after_value={**apply_value, "id": item.id}, + actor_user_id=principal.user.id, + approval=approval, + target={"kind": item.kind, "id": item.id}, + audit_event="governance_template.updated", + ) + _record_governance_template_change(session, item=item, operation="created", principal=principal) session.commit() return _governance_template_item(session, item) @@ -788,6 +1266,20 @@ def update_governance_template( item = session.get(GovernanceTemplate, template_id) if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") + before_value = _governance_template_item(session, item).model_dump(mode="json") + apply_value = payload.model_dump(exclude={"change_request_id"}) + try: + approval = ensure_configuration_change_allowed( + session, + key="governance_templates", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=payload.change_request_id, + target={"kind": item.kind, "id": item.id}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: update_template( session, @@ -809,6 +1301,17 @@ def update_governance_template( object_id=item.id, details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]}, ) + record_configuration_change_applied( + session, + key="governance_templates", + before_value=before_value, + after_value=_governance_template_item(session, item).model_dump(mode="json"), + actor_user_id=principal.user.id, + approval=approval, + target={"kind": item.kind, "id": item.id}, + audit_event="governance_template.updated", + ) + _record_governance_template_change(session, item=item, operation="updated", principal=principal) session.commit() return _governance_template_item(session, item) @@ -816,6 +1319,7 @@ def update_governance_template( @router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_governance_template( template_id: str, + change_request_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:governance:write")), ): @@ -823,6 +1327,20 @@ def remove_governance_template( if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found") kind = item.kind + before_value = _governance_template_item(session, item).model_dump(mode="json") + apply_value = {"id": item.id, "kind": item.kind, "delete": True} + try: + approval = ensure_configuration_change_allowed( + session, + key="governance_templates", + value=apply_value, + actor_user_id=principal.user.id, + actor_scopes=tuple(principal.scopes), + change_request_id=change_request_id, + target={"kind": item.kind, "id": item.id}, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc try: delete_template(session, item) except (AdminConflictError, AdminValidationError) as exc: @@ -836,5 +1354,16 @@ def remove_governance_template( object_id=template_id, details={}, ) + record_configuration_change_applied( + session, + key="governance_templates", + before_value=before_value, + after_value=None, + actor_user_id=principal.user.id, + approval=approval, + target={"kind": kind, "id": template_id}, + audit_event="governance_template.updated", + ) + _record_governance_template_change(session, item=item, operation="deleted", principal=principal) session.commit() return None diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 69abf62..75cbaa6 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -5,6 +5,8 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from govoplan_core.api.v1.schemas import DeltaDeletedItem + RETENTION_DAY_KEYS = ( "raw_campaign_json_retention_days", @@ -65,6 +67,14 @@ class MaintenanceModeItem(BaseModel): message: str | None = Field(default=None, max_length=500) +class LanguagePackageItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: str = Field(min_length=1, max_length=20) + label: str = Field(min_length=1, max_length=100) + native_label: str | None = Field(default=None, max_length=100) + + class AdminOverviewResponse(BaseModel): active_tenant_id: str active_tenant_name: str @@ -87,9 +97,21 @@ class SystemSettingsItem(BaseModel): allow_tenant_api_keys: bool = True privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem) maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem) + available_languages: list[LanguagePackageItem] = Field(default_factory=list) + enabled_language_codes: list[str] = Field(default_factory=list) settings: dict[str, Any] = Field(default_factory=dict) +class SystemSettingsDeltaResponse(BaseModel): + item: SystemSettingsItem | None = None + sections: dict[str, Any] = Field(default_factory=dict) + changed_sections: list[str] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class SystemSettingsUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -99,6 +121,9 @@ class SystemSettingsUpdateRequest(BaseModel): allow_tenant_api_keys: bool privacy_retention_policy: PrivacyRetentionPolicyItem | None = None maintenance_mode: MaintenanceModeItem | None = None + available_languages: list[LanguagePackageItem] | None = None + enabled_language_codes: list[str] | None = None + change_request_id: str | None = None class ModuleCatalogItem(BaseModel): @@ -143,6 +168,7 @@ class ModuleStateUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") enabled_modules: list[str] = Field(default_factory=list) + change_request_id: str | None = None class ModuleInstallPlanItem(BaseModel): @@ -213,6 +239,9 @@ class ModuleInstallerRunSummary(BaseModel): status: str started_at: str | None = None finished_at: str | None = None + request_id: str | None = None + requested_by: str | None = None + trace: dict[str, Any] | None = None rollback_status: str | None = None supervisor_status: str | None = None error: str | None = None @@ -224,6 +253,9 @@ class ModuleInstallerRunSummary(BaseModel): class ModuleInstallerRunListResponse(BaseModel): runs: list[ModuleInstallerRunSummary] = Field(default_factory=list) lock: ModuleInstallerLockStatus + cursor: str | None = None + next_cursor: str | None = None + full: bool = False class ModuleInstallerRequestOptions(BaseModel): @@ -256,6 +288,7 @@ class ModuleInstallerRequestItem(BaseModel): cancelled_at: str | None = None cancelled_by: str | None = None retry_of: str | None = None + trace: dict[str, Any] | None = None error: str | None = None record_path: str | None = None options: dict[str, Any] = Field(default_factory=dict) @@ -264,6 +297,9 @@ class ModuleInstallerRequestItem(BaseModel): class ModuleInstallerRequestListResponse(BaseModel): requests: list[ModuleInstallerRequestItem] = Field(default_factory=list) daemon: ModuleInstallerDaemonStatus + cursor: str | None = None + next_cursor: str | None = None + full: bool = False class ModuleInstallerRequestCreateRequest(BaseModel): @@ -291,6 +327,27 @@ class ModulePackageCatalogItem(BaseModel): tags: list[str] = Field(default_factory=list) +class ModuleLicenseDiagnostics(BaseModel): + configured: bool = False + valid: bool = True + path: str | None = None + license_id: str | None = None + subject: str | None = None + features: list[str] = Field(default_factory=list) + valid_from: str | None = None + valid_until: str | None = None + signed: bool = False + trusted: bool = False + key_id: str | None = None + enforced: bool = False + allowed: bool = True + required_features: list[str] = Field(default_factory=list) + missing_features: list[str] = Field(default_factory=list) + expires_in_days: int | None = None + reason: str | None = None + guidance: list[str] = Field(default_factory=list) + + class ModulePackageCatalogResponse(BaseModel): modules: list[ModulePackageCatalogItem] = Field(default_factory=list) configured: bool = False @@ -298,13 +355,17 @@ class ModulePackageCatalogResponse(BaseModel): path: str | None = None source: str | None = None source_type: str | None = None + cache_used: bool = False + cache_path: str | None = None channel: str | None = None sequence: int | None = None generated_at: str | None = None + not_before: str | None = None expires_at: str | None = None signed: bool = False trusted: bool = False key_id: str | None = None + license: ModuleLicenseDiagnostics = Field(default_factory=ModuleLicenseDiagnostics) warnings: list[str] = Field(default_factory=list) error: str | None = None @@ -313,6 +374,7 @@ class ModuleInstallPlanUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") items: list[ModuleInstallPlanItem] = Field(default_factory=list) + change_request_id: str | None = None class GovernanceAssignment(BaseModel): @@ -338,6 +400,14 @@ class GovernanceTemplateListResponse(BaseModel): templates: list[GovernanceTemplateItem] +class GovernanceTemplateListDeltaResponse(BaseModel): + templates: list[GovernanceTemplateItem] = Field(default_factory=list) + deleted: list[DeltaDeletedItem] = Field(default_factory=list) + watermark: str | None = None + has_more: bool = False + full: bool = False + + class GovernanceTemplateCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -348,6 +418,7 @@ class GovernanceTemplateCreateRequest(BaseModel): permissions: list[str] = Field(default_factory=list) is_active: bool = True assignments: list[GovernanceAssignment] = Field(default_factory=list) + change_request_id: str | None = None class GovernanceTemplateUpdateRequest(BaseModel): @@ -358,3 +429,4 @@ class GovernanceTemplateUpdateRequest(BaseModel): permissions: list[str] = Field(default_factory=list) is_active: bool = True assignments: list[GovernanceAssignment] = Field(default_factory=list) + change_request_id: str | None = None diff --git a/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5a119ababb18df0f22616e8264c55d0d7c92d216..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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!(( diff --git a/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc deleted file mode 100644 index c7b632f107befc1de98e77b97f91268b4a866bac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 613e4bb42d934c1d44de2b2af595ec53727181d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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_ diff --git a/src/govoplan_admin/backend/db/models.py b/src/govoplan_admin/backend/db/models.py index 0b2f941..9f89675 100644 --- a/src/govoplan_admin/backend/db/models.py +++ b/src/govoplan_admin/backend/db/models.py @@ -13,7 +13,7 @@ def new_uuid() -> str: class GovernanceTemplate(Base, TimestampMixin): - __tablename__ = "governance_templates" + __tablename__ = "admin_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) @@ -26,12 +26,12 @@ class GovernanceTemplate(Base, TimestampMixin): class GovernanceTemplateAssignment(Base, TimestampMixin): - __tablename__ = "governance_template_assignments" + __tablename__ = "admin_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) + template_id: Mapped[str] = mapped_column(ForeignKey("admin_governance_templates.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False) diff --git a/webui/package.json b/webui/package.json index 44ee4cb..c5bc812 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,7 @@ }, "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1" diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index e1b2dfa..61e6f96 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,4 +1,4 @@ -import type { ApiSettings } from "@govoplan/core-webui"; +import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; import { apiFetch } from "@govoplan/core-webui"; export type PermissionItem = { @@ -75,6 +75,12 @@ export type MaintenanceMode = { message?: string | null; }; +export type LanguagePackage = { + code: string; + label: string; + native_label?: string | null; +}; + export type SystemSettingsItem = { default_locale: string; allow_tenant_custom_groups: boolean; @@ -82,9 +88,20 @@ export type SystemSettingsItem = { allow_tenant_api_keys: boolean; privacy_retention_policy: PrivacyRetentionPolicy; maintenance_mode: MaintenanceMode; + available_languages: LanguagePackage[]; + enabled_language_codes: string[]; settings: Record; }; +export type SystemSettingsDeltaSections = Partial<{ + defaults: Pick; + tenant_capabilities: Pick; + languages: Pick; + privacy_retention_policy: SystemSettingsItem["privacy_retention_policy"]; + maintenance_mode: SystemSettingsItem["maintenance_mode"]; + settings: SystemSettingsItem["settings"]; +}>; + export type SystemSettingsUpdatePayload = { default_locale: string; allow_tenant_custom_groups: boolean; @@ -92,8 +109,106 @@ export type SystemSettingsUpdatePayload = { allow_tenant_api_keys: boolean; privacy_retention_policy?: PrivacyRetentionPolicy | null; maintenance_mode?: MaintenanceMode | null; + available_languages?: LanguagePackage[] | null; + enabled_language_codes?: string[] | null; + change_request_id?: string | null; }; +export type ConfigurationChangeRequest = { + id: string; + key: string; + label?: string; + target?: Record; + dry_run: boolean; + requested_by: string; + requested_at: string; + updated_at: string; + status: string; + approvals: Array>; + plan: Record; + value_preview?: unknown; +}; + +export type ConfigurationChangeRecord = { + id: string; + version: number; + key: string; + target?: Record; + actor_user_id: string; + approval_request_id?: string | null; + approval_user_ids: string[]; + before?: unknown; + after?: unknown; + rollback_value?: unknown; + status: string; + created_at: string; +}; + +export type ConfigurationPackageDiagnostic = { + severity: "blocker" | "warning" | "info"; + code: string; + message: string; + module_id?: string | null; + object_ref?: string | null; + resolution?: string | null; +}; + +export type ConfigurationPackageRequiredData = { + key: string; + label: string; + data_type: string; + required: boolean; + secret: boolean; + description?: string | null; +}; + +export type ConfigurationPackagePlanItem = { + action: "create" | "update" | "bind" | "skip" | "blocked" | "noop"; + module_id: string; + fragment_type: string; + fragment_id?: string | null; + summary?: string | null; +}; + +export type ConfigurationPackageFragment = { + module_id: string; + fragment_type: string; + fragment_id?: string | null; + payload: Record; +}; + +export type ConfigurationPackageRunPayload = { + package: Record; + tenant_id?: string | null; + supplied_data?: Record; + change_request_id?: string | null; +}; + +type DeltaResponseFields = { + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + +function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { + const params = new URLSearchParams(); + if (options.since) params.set("since", options.since); + if (options.limit) params.set("limit", String(options.limit)); + return params.toString() ? `?${params.toString()}` : ""; +} + +export type SystemSettingsDeltaResponse = { + item?: SystemSettingsItem | null; + sections: SystemSettingsDeltaSections; + changed_sections: string[]; +} & DeltaResponseFields; + +export type ConfigurationChangesDeltaResponse = { + requests: ConfigurationChangeRequest[]; + history: ConfigurationChangeRecord[]; +} & DeltaResponseFields; + export type ModuleCatalogItem = { id: string; name: string; @@ -196,6 +311,9 @@ export type ModuleInstallerRunSummary = { status: string; started_at?: string | null; finished_at?: string | null; + request_id?: string | null; + requested_by?: string | null; + trace?: Record | null; rollback_status?: string | null; supervisor_status?: string | null; error?: string | null; @@ -207,6 +325,9 @@ export type ModuleInstallerRunSummary = { export type ModuleInstallerRunListResponse = { runs: ModuleInstallerRunSummary[]; lock: ModuleInstallerLockStatus; + cursor?: string | null; + next_cursor?: string | null; + full?: boolean; }; export type ModuleInstallerRequestOptions = { @@ -235,6 +356,7 @@ export type ModuleInstallerRequestItem = { cancelled_at?: string | null; cancelled_by?: string | null; retry_of?: string | null; + trace?: Record | null; error?: string | null; record_path?: string | null; options: Record; @@ -243,6 +365,9 @@ export type ModuleInstallerRequestItem = { export type ModuleInstallerRequestListResponse = { requests: ModuleInstallerRequestItem[]; daemon: ModuleInstallerDaemonStatus; + cursor?: string | null; + next_cursor?: string | null; + full?: boolean; }; export type ModulePackageCatalogItem = { @@ -264,6 +389,27 @@ export type ModulePackageCatalogItem = { tags: string[]; }; +export type ModuleLicenseDiagnostics = { + configured: boolean; + valid: boolean; + path?: string | null; + license_id?: string | null; + subject?: string | null; + features: string[]; + valid_from?: string | null; + valid_until?: string | null; + signed: boolean; + trusted: boolean; + key_id?: string | null; + enforced: boolean; + allowed: boolean; + required_features: string[]; + missing_features: string[]; + expires_in_days?: number | null; + reason?: string | null; + guidance: string[]; +}; + export type ModulePackageCatalogResponse = { modules: ModulePackageCatalogItem[]; configured: boolean; @@ -271,13 +417,17 @@ export type ModulePackageCatalogResponse = { path?: string | null; source?: string | null; source_type?: string | null; + cache_used: boolean; + cache_path?: string | null; channel?: string | null; sequence?: number | null; generated_at?: string | null; + not_before?: string | null; expires_at?: string | null; signed: boolean; trusted: boolean; key_id?: string | null; + license: ModuleLicenseDiagnostics; warnings: string[]; error?: string | null; }; @@ -319,6 +469,11 @@ export function fetchSystemSettings(settings: ApiSettings): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/system/settings/delta${suffix}`); +} + export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); } @@ -327,10 +482,10 @@ export function fetchModuleCatalog(settings: ApiSettings): Promise { +export function updateModuleState(settings: ApiSettings, enabledModules: string[], changeRequestId?: string | null): Promise { return apiFetch(settings, "/api/v1/admin/system/modules", { method: "PUT", - body: JSON.stringify({ enabled_modules: enabledModules }) + body: JSON.stringify({ enabled_modules: enabledModules, change_request_id: changeRequestId ?? null }) }); } @@ -338,12 +493,20 @@ export function fetchModuleInstallPlan(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/system/modules/install-runs"); +export function fetchModuleInstallerRuns(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise { + const params = new URLSearchParams(); + if (options.page_size) params.set("page_size", String(options.page_size)); + if (options.cursor) params.set("cursor", options.cursor); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/system/modules/install-runs${suffix}`); } -export function fetchModuleInstallerRequests(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/system/modules/install-requests"); +export function fetchModuleInstallerRequests(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise { + const params = new URLSearchParams(); + if (options.page_size) params.set("page_size", String(options.page_size)); + if (options.cursor) params.set("cursor", options.cursor); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/system/modules/install-requests${suffix}`); } export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise { @@ -373,10 +536,10 @@ export function planModuleUninstall(settings: ApiSettings, moduleId: string): Pr return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" }); } -export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise { +export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[], changeRequestId?: string | null): Promise { return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { method: "PUT", - body: JSON.stringify({ items }) + body: JSON.stringify({ items, change_request_id: changeRequestId ?? null }) }); } @@ -390,14 +553,88 @@ export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "gr return response.templates; } -export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { +export function createGovernanceTemplate(settings: ApiSettings, payload: Omit & { change_request_id?: string | null }): 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 { +export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit & { change_request_id?: string | null }): 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" }); +export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise { + const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : ""; + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" }); +} + +export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> { + return apiFetch(settings, "/api/v1/admin/configuration-changes"); +} + +export function fetchConfigurationChangesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { + const suffix = deltaSuffix(options); + return apiFetch(settings, `/api/v1/admin/configuration-changes/delta${suffix}`); +} + +export async function createConfigurationChangeRequest(settings: ApiSettings, payload: { + key: string; + value?: unknown; + dry_run?: boolean; + target?: Record; + reason?: string | null; +}): Promise { + const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, "/api/v1/admin/configuration-change-requests", { + method: "POST", + body: JSON.stringify(payload) + }); + return response.request; +} + +export async function approveConfigurationChangeRequest(settings: ApiSettings, requestId: string, reason?: string | null): Promise { + const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, `/api/v1/admin/configuration-change-requests/${encodeURIComponent(requestId)}/approve`, { + method: "POST", + body: JSON.stringify({ reason: reason ?? null }) + }); + return response.request; +} + +export function fetchConfigurationPackageCatalogValidation(settings: ApiSettings): Promise<{ validation: Record }> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/catalog"); +} + +export function dryRunConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{ + diagnostics: ConfigurationPackageDiagnostic[]; + required_data: ConfigurationPackageRequiredData[]; + plan: ConfigurationPackagePlanItem[]; +}> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/dry-run", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function applyConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{ + diagnostics: ConfigurationPackageDiagnostic[]; + created_refs: Record; + updated_refs: Record; +}> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/apply", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function exportConfigurationPackage(settings: ApiSettings, payload: { + tenant_id?: string | null; + scopes?: string[]; + module_ids?: string[]; + object_refs?: string[]; +}): Promise<{ + fragments: ConfigurationPackageFragment[]; + data_requirements: ConfigurationPackageRequiredData[]; + diagnostics: ConfigurationPackageDiagnostic[]; +}> { + return apiFetch(settings, "/api/v1/admin/configuration-packages/export", { + method: "POST", + body: JSON.stringify(payload) + }); } diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx index 6935319..ecdd476 100644 --- a/webui/src/features/admin/AdminOverviewPanel.tsx +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -5,7 +5,7 @@ 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 }) { +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); @@ -13,81 +13,112 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio async function load() { setLoading(true); setError(""); - try { setOverview(await fetchAdminOverview(settings)); } - catch (err) { setError(adminErrorMessage(err)); } - finally { setLoading(false); } + try {setOverview(await fetchAdminOverview(settings));} + catch (err) {setError(adminErrorMessage(err));} finally + {setLoading(false);} } - useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]); + 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)); + 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}> + void load()} disabled={loading}>i18n:govoplan-admin.reload.cce71553}> {overview && <> {hasSystemMetrics && <> -

System
+
i18n:govoplan-admin.system.bc0792d8
- - - - + + + +
} - {hasSystemArea(availableSections) && + {hasAnySection(availableSections, platformSectionIds) &&
- {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")} />} + {availableSections.has("system-modules") && onSelect("system-modules")} />} + {availableSections.has("system-configuration-packages") && onSelect("system-configuration-packages")} />} + {availableSections.has("system-settings") && onSelect("system-settings")} />} + {availableSections.has("system-configuration-changes") && onSelect("system-configuration-changes")} />} + {availableSections.has("system-audit") && onSelect("system-audit")} />}
} -
Active tenant: {overview.active_tenant_name}
+ {hasAnySection(availableSections, globalSectionIds) && +
+ {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-file-connectors") && onSelect("system-file-connectors")} />} + {availableSections.has("system-mail-servers") && onSelect("system-mail-servers")} />} + {availableSections.has("system-retention") && onSelect("system-retention")} />} +
+
} + +
i18n:govoplan-admin.active_tenant.dfadf0aa {overview.active_tenant_name}
- - - - + + + +
- + {hasAnySection(availableSections, tenantSectionIds) &&
- {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")} />} + {availableSections.has("tenant-roles") && onSelect("tenant-roles")} />} + {availableSections.has("tenant-groups") && onSelect("tenant-groups")} />} + {availableSections.has("tenant-users") && onSelect("tenant-users")} />} + {availableSections.has("tenant-file-connectors") && onSelect("tenant-file-connectors")} />} + {availableSections.has("tenant-mail-servers") && onSelect("tenant-mail-servers")} />} + {availableSections.has("tenant-api-keys") && onSelect("tenant-api-keys")} />} + {availableSections.has("tenant-retention") && onSelect("tenant-retention")} />} + {availableSections.has("tenant-settings") && onSelect("tenant-settings")} />} + {availableSections.has("tenant-audit") && onSelect("tenant-audit")} />}
-
+
} + + {hasAnySection(availableSections, groupSectionIds) && +
+ {availableSections.has("tenant-group-file-connectors") && onSelect("tenant-group-file-connectors")} />} + {availableSections.has("tenant-group-mail-servers") && onSelect("tenant-group-mail-servers")} />} + {availableSections.has("tenant-group-retention") && onSelect("tenant-group-retention")} />} +
+
} + + {hasAnySection(availableSections, userSectionIds) && +
+ {availableSections.has("tenant-user-file-connectors") && onSelect("tenant-user-file-connectors")} />} + {availableSections.has("tenant-user-mail-servers") && onSelect("tenant-user-mail-servers")} />} + {availableSections.has("tenant-user-retention") && onSelect("tenant-user-retention")} />} +
+
} } - - ); + ); + } -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)); +const platformSectionIds = ["system-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"]; +const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention"]; +const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-settings", "tenant-audit"]; +const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention"]; +const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention"]; + +function hasAnySection(sections: ReadonlySet, candidates: readonly string[]): boolean { + return candidates.some((section) => sections.has(section)); } -function Metric({ title, value, text }: { title: string; value: string | number; text: string }) { +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 }) { +function AreaLink({ title, text, onClick }: {title: string;text: string;onClick: () => void;}) { return ; } diff --git a/webui/src/features/admin/ConfigurationChangesPanel.tsx b/webui/src/features/admin/ConfigurationChangesPanel.tsx new file mode 100644 index 0000000..6387469 --- /dev/null +++ b/webui/src/features/admin/ConfigurationChangesPanel.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, RefreshCw } from "lucide-react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui"; +import { + approveConfigurationChangeRequest, + fetchConfigurationChangesDelta, + type ConfigurationChangeRecord, + type ConfigurationChangeRequest } from +"../../api/admin"; + +const DELTA_KEY = "admin:configuration-changes"; + +export default function ConfigurationChangesPanel({ settings, canApprove }: {settings: ApiSettings;canApprove: boolean;}) { + const [requests, setRequests] = useState([]); + const [history, setHistory] = useState([]); + const requestsRef = useRef([]); + const historyRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + let nextWatermark = getDeltaWatermark(DELTA_KEY); + let nextRequests = requestsRef.current; + let nextHistory = historyRef.current; + let hasMore = false; + do { + const payload = await fetchConfigurationChangesDelta(settings, { since: nextWatermark }); + nextRequests = payload.full ? payload.requests : mergeDeltaRows(nextRequests, payload.requests, payload.deleted, (request) => request.id, { sort: sortConfigurationRequests }); + nextHistory = payload.full ? payload.history : mergeDeltaRows(nextHistory, payload.history, payload.deleted, (record) => record.id, { sort: sortConfigurationHistory }); + nextWatermark = payload.watermark ?? null; + hasMore = payload.has_more; + } while (hasMore); + requestsRef.current = nextRequests; + historyRef.current = nextHistory; + setRequests(nextRequests); + setHistory(nextHistory); + setDeltaWatermark(DELTA_KEY, nextWatermark); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + requestsRef.current = []; + historyRef.current = []; + resetDeltaWatermark(DELTA_KEY); + void load(); + }, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]); + + async function approve(request: ConfigurationChangeRequest) { + setBusyId(request.id); + setError(""); + setMessage(""); + try { + await approveConfigurationChangeRequest(settings, request.id); + setMessage(i18nMessage("i18n:govoplan-admin.approved_value.52da808b", { value0: request.key })); + await load(); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusyId(""); + } + } + + const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]); + + return ( + void load()} disabled={loading}> i18n:govoplan-admin.reload.cce71553}> + + +
+ + + + + + + + + + + + + {pending.map((request) => + + + + + + + + + )} + {!pending.length && } + +
i18n:govoplan-admin.setting.fb449f71i18n:govoplan-admin.status.bae7d5bei18n:govoplan-admin.requested.c26bf60fi18n:govoplan-admin.approvals.deb9d03ci18n:govoplan-admin.target.61ad50a9i18n:govoplan-admin.action.97c89a4d
{request.label || request.key}{request.key}{formatDateTime(request.requested_at)}{request.approvals.length}{targetLabel(request.target)} + {canApprove && request.status === "pending_approval" && + + } +
i18n:govoplan-admin.no_open_requests.580c95f9
+
+
+ + +
+ + + + + + + + + + + + + {history.map((record) => + + + + + + + + + )} + {!history.length && } + +
i18n:govoplan-admin.version.2da600bfi18n:govoplan-admin.setting.fb449f71i18n:govoplan-admin.status.bae7d5bei18n:govoplan-admin.applied.a3e4a569i18n:govoplan-admin.approvers.0e2de1fbi18n:govoplan-admin.target.61ad50a9
#{record.version}{record.key}{record.id}{formatDateTime(record.created_at)}{record.approval_user_ids.length || "-"}{targetLabel(record.target)}
i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7
+
+
+
); + +} + +function statusTone(status: string): string { + if (status === "approved" || status === "applied") return "success"; + if (status === "pending_approval") return "warning"; + if (status === "rejected") return "error"; + return "inactive"; +} + +function targetLabel(target?: Record): string { + if (!target || !Object.keys(target).length) return "-"; + return Object.entries(target).map(([key, value]) => `${key}: ${String(value)}`).join(", "); +} + +function sortConfigurationRequests(left: ConfigurationChangeRequest, right: ConfigurationChangeRequest): number { + return new Date(right.updated_at || right.requested_at).getTime() - new Date(left.updated_at || left.requested_at).getTime(); +} + +function sortConfigurationHistory(left: ConfigurationChangeRecord, right: ConfigurationChangeRecord): number { + return right.version - left.version; +} diff --git a/webui/src/features/admin/ConfigurationPackagesPanel.tsx b/webui/src/features/admin/ConfigurationPackagesPanel.tsx new file mode 100644 index 0000000..a285751 --- /dev/null +++ b/webui/src/features/admin/ConfigurationPackagesPanel.tsx @@ -0,0 +1,374 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Download, Play, RefreshCw } from "lucide-react"; +import type { ApiSettings } from "@govoplan/core-webui"; +import { AdminPageLayout, Button, Card, StatusBadge, adminErrorMessage, i18nMessage } from "@govoplan/core-webui"; +import { + applyConfigurationPackage, + createConfigurationChangeRequest, + dryRunConfigurationPackage, + exportConfigurationPackage, + fetchConfigurationPackageCatalogValidation, + type ConfigurationChangeRequest, + type ConfigurationPackageDiagnostic, + type ConfigurationPackagePlanItem, + type ConfigurationPackageRequiredData } from +"../../api/admin"; + +const SAMPLE_ACCESS_PACKAGE = { + package_id: "govoplan.access.minimal-office", + name: "i18n:govoplan-admin.minimal_office_access.af48f49a", + version: "0.1.0", + required_modules: [{ module_id: "access" }], + required_capabilities: ["configuration.provider", "access.configuration"], + fragments: [ + { + module_id: "access", + fragment_type: "roles", + payload: { + items: [ + { + slug: "case-clerk", + name: "i18n:govoplan-admin.case_clerk.b78a314a", + description: "i18n:govoplan-admin.handles_incoming_administrative_work.dc80c349", + permissions: ["admin:users:read"] + }] + + } + }, + { + module_id: "access", + fragment_type: "groups", + payload: { items: [{ slug: "front-office", name: "i18n:govoplan-admin.front_office.d9dcfee1" }] } + }, + { + module_id: "access", + fragment_type: "group_role_assignments", + payload: { items: [{ group: "front-office", role: "case-clerk" }] } + }] + +}; + +type DryRunResult = { + diagnostics: ConfigurationPackageDiagnostic[]; + required_data: ConfigurationPackageRequiredData[]; + plan: ConfigurationPackagePlanItem[]; +}; + +type ApplyResult = { + diagnostics: ConfigurationPackageDiagnostic[]; + created_refs: Record; + updated_refs: Record; +}; + +export default function ConfigurationPackagesPanel({ settings, canWrite }: {settings: ApiSettings;canWrite: boolean;}) { + const [catalogValidation, setCatalogValidation] = useState | null>(null); + const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2)); + const [tenantId, setTenantId] = useState(""); + const [suppliedDataText, setSuppliedDataText] = useState("{}"); + const [changeRequestId, setChangeRequestId] = useState(""); + const [dryRun, setDryRun] = useState(null); + const [applyResult, setApplyResult] = useState(null); + const [exportText, setExportText] = useState(""); + const [lastRequest, setLastRequest] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + const parsedPackage = useMemo(() => parseObject(packageText), [packageText]); + const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]); + const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value); + + async function loadCatalog() { + setLoading(true); + setError(""); + try { + const response = await fetchConfigurationPackageCatalogValidation(settings); + setCatalogValidation(response.validation); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]); + + async function runDryRun() { + const manifest = requireParsedPackage(); + const suppliedData = requireParsedSuppliedData(); + if (!manifest || !suppliedData) return; + setBusy("dry-run"); + setError(""); + setMessage(""); + setApplyResult(null); + try { + const result = await dryRunConfigurationPackage(settings, runPayload(manifest, suppliedData)); + setDryRun(result); + setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.preflight_finished_with_blockers.7e2ca12b" : "i18n:govoplan-admin.preflight_passed.c0c99055"); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + async function requestApproval() { + const manifest = requireParsedPackage(); + if (!manifest) return; + setBusy("approval"); + setError(""); + setMessage(""); + try { + const request = await createConfigurationChangeRequest(settings, { + key: "configuration_packages.apply", + value: manifest, + dry_run: true, + target: tenantId.trim() ? { tenant_id: tenantId.trim() } : {}, + reason: "i18n:govoplan-admin.configuration_package_apply.c270e5f3" + }); + setLastRequest(request); + setChangeRequestId(request.id); + setMessage(i18nMessage("i18n:govoplan-admin.change_request_created_value.4af6d3d2", { value0: request.id })); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + async function applyPackage() { + const manifest = requireParsedPackage(); + const suppliedData = requireParsedSuppliedData(); + if (!manifest || !suppliedData) return; + setBusy("apply"); + setError(""); + setMessage(""); + try { + const result = await applyConfigurationPackage(settings, runPayload(manifest, suppliedData, changeRequestId.trim() || null)); + setApplyResult(result); + setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.apply_finished_with_blockers.78487a12" : "i18n:govoplan-admin.package_applied.63782ce7"); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + async function exportAccessPackage() { + setBusy("export"); + setError(""); + setMessage(""); + try { + const result = await exportConfigurationPackage(settings, { + tenant_id: tenantId.trim() || null, + module_ids: ["access"], + scopes: tenantId.trim() ? ["tenant"] : ["system"] + }); + const exported = { + package_id: tenantId.trim() ? "govoplan.export.access-tenant" : "govoplan.export.access-system", + name: tenantId.trim() ? "i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c" : "i18n:govoplan-admin.exported_system_access_configuration.2e1c6f4c", + version: "0.1.0", + required_modules: [{ module_id: "access" }], + required_capabilities: ["configuration.provider", "access.configuration"], + fragments: result.fragments, + data_requirements: result.data_requirements + }; + setExportText(JSON.stringify(exported, null, 2)); + setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.export_finished_with_blockers.e2f70611" : "i18n:govoplan-admin.access_configuration_exported.098f200d"); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(""); + } + } + + function runPayload(manifest: Record, suppliedData: Record, requestId?: string | null) { + return { + package: manifest, + tenant_id: tenantId.trim() || null, + supplied_data: suppliedData, + change_request_id: requestId ?? null + }; + } + + function requireParsedPackage(): Record | null { + if (!parsedPackage.value) { + setError(parsedPackage.error || "i18n:govoplan-admin.package_json_must_be_an_object.db825bb3"); + return null; + } + return parsedPackage.value; + } + + function requireParsedSuppliedData(): Record | null { + if (!parsedSuppliedData.value) { + setError(parsedSuppliedData.error || "i18n:govoplan-admin.supplied_data_json_must_be_an_object.b265eb92"); + return null; + } + return parsedSuppliedData.value; + } + + return ( + void loadCatalog()} disabled={loading || Boolean(busy)}> i18n:govoplan-admin.reload.cce71553}> + + +
+ + {catalogValidation?.signed !== undefined && } + {catalogValidation?.trusted !== undefined && } +
+
{JSON.stringify(catalogValidation ?? {}, null, 2)}
+
+ + +
+ + +