Release v0.1.5
This commit is contained in:
35
src/govoplan_admin.egg-info/PKG-INFO
Normal file
35
src/govoplan_admin.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,35 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: govoplan-admin
|
||||
Version: 0.1.4
|
||||
Summary: GovOPlaN generic administration module.
|
||||
Author: GovOPlaN
|
||||
Requires-Python: >=3.12
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: govoplan-core>=0.1.4
|
||||
Requires-Dist: govoplan-access>=0.1.4
|
||||
|
||||
# GovOPlaN Admin
|
||||
|
||||
`govoplan-admin` owns generic system administration API and WebUI contributions
|
||||
during the GovOPlaN module split.
|
||||
|
||||
This repository owns the live `governance_templates` and
|
||||
`governance_template_assignments` tables while preserving their historical
|
||||
table names. It contributes the stable
|
||||
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
||||
CRUD plus materialization into access-owned tenant groups and roles.
|
||||
|
||||
## WebUI Package
|
||||
|
||||
The repository root and `webui/` directory both expose the package
|
||||
`@govoplan/admin-webui`. The package does not contribute the `/admin` route
|
||||
itself; `govoplan-access` owns the route shell. Instead, admin contributes
|
||||
section entries through core's `admin.sections` UI capability:
|
||||
|
||||
- overview
|
||||
- system settings
|
||||
- governance template tenant roles
|
||||
- governance template groups
|
||||
|
||||
The route shell in access collects those sections at runtime, applies their
|
||||
scope requirements, and renders them without importing admin package internals.
|
||||
19
src/govoplan_admin.egg-info/SOURCES.txt
Normal file
19
src/govoplan_admin.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/govoplan_admin/__init__.py
|
||||
src/govoplan_admin/py.typed
|
||||
src/govoplan_admin.egg-info/PKG-INFO
|
||||
src/govoplan_admin.egg-info/SOURCES.txt
|
||||
src/govoplan_admin.egg-info/dependency_links.txt
|
||||
src/govoplan_admin.egg-info/entry_points.txt
|
||||
src/govoplan_admin.egg-info/requires.txt
|
||||
src/govoplan_admin.egg-info/top_level.txt
|
||||
src/govoplan_admin/backend/__init__.py
|
||||
src/govoplan_admin/backend/governance.py
|
||||
src/govoplan_admin/backend/manifest.py
|
||||
src/govoplan_admin/backend/api/__init__.py
|
||||
src/govoplan_admin/backend/api/v1/__init__.py
|
||||
src/govoplan_admin/backend/api/v1/routes.py
|
||||
src/govoplan_admin/backend/api/v1/schemas.py
|
||||
src/govoplan_admin/backend/db/__init__.py
|
||||
src/govoplan_admin/backend/db/models.py
|
||||
1
src/govoplan_admin.egg-info/dependency_links.txt
Normal file
1
src/govoplan_admin.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2
src/govoplan_admin.egg-info/entry_points.txt
Normal file
2
src/govoplan_admin.egg-info/entry_points.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
[govoplan.modules]
|
||||
admin = govoplan_admin.backend.manifest:get_manifest
|
||||
2
src/govoplan_admin.egg-info/requires.txt
Normal file
2
src/govoplan_admin.egg-info/requires.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
govoplan-core>=0.1.4
|
||||
govoplan-access>=0.1.4
|
||||
1
src/govoplan_admin.egg-info/top_level.txt
Normal file
1
src/govoplan_admin.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
govoplan_admin
|
||||
2
src/govoplan_admin/__init__.py
Normal file
2
src/govoplan_admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""GovOPlaN generic administration module."""
|
||||
|
||||
BIN
src/govoplan_admin/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
2
src/govoplan_admin/backend/__init__.py
Normal file
2
src/govoplan_admin/backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Backend integration for the GovOPlaN admin module."""
|
||||
|
||||
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/backend/__pycache__/manifest.cpython-313.pyc
Normal file
Binary file not shown.
0
src/govoplan_admin/backend/api/__init__.py
Normal file
0
src/govoplan_admin/backend/api/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
0
src/govoplan_admin/backend/api/v1/__init__.py
Normal file
0
src/govoplan_admin/backend/api/v1/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
840
src/govoplan_admin/backend/api/v1/routes.py
Normal file
840
src/govoplan_admin/backend/api/v1/routes.py
Normal file
@@ -0,0 +1,840 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_admin.backend.governance import create_template, delete_template, update_template
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
|
||||
from govoplan_core.core.module_management import (
|
||||
PROTECTED_MODULES,
|
||||
ModuleInstallPlanItem,
|
||||
ModuleManagementError,
|
||||
configured_enabled_modules,
|
||||
module_install_plan_commands,
|
||||
module_dependents,
|
||||
module_uninstall_plan_item,
|
||||
plan_desired_enabled_modules,
|
||||
save_desired_enabled_modules,
|
||||
save_module_install_plan,
|
||||
saved_desired_enabled_modules,
|
||||
saved_module_install_plan,
|
||||
)
|
||||
from govoplan_core.core.module_installer import (
|
||||
ModuleInstallerError,
|
||||
cancel_module_installer_request,
|
||||
default_installer_runtime_dir,
|
||||
list_module_installer_runs,
|
||||
list_module_installer_requests,
|
||||
module_installer_daemon_status,
|
||||
module_install_preflight,
|
||||
module_installer_lock_status,
|
||||
queue_module_installer_request,
|
||||
read_module_installer_run,
|
||||
read_module_installer_request,
|
||||
retry_module_installer_request,
|
||||
)
|
||||
from govoplan_core.core.module_license import module_license_decision
|
||||
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
|
||||
from govoplan_core.core.lifecycle import ModuleLifecycleManager
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.privacy.retention import privacy_policy_from_settings, set_privacy_policy
|
||||
from govoplan_core.security.permissions import ALL_PERMISSIONS, effective_permission_count
|
||||
from govoplan_core.server.registry import available_module_manifests
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_core.tenancy.service import tenant_counts
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
from .schemas import (
|
||||
AdminOverviewResponse,
|
||||
GovernanceTemplateCreateRequest,
|
||||
GovernanceTemplateItem,
|
||||
GovernanceTemplateListResponse,
|
||||
GovernanceTemplateUpdateRequest,
|
||||
MaintenanceModeItem,
|
||||
ModuleCatalogItem,
|
||||
ModuleCatalogResponse,
|
||||
ModuleInstallPreflightResponse,
|
||||
ModuleInstallerLockStatus,
|
||||
ModuleInstallerDaemonStatus,
|
||||
ModuleInstallerRequestCreateRequest,
|
||||
ModuleInstallerRequestItem,
|
||||
ModuleInstallerRequestListResponse,
|
||||
ModuleInstallerRunListResponse,
|
||||
ModuleInstallerRunSummary,
|
||||
ModuleInstallPlanResponse,
|
||||
ModuleInstallPlanUpdateRequest,
|
||||
ModulePackageCatalogItem,
|
||||
ModulePackageCatalogResponse,
|
||||
ModuleStateUpdateRequest,
|
||||
PrivacyRetentionPolicyItem,
|
||||
SystemSettingsItem,
|
||||
SystemSettingsUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _access_administration() -> AccessAdministration:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is not configured")
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION)
|
||||
if not isinstance(capability, AccessAdministration):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is invalid")
|
||||
return capability
|
||||
|
||||
|
||||
def _request_registry(request: Request) -> PlatformRegistry:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured")
|
||||
return registry
|
||||
|
||||
|
||||
def _request_lifecycle(request: Request) -> ModuleLifecycleManager:
|
||||
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||
if not isinstance(lifecycle, ModuleLifecycleManager):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module lifecycle manager is not configured")
|
||||
return lifecycle
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, AdminValidationError):
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem:
|
||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||
GovernanceTemplateAssignment.template_id == item.id
|
||||
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
|
||||
return GovernanceTemplateItem(
|
||||
id=item.id,
|
||||
kind=item.kind,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
permissions=item.permissions or [],
|
||||
effective_permission_count=effective_permission_count(item.permissions or [], level="tenant") if item.kind == "role" else 0,
|
||||
is_active=item.is_active,
|
||||
assignments=[{"tenant_id": row.tenant_id, "mode": row.mode} for row in assignments],
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse:
|
||||
registry = _request_registry(request)
|
||||
current_enabled = [manifest.id for manifest in registry.manifests()]
|
||||
configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules))
|
||||
desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled))
|
||||
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||
available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True)
|
||||
dependents = module_dependents(available)
|
||||
current_set = set(current_enabled)
|
||||
desired_set = set(desired_enabled)
|
||||
protected_set = set(PROTECTED_MODULES)
|
||||
items: list[ModuleCatalogItem] = []
|
||||
for module_id, manifest in sorted(available.items(), key=lambda item: item[0]):
|
||||
migration = manifest.migration_spec
|
||||
frontend = manifest.frontend
|
||||
items.append(ModuleCatalogItem(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
installed=True,
|
||||
current_enabled=manifest.id in current_set,
|
||||
desired_enabled=manifest.id in desired_set,
|
||||
protected=manifest.id in protected_set,
|
||||
restart_required=(manifest.id in current_set) != (manifest.id in desired_set),
|
||||
dependencies=list(manifest.dependencies),
|
||||
optional_dependencies=list(manifest.optional_dependencies),
|
||||
dependents=list(dependents.get(manifest.id, ())),
|
||||
permissions_count=len(manifest.permissions),
|
||||
role_templates_count=len(manifest.role_templates),
|
||||
route_contributed=manifest.route_factory is not None,
|
||||
nav_count=len(manifest.nav_items),
|
||||
frontend_package=frontend.package_name if frontend else None,
|
||||
migration_module_id=migration.module_id if migration else None,
|
||||
migration_script_location=migration.script_location if migration else None,
|
||||
runtime_toggle_supported=True,
|
||||
install_uninstall_supported=manifest.id not in protected_set,
|
||||
))
|
||||
restart_required = current_set != desired_set
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
return ModuleCatalogResponse(
|
||||
modules=items,
|
||||
current_enabled=current_enabled,
|
||||
desired_enabled=desired_enabled,
|
||||
configured_enabled=configured_enabled,
|
||||
protected_modules=list(PROTECTED_MODULES),
|
||||
restart_required=restart_required,
|
||||
runtime_toggle_supported=True,
|
||||
install_uninstall_supported=True,
|
||||
install_plan_supported=True,
|
||||
package_mutation_supported=False,
|
||||
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||
notes=notes or [
|
||||
"Enable/disable changes are applied to the running server and saved as startup state.",
|
||||
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _module_install_plan_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleInstallPlanResponse:
|
||||
plan = saved_module_install_plan(session)
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
registry = _request_registry(request)
|
||||
current_enabled = [manifest.id for manifest in registry.manifests()]
|
||||
configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules))
|
||||
desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled))
|
||||
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||
available = dict(lifecycle.available_modules) if isinstance(lifecycle, ModuleLifecycleManager) else available_module_manifests(ignore_load_errors=True)
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
available=available,
|
||||
current_enabled=current_enabled,
|
||||
desired_enabled=desired_enabled,
|
||||
maintenance_mode=maintenance_mode.enabled,
|
||||
session=session,
|
||||
webui_root=_webui_root(),
|
||||
runtime_dir=default_installer_runtime_dir(core_settings.database_url),
|
||||
)
|
||||
default_notes = [
|
||||
"The running server does not install or remove packages inside the FastAPI request.",
|
||||
"Use govoplan-module-installer from an operator shell while maintenance mode is active.",
|
||||
]
|
||||
if not maintenance_mode.enabled:
|
||||
default_notes.append("Maintenance mode is currently off.")
|
||||
return ModuleInstallPlanResponse(
|
||||
items=[item.as_dict() for item in plan.items],
|
||||
updated_at=plan.updated_at,
|
||||
commands=list(module_install_plan_commands(plan)),
|
||||
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||
preflight=ModuleInstallPreflightResponse.model_validate(preflight.as_dict()),
|
||||
notes=notes or default_notes,
|
||||
)
|
||||
|
||||
|
||||
def _webui_root() -> Path | None:
|
||||
configured = os.getenv("GOVOPLAN_WEBUI_ROOT")
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
candidate = Path.cwd() / "webui"
|
||||
return candidate if candidate.exists() else None
|
||||
|
||||
|
||||
def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem):
|
||||
existing = saved_module_install_plan(session)
|
||||
retained = [
|
||||
current
|
||||
for current in existing.items
|
||||
if not (current.status == "planned" and current.module_id == item.module_id)
|
||||
]
|
||||
return save_module_install_plan(session, [*retained, item])
|
||||
|
||||
|
||||
def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
|
||||
result = validate_module_package_catalog()
|
||||
if not result.get("valid"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(result.get("error") or "Module package catalog is invalid."),
|
||||
)
|
||||
for raw_item in result.get("modules", []):
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
if raw_item.get("module_id") != module_id or raw_item.get("action") != "install":
|
||||
continue
|
||||
license_decision = module_license_decision(_catalog_license_features(raw_item))
|
||||
if not license_decision.get("allowed"):
|
||||
missing = license_decision.get("missing_features")
|
||||
missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"License does not allow installing {module_id}: {missing_text}.",
|
||||
)
|
||||
notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None
|
||||
if license_decision.get("reason") and license_decision.get("missing_features"):
|
||||
prefix = f"{notes}\n" if notes else ""
|
||||
notes = f"{prefix}License warning: {license_decision['reason']}"
|
||||
return ModuleInstallPlanItem(
|
||||
module_id=str(raw_item["module_id"]),
|
||||
action="install",
|
||||
python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None,
|
||||
python_ref=raw_item.get("python_ref") if isinstance(raw_item.get("python_ref"), str) else None,
|
||||
webui_package=raw_item.get("webui_package") if isinstance(raw_item.get("webui_package"), str) else None,
|
||||
webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None,
|
||||
notes=notes,
|
||||
), result
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install entry not found: {module_id}")
|
||||
|
||||
|
||||
def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
|
||||
value = raw_item.get("license_features")
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem:
|
||||
payload = dict(raw_item)
|
||||
decision = module_license_decision(_catalog_license_features(raw_item))
|
||||
payload["license_allowed"] = bool(decision.get("allowed"))
|
||||
payload["license_enforced"] = bool(decision.get("enforced"))
|
||||
missing = decision.get("missing_features")
|
||||
payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else []
|
||||
reason = decision.get("reason")
|
||||
payload["license_reason"] = reason if isinstance(reason, str) else None
|
||||
return ModulePackageCatalogItem.model_validate(payload)
|
||||
|
||||
|
||||
@router.get("/overview", response_model=AdminOverviewResponse)
|
||||
def admin_overview(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(
|
||||
"admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read",
|
||||
"admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
|
||||
"system:settings:read", "system:governance:read", "system:audit:read",
|
||||
)),
|
||||
):
|
||||
tenant = session.get(Tenant, principal.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
counts = tenant_counts(session, tenant.id)
|
||||
access_admin = _access_administration()
|
||||
capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)]
|
||||
return AdminOverviewResponse(
|
||||
active_tenant_id=tenant.id,
|
||||
active_tenant_name=tenant.name,
|
||||
tenant_count=session.query(Tenant).count() if has_scope(principal, "system:tenants:read") else None,
|
||||
system_account_count=access_admin.system_account_count(session) if has_scope(principal, "system:accounts:read") or has_scope(principal, "system:access:read") else None,
|
||||
system_group_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "group").count() if has_scope(principal, "system:governance:read") else None,
|
||||
system_role_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "role").count() if has_scope(principal, "system:governance:read") else None,
|
||||
user_count=counts["users"],
|
||||
active_user_count=counts["active_users"],
|
||||
group_count=counts["groups"],
|
||||
role_count=access_admin.role_count_for_tenant(session, tenant.id),
|
||||
active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id),
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/modules", response_model=ModuleCatalogResponse)
|
||||
def list_system_modules(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
return _module_catalog_response(session, request)
|
||||
|
||||
|
||||
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||
def read_module_install_plan(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
return _module_install_plan_response(session, request)
|
||||
|
||||
|
||||
@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse)
|
||||
def list_module_install_runs(
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
return ModuleInstallerRunListResponse(
|
||||
runs=[
|
||||
ModuleInstallerRunSummary.model_validate(item)
|
||||
for item in list_module_installer_runs(runtime_dir=runtime_dir)
|
||||
],
|
||||
lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/modules/install-runs/{run_id}")
|
||||
def read_module_install_run_detail(
|
||||
run_id: str,
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
try:
|
||||
return read_module_installer_run(runtime_dir=runtime_dir, run_id=run_id)
|
||||
except ModuleInstallerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse)
|
||||
def list_module_install_requests(
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
return ModuleInstallerRequestListResponse(
|
||||
requests=[
|
||||
ModuleInstallerRequestItem.model_validate(item)
|
||||
for item in list_module_installer_requests(runtime_dir=runtime_dir)
|
||||
],
|
||||
daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/modules/install-requests/{request_id}")
|
||||
def read_module_install_request_detail(
|
||||
request_id: str,
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
try:
|
||||
return read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)
|
||||
except ModuleInstallerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem)
|
||||
def create_module_install_request(
|
||||
payload: ModuleInstallerRequestCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if not maintenance_mode.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before queuing installer requests.")
|
||||
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Queuing installer requests requires maintenance access.")
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
request = queue_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
requested_by=principal.user.id,
|
||||
options=payload.options.model_dump(exclude_none=True),
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.install_request_queued",
|
||||
scope="system",
|
||||
object_type="module_install_request",
|
||||
object_id=str(request["request_id"]),
|
||||
details={"options": payload.options.model_dump(exclude_none=True)},
|
||||
)
|
||||
session.commit()
|
||||
return ModuleInstallerRequestItem.model_validate(request)
|
||||
|
||||
|
||||
@router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem)
|
||||
def cancel_module_install_request(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if not maintenance_mode.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.")
|
||||
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.")
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
try:
|
||||
request = cancel_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
request_id=request_id,
|
||||
cancelled_by=principal.user.id,
|
||||
)
|
||||
except ModuleInstallerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.install_request_cancelled",
|
||||
scope="system",
|
||||
object_type="module_install_request",
|
||||
object_id=request_id,
|
||||
details={},
|
||||
)
|
||||
session.commit()
|
||||
return ModuleInstallerRequestItem.model_validate(request)
|
||||
|
||||
|
||||
@router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem)
|
||||
def retry_module_install_request(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if not maintenance_mode.enabled:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.")
|
||||
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.")
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
try:
|
||||
request = retry_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
request_id=request_id,
|
||||
requested_by=principal.user.id,
|
||||
)
|
||||
except ModuleInstallerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.install_request_retried",
|
||||
scope="system",
|
||||
object_type="module_install_request",
|
||||
object_id=request_id,
|
||||
details={"new_request_id": request["request_id"]},
|
||||
)
|
||||
session.commit()
|
||||
return ModuleInstallerRequestItem.model_validate(request)
|
||||
|
||||
|
||||
@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse)
|
||||
def read_module_package_catalog(
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
result = validate_module_package_catalog()
|
||||
return ModulePackageCatalogResponse(
|
||||
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
|
||||
configured=bool(result["configured"]),
|
||||
valid=bool(result["valid"]),
|
||||
path=result["path"] if isinstance(result["path"], str) else None,
|
||||
source=result["source"] if isinstance(result.get("source"), str) else None,
|
||||
source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None,
|
||||
channel=result["channel"] if isinstance(result["channel"], str) else None,
|
||||
sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None,
|
||||
generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None,
|
||||
expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None,
|
||||
signed=bool(result["signed"]),
|
||||
trusted=bool(result["trusted"]),
|
||||
key_id=result["key_id"] if isinstance(result["key_id"], str) else None,
|
||||
warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [],
|
||||
error=result["error"] if isinstance(result["error"], str) else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/system/modules/install-plan/catalog/{module_id}", response_model=ModuleInstallPlanResponse)
|
||||
def plan_module_install_from_catalog(
|
||||
module_id: str,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
try:
|
||||
item, validation = _catalog_plan_item(module_id)
|
||||
plan = _upsert_install_plan_item(session, item)
|
||||
record_module_package_catalog_acceptance(validation)
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.install_plan_catalog_added",
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id=module_id,
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."])
|
||||
|
||||
|
||||
@router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse)
|
||||
def plan_module_uninstall(
|
||||
module_id: str,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
registry = _request_registry(request)
|
||||
lifecycle = _request_lifecycle(request)
|
||||
current_enabled = {manifest.id for manifest in registry.manifests()}
|
||||
configured_enabled = configured_enabled_modules(core_settings.enabled_modules)
|
||||
desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled))
|
||||
if module_id in PROTECTED_MODULES:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.")
|
||||
if module_id in current_enabled or module_id in desired_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.")
|
||||
try:
|
||||
item = module_uninstall_plan_item(module_id, lifecycle.available_modules)
|
||||
plan = _upsert_install_plan_item(session, item)
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.uninstall_plan_added",
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id=module_id,
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."])
|
||||
|
||||
|
||||
@router.put("/system/modules", response_model=ModuleCatalogResponse)
|
||||
def update_system_modules(
|
||||
request: Request,
|
||||
payload: ModuleStateUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
lifecycle = _request_lifecycle(request)
|
||||
available = lifecycle.available_modules
|
||||
try:
|
||||
plan = plan_desired_enabled_modules(payload.enabled_modules, available)
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
try:
|
||||
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
desired = save_desired_enabled_modules(session, result.enabled_modules)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.desired_state_updated",
|
||||
scope="system",
|
||||
object_type="module_state",
|
||||
object_id="global",
|
||||
details={
|
||||
"desired_enabled": list(desired),
|
||||
"added_dependencies": list(plan.added_dependencies),
|
||||
"activated": list(result.activated_modules),
|
||||
"deactivated": list(result.deactivated_modules),
|
||||
"mounted": list(result.mounted_modules),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
notes = ["Module state saved and applied to the running server."]
|
||||
if plan.added_dependencies:
|
||||
notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies))
|
||||
if result.activated_modules:
|
||||
notes.append("Activated: " + ", ".join(result.activated_modules))
|
||||
if result.deactivated_modules:
|
||||
notes.append("Deactivated: " + ", ".join(result.deactivated_modules))
|
||||
return _module_catalog_response(session, request, notes=notes)
|
||||
|
||||
|
||||
@router.put("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||
def update_module_install_plan(
|
||||
request: Request,
|
||||
payload: ModuleInstallPlanUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
try:
|
||||
plan = save_module_install_plan(session, [item.model_dump() for item in payload.items])
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.install_plan_updated",
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id="global",
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=["Module package install plan saved."])
|
||||
|
||||
|
||||
@router.delete("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
|
||||
def clear_module_install_plan(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
plan = save_module_install_plan(session, ())
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="system_modules.install_plan_cleared",
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id="global",
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=["Module package install plan cleared."])
|
||||
|
||||
|
||||
@router.get("/system/settings", response_model=SystemSettingsItem)
|
||||
def read_system_settings(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
item = get_system_settings(session)
|
||||
session.commit()
|
||||
policy = privacy_policy_from_settings(item)
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
return SystemSettingsItem(
|
||||
default_locale=item.default_locale,
|
||||
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
|
||||
allow_tenant_custom_roles=item.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys=item.allow_tenant_api_keys,
|
||||
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
|
||||
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||
settings=item.settings or {},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/system/settings", response_model=SystemSettingsItem)
|
||||
def write_system_settings(
|
||||
payload: SystemSettingsUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
item = get_system_settings(session)
|
||||
item.default_locale = payload.default_locale.strip() or "en"
|
||||
item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups
|
||||
item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles
|
||||
item.allow_tenant_api_keys = payload.allow_tenant_api_keys
|
||||
if payload.privacy_retention_policy is not None:
|
||||
set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json"))
|
||||
if payload.maintenance_mode is not None:
|
||||
current = saved_maintenance_mode(session)
|
||||
next_mode = MaintenanceMode(
|
||||
enabled=payload.maintenance_mode.enabled,
|
||||
message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None,
|
||||
)
|
||||
if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.",
|
||||
)
|
||||
save_maintenance_mode(session, next_mode)
|
||||
session.add(item)
|
||||
audit_from_principal(
|
||||
session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id,
|
||||
details=payload.model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
return read_system_settings(session=session, principal=principal)
|
||||
|
||||
|
||||
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
|
||||
def list_governance_templates(
|
||||
kind: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
|
||||
):
|
||||
del principal
|
||||
query = session.query(GovernanceTemplate)
|
||||
if kind:
|
||||
query = query.filter(GovernanceTemplate.kind == kind)
|
||||
items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
|
||||
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items])
|
||||
|
||||
|
||||
@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED)
|
||||
def create_governance_template(
|
||||
payload: GovernanceTemplateCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
try:
|
||||
item = create_template(
|
||||
session,
|
||||
kind=payload.kind,
|
||||
slug=payload.slug,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
permissions=payload.permissions,
|
||||
is_active=payload.is_active,
|
||||
assignments=[assignment.model_dump() for assignment in payload.assignments],
|
||||
)
|
||||
except (AdminConflictError, AdminValidationError) as exc:
|
||||
raise _http_admin_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="governance_template.created",
|
||||
scope="system",
|
||||
object_type=f"{payload.kind}_template",
|
||||
object_id=item.id,
|
||||
details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
|
||||
)
|
||||
session.commit()
|
||||
return _governance_template_item(session, item)
|
||||
|
||||
|
||||
@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem)
|
||||
def update_governance_template(
|
||||
template_id: str,
|
||||
payload: GovernanceTemplateUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
item = session.get(GovernanceTemplate, template_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
|
||||
try:
|
||||
update_template(
|
||||
session,
|
||||
item,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
permissions=payload.permissions,
|
||||
is_active=payload.is_active,
|
||||
assignments=[assignment.model_dump() for assignment in payload.assignments],
|
||||
)
|
||||
except (AdminConflictError, AdminValidationError) as exc:
|
||||
raise _http_admin_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="governance_template.updated",
|
||||
scope="system",
|
||||
object_type=f"{item.kind}_template",
|
||||
object_id=item.id,
|
||||
details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
|
||||
)
|
||||
session.commit()
|
||||
return _governance_template_item(session, item)
|
||||
|
||||
|
||||
@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_governance_template(
|
||||
template_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
item = session.get(GovernanceTemplate, template_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
|
||||
kind = item.kind
|
||||
try:
|
||||
delete_template(session, item)
|
||||
except (AdminConflictError, AdminValidationError) as exc:
|
||||
raise _http_admin_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="governance_template.deleted",
|
||||
scope="system",
|
||||
object_type=f"{kind}_template",
|
||||
object_id=template_id,
|
||||
details={},
|
||||
)
|
||||
session.commit()
|
||||
return None
|
||||
360
src/govoplan_admin/backend/api/v1/schemas.py
Normal file
360
src/govoplan_admin/backend/api/v1/schemas.py
Normal file
@@ -0,0 +1,360 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
RETENTION_DAY_KEYS = (
|
||||
"raw_campaign_json_retention_days",
|
||||
"generated_eml_retention_days",
|
||||
"stored_report_detail_retention_days",
|
||||
"mock_mailbox_retention_days",
|
||||
"audit_detail_retention_days",
|
||||
)
|
||||
|
||||
|
||||
RETENTION_POLICY_FIELD_KEYS = (
|
||||
"store_raw_campaign_json",
|
||||
*RETENTION_DAY_KEYS,
|
||||
"audit_detail_level",
|
||||
)
|
||||
|
||||
|
||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
||||
|
||||
|
||||
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
||||
if value in (None, ""):
|
||||
return default_allow_lower_level_limits() if fill_defaults else None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("allow_lower_level_limits must be an object")
|
||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
||||
for key, allowed in value.items():
|
||||
clean_key = str(key)
|
||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
||||
normalized[clean_key] = bool(allowed)
|
||||
return normalized
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool = True
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||
|
||||
|
||||
class MaintenanceModeItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
message: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminOverviewResponse(BaseModel):
|
||||
active_tenant_id: str
|
||||
active_tenant_name: str
|
||||
tenant_count: int | None = None
|
||||
system_account_count: int | None = None
|
||||
system_group_template_count: int | None = None
|
||||
system_role_template_count: int | None = None
|
||||
user_count: int
|
||||
active_user_count: int
|
||||
group_count: int
|
||||
role_count: int
|
||||
active_api_key_count: int
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemSettingsItem(BaseModel):
|
||||
default_locale: str = "en"
|
||||
allow_tenant_custom_groups: bool = True
|
||||
allow_tenant_custom_roles: bool = True
|
||||
allow_tenant_api_keys: bool = True
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SystemSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
default_locale: str = Field(min_length=1, max_length=20)
|
||||
allow_tenant_custom_groups: bool
|
||||
allow_tenant_custom_roles: bool
|
||||
allow_tenant_api_keys: bool
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||
maintenance_mode: MaintenanceModeItem | None = None
|
||||
|
||||
|
||||
class ModuleCatalogItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
installed: bool = True
|
||||
current_enabled: bool = False
|
||||
desired_enabled: bool = False
|
||||
protected: bool = False
|
||||
restart_required: bool = False
|
||||
dependencies: list[str] = Field(default_factory=list)
|
||||
optional_dependencies: list[str] = Field(default_factory=list)
|
||||
dependents: list[str] = Field(default_factory=list)
|
||||
permissions_count: int = 0
|
||||
role_templates_count: int = 0
|
||||
route_contributed: bool = False
|
||||
nav_count: int = 0
|
||||
frontend_package: str | None = None
|
||||
migration_module_id: str | None = None
|
||||
migration_script_location: str | None = None
|
||||
runtime_toggle_supported: bool = False
|
||||
install_uninstall_supported: bool = False
|
||||
|
||||
|
||||
class ModuleCatalogResponse(BaseModel):
|
||||
modules: list[ModuleCatalogItem]
|
||||
current_enabled: list[str]
|
||||
desired_enabled: list[str]
|
||||
configured_enabled: list[str]
|
||||
protected_modules: list[str]
|
||||
restart_required: bool = False
|
||||
runtime_toggle_supported: bool = False
|
||||
install_uninstall_supported: bool = False
|
||||
install_plan_supported: bool = False
|
||||
package_mutation_supported: bool = False
|
||||
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleStateUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled_modules: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleInstallPlanItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
module_id: str = Field(min_length=1, max_length=120)
|
||||
action: Literal["install", "uninstall"]
|
||||
python_package: str | None = Field(default=None, max_length=200)
|
||||
python_ref: str | None = Field(default=None, max_length=1000)
|
||||
webui_package: str | None = Field(default=None, max_length=200)
|
||||
webui_ref: str | None = Field(default=None, max_length=1000)
|
||||
destroy_data: bool = False
|
||||
status: Literal["planned", "applied", "blocked"] = "planned"
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ModuleInstallPreflightIssue(BaseModel):
|
||||
severity: Literal["blocker", "warning", "info"]
|
||||
code: str
|
||||
message: str
|
||||
module_id: str | None = None
|
||||
|
||||
|
||||
class ModuleInstallChecklistItem(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
status: Literal["done", "pending", "blocked", "warning"]
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class ModuleInstallPreflightResponse(BaseModel):
|
||||
allowed: bool
|
||||
maintenance_mode: bool
|
||||
frontend_rebuild_required: bool
|
||||
restart_required: bool
|
||||
commands: list[str] = Field(default_factory=list)
|
||||
rollback_commands: list[str] = Field(default_factory=list)
|
||||
issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list)
|
||||
checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleInstallPlanResponse(BaseModel):
|
||||
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
||||
updated_at: str | None = None
|
||||
commands: list[str] = Field(default_factory=list)
|
||||
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||
preflight: ModuleInstallPreflightResponse | None = None
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleInstallerLockStatus(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
locked: bool = False
|
||||
path: str
|
||||
|
||||
|
||||
class ModuleInstallerDaemonStatus(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
running: bool = False
|
||||
status: str = "unknown"
|
||||
path: str
|
||||
|
||||
|
||||
class ModuleInstallerRunSummary(BaseModel):
|
||||
run_id: str
|
||||
status: str
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
rollback_status: str | None = None
|
||||
supervisor_status: str | None = None
|
||||
error: str | None = None
|
||||
record_path: str
|
||||
commands_count: int = 0
|
||||
planned_modules: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleInstallerRunListResponse(BaseModel):
|
||||
runs: list[ModuleInstallerRunSummary] = Field(default_factory=list)
|
||||
lock: ModuleInstallerLockStatus
|
||||
|
||||
|
||||
class ModuleInstallerRequestOptions(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
build_webui: bool = False
|
||||
migrate_database: bool = True
|
||||
webui_root: str | None = Field(default=None, max_length=1000)
|
||||
npm_bin: str | None = Field(default=None, max_length=500)
|
||||
database_backup_command: str | None = Field(default=None, max_length=4000)
|
||||
database_restore_command: str | None = Field(default=None, max_length=4000)
|
||||
database_restore_check_command: str | None = Field(default=None, max_length=4000)
|
||||
activate_installed_modules: bool = True
|
||||
remove_uninstalled_modules_from_desired: bool = True
|
||||
restart_commands: list[str] = Field(default_factory=list)
|
||||
health_urls: list[str] = Field(default_factory=list)
|
||||
health_timeout_seconds: float = Field(default=60.0, ge=1, le=3600)
|
||||
health_interval_seconds: float = Field(default=2.0, ge=0.2, le=120)
|
||||
|
||||
|
||||
class ModuleInstallerRequestItem(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
request_id: str
|
||||
status: str
|
||||
created_at: str
|
||||
requested_by: str | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
cancelled_at: str | None = None
|
||||
cancelled_by: str | None = None
|
||||
retry_of: str | None = None
|
||||
error: str | None = None
|
||||
record_path: str | None = None
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ModuleInstallerRequestListResponse(BaseModel):
|
||||
requests: list[ModuleInstallerRequestItem] = Field(default_factory=list)
|
||||
daemon: ModuleInstallerDaemonStatus
|
||||
|
||||
|
||||
class ModuleInstallerRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions)
|
||||
|
||||
|
||||
class ModulePackageCatalogItem(BaseModel):
|
||||
module_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
version: str | None = None
|
||||
action: Literal["install", "uninstall"] = "install"
|
||||
python_package: str | None = None
|
||||
python_ref: str | None = None
|
||||
webui_package: str | None = None
|
||||
webui_ref: str | None = None
|
||||
license_features: list[str] = Field(default_factory=list)
|
||||
license_allowed: bool = True
|
||||
license_enforced: bool = False
|
||||
license_missing_features: list[str] = Field(default_factory=list)
|
||||
license_reason: str | None = None
|
||||
notes: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModulePackageCatalogResponse(BaseModel):
|
||||
modules: list[ModulePackageCatalogItem] = Field(default_factory=list)
|
||||
configured: bool = False
|
||||
valid: bool = True
|
||||
path: str | None = None
|
||||
source: str | None = None
|
||||
source_type: str | None = None
|
||||
channel: str | None = None
|
||||
sequence: int | None = None
|
||||
generated_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
signed: bool = False
|
||||
trusted: bool = False
|
||||
key_id: str | None = None
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ModuleInstallPlanUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GovernanceAssignment(BaseModel):
|
||||
tenant_id: str
|
||||
mode: Literal["available", "required"] = "available"
|
||||
|
||||
|
||||
class GovernanceTemplateItem(BaseModel):
|
||||
id: str
|
||||
kind: Literal["group", "role"]
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
effective_permission_count: int = 0
|
||||
is_active: bool = True
|
||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class GovernanceTemplateListResponse(BaseModel):
|
||||
templates: list[GovernanceTemplateItem]
|
||||
|
||||
|
||||
class GovernanceTemplateCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: Literal["group", "role"]
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GovernanceTemplateUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||
2
src/govoplan_admin/backend/db/__init__.py
Normal file
2
src/govoplan_admin/backend/db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Admin-owned SQLAlchemy metadata and models."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc
Normal file
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc
Normal file
BIN
src/govoplan_admin/backend/db/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
39
src/govoplan_admin/backend/db/models.py
Normal file
39
src/govoplan_admin/backend/db/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class GovernanceTemplate(Base, TimestampMixin):
|
||||
__tablename__ = "governance_templates"
|
||||
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class GovernanceTemplateAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "governance_template_assignments"
|
||||
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
|
||||
|
||||
|
||||
__all__ = ["GovernanceTemplate", "GovernanceTemplateAssignment", "new_uuid"]
|
||||
|
||||
169
src/govoplan_admin/backend/governance.py
Normal file
169
src/govoplan_admin/backend/governance.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
AccessGovernanceMaterializer,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.security.permissions import validate_tenant_permissions
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
TEMPLATE_KINDS = {"group", "role"}
|
||||
ASSIGNMENT_MODES = {"available", "required"}
|
||||
|
||||
|
||||
def _governance_materializer() -> AccessGovernanceMaterializer:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER):
|
||||
raise AdminValidationError("Access governance materializer capability is not configured.")
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||
if not isinstance(capability, AccessGovernanceMaterializer):
|
||||
raise AdminValidationError("Access governance materializer capability is invalid.")
|
||||
return capability
|
||||
|
||||
|
||||
def _materialization(
|
||||
item: GovernanceTemplate,
|
||||
*,
|
||||
tenant_id: str,
|
||||
required: bool = False,
|
||||
) -> GovernanceTemplateMaterialization:
|
||||
return GovernanceTemplateMaterialization(
|
||||
template_id=item.id,
|
||||
kind=item.kind, # type: ignore[arg-type]
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
permissions=tuple(item.permissions or []),
|
||||
is_active=item.is_active,
|
||||
required=required,
|
||||
)
|
||||
|
||||
|
||||
def validate_template(kind: str, permissions: list[str]) -> list[str]:
|
||||
if kind not in TEMPLATE_KINDS:
|
||||
raise AdminValidationError("Template kind must be group or role.")
|
||||
if kind == "group":
|
||||
if permissions:
|
||||
raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.")
|
||||
return []
|
||||
try:
|
||||
return validate_tenant_permissions(permissions)
|
||||
except ValueError as exc:
|
||||
raise AdminValidationError(str(exc)) from exc
|
||||
|
||||
|
||||
def create_template(
|
||||
session: Session,
|
||||
*,
|
||||
kind: str,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
permissions: list[str],
|
||||
is_active: bool,
|
||||
assignments: list[dict[str, str]],
|
||||
) -> GovernanceTemplate:
|
||||
normalized_slug = slugify(slug)
|
||||
permissions = validate_template(kind, permissions)
|
||||
exists = session.query(GovernanceTemplate).filter(
|
||||
GovernanceTemplate.kind == kind,
|
||||
GovernanceTemplate.slug == normalized_slug,
|
||||
).first()
|
||||
if exists:
|
||||
raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.")
|
||||
item = GovernanceTemplate(
|
||||
kind=kind,
|
||||
slug=normalized_slug,
|
||||
name=name.strip(),
|
||||
description=description,
|
||||
permissions=permissions,
|
||||
is_active=is_active,
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
set_template_assignments(session, item, assignments)
|
||||
return item
|
||||
|
||||
|
||||
def update_template(
|
||||
session: Session,
|
||||
item: GovernanceTemplate,
|
||||
*,
|
||||
name: str,
|
||||
description: str | None,
|
||||
permissions: list[str],
|
||||
is_active: bool,
|
||||
assignments: list[dict[str, str]],
|
||||
) -> GovernanceTemplate:
|
||||
item.name = name.strip()
|
||||
item.description = description
|
||||
item.permissions = validate_template(item.kind, permissions)
|
||||
item.is_active = is_active
|
||||
set_template_assignments(session, item, assignments)
|
||||
sync_template(session, item)
|
||||
return item
|
||||
|
||||
|
||||
def set_template_assignments(
|
||||
session: Session,
|
||||
item: GovernanceTemplate,
|
||||
assignments: list[dict[str, str]],
|
||||
) -> None:
|
||||
desired: dict[str, str] = {}
|
||||
for assignment in assignments:
|
||||
tenant_id = assignment.get("tenant_id", "")
|
||||
mode = assignment.get("mode", "available")
|
||||
if mode not in ASSIGNMENT_MODES:
|
||||
raise AdminValidationError("Template assignment mode must be available or required.")
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
|
||||
desired[tenant_id] = mode
|
||||
|
||||
existing = {
|
||||
row.tenant_id: row
|
||||
for row in session.query(GovernanceTemplateAssignment)
|
||||
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
||||
.all()
|
||||
}
|
||||
for tenant_id, row in list(existing.items()):
|
||||
if tenant_id in desired:
|
||||
row.mode = desired[tenant_id]
|
||||
continue
|
||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id))
|
||||
session.delete(row)
|
||||
|
||||
for tenant_id, mode in desired.items():
|
||||
if tenant_id not in existing:
|
||||
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
|
||||
session.flush()
|
||||
sync_template(session, item)
|
||||
|
||||
|
||||
def sync_template(session: Session, item: GovernanceTemplate) -> None:
|
||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||
GovernanceTemplateAssignment.template_id == item.id
|
||||
).all()
|
||||
for assignment in assignments:
|
||||
required = assignment.mode == "required"
|
||||
_governance_materializer().sync_template(
|
||||
session,
|
||||
_materialization(item, tenant_id=assignment.tenant_id, required=required),
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def delete_template(session: Session, item: GovernanceTemplate) -> None:
|
||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||
GovernanceTemplateAssignment.template_id == item.id
|
||||
).all()
|
||||
for assignment in assignments:
|
||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id))
|
||||
session.delete(item)
|
||||
34
src/govoplan_admin/backend/manifest.py
Normal file
34
src/govoplan_admin/backend/manifest.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_admin.backend.api.v1.routes import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="admin",
|
||||
name="Admin",
|
||||
version="0.1.5",
|
||||
dependencies=("access",),
|
||||
route_factory=_route_factory,
|
||||
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
admin_models.GovernanceTemplate,
|
||||
admin_models.GovernanceTemplateAssignment,
|
||||
label="Admin",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
1
src/govoplan_admin/py.typed
Normal file
1
src/govoplan_admin/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user