chore: sync GovOPlaN module split state
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,17 +2,33 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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_access.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal, audit_operation_context
|
||||
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.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
max_sequence_id,
|
||||
record_change,
|
||||
sequence_entries_since,
|
||||
sequence_watermark_is_expired,
|
||||
)
|
||||
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
|
||||
from govoplan_core.core.configuration_control import (
|
||||
ConfigurationChangeApproval,
|
||||
ConfigurationControlError,
|
||||
ensure_configuration_change_allowed,
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.module_management import (
|
||||
PROTECTED_MODULES,
|
||||
ModuleInstallPlanItem,
|
||||
@@ -41,13 +57,20 @@ from govoplan_core.core.module_installer import (
|
||||
read_module_installer_request,
|
||||
retry_module_installer_request,
|
||||
)
|
||||
from govoplan_core.core.module_license import module_license_decision
|
||||
from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics
|
||||
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.i18n import (
|
||||
i18n_settings,
|
||||
normalize_enabled_language_codes,
|
||||
normalize_language_packages,
|
||||
system_i18n_payload,
|
||||
update_i18n_settings,
|
||||
)
|
||||
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
|
||||
@@ -59,6 +82,7 @@ from .schemas import (
|
||||
AdminOverviewResponse,
|
||||
GovernanceTemplateCreateRequest,
|
||||
GovernanceTemplateItem,
|
||||
GovernanceTemplateListDeltaResponse,
|
||||
GovernanceTemplateListResponse,
|
||||
GovernanceTemplateUpdateRequest,
|
||||
MaintenanceModeItem,
|
||||
@@ -76,14 +100,33 @@ from .schemas import (
|
||||
ModuleInstallPlanUpdateRequest,
|
||||
ModulePackageCatalogItem,
|
||||
ModulePackageCatalogResponse,
|
||||
ModuleLicenseDiagnostics,
|
||||
ModuleStateUpdateRequest,
|
||||
PrivacyRetentionPolicyItem,
|
||||
SystemSettingsDeltaResponse,
|
||||
SystemSettingsItem,
|
||||
SystemSettingsUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
ADMIN_MODULE_ID = "admin"
|
||||
SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
|
||||
SYSTEM_SETTINGS_RESOURCE = "system_settings_section"
|
||||
GOVERNANCE_TEMPLATES_COLLECTION = "admin.governance_templates"
|
||||
GOVERNANCE_TEMPLATE_RESOURCE = "governance_template"
|
||||
INSTALLER_RUNS_CURSOR_SCOPE = "admin.installer.runs.v1"
|
||||
INSTALLER_REQUESTS_CURSOR_SCOPE = "admin.installer.requests.v1"
|
||||
DEFAULT_INSTALLER_HISTORY_PAGE_SIZE = 25
|
||||
SYSTEM_SETTINGS_SECTIONS = (
|
||||
"defaults",
|
||||
"tenant_capabilities",
|
||||
"languages",
|
||||
"privacy_retention_policy",
|
||||
"maintenance_mode",
|
||||
"settings",
|
||||
)
|
||||
|
||||
|
||||
def _access_administration() -> AccessAdministration:
|
||||
registry = get_registry()
|
||||
@@ -113,10 +156,136 @@ def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, AdminValidationError):
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": exc.code, "message": str(exc), "plan": exc.plan},
|
||||
)
|
||||
|
||||
|
||||
def _admin_delta_watermark(session: Session, collections: tuple[str, ...]) -> str:
|
||||
return encode_sequence_watermark(max_sequence_id(session, module_id=ADMIN_MODULE_ID, collections=collections))
|
||||
|
||||
|
||||
def _admin_delta_entries(session: Session, *, collections: tuple[str, ...], since: str, limit: int):
|
||||
try:
|
||||
since_sequence = decode_sequence_watermark(since)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
if sequence_watermark_is_expired(
|
||||
session,
|
||||
since=since_sequence,
|
||||
module_id=ADMIN_MODULE_ID,
|
||||
collections=collections,
|
||||
):
|
||||
return None, False
|
||||
entries_plus_one = sequence_entries_since(
|
||||
session,
|
||||
since=since_sequence,
|
||||
module_id=ADMIN_MODULE_ID,
|
||||
collections=collections,
|
||||
limit=limit + 1,
|
||||
)
|
||||
has_more = len(entries_plus_one) > limit
|
||||
return entries_plus_one[:limit], has_more
|
||||
|
||||
|
||||
def _admin_delta_response_watermark(session: Session, *, collections: tuple[str, ...], entries, has_more: bool) -> str:
|
||||
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _admin_delta_watermark(session, collections)
|
||||
|
||||
|
||||
def _history_cursor_fingerprint(scope: str, *, page_size: int) -> str:
|
||||
return keyset_query_fingerprint(scope, {"page_size": page_size, "sort": "id.desc"})
|
||||
|
||||
|
||||
def _window_history_records(records: list[dict[str, object]], *, id_key: str, scope: str, page_size: int, cursor: str | None):
|
||||
start = 0
|
||||
full = False
|
||||
fingerprint = _history_cursor_fingerprint(scope, page_size=page_size)
|
||||
if cursor:
|
||||
try:
|
||||
values = decode_keyset_cursor(scope, cursor, fingerprint=fingerprint)
|
||||
except KeysetCursorError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
after_id = values.get(id_key) if values else None
|
||||
if not isinstance(after_id, str):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
|
||||
matching_index = next((index for index, item in enumerate(records) if str(item.get(id_key) or "") == after_id), None)
|
||||
if matching_index is None:
|
||||
full = True
|
||||
else:
|
||||
start = matching_index + 1
|
||||
page = records[start:start + page_size]
|
||||
next_cursor = None
|
||||
if start + page_size < len(records) and page:
|
||||
next_cursor = encode_keyset_cursor(scope, fingerprint=fingerprint, values={id_key: str(page[-1].get(id_key) or ""), "page_size": page_size})
|
||||
return page, next_cursor, full
|
||||
|
||||
|
||||
def _system_settings_item(session: Session) -> SystemSettingsItem:
|
||||
item = get_system_settings(session)
|
||||
policy = privacy_policy_from_settings(item)
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
i18n_payload = system_i18n_payload(item)
|
||||
return SystemSettingsItem(
|
||||
default_locale=item.default_locale,
|
||||
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
|
||||
allow_tenant_custom_roles=item.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys=item.allow_tenant_api_keys,
|
||||
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
|
||||
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||
available_languages=i18n_payload["available_languages"],
|
||||
enabled_language_codes=i18n_payload["enabled_languages"],
|
||||
settings=item.settings or {},
|
||||
)
|
||||
|
||||
|
||||
def _system_settings_sections(item: SystemSettingsItem) -> dict[str, Any]:
|
||||
payload = item.model_dump(mode="json")
|
||||
return {
|
||||
"defaults": {"default_locale": payload["default_locale"]},
|
||||
"tenant_capabilities": {
|
||||
"allow_tenant_custom_groups": payload["allow_tenant_custom_groups"],
|
||||
"allow_tenant_custom_roles": payload["allow_tenant_custom_roles"],
|
||||
"allow_tenant_api_keys": payload["allow_tenant_api_keys"],
|
||||
},
|
||||
"languages": {
|
||||
"available_languages": payload["available_languages"],
|
||||
"enabled_language_codes": payload["enabled_language_codes"],
|
||||
},
|
||||
"privacy_retention_policy": payload["privacy_retention_policy"],
|
||||
"maintenance_mode": payload["maintenance_mode"],
|
||||
"settings": payload["settings"],
|
||||
}
|
||||
|
||||
|
||||
def _record_system_settings_section_changes(
|
||||
session: Session,
|
||||
*,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
for section in SYSTEM_SETTINGS_SECTIONS:
|
||||
if before.get(section) == after.get(section):
|
||||
continue
|
||||
record_change(
|
||||
session,
|
||||
module_id=ADMIN_MODULE_ID,
|
||||
collection=SYSTEM_SETTINGS_COLLECTION,
|
||||
resource_type=SYSTEM_SETTINGS_RESOURCE,
|
||||
resource_id=section,
|
||||
operation="updated",
|
||||
actor_type="user",
|
||||
actor_id=principal.user.id,
|
||||
payload={"section": section},
|
||||
)
|
||||
|
||||
|
||||
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem:
|
||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||
GovernanceTemplateAssignment.template_id == item.id
|
||||
@@ -136,6 +305,28 @@ def _governance_template_item(session: Session, item: GovernanceTemplate) -> Gov
|
||||
)
|
||||
|
||||
|
||||
def _record_governance_template_change(session: Session, *, item: GovernanceTemplate, operation: str, principal: ApiPrincipal) -> None:
|
||||
record_change(
|
||||
session,
|
||||
module_id=ADMIN_MODULE_ID,
|
||||
collection=GOVERNANCE_TEMPLATES_COLLECTION,
|
||||
resource_type=GOVERNANCE_TEMPLATE_RESOURCE,
|
||||
resource_id=item.id,
|
||||
operation=operation,
|
||||
actor_type="user",
|
||||
actor_id=principal.user.id,
|
||||
payload={"kind": item.kind, "slug": item.slug, "name": item.name, "is_active": item.is_active},
|
||||
)
|
||||
|
||||
|
||||
def _governance_template_deleted_entries(entries, visible_template_ids: set[str]):
|
||||
return [
|
||||
{"id": entry.resource_id, "resource_type": entry.resource_type or GOVERNANCE_TEMPLATE_RESOURCE}
|
||||
for entry in entries
|
||||
if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_template_ids)
|
||||
]
|
||||
|
||||
|
||||
def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse:
|
||||
registry = _request_registry(request)
|
||||
current_enabled = [manifest.id for manifest in registry.manifests()]
|
||||
@@ -251,7 +442,7 @@ def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str,
|
||||
result = validate_module_package_catalog()
|
||||
if not result.get("valid"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(result.get("error") or "Module package catalog is invalid."),
|
||||
)
|
||||
for raw_item in result.get("modules", []):
|
||||
@@ -302,6 +493,14 @@ def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCa
|
||||
return ModulePackageCatalogItem.model_validate(payload)
|
||||
|
||||
|
||||
def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
|
||||
required: list[str] = []
|
||||
for raw_item in result.get("modules", []):
|
||||
if isinstance(raw_item, dict):
|
||||
required.extend(_catalog_license_features(raw_item))
|
||||
return list(dict.fromkeys(required))
|
||||
|
||||
|
||||
@router.get("/overview", response_model=AdminOverviewResponse)
|
||||
def admin_overview(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -355,16 +554,23 @@ def read_module_install_plan(
|
||||
|
||||
@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse)
|
||||
def list_module_install_runs(
|
||||
page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
records = list(list_module_installer_runs(runtime_dir=runtime_dir, limit=1000))
|
||||
page, next_cursor, full = _window_history_records(records, id_key="run_id", scope=INSTALLER_RUNS_CURSOR_SCOPE, page_size=page_size, cursor=cursor)
|
||||
return ModuleInstallerRunListResponse(
|
||||
runs=[
|
||||
ModuleInstallerRunSummary.model_validate(item)
|
||||
for item in list_module_installer_runs(runtime_dir=runtime_dir)
|
||||
for item in page
|
||||
],
|
||||
lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)),
|
||||
cursor=cursor,
|
||||
next_cursor=next_cursor,
|
||||
full=full,
|
||||
)
|
||||
|
||||
|
||||
@@ -383,16 +589,23 @@ def read_module_install_run_detail(
|
||||
|
||||
@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse)
|
||||
def list_module_install_requests(
|
||||
page_size: int = Query(default=DEFAULT_INSTALLER_HISTORY_PAGE_SIZE, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
|
||||
records = list(list_module_installer_requests(runtime_dir=runtime_dir, limit=1000))
|
||||
page, next_cursor, full = _window_history_records(records, id_key="request_id", scope=INSTALLER_REQUESTS_CURSOR_SCOPE, page_size=page_size, cursor=cursor)
|
||||
return ModuleInstallerRequestListResponse(
|
||||
requests=[
|
||||
ModuleInstallerRequestItem.model_validate(item)
|
||||
for item in list_module_installer_requests(runtime_dir=runtime_dir)
|
||||
for item in page
|
||||
],
|
||||
daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)),
|
||||
cursor=cursor,
|
||||
next_cursor=next_cursor,
|
||||
full=full,
|
||||
)
|
||||
|
||||
|
||||
@@ -433,7 +646,12 @@ def create_module_install_request(
|
||||
scope="system",
|
||||
object_type="module_install_request",
|
||||
object_id=str(request["request_id"]),
|
||||
details={"options": payload.options.model_dump(exclude_none=True)},
|
||||
details=audit_operation_context(
|
||||
request_id=request.get("request_id"),
|
||||
outcome=request.get("status"),
|
||||
trace=request.get("trace") if isinstance(request.get("trace"), dict) else None,
|
||||
options=payload.options.model_dump(exclude_none=True),
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return ModuleInstallerRequestItem.model_validate(request)
|
||||
@@ -458,7 +676,7 @@ def cancel_module_install_request(
|
||||
cancelled_by=principal.user.id,
|
||||
)
|
||||
except ModuleInstallerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -466,7 +684,12 @@ def cancel_module_install_request(
|
||||
scope="system",
|
||||
object_type="module_install_request",
|
||||
object_id=request_id,
|
||||
details={},
|
||||
details=audit_operation_context(
|
||||
request_id=request_id,
|
||||
outcome=request.get("status"),
|
||||
trace=request.get("trace") if isinstance(request.get("trace"), dict) else None,
|
||||
cancelled_by=request.get("cancelled_by"),
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return ModuleInstallerRequestItem.model_validate(request)
|
||||
@@ -491,7 +714,7 @@ def retry_module_install_request(
|
||||
requested_by=principal.user.id,
|
||||
)
|
||||
except ModuleInstallerError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -499,7 +722,13 @@ def retry_module_install_request(
|
||||
scope="system",
|
||||
object_type="module_install_request",
|
||||
object_id=request_id,
|
||||
details={"new_request_id": request["request_id"]},
|
||||
details=audit_operation_context(
|
||||
request_id=request_id,
|
||||
outcome=request.get("status"),
|
||||
trace=request.get("trace") if isinstance(request.get("trace"), dict) else None,
|
||||
retry_of=request.get("retry_of"),
|
||||
new_request_id=request.get("request_id"),
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return ModuleInstallerRequestItem.model_validate(request)
|
||||
@@ -511,6 +740,7 @@ def read_module_package_catalog(
|
||||
):
|
||||
del principal
|
||||
result = validate_module_package_catalog()
|
||||
license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result))
|
||||
return ModulePackageCatalogResponse(
|
||||
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
|
||||
configured=bool(result["configured"]),
|
||||
@@ -518,13 +748,17 @@ def read_module_package_catalog(
|
||||
path=result["path"] if isinstance(result["path"], str) else None,
|
||||
source=result["source"] if isinstance(result.get("source"), str) else None,
|
||||
source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None,
|
||||
cache_used=bool(result.get("cache_used")),
|
||||
cache_path=result["cache_path"] if isinstance(result.get("cache_path"), str) else None,
|
||||
channel=result["channel"] if isinstance(result["channel"], str) else None,
|
||||
sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None,
|
||||
generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None,
|
||||
not_before=result["not_before"] if isinstance(result.get("not_before"), str) else None,
|
||||
expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None,
|
||||
signed=bool(result["signed"]),
|
||||
trusted=bool(result["trusted"]),
|
||||
key_id=result["key_id"] if isinstance(result["key_id"], str) else None,
|
||||
license=ModuleLicenseDiagnostics.model_validate(license_diagnostics),
|
||||
warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [],
|
||||
error=result["error"] if isinstance(result["error"], str) else None,
|
||||
)
|
||||
@@ -542,7 +776,7 @@ def plan_module_install_from_catalog(
|
||||
plan = _upsert_install_plan_item(session, item)
|
||||
record_module_package_catalog_acceptance(validation)
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -550,7 +784,16 @@ def plan_module_install_from_catalog(
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id=module_id,
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
details=audit_operation_context(
|
||||
module_id=module_id,
|
||||
outcome="planned",
|
||||
items=[item.as_dict() for item in plan.items],
|
||||
catalog_validation={
|
||||
"valid": validation.get("valid"),
|
||||
"channel": validation.get("channel"),
|
||||
"key_id": validation.get("key_id"),
|
||||
},
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."])
|
||||
@@ -569,14 +812,14 @@ def plan_module_uninstall(
|
||||
configured_enabled = configured_enabled_modules(core_settings.enabled_modules)
|
||||
desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled))
|
||||
if module_id in PROTECTED_MODULES:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.")
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Protected platform modules cannot be uninstalled.")
|
||||
if module_id in current_enabled or module_id in desired_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.")
|
||||
try:
|
||||
item = module_uninstall_plan_item(module_id, lifecycle.available_modules)
|
||||
plan = _upsert_install_plan_item(session, item)
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -584,7 +827,11 @@ def plan_module_uninstall(
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id=module_id,
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
details=audit_operation_context(
|
||||
module_id=module_id,
|
||||
outcome="planned",
|
||||
items=[item.as_dict() for item in plan.items],
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."])
|
||||
@@ -599,14 +846,28 @@ def update_system_modules(
|
||||
):
|
||||
lifecycle = _request_lifecycle(request)
|
||||
available = lifecycle.available_modules
|
||||
before_value = {"enabled_modules": list(saved_desired_enabled_modules(session, configured_enabled_modules(core_settings.enabled_modules)))}
|
||||
apply_value = {"enabled_modules": list(payload.enabled_modules)}
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="module_management.desired_enabled",
|
||||
value=apply_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=payload.change_request_id,
|
||||
target={"scope": "system"},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
try:
|
||||
plan = plan_desired_enabled_modules(payload.enabled_modules, available)
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
try:
|
||||
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
desired = save_desired_enabled_modules(session, result.enabled_modules)
|
||||
audit_from_principal(
|
||||
session,
|
||||
@@ -615,13 +876,24 @@ def update_system_modules(
|
||||
scope="system",
|
||||
object_type="module_state",
|
||||
object_id="global",
|
||||
details={
|
||||
"desired_enabled": list(desired),
|
||||
"added_dependencies": list(plan.added_dependencies),
|
||||
"activated": list(result.activated_modules),
|
||||
"deactivated": list(result.deactivated_modules),
|
||||
"mounted": list(result.mounted_modules),
|
||||
},
|
||||
details=audit_operation_context(
|
||||
outcome="applied",
|
||||
desired_enabled=list(desired),
|
||||
added_dependencies=list(plan.added_dependencies),
|
||||
activated=list(result.activated_modules),
|
||||
deactivated=list(result.deactivated_modules),
|
||||
mounted=list(result.mounted_modules),
|
||||
),
|
||||
)
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="module_management.desired_enabled",
|
||||
before_value=before_value,
|
||||
after_value={"enabled_modules": list(desired)},
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"scope": "system"},
|
||||
audit_event="module_management.updated",
|
||||
)
|
||||
session.commit()
|
||||
notes = ["Module state saved and applied to the running server."]
|
||||
@@ -641,10 +913,24 @@ def update_module_install_plan(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
before_value = {"items": [item.as_dict() for item in saved_module_install_plan(session).items]}
|
||||
apply_value = {"items": [item.model_dump() for item in payload.items]}
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="module_management.install_plan",
|
||||
value=apply_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=payload.change_request_id,
|
||||
target={"scope": "system"},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
try:
|
||||
plan = save_module_install_plan(session, [item.model_dump() for item in payload.items])
|
||||
except ModuleManagementError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -652,7 +938,21 @@ def update_module_install_plan(
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id="global",
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
details=audit_operation_context(
|
||||
outcome="saved",
|
||||
items=[item.as_dict() for item in plan.items],
|
||||
item_count=len(plan.items),
|
||||
),
|
||||
)
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="module_management.install_plan",
|
||||
before_value=before_value,
|
||||
after_value={"items": [item.as_dict() for item in plan.items]},
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"scope": "system"},
|
||||
audit_event="module_install.requested",
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=["Module package install plan saved."])
|
||||
@@ -672,7 +972,11 @@ def clear_module_install_plan(
|
||||
scope="system",
|
||||
object_type="module_install_plan",
|
||||
object_id="global",
|
||||
details={"items": [item.as_dict() for item in plan.items]},
|
||||
details=audit_operation_context(
|
||||
outcome="cleared",
|
||||
items=[item.as_dict() for item in plan.items],
|
||||
item_count=len(plan.items),
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return _module_install_plan_response(session, request, notes=["Module package install plan cleared."])
|
||||
@@ -683,18 +987,59 @@ def read_system_settings(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
item = get_system_settings(session)
|
||||
get_system_settings(session)
|
||||
session.commit()
|
||||
policy = privacy_policy_from_settings(item)
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
return SystemSettingsItem(
|
||||
default_locale=item.default_locale,
|
||||
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
|
||||
allow_tenant_custom_roles=item.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys=item.allow_tenant_api_keys,
|
||||
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
|
||||
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
|
||||
settings=item.settings or {},
|
||||
return _system_settings_item(session)
|
||||
|
||||
|
||||
def _full_system_settings_delta_response(session: Session) -> SystemSettingsDeltaResponse:
|
||||
item = _system_settings_item(session)
|
||||
return SystemSettingsDeltaResponse(
|
||||
item=item,
|
||||
sections=_system_settings_sections(item),
|
||||
changed_sections=list(SYSTEM_SETTINGS_SECTIONS),
|
||||
deleted=[],
|
||||
watermark=_admin_delta_watermark(session, (SYSTEM_SETTINGS_COLLECTION,)),
|
||||
has_more=False,
|
||||
full=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/settings/delta", response_model=SystemSettingsDeltaResponse)
|
||||
def read_system_settings_delta(
|
||||
since: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
get_system_settings(session)
|
||||
session.commit()
|
||||
if since is None:
|
||||
return _full_system_settings_delta_response(session)
|
||||
entries, has_more = _admin_delta_entries(session, collections=(SYSTEM_SETTINGS_COLLECTION,), since=since, limit=limit)
|
||||
if entries is None:
|
||||
return _full_system_settings_delta_response(session)
|
||||
changed_sections = [
|
||||
section
|
||||
for section in SYSTEM_SETTINGS_SECTIONS
|
||||
if section in {entry.resource_id for entry in entries if entry.resource_type == SYSTEM_SETTINGS_RESOURCE}
|
||||
]
|
||||
item = _system_settings_item(session)
|
||||
sections = _system_settings_sections(item)
|
||||
return SystemSettingsDeltaResponse(
|
||||
item=None,
|
||||
sections={section: sections[section] for section in changed_sections},
|
||||
changed_sections=changed_sections,
|
||||
deleted=[],
|
||||
watermark=_admin_delta_response_watermark(
|
||||
session,
|
||||
collections=(SYSTEM_SETTINGS_COLLECTION,),
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
),
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -705,10 +1050,45 @@ def write_system_settings(
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
):
|
||||
item = get_system_settings(session)
|
||||
privacy_approval: ConfigurationChangeApproval | None = None
|
||||
maintenance_approval: ConfigurationChangeApproval | None = None
|
||||
before_sections = _system_settings_sections(_system_settings_item(session))
|
||||
before_privacy = privacy_policy_from_settings(item).model_dump(mode="json")
|
||||
before_maintenance = saved_maintenance_mode(session).as_dict()
|
||||
if payload.privacy_retention_policy is not None:
|
||||
privacy_value = payload.privacy_retention_policy.model_dump(mode="json")
|
||||
try:
|
||||
privacy_approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="privacy_retention_policy",
|
||||
value=privacy_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=payload.change_request_id,
|
||||
target={"scope": "system"},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
current_i18n = i18n_settings(item.settings)
|
||||
raw_available = payload.available_languages if "available_languages" in payload.model_fields_set else current_i18n.get("available_languages")
|
||||
raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes", current_i18n.get("enabled_languages"))
|
||||
available_languages = normalize_language_packages(
|
||||
[item.model_dump() for item in raw_available] if payload.available_languages is not None else raw_available
|
||||
)
|
||||
enabled_language_codes = normalize_enabled_language_codes(
|
||||
raw_enabled,
|
||||
available_languages,
|
||||
default_locale=payload.default_locale,
|
||||
)
|
||||
item.default_locale = payload.default_locale.strip() or "en"
|
||||
item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups
|
||||
item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles
|
||||
item.allow_tenant_api_keys = payload.allow_tenant_api_keys
|
||||
item.settings = update_i18n_settings(
|
||||
item.settings,
|
||||
available_languages=available_languages,
|
||||
enabled_language_codes=enabled_language_codes,
|
||||
)
|
||||
if payload.privacy_retention_policy is not None:
|
||||
set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json"))
|
||||
if payload.maintenance_mode is not None:
|
||||
@@ -717,9 +1097,21 @@ def write_system_settings(
|
||||
enabled=payload.maintenance_mode.enabled,
|
||||
message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None,
|
||||
)
|
||||
try:
|
||||
maintenance_approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="maintenance_mode",
|
||||
value=next_mode.as_dict(),
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=None,
|
||||
target={"scope": "system"},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.",
|
||||
)
|
||||
save_maintenance_mode(session, next_mode)
|
||||
@@ -728,8 +1120,32 @@ def write_system_settings(
|
||||
session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id,
|
||||
details=payload.model_dump(),
|
||||
)
|
||||
if payload.privacy_retention_policy is not None:
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="privacy_retention_policy",
|
||||
before_value=before_privacy,
|
||||
after_value=privacy_policy_from_settings(item).model_dump(mode="json"),
|
||||
actor_user_id=principal.user.id,
|
||||
approval=privacy_approval,
|
||||
target={"scope": "system"},
|
||||
audit_event="privacy_retention.policy_updated",
|
||||
)
|
||||
if payload.maintenance_mode is not None:
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="maintenance_mode",
|
||||
before_value=before_maintenance,
|
||||
after_value=saved_maintenance_mode(session).as_dict(),
|
||||
actor_user_id=principal.user.id,
|
||||
approval=maintenance_approval,
|
||||
target={"scope": "system"},
|
||||
audit_event="system_settings.updated",
|
||||
)
|
||||
after_sections = _system_settings_sections(_system_settings_item(session))
|
||||
_record_system_settings_section_changes(session, before=before_sections, after=after_sections, principal=principal)
|
||||
session.commit()
|
||||
return read_system_settings(session=session, principal=principal)
|
||||
return _system_settings_item(session)
|
||||
|
||||
|
||||
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
|
||||
@@ -746,12 +1162,63 @@ def list_governance_templates(
|
||||
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items])
|
||||
|
||||
|
||||
def _full_governance_template_delta_response(session: Session) -> GovernanceTemplateListDeltaResponse:
|
||||
items = session.query(GovernanceTemplate).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
|
||||
return GovernanceTemplateListDeltaResponse(
|
||||
templates=[_governance_template_item(session, item) for item in items],
|
||||
deleted=[],
|
||||
watermark=_admin_delta_watermark(session, (GOVERNANCE_TEMPLATES_COLLECTION,)),
|
||||
has_more=False,
|
||||
full=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/governance-templates/delta", response_model=GovernanceTemplateListDeltaResponse)
|
||||
def list_governance_templates_delta(
|
||||
since: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
|
||||
):
|
||||
del principal
|
||||
if since is None:
|
||||
return _full_governance_template_delta_response(session)
|
||||
entries, has_more = _admin_delta_entries(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), since=since, limit=limit)
|
||||
if entries is None:
|
||||
return _full_governance_template_delta_response(session)
|
||||
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
|
||||
items = []
|
||||
if changed_ids:
|
||||
items = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(changed_ids)).order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
|
||||
visible_template_ids = {item.id for item in items}
|
||||
return GovernanceTemplateListDeltaResponse(
|
||||
templates=[_governance_template_item(session, item) for item in items],
|
||||
deleted=_governance_template_deleted_entries(entries, visible_template_ids),
|
||||
watermark=_admin_delta_response_watermark(session, collections=(GOVERNANCE_TEMPLATES_COLLECTION,), entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED)
|
||||
def create_governance_template(
|
||||
payload: GovernanceTemplateCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
apply_value = payload.model_dump(exclude={"change_request_id"})
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="governance_templates",
|
||||
value=apply_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=payload.change_request_id,
|
||||
target={"kind": payload.kind, "slug": payload.slug},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
try:
|
||||
item = create_template(
|
||||
session,
|
||||
@@ -774,6 +1241,17 @@ def create_governance_template(
|
||||
object_id=item.id,
|
||||
details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
|
||||
)
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="governance_templates",
|
||||
before_value=None,
|
||||
after_value={**apply_value, "id": item.id},
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"kind": item.kind, "id": item.id},
|
||||
audit_event="governance_template.updated",
|
||||
)
|
||||
_record_governance_template_change(session, item=item, operation="created", principal=principal)
|
||||
session.commit()
|
||||
return _governance_template_item(session, item)
|
||||
|
||||
@@ -788,6 +1266,20 @@ def update_governance_template(
|
||||
item = session.get(GovernanceTemplate, template_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
|
||||
before_value = _governance_template_item(session, item).model_dump(mode="json")
|
||||
apply_value = payload.model_dump(exclude={"change_request_id"})
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="governance_templates",
|
||||
value=apply_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=payload.change_request_id,
|
||||
target={"kind": item.kind, "id": item.id},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
try:
|
||||
update_template(
|
||||
session,
|
||||
@@ -809,6 +1301,17 @@ def update_governance_template(
|
||||
object_id=item.id,
|
||||
details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
|
||||
)
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="governance_templates",
|
||||
before_value=before_value,
|
||||
after_value=_governance_template_item(session, item).model_dump(mode="json"),
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"kind": item.kind, "id": item.id},
|
||||
audit_event="governance_template.updated",
|
||||
)
|
||||
_record_governance_template_change(session, item=item, operation="updated", principal=principal)
|
||||
session.commit()
|
||||
return _governance_template_item(session, item)
|
||||
|
||||
@@ -816,6 +1319,7 @@ def update_governance_template(
|
||||
@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_governance_template(
|
||||
template_id: str,
|
||||
change_request_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
@@ -823,6 +1327,20 @@ def remove_governance_template(
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
|
||||
kind = item.kind
|
||||
before_value = _governance_template_item(session, item).model_dump(mode="json")
|
||||
apply_value = {"id": item.id, "kind": item.kind, "delete": True}
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key="governance_templates",
|
||||
value=apply_value,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=change_request_id,
|
||||
target={"kind": item.kind, "id": item.id},
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
try:
|
||||
delete_template(session, item)
|
||||
except (AdminConflictError, AdminValidationError) as exc:
|
||||
@@ -836,5 +1354,16 @@ def remove_governance_template(
|
||||
object_id=template_id,
|
||||
details={},
|
||||
)
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="governance_templates",
|
||||
before_value=before_value,
|
||||
after_value=None,
|
||||
actor_user_id=principal.user.id,
|
||||
approval=approval,
|
||||
target={"kind": kind, "id": template_id},
|
||||
audit_event="governance_template.updated",
|
||||
)
|
||||
_record_governance_template_change(session, item=item, operation="deleted", principal=principal)
|
||||
session.commit()
|
||||
return None
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
|
||||
|
||||
RETENTION_DAY_KEYS = (
|
||||
"raw_campaign_json_retention_days",
|
||||
@@ -65,6 +67,14 @@ class MaintenanceModeItem(BaseModel):
|
||||
message: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class LanguagePackageItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
code: str = Field(min_length=1, max_length=20)
|
||||
label: str = Field(min_length=1, max_length=100)
|
||||
native_label: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class AdminOverviewResponse(BaseModel):
|
||||
active_tenant_id: str
|
||||
active_tenant_name: str
|
||||
@@ -87,9 +97,21 @@ class SystemSettingsItem(BaseModel):
|
||||
allow_tenant_api_keys: bool = True
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||
available_languages: list[LanguagePackageItem] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SystemSettingsDeltaResponse(BaseModel):
|
||||
item: SystemSettingsItem | None = None
|
||||
sections: dict[str, Any] = Field(default_factory=dict)
|
||||
changed_sections: list[str] = Field(default_factory=list)
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class SystemSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -99,6 +121,9 @@ class SystemSettingsUpdateRequest(BaseModel):
|
||||
allow_tenant_api_keys: bool
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||
maintenance_mode: MaintenanceModeItem | None = None
|
||||
available_languages: list[LanguagePackageItem] | None = None
|
||||
enabled_language_codes: list[str] | None = None
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class ModuleCatalogItem(BaseModel):
|
||||
@@ -143,6 +168,7 @@ class ModuleStateUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled_modules: list[str] = Field(default_factory=list)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class ModuleInstallPlanItem(BaseModel):
|
||||
@@ -213,6 +239,9 @@ class ModuleInstallerRunSummary(BaseModel):
|
||||
status: str
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
request_id: str | None = None
|
||||
requested_by: str | None = None
|
||||
trace: dict[str, Any] | None = None
|
||||
rollback_status: str | None = None
|
||||
supervisor_status: str | None = None
|
||||
error: str | None = None
|
||||
@@ -224,6 +253,9 @@ class ModuleInstallerRunSummary(BaseModel):
|
||||
class ModuleInstallerRunListResponse(BaseModel):
|
||||
runs: list[ModuleInstallerRunSummary] = Field(default_factory=list)
|
||||
lock: ModuleInstallerLockStatus
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
full: bool = False
|
||||
|
||||
|
||||
class ModuleInstallerRequestOptions(BaseModel):
|
||||
@@ -256,6 +288,7 @@ class ModuleInstallerRequestItem(BaseModel):
|
||||
cancelled_at: str | None = None
|
||||
cancelled_by: str | None = None
|
||||
retry_of: str | None = None
|
||||
trace: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
record_path: str | None = None
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -264,6 +297,9 @@ class ModuleInstallerRequestItem(BaseModel):
|
||||
class ModuleInstallerRequestListResponse(BaseModel):
|
||||
requests: list[ModuleInstallerRequestItem] = Field(default_factory=list)
|
||||
daemon: ModuleInstallerDaemonStatus
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
full: bool = False
|
||||
|
||||
|
||||
class ModuleInstallerRequestCreateRequest(BaseModel):
|
||||
@@ -291,6 +327,27 @@ class ModulePackageCatalogItem(BaseModel):
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModuleLicenseDiagnostics(BaseModel):
|
||||
configured: bool = False
|
||||
valid: bool = True
|
||||
path: str | None = None
|
||||
license_id: str | None = None
|
||||
subject: str | None = None
|
||||
features: list[str] = Field(default_factory=list)
|
||||
valid_from: str | None = None
|
||||
valid_until: str | None = None
|
||||
signed: bool = False
|
||||
trusted: bool = False
|
||||
key_id: str | None = None
|
||||
enforced: bool = False
|
||||
allowed: bool = True
|
||||
required_features: list[str] = Field(default_factory=list)
|
||||
missing_features: list[str] = Field(default_factory=list)
|
||||
expires_in_days: int | None = None
|
||||
reason: str | None = None
|
||||
guidance: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ModulePackageCatalogResponse(BaseModel):
|
||||
modules: list[ModulePackageCatalogItem] = Field(default_factory=list)
|
||||
configured: bool = False
|
||||
@@ -298,13 +355,17 @@ class ModulePackageCatalogResponse(BaseModel):
|
||||
path: str | None = None
|
||||
source: str | None = None
|
||||
source_type: str | None = None
|
||||
cache_used: bool = False
|
||||
cache_path: str | None = None
|
||||
channel: str | None = None
|
||||
sequence: int | None = None
|
||||
generated_at: str | None = None
|
||||
not_before: str | None = None
|
||||
expires_at: str | None = None
|
||||
signed: bool = False
|
||||
trusted: bool = False
|
||||
key_id: str | None = None
|
||||
license: ModuleLicenseDiagnostics = Field(default_factory=ModuleLicenseDiagnostics)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
@@ -313,6 +374,7 @@ class ModuleInstallPlanUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class GovernanceAssignment(BaseModel):
|
||||
@@ -338,6 +400,14 @@ class GovernanceTemplateListResponse(BaseModel):
|
||||
templates: list[GovernanceTemplateItem]
|
||||
|
||||
|
||||
class GovernanceTemplateListDeltaResponse(BaseModel):
|
||||
templates: list[GovernanceTemplateItem] = Field(default_factory=list)
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class GovernanceTemplateCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -348,6 +418,7 @@ class GovernanceTemplateCreateRequest(BaseModel):
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class GovernanceTemplateUpdateRequest(BaseModel):
|
||||
@@ -358,3 +429,4 @@ class GovernanceTemplateUpdateRequest(BaseModel):
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||
change_request_id: str | None = None
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ def new_uuid() -> str:
|
||||
|
||||
|
||||
class GovernanceTemplate(Base, TimestampMixin):
|
||||
__tablename__ = "governance_templates"
|
||||
__tablename__ = "admin_governance_templates"
|
||||
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
@@ -26,12 +26,12 @@ class GovernanceTemplate(Base, TimestampMixin):
|
||||
|
||||
|
||||
class GovernanceTemplateAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "governance_template_assignments"
|
||||
__tablename__ = "admin_governance_template_assignments"
|
||||
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
template_id: Mapped[str] = mapped_column(ForeignKey("admin_governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user