From c25e81fce9797b8e88da4d38f52bd648ad354186 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 7 Jul 2026 15:49:06 +0200 Subject: [PATCH] Release v0.1.5 --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 +++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 +++ .gitea/ISSUE_TEMPLATE/task.md | 28 ++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 ++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 ++ README.md | 8 + pyproject.toml | 23 ++ src/govoplan_tenancy.egg-info/PKG-INFO | 17 ++ src/govoplan_tenancy.egg-info/SOURCES.txt | 19 ++ .../dependency_links.txt | 1 + .../entry_points.txt | 2 + src/govoplan_tenancy.egg-info/requires.txt | 1 + src/govoplan_tenancy.egg-info/top_level.txt | 1 + src/govoplan_tenancy/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 199 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 199 bytes src/govoplan_tenancy/backend/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 235 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 235 bytes .../__pycache__/capabilities.cpython-313.pyc | Bin 0 -> 1852 bytes .../__pycache__/manifest.cpython-312.pyc | Bin 0 -> 1578 bytes .../__pycache__/manifest.cpython-313.pyc | Bin 0 -> 1555 bytes src/govoplan_tenancy/backend/api/__init__.py | 0 .../api/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 172 bytes .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 172 bytes .../backend/api/v1/__init__.py | 0 .../v1/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 175 bytes .../v1/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 175 bytes .../api/v1/__pycache__/routes.cpython-312.pyc | Bin 0 -> 13367 bytes .../api/v1/__pycache__/routes.cpython-313.pyc | Bin 0 -> 13414 bytes .../v1/__pycache__/schemas.cpython-312.pyc | Bin 0 -> 4075 bytes .../v1/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 4229 bytes src/govoplan_tenancy/backend/api/v1/routes.py | 248 ++++++++++++++++++ .../backend/api/v1/schemas.py | 78 ++++++ src/govoplan_tenancy/backend/capabilities.py | 26 ++ src/govoplan_tenancy/backend/db/__init__.py | 2 + .../db/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 231 bytes .../db/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 231 bytes .../db/__pycache__/models.cpython-312.pyc | Bin 0 -> 2115 bytes .../db/__pycache__/models.cpython-313.pyc | Bin 0 -> 2117 bytes src/govoplan_tenancy/backend/db/models.py | 33 +++ src/govoplan_tenancy/backend/manifest.py | 40 +++ src/govoplan_tenancy/py.typed | 1 + 45 files changed, 667 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/govoplan_tenancy.egg-info/PKG-INFO create mode 100644 src/govoplan_tenancy.egg-info/SOURCES.txt create mode 100644 src/govoplan_tenancy.egg-info/dependency_links.txt create mode 100644 src/govoplan_tenancy.egg-info/entry_points.txt create mode 100644 src/govoplan_tenancy.egg-info/requires.txt create mode 100644 src/govoplan_tenancy.egg-info/top_level.txt create mode 100644 src/govoplan_tenancy/__init__.py create mode 100644 src/govoplan_tenancy/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_tenancy/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/__init__.py create mode 100644 src/govoplan_tenancy/backend/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/__pycache__/manifest.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/api/__init__.py create mode 100644 src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/__init__.py create mode 100644 src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/api/v1/routes.py create mode 100644 src/govoplan_tenancy/backend/api/v1/schemas.py create mode 100644 src/govoplan_tenancy/backend/capabilities.py create mode 100644 src/govoplan_tenancy/backend/db/__init__.py create mode 100644 src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc create mode 100644 src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc create mode 100644 src/govoplan_tenancy/backend/db/models.py create mode 100644 src/govoplan_tenancy/backend/manifest.py create mode 100644 src/govoplan_tenancy/py.typed diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1edbc30 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/tenancy +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..d63f1c0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/tenancy + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f1ee238 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/tenancy +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..2fab7ca --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/tenancy +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..5e0fe93 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/tenancy +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a8f383 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# GovOPlaN Tenancy + +`govoplan-tenancy` owns the live `tenants` table, tenant lifecycle, and tenant +settings API route contributions during the GovOPlaN module split. + +`govoplan-access` depends on this module, and core's registry inserts tenancy +before access for authenticated platform composition. The historical `tenants` +table name is intentionally preserved for migration compatibility. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5a192cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-tenancy" +version = "0.1.5" +description = "GovOPlaN tenancy platform module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.5", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_tenancy = ["py.typed"] + +[project.entry-points."govoplan.modules"] +tenancy = "govoplan_tenancy.backend.manifest:get_manifest" diff --git a/src/govoplan_tenancy.egg-info/PKG-INFO b/src/govoplan_tenancy.egg-info/PKG-INFO new file mode 100644 index 0000000..8bfe04a --- /dev/null +++ b/src/govoplan_tenancy.egg-info/PKG-INFO @@ -0,0 +1,17 @@ +Metadata-Version: 2.4 +Name: govoplan-tenancy +Version: 0.1.4 +Summary: GovOPlaN tenancy platform module. +Author: GovOPlaN +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +Requires-Dist: govoplan-core>=0.1.4 + +# GovOPlaN Tenancy + +`govoplan-tenancy` owns the live `tenants` table, tenant lifecycle, and tenant +settings API route contributions during the GovOPlaN module split. + +`govoplan-access` depends on this module, and core's registry inserts tenancy +before access for authenticated platform composition. The historical `tenants` +table name is intentionally preserved for migration compatibility. diff --git a/src/govoplan_tenancy.egg-info/SOURCES.txt b/src/govoplan_tenancy.egg-info/SOURCES.txt new file mode 100644 index 0000000..7fff275 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/SOURCES.txt @@ -0,0 +1,19 @@ +README.md +pyproject.toml +src/govoplan_tenancy/__init__.py +src/govoplan_tenancy/py.typed +src/govoplan_tenancy.egg-info/PKG-INFO +src/govoplan_tenancy.egg-info/SOURCES.txt +src/govoplan_tenancy.egg-info/dependency_links.txt +src/govoplan_tenancy.egg-info/entry_points.txt +src/govoplan_tenancy.egg-info/requires.txt +src/govoplan_tenancy.egg-info/top_level.txt +src/govoplan_tenancy/backend/__init__.py +src/govoplan_tenancy/backend/capabilities.py +src/govoplan_tenancy/backend/manifest.py +src/govoplan_tenancy/backend/api/__init__.py +src/govoplan_tenancy/backend/api/v1/__init__.py +src/govoplan_tenancy/backend/api/v1/routes.py +src/govoplan_tenancy/backend/api/v1/schemas.py +src/govoplan_tenancy/backend/db/__init__.py +src/govoplan_tenancy/backend/db/models.py \ No newline at end of file diff --git a/src/govoplan_tenancy.egg-info/dependency_links.txt b/src/govoplan_tenancy.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_tenancy.egg-info/entry_points.txt b/src/govoplan_tenancy.egg-info/entry_points.txt new file mode 100644 index 0000000..71ba448 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[govoplan.modules] +tenancy = govoplan_tenancy.backend.manifest:get_manifest diff --git a/src/govoplan_tenancy.egg-info/requires.txt b/src/govoplan_tenancy.egg-info/requires.txt new file mode 100644 index 0000000..5182e50 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/requires.txt @@ -0,0 +1 @@ +govoplan-core>=0.1.4 diff --git a/src/govoplan_tenancy.egg-info/top_level.txt b/src/govoplan_tenancy.egg-info/top_level.txt new file mode 100644 index 0000000..5a59e6b --- /dev/null +++ b/src/govoplan_tenancy.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_tenancy diff --git a/src/govoplan_tenancy/__init__.py b/src/govoplan_tenancy/__init__.py new file mode 100644 index 0000000..53e3afd --- /dev/null +++ b/src/govoplan_tenancy/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN tenancy module.""" + diff --git a/src/govoplan_tenancy/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0aae74d9c83d392beb9280b014dbd18c2d09f3fa GIT binary patch literal 199 zcmX@j%ge<81V1{xvlM{zV-N=h7@>^M96-i&h7^VC%vZ!yRfu>d&$ D%pNv) literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4772670687a227b27ba4e34e7c63600deba0c64c GIT binary patch literal 199 zcmey&%ge<81V1{xvlM{zV-N=h7@>^M96-iYhG2#whIB?vrYZ^d{4)Q5oJ2o`lGMD! zyyQxS-29Z%oK!tOO~za7@$o77$?@?k89sxw-?G=w%`4G&aSUC%vZ!yRfu>d&$ D(hD|I literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/__init__.py b/src/govoplan_tenancy/backend/__init__.py new file mode 100644 index 0000000..d81dd8d --- /dev/null +++ b/src/govoplan_tenancy/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN tenancy module.""" + diff --git a/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cc2b5a22f33fd04417fad7348c06c0da4b21f657 GIT binary patch literal 235 zcmX@j%ge<81V1{xvuuF$V-N=h7@>^M96-i&h7^VOV literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d49e1fe419e62ad44f80f39bbeaaa4fd32f11165 GIT binary patch literal 235 zcmey&%ge<81V1{xvuuF$V-N=h7@>^M96-iYhG2#whIB?vrYaMs#N_PMycC7Zypq)P zqQsKS{5*xU{33;tj8p~p{4)Q5oJ2o`lGMD!yyQxS-29Z%oK!tOO~za7@$o77$?@?k z89sxIzU8i;n^&Um;uzwnpPpHwpPpZqUyzfSrwi4pUtE+77mSAqCP6IMkB`sH%PfhH k*DI*J#bE=n#jc1AXfDXz#UM9-U}j`wyv1Nu!~*010F?Vf`Tzg` literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36a4539afa7a06b52417df3b7abb5ee743c70bbb GIT binary patch literal 1852 zcmZux%}*Og6o30^uRox~5CWmW5OBL`YZ?d#R1!6pRx%B(yz$AXtMz(qZMs>LnKdd> zr65(Rf_gxeic0T2B=`OS?SD{|NLZtA>LJRF3aUss_04|pQJ7u5c{B5yH}l^6y*bS1 zGl1=t^u>ekC4j&AAZbdZ2!|`EY=Z%Yumlx>2t}@oOJYSLQbi_mMIlN!CoQR!6iJ1; zyp*nJL_=LOlu>vflgt&E1VbGKBekN;<7vE+Y&)%IUUO9!Sl06WPGALY$EUi)a$gbG zx7}6Ct2!;F-FJM;54hTwsI=p)IRtm++D>3Lt-xwn)WNYDA#8zLj#~+&*FEjw`69F}(dNYiB| zx=kkg)-u| z|D$llUZ0`FKAtp_$%bWrL(8k{EE^#LlQ=C>K%?A6dI!Y@yvz>v zvIAQSn+v^sVXMAbPr9oUdsjce|H-%iXi82!07X#`(lEPtGCV{JU~`eQIQqgNkJvV> z01*rzq9G8;Pz^Dpk8Gq2Dby50CTd&J(`>+aqo4-tPltO$ zW53jYs{eNR>B#e;xhJ3XZhX9ZWAW!tcGg}LN`FkfQJ}B1m!HP}EyZxaucf=1^rx1& zJDfU`8XovCHTO~3hMG{t{{M}aa2v4fE=beGJCcx30U1GSx=7x|j$DiMG3=POn!GHO z=Pn6OpWP+I@s9&kJRhL5h+*7JODHy=mmS>8Ufbn=saF{8^%u4tZ$92x-y5IZ9iM&P zKlfL9jtnC787%8^cqJy$fH0LfL6`V%-$|IzhtdA;a4e!Er|eZ$)k zo;@~_JV>V9Ax=3ImTe=cBPYWrju#fYau(Talw;~gg)n)%98%d{FUOBq8Ic$f$D=3Z z5xH{KJ(gaK0(Xbp#Pg72K&Me0NP-~j!{jTtw+r|7;pRTfAE~OK9$nIeTRVOK0(PPA F{{bY`m)ZaT literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9f50d50ead3616e69b64ba1b25e521b7416d482 GIT binary patch literal 1578 zcmZ`&O>Y}T7@l3Pz3Xo$P7VpkwL)8J0X98wKq?V#h)Si}tzruaUsmJYiM3%r+?jEN zQG#E==!<->Y_HlyICE`Ws+Io}!I~0gvF`^Qb0_c_ zxLZ5T2hH1e@7?WwX?Azp&Gt^Tcg?-s&i#8|?CwETbx0_47$-hAIqti}9QlsVICfog zFv8TXCBl5m8Bp1`Gb9$cfjx4`PT+I$H4h65w}pLYpD+$RwcD5x*i&9$6PH;^d@I<+ z%@YVI^9mTp=v&YPlmJzZKou1|Qcj}c4yq;ERFp%fmCi%AOML9}J;DO_kkFbMUK<35 z!O+FN3FxtJjq81EeMNk`Zs8&BJFdeW!e~ykP($5X;FUfTeXG@%cN(708=p11&Bnmt zjkw_lQNsqK)-o8M(uhuJc+rjP!*N(zWVuNre6$27h|y##uuRa@D9*Gy&$4g-maUv= zl{o~_>xo%|%w=L8DHrHS(O|?S2iIfBF}fuOc<9s*Z`Nfcqh)AXQ)vMX82FYPIOverl zk8o^bjzPbqi~Gc_>7fb<&Bq=Ibr6xksD}9qhld3(9yXJSY1_CokLHjDhmH+@4%e1C zk0b1$w*?57pO`#3ZQ|m`h%XaBr_u&sMl9m@NZxHhNNzy58E5D<_}9DeW8(Eq(4X1z zuTs}n!A5otg!d(wDrnx_Y05MGpAW*om6w3-Ismldh7GaEPB42$?KH^h8 zMMjgNZ1as}jHaM~3OEu(xPGzJ?!$O6LerwnTg%+#=VSM&nB1@sCCtS;mLn~PgKmg) zN~+K4JK{J&61^%GF(Q)2m&UCH`fb{Pn9zbfgylS;D9Q|N{fj=Iq1Ioh^8|Hf==uzO zI!|fJm3cu^R-YG8ZgZ-ars>sbt~_0>{!y+z{`d#}^U;q-Ps^=m#*N37-)q}XYTLg! kPmKrX3H3_q+(7B~&fdQT&SvUGIhRu>#*2!gtVu}!1JF9HQvd(} literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97c0deb8a6bbd97517890963cc19c24610352a5b GIT binary patch literal 1555 zcmZ`&-EJF26rQ!$-gW$&IJrnbu4U4K1=y`rE+U19G>V{h>{i}@Kv-Ih*Ar{Yde=R( zjxbUszeL`lNIU>YNbmq$G~xoY!h#BM!Ck79d(Mn^U8R(fcIN!e+3%e1%w;yaLU4Wh z&C!GJRYLw0O0+TyLAm@1g)?FhLurzR0!mrPv1Y6hhq#pEO|_ALgp}20vY~<2&_Qpc zASG>y=1LI@sjFhorq`QT#Cgg6qQ;7h*IE~Cs ztedEiZmzTa!Wv0_7m zS~=LizklcM(cRyI6F;JH5g(hqVAwMAX~gmE8ps&^=7cqWHlpE=x8qFV5b z;TI=Fxh$b@M)r{h>^?H#DrNGqQX%i5U94?VjkiIBW2RN(p>xb!>iQkVJ?E4`H5ObS zc&FaTp{|MOscTJYJ!*Zy+zfdQf&!)$GsstQo06HHpQ|4}O~3wgy7WXXU80e^x*O~0 z`ENI_^dJe|*q4Rhh#*JkmW9)iT|2#1laU;Xn6w&$93D~*tA;!pf}AO%%oA$)9!y{Z zjRG~#;ub=G|Bv+Qv<1Q(XkmK-O%h>EzDnGz-KyQj)~1kW90=3y2l|ltv`>ACq$M2M zV@_2IV(3s^I%I){%yFF0AbaKKL2ejLn90S6ZN4;w<_Nq~yN{QH^(DEJ5Id4=A;RSn z<0eN;6n;iNUplazN*jUc5s6co4Yw)zEnS@6lfmt%qr!p3 zCxQ86a6uR9MzCUgob5Mo9{0e{APx<~hBt+QO|Aui$ zVgLv@5=5|ZHMHNu@!$+nBF$?{-^*{N-vcqZK`wNdi^nTRS`G(n3U^Yn@53A7ccMwK zEjBSCi#F=J3-o(XN1Nb+Kg8`Kt|-c!RQ@6#%}MKbVw@9WPHxP}2hWqLa_xCeRo0&6 zNM>uM6=tcmS*AE!EB{(7Ki>J)|KZVhkIsv&r}|ruOTSe2&Z~Pr+UNTHi+Jo>@>P{wB#AY&>+I)f&o-%5reCLr%KNa~iier{fgzKdgs zqkej3iGF&1S$;uIVxDeEYF=Vqa;1K8Q8HXG9wwNSn4F!Om!h9okf|RZpP83g5+AQu iP>P{wB#AY&>+I)f&o-%5reCLr%KNa~iKer{fgzKdgs zqkej3iGF&1S$;uIVxDeEYF=Vqa;1K8Q8HXG9wwNSn4F!Om!h9okf~p0s2?AnnU`4- lAFo$Xd5gm)H$SB`C)KWq6=*!j`eG2{BQql-V-Yiu1pq{{Eb#yU literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b58cf8b1b3ad12ca83cf63bd5e63d78b4c7671b GIT binary patch literal 13367 zcmcgzU2q%Mb>79_f&>Zh2O>lQB*7n%gv3AnOSCMB;-4r{q)gFL1W86A?2-bF1(@BX zB+{UV%y?pZn$~uwGg79`M4o0EdBjfi(1$!aooVYNFIa)fqC51&ojkO8qb;RroR^++ zcNYMmm2CH+SH#`B_ntlX-gECg=ey@#{kpQUoPzMx(8T;dhA8TH_+kV`OVCe#V5O)# z6icymg5v2gO=H@Uu<+KfmA8d$ygh8^9bpH_wcb|nIQW4MuT3ODi1;bxLokqGiF;TA|&vhGAH-xhA;+r#bruJA6tBizAvhCBJL za2Fp6hxpy$-85yPI4kR6tLEtS>IW9B@8LacCtLHDE!@p^v9<8*VMDAJp1tgDb_YEB zxIObxLtup`TgUBM_ZeJ(vyJmVptW**gI^81)5tNNvz9Un|FL`6`nT*4bavsr w< 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 literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a546619be2ff52de24c93b29177be68a86b30b39 GIT binary patch literal 13414 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 Zo({`D!@2NkXBGK_z5Ksn?gzpv{{_CvswV&d literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a757006d2242109aa32804cd11199ce6cba24941 GIT binary patch literal 4229 zcmb7HO>7&-72YL>f6HI}Su$-|66HvyY=?E6w6?TKVH#FR zBv$<%-vyy@dX z!b2T+nD7wc5yVx6ht(*Ij-B+1!}x@nyy@o=>X&L+3X0lyQ|3`{5K{-5qhsJ0rVce* zacZ4uwi47D?i!y|M_|NR>XQcTT$eqio(FW4>=!^AJJH5To8WzAC7Th+$A+!j?rdqz zv=>}s?Fcw33BTiV;WJA|;3ny)1mcxXD9}Z-47==p1G@f0OJ|N%Eg$^{$1z)9GnU^f zFns(E5Ydw16uG&MCo6o0eKd%(`9-K?tm;+E>2#Q1#(yU5Rw~SY^NVbqw zy$ENy@itL{qy8GEzcLQ2cp}1%;ku?>cHTx;Diy2s(gEGFD&K3xs^eDHv@)+$*B#M+ z3gi{ba74nx^mSAF&e(7^lkNF&z9(YPSY0)WuKCQ+%9UpZ2WJ%x!O$oorlY~Sz_!`3 zF4K0Oh+wf&wOxlZ+?P`12GF^r>8?qu?XUrE`15!N*HnWhOv)7C1bs)c$?)1Fp! zt7%DU+N#MN*D`Ixu7DYUj_aan91o2Tp%_MS7R3b+g5iF62HO`=e1u{O1+sw8fDl8P z=HSgM!kS!eu2fyafjO^&?HK-&c#vDO-Q2y|$FsSz>E^%>m32$EuewN`;zrKl#S>6# zfh&6PJHsyJz_qz&*K$tr8)Hp(K3?D8F_@DCuG;w%h##eTIQsnlVQOf%@1?V!y1sSq z#bk|L{~w@!A+xo(ovyLWVS4N(`>F2@-%sbambd3>EPpt5@%7{Vv0Gd7FYFq-RiD1R z$A)*#)!60w)XW|`vlFecnR;dlx-Zt))ZvwDZ!Yd%xxdFoc5*d#zn&a;{_t=#^ZL{M z(HmP|yjZTW8}-ykjg5djU?cpq*1AuHlxCcjkH^9M?vSD~9w-O0LDBDBxFyqZ9~sU% zggfIX4MR7FEP%f5x{gmoh#U%3!t+>u733eoljl(U3dJ=PpP={@1@`h*`fJo~v;@Aw z34;J%$1?{k<8?xN@9m)FJ=?)p$taRQN`i#LREm&lmX_X)W^+G{j1#1MJ$MYeCz|hTgsBS)8!f#_ym31-{Zb0|GXl zf)lPs6VLPYvB|Ba?U@5M>A3+r_IATKSl;7?)~Sy7+U}_{#_PA4?l|Mqu7Ob2^hooh z`R6d8`#9$iLxHokjx%xTfL-$Tfu>XY!1x0{`FIy(_dW^?(wtBALplnmtPoTKbfBmO zNrMUls)nHICAknJIczCpi$Y?L950HCqX4^*38d zYI!Mekf+L_?2s7n{5VGe^O5niYB=uEC0M%%u2%SpS%RbxFn(~kPBd!Aye4~66(k%) z+Y6vPFBultS76$ad7LnzoUzGYOWFg`}SOnTY zzXXwj0ezCtqG}0jacX(=VI1lyuzB=hJV~uKeOQ}5Ur~;2(T4)z0&IonK`iqWsu&UY zc@!9RI@0DjY+}&Z9BS7BSZg$Ei+A%Tiz7ck9{vD3QM#r~O@X^joL+RHX?t{3 z?aq8CbJj;EwiaIe{(wz*1j04+CJ++2n?UU{n=~(pU^=1bL#2061uCf#pp;j9dnAIQ zybw~O5M#pdeZtf@Q6|A71v3lcsCqT-wveOo>}eT^1jcD{fDuaS3)$b0?R#H8*;*!TGi z<7dYeAjf@zSTCP_g$5M(W@i+{DSkr%c)zx=W+|5eKTLmK^?H1<#FZcVz|2qxsS+h#+8MX{1=nXz=r?; literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py new file mode 100644 index 0000000..caafd30 --- /dev/null +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from govoplan_core.admin.common import AdminValidationError, slugify +from govoplan_core.admin.settings import get_system_settings +from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_scope +from govoplan_core.audit.logging import audit_event +from govoplan_core.core.access import CAPABILITY_ACCESS_TENANT_PROVISIONER, TenantAccessProvisioner +from govoplan_core.core.runtime import get_registry +from govoplan_core.db.session import get_session +from govoplan_core.tenancy.service import ( + assert_tenant_governance_override_allowed, + effective_tenant_governance, + tenant_counts, +) +from govoplan_tenancy.backend.db.models import Tenant + +from .schemas import ( + TenantAdminItem, + TenantCreateRequest, + TenantListResponse, + TenantOwnerCandidate, + TenantOwnerCandidateListResponse, + TenantSettingsItem, + TenantSettingsUpdateRequest, + TenantUpdateRequest, +) + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _tenant_access_provisioner() -> TenantAccessProvisioner: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access tenant provisioner capability is not configured") + capability = registry.require_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER) + if not isinstance(capability, TenantAccessProvisioner): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access tenant provisioner capability is invalid") + return capability + + +def _require_permission(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") + + +def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem: + governance = effective_tenant_governance(session, tenant) + return TenantAdminItem( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + description=tenant.description, + default_locale=tenant.default_locale, + settings=tenant.settings or {}, + allow_custom_groups=tenant.allow_custom_groups, + allow_custom_roles=tenant.allow_custom_roles, + allow_api_keys=tenant.allow_api_keys, + effective_governance={ + "allow_custom_groups": governance.allow_custom_groups, + "allow_custom_roles": governance.allow_custom_roles, + "allow_api_keys": governance.allow_api_keys, + }, + is_active=tenant.is_active, + counts=tenant_counts(session, tenant.id), + created_at=tenant.created_at, + updated_at=tenant.updated_at, + ) + + +def _tenant_settings_item(tenant: Tenant) -> TenantSettingsItem: + return TenantSettingsItem( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + default_locale=tenant.default_locale, + settings=tenant.settings or {}, + ) + + +@router.get("/tenants", response_model=TenantListResponse) +def list_tenants( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:read")), +): + tenants = session.query(Tenant).order_by(Tenant.name.asc()).all() + return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants]) + + +@router.get("/tenants/owner-candidates", response_model=TenantOwnerCandidateListResponse) +def list_tenant_owner_candidates( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:create")), +): + del principal + accounts = _tenant_access_provisioner().tenant_owner_candidates(session) + return TenantOwnerCandidateListResponse( + accounts=[ + TenantOwnerCandidate(account_id=account.account_id, email=account.email, display_name=account.display_name) + for account in accounts + ] + ) + + +@router.post("/tenants", response_model=TenantAdminItem, status_code=status.HTTP_201_CREATED) +def create_tenant( + payload: TenantCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("system:tenants:create")), +): + tenant_slug = slugify(payload.slug) + if session.query(Tenant).filter(Tenant.slug == tenant_slug).count(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A tenant with this slug already exists") + system_defaults = get_system_settings(session) + try: + assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups) + assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) + assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) + except AdminValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + tenant = Tenant( + slug=tenant_slug, + name=payload.name.strip(), + description=payload.description.strip() if payload.description else None, + default_locale=payload.default_locale.strip() or system_defaults.default_locale, + settings=payload.settings, + allow_custom_groups=payload.allow_custom_groups, + allow_custom_roles=payload.allow_custom_roles, + allow_api_keys=payload.allow_api_keys, + is_active=True, + ) + session.add(tenant) + session.flush() + owner_account_id = payload.owner_account_id or principal.account_id + try: + _tenant_access_provisioner().ensure_tenant_owner_membership( + session, + tenant=tenant, + owner_account_id=owner_account_id, + ) + except AdminValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + audit_event( + session, + tenant_id=tenant.id, + user_id=principal.user.id, + action="tenant.created", + object_type="tenant", + object_id=tenant.id, + scope="system", + details={"slug": tenant.slug, "name": tenant.name, "creator_account_id": principal.account_id, "owner_account_id": owner_account_id}, + ) + session.commit() + return _tenant_item(session, tenant) + + +@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem) +def update_tenant( + tenant_id: str, + payload: TenantUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"} + if payload.model_fields_set.intersection(non_status_fields): + _require_permission(principal, "system:tenants:update") + if payload.is_active is not None: + _require_permission(principal, "system:tenants:suspend") + tenant = session.get(Tenant, tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + if payload.name is not None: + tenant.name = payload.name.strip() + if "description" in payload.model_fields_set: + tenant.description = payload.description.strip() if payload.description and payload.description.strip() else None + if payload.default_locale is not None: + tenant.default_locale = payload.default_locale.strip() or "en" + if payload.settings is not None: + tenant.settings = payload.settings + try: + if "allow_custom_groups" in payload.model_fields_set: + assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups) + tenant.allow_custom_groups = payload.allow_custom_groups + if "allow_custom_roles" in payload.model_fields_set: + assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles) + tenant.allow_custom_roles = payload.allow_custom_roles + if "allow_api_keys" in payload.model_fields_set: + assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys) + tenant.allow_api_keys = payload.allow_api_keys + except AdminValidationError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + if payload.is_active is not None: + if not payload.is_active and tenant.id == principal.tenant_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Switch to another tenant before suspending the active tenant.", + ) + tenant.is_active = payload.is_active + session.add(tenant) + audit_event( + session, + tenant_id=tenant.id, + user_id=principal.user.id, + action="tenant.updated", + scope="system", + object_type="tenant", + object_id=tenant.id, + details=payload.model_dump(exclude_unset=True), + ) + session.commit() + return _tenant_item(session, tenant) + + +@router.get("/tenant/settings", response_model=TenantSettingsItem) +def get_tenant_settings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:settings:read")), +): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + return _tenant_settings_item(tenant) + + +@router.patch("/tenant/settings", response_model=TenantSettingsItem) +def update_tenant_settings( + payload: TenantSettingsUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:settings:write")), +): + tenant = session.get(Tenant, principal.tenant_id) + if tenant is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + tenant.default_locale = payload.default_locale.strip() or "en" + session.add(tenant) + audit_event( + session, + tenant_id=tenant.id, + user_id=principal.user.id, + action="tenant.settings.updated", + object_type="tenant", + object_id=tenant.id, + details={"default_locale": tenant.default_locale}, + ) + session.commit() + return _tenant_settings_item(tenant) diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py new file mode 100644 index 0000000..6566a69 --- /dev/null +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class TenantAdminItem(BaseModel): + id: str + slug: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + default_locale: str = Field(default="en", min_length=1, max_length=20) + settings: dict[str, Any] = Field(default_factory=dict) + allow_custom_groups: bool | None = None + allow_custom_roles: bool | None = None + allow_api_keys: bool | None = None + effective_governance: dict[str, bool] = Field(default_factory=dict) + is_active: bool + counts: dict[str, int] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class TenantListResponse(BaseModel): + tenants: list[TenantAdminItem] + + +class TenantOwnerCandidate(BaseModel): + account_id: str + email: str + display_name: str | None = None + + +class TenantOwnerCandidateListResponse(BaseModel): + accounts: list[TenantOwnerCandidate] + + +class TenantCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + slug: str + name: str + owner_account_id: str | None = None + description: str | None = None + default_locale: str = "en" + settings: dict[str, Any] = Field(default_factory=dict) + allow_custom_groups: bool | None = None + allow_custom_roles: bool | None = None + allow_api_keys: bool | None = None + + +class TenantUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + default_locale: str | None = Field(default=None, min_length=1, max_length=20) + settings: dict[str, Any] | None = None + allow_custom_groups: bool | None = None + allow_custom_roles: bool | None = None + allow_api_keys: bool | None = None + is_active: bool | None = None + + +class TenantSettingsItem(BaseModel): + id: str + slug: str + name: str + default_locale: str = Field(default="en", min_length=1, max_length=20) + settings: dict[str, Any] = Field(default_factory=dict) + + +class TenantSettingsUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + default_locale: str = Field(min_length=1, max_length=20) diff --git a/src/govoplan_tenancy/backend/capabilities.py b/src/govoplan_tenancy/backend/capabilities.py new file mode 100644 index 0000000..2a308d9 --- /dev/null +++ b/src/govoplan_tenancy/backend/capabilities.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from govoplan_core.core.access import PrincipalRef, TenantRef, TenantResolver +from govoplan_core.db.session import get_database +from govoplan_tenancy.backend.db.models import Tenant + + +def _tenant_ref(tenant: Tenant) -> TenantRef: + return TenantRef( + id=tenant.id, + name=tenant.name, + slug=tenant.slug, + status="active" if tenant.is_active else "inactive", + ) + + +class SqlTenantResolver(TenantResolver): + def get_tenant(self, tenant_id: str) -> TenantRef | None: + with get_database().session() as session: + tenant = session.get(Tenant, tenant_id) + return _tenant_ref(tenant) if tenant is not None else None + + def current_tenant(self, principal: PrincipalRef) -> TenantRef | None: + if principal.tenant_id is None: + return None + return self.get_tenant(principal.tenant_id) diff --git a/src/govoplan_tenancy/backend/db/__init__.py b/src/govoplan_tenancy/backend/db/__init__.py new file mode 100644 index 0000000..8f70923 --- /dev/null +++ b/src/govoplan_tenancy/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Tenancy-owned SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14d011e669e53cbfac9ece60361a1e9ff4096b22 GIT binary patch literal 231 zcmX@j%ge<81W%ZJvP^;WV-N=h7@>^M96-i&h7^V3o0 zpPN^r@8THZsGpu$qMx2$mS2#Qn5SC;F<8I2C>bso4--sEOwLZtOVLkB(vOeN%*!l^ okJl@x{Ka7dw5T*E)vkyQXf(*p#US^7U}j`wyv1N#!~*010KP>*y#N3J literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e76fe4a694fd938b315f5e509a9b9e217240106 GIT binary patch literal 231 zcmey&%ge<81W%ZJvP^;WV-N=h7@>^M96-iYhG2#whIB?vrYhZ#)V##Jbso4--sEOwLZtOVLkB(vOeN%*!l^ okJl@xyv1Pyw5T*E)vkyQXf(*p#US^7U}j`wyv1N#!~*010LC&wm;e9( literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e876a04d7455fb8662daac1f5305ffe090aea04b GIT binary patch literal 2115 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 MZatj*G2mRy|IzpF literal 0 HcmV?d00001 diff --git a/src/govoplan_tenancy/backend/db/models.py b/src/govoplan_tenancy/backend/db/models.py new file mode 100644 index 0000000..77c2dfd --- /dev/null +++ b/src/govoplan_tenancy/backend/db/models.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import Boolean, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class Tenant(Base, TimestampMixin): + __tablename__ = "tenants" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + users: Mapped[list["User"]] = relationship("User", back_populates="tenant", cascade="all, delete-orphan") + + +__all__ = ["Tenant", "new_uuid"] diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py new file mode 100644 index 0000000..a6da9fc --- /dev/null +++ b/src/govoplan_tenancy/backend/manifest.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from govoplan_core.core.access import CAPABILITY_TENANCY_TENANT_RESOLVER +from govoplan_core.core.module_guards import persistent_table_uninstall_guard +from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.db.base import Base +from govoplan_tenancy.backend.db import models as tenancy_models # noqa: F401 - populate Tenancy ORM metadata + + +def _tenant_resolver(context: ModuleContext): + del context + from govoplan_tenancy.backend.capabilities import SqlTenantResolver + + return SqlTenantResolver() + + +def _route_factory(context: ModuleContext): + del context + from govoplan_tenancy.backend.api.v1.routes import router + + return router + + +manifest = ModuleManifest( + id="tenancy", + name="Tenancy", + version="0.1.5", + route_factory=_route_factory, + migration_spec=MigrationSpec(module_id="tenancy", metadata=Base.metadata), + uninstall_guard_providers=( + persistent_table_uninstall_guard(tenancy_models.Tenant, label="Tenancy"), + ), + capability_factories={ + CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver, + }, +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_tenancy/py.typed b/src/govoplan_tenancy/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tenancy/py.typed @@ -0,0 +1 @@ +