diff --git a/README.md b/README.md index 51b411a..eceef69 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,19 @@ Package mutation is intentionally not executed inside the FastAPI request. The admin UI records operator intent and queues or renders commands for the trusted installer process described in `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`. + +## Package Surfaces + +The admin UI intentionally exposes two different package concepts: + +- **Configuration packages** are import/export bundles for module-owned + configuration data. They support dry-run diagnostics, approval, apply, and + export workflows without installing Python or WebUI packages. +- **Module package catalog** and **operator install plan** live under + **Modules**. They describe approved release artifacts, install/update/remove + plans, preflight blockers, maintenance-mode requirements, installer-daemon + requests, and rollback visibility. + +These surfaces should stay separate in navigation and copy. If future UI work +combines them visually, it must still preserve the operator distinction between +configuration mutation and package installation. diff --git a/src/govoplan_admin/backend/api/v1/routes.py b/src/govoplan_admin/backend/api/v1/routes.py index 0819bca..14f59ca 100644 --- a/src/govoplan_admin/backend/api/v1/routes.py +++ b/src/govoplan_admin/backend/api/v1/routes.py @@ -59,6 +59,12 @@ from govoplan_core.core.module_installer import ( read_module_installer_request, retry_module_installer_request, ) +from govoplan_core.core.module_installer_notifications import ( + emit_module_installer_notification, + installer_notification_body, + installer_notification_priority, + installer_notification_subject, +) 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 @@ -147,6 +153,11 @@ def _request_registry(request: Request) -> PlatformRegistry: return registry +def _optional_request_registry(request: Request) -> PlatformRegistry | None: + registry = getattr(request.app.state, "govoplan_registry", None) + return registry if isinstance(registry, PlatformRegistry) else None + + def _request_lifecycle(request: Request) -> ModuleLifecycleManager: lifecycle = getattr(request.app.state, "govoplan_lifecycle", None) if not isinstance(lifecycle, ModuleLifecycleManager): @@ -154,6 +165,29 @@ def _request_lifecycle(request: Request) -> ModuleLifecycleManager: return lifecycle +def _emit_installer_request_notification( + *, + session: Session, + registry: PlatformRegistry | None, + tenant_id: str | None, + request: dict[str, object], + event_kind: str, + recipient_id: str | None = None, +) -> None: + status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1]) + emit_module_installer_notification( + session=session, + registry=registry, + tenant_id=tenant_id, + request=request, + event_kind=event_kind, + subject=installer_notification_subject(event_kind, request), + body_text=installer_notification_body(event_kind, request), + recipient_id=recipient_id, + priority=installer_notification_priority(status_value), + ) + + def _http_admin_error(exc: Exception) -> HTTPException: if isinstance(exc, AdminConflictError): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) @@ -663,6 +697,7 @@ def read_module_install_request_detail( @router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem) def create_module_install_request( payload: ModuleInstallerRequestCreateRequest, + request_context: Request, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): @@ -675,8 +710,17 @@ def create_module_install_request( request = queue_module_installer_request( runtime_dir=runtime_dir, requested_by=principal.user.id, + tenant_id=principal.tenant_id, options=payload.options.model_dump(exclude_none=True), ) + _emit_installer_request_notification( + session=session, + registry=_optional_request_registry(request_context), + tenant_id=principal.tenant_id, + request=request, + event_kind="module_installer.request.queued", + recipient_id=principal.user.id, + ) audit_from_principal( session, principal, @@ -698,6 +742,7 @@ def create_module_install_request( @router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem) def cancel_module_install_request( request_id: str, + request_context: Request, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): @@ -715,6 +760,14 @@ def cancel_module_install_request( ) except ModuleInstallerError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + _emit_installer_request_notification( + session=session, + registry=_optional_request_registry(request_context), + tenant_id=str(request.get("tenant_id") or principal.tenant_id), + request=request, + event_kind="module_installer.request.cancelled", + recipient_id=str(request.get("requested_by") or principal.user.id), + ) audit_from_principal( session, principal, @@ -736,6 +789,7 @@ def cancel_module_install_request( @router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem) def retry_module_install_request( request_id: str, + request_context: Request, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:settings:write")), ): @@ -753,6 +807,14 @@ def retry_module_install_request( ) except ModuleInstallerError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + _emit_installer_request_notification( + session=session, + registry=_optional_request_registry(request_context), + tenant_id=str(request.get("tenant_id") or principal.tenant_id), + request=request, + event_kind="module_installer.request.queued", + recipient_id=principal.user.id, + ) audit_from_principal( session, principal, diff --git a/src/govoplan_admin/backend/api/v1/schemas.py b/src/govoplan_admin/backend/api/v1/schemas.py index 614472d..6eeabf4 100644 --- a/src/govoplan_admin/backend/api/v1/schemas.py +++ b/src/govoplan_admin/backend/api/v1/schemas.py @@ -3,61 +3,10 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field from govoplan_core.api.v1.schemas import DeltaDeletedItem - - -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) +from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem class MaintenanceModeItem(BaseModel): diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 5ce4a91..88c074f 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,74 +1,16 @@ -import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; -import { apiFetch } from "@govoplan/core-webui"; - -export type PermissionItem = { - scope: string; - label: string; - description: string; - category: string; - level: "tenant" | "system"; - system_template_id?: string | null; - system_required?: boolean; -}; - -export type AdminOverview = { - active_tenant_id: string; - active_tenant_name: string; - tenant_count?: number | null; - system_account_count?: number | null; - system_group_template_count?: number | null; - system_role_template_count?: number | null; - user_count: number; - active_user_count: number; - group_count: number; - role_count: number; - active_api_key_count: number; - capabilities: string[]; -}; - -export type TenantAdminItem = { - id: string; - slug: string; - name: string; - description?: string | null; - default_locale: string; - settings: Record; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; - effective_governance: Record; - is_active: boolean; - counts: Record; - created_at: string; - updated_at: string; -}; - -export type PrivacyRetentionPolicyFieldKey = - | "store_raw_campaign_json" - | "raw_campaign_json_retention_days" - | "generated_eml_retention_days" - | "stored_report_detail_retention_days" - | "mock_mailbox_retention_days" - | "audit_detail_retention_days" - | "audit_detail_level"; - -export type PrivacyRetentionLimitPermissions = Record; -export type PrivacyRetentionLimitPermissionPatch = Partial; - -export type PrivacyRetentionPolicy = { - store_raw_campaign_json: boolean; - raw_campaign_json_retention_days?: number | null; - generated_eml_retention_days?: number | null; - stored_report_detail_retention_days?: number | null; - mock_mailbox_retention_days?: number | null; - audit_detail_retention_days?: number | null; - audit_detail_level: "full" | "redacted" | "minimal"; - allow_lower_level_limits: PrivacyRetentionLimitPermissions; -}; - -export type PrivacyRetentionPolicyPatch = Partial> & { - allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch; -}; +import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui"; +import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui"; +export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; +export type { + AdminOverview, + PrivacyRetentionLimitPermissionPatch, + PrivacyRetentionLimitPermissions, + PrivacyRetentionPolicy, + PrivacyRetentionPolicyFieldKey, + PrivacyRetentionPolicyPatch, + PermissionItem, + TenantAdminItem +} from "@govoplan/core-webui"; export type MaintenanceMode = { enabled: boolean; @@ -192,10 +134,7 @@ type DeltaResponseFields = { }; function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { - const params = new URLSearchParams(); - if (options.since) params.set("since", options.since); - if (options.limit) params.set("limit", String(options.limit)); - return params.toString() ? `?${params.toString()}` : ""; + return apiQuery(options); } export type SystemSettingsDeltaResponse = { @@ -541,20 +480,6 @@ export type GovernanceTemplateItem = { updated_at: string; }; -export function fetchAdminOverview(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/overview"); -} - -export async function fetchPermissionCatalog(settings: ApiSettings): Promise { - const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); - return response.permissions; -} - -export async function fetchTenants(settings: ApiSettings): Promise { - const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); - return response.tenants; -} - export function fetchSystemSettings(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/admin/system/settings"); } @@ -584,19 +509,11 @@ export function fetchModuleInstallPlan(settings: ApiSettings): Promise { - const params = new URLSearchParams(); - if (options.page_size) params.set("page_size", String(options.page_size)); - if (options.cursor) params.set("cursor", options.cursor); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/admin/system/modules/install-runs${suffix}`); + return apiFetch(settings, apiPath("/api/v1/admin/system/modules/install-runs", options)); } export function fetchModuleInstallerRequests(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise { - const params = new URLSearchParams(); - if (options.page_size) params.set("page_size", String(options.page_size)); - if (options.cursor) params.set("cursor", options.cursor); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/admin/system/modules/install-requests${suffix}`); + return apiFetch(settings, apiPath("/api/v1/admin/system/modules/install-requests", options)); } export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise { @@ -638,9 +555,7 @@ export function clearModuleInstallPlan(settings: ApiSettings): Promise { - const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; - const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); - return response.templates; + return apiGetList(settings, "/api/v1/admin/system/governance-templates", "templates", { kind }); } export function createGovernanceTemplate(settings: ApiSettings, payload: Omit & { change_request_id?: string | null }): Promise { @@ -652,8 +567,7 @@ export function updateGovernanceTemplate(settings: ApiSettings, templateId: stri } export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise { - const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : ""; - return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" }); + return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" }); } export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> { diff --git a/webui/src/features/admin/AdminOverviewPanel.tsx b/webui/src/features/admin/AdminOverviewPanel.tsx index ecdd476..2e38823 100644 --- a/webui/src/features/admin/AdminOverviewPanel.tsx +++ b/webui/src/features/admin/AdminOverviewPanel.tsx @@ -43,7 +43,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio {hasAnySection(availableSections, platformSectionIds) &&
{availableSections.has("system-modules") && onSelect("system-modules")} />} - {availableSections.has("system-configuration-packages") && onSelect("system-configuration-packages")} />} + {availableSections.has("system-configuration-packages") && onSelect("system-configuration-packages")} />} {availableSections.has("system-settings") && onSelect("system-settings")} />} {availableSections.has("system-configuration-changes") && onSelect("system-configuration-changes")} />} {availableSections.has("system-audit") && onSelect("system-audit")} />} diff --git a/webui/src/module.ts b/webui/src/module.ts index 8d6165d..849485f 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -54,7 +54,7 @@ const adminSections: AdminSectionsUiCapability = { }, { id: "system-configuration-packages", - label: "i18n:govoplan-admin.packages.0a999012", + label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", group: "SYSTEM", order: 30, allOf: ["system:settings:read"], @@ -115,4 +115,4 @@ export const adminModule: PlatformWebModule = { } }; -export default adminModule; \ No newline at end of file +export default adminModule;