intermittent commit
This commit is contained in:
16
README.md
16
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
counts: Record<string, number>;
|
||||
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<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
||||
|
||||
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<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
||||
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<AdminOverview> {
|
||||
return apiFetch(settings, "/api/v1/admin/overview");
|
||||
}
|
||||
|
||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
||||
return response.permissions;
|
||||
}
|
||||
|
||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
||||
return response.tenants;
|
||||
}
|
||||
|
||||
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||
}
|
||||
@@ -584,19 +509,11 @@ export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleIns
|
||||
}
|
||||
|
||||
export function fetchModuleInstallerRuns(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRunListResponse> {
|
||||
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<ModuleInstallerRequestListResponse> {
|
||||
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<ModuleInstallerRequestItem> {
|
||||
@@ -638,9 +555,7 @@ export function clearModuleInstallPlan(settings: ApiSettings): Promise<ModuleIns
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
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<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||
}
|
||||
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
|
||||
@@ -652,8 +567,7 @@ export function updateGovernanceTemplate(settings: ApiSettings, templateId: stri
|
||||
}
|
||||
|
||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> {
|
||||
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[] }> {
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{hasAnySection(availableSections, platformSectionIds) && <Card title="ADMINISTRATION">
|
||||
<div className="admin-overview-grid">
|
||||
{availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />}
|
||||
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.packages.0a999012" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
|
||||
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.configuration_packages.eb2f05f1" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
|
||||
{availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />}
|
||||
{availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />}
|
||||
{availableSections.has("system-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.system_level_administrative_history.49f76723" onClick={() => onSelect("system-audit")} />}
|
||||
|
||||
@@ -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;
|
||||
export default adminModule;
|
||||
|
||||
Reference in New Issue
Block a user