From 56f0661a106452170e4662d362a41a47ee183f73 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 01:47:04 +0200 Subject: [PATCH] feat(tenancy): add tenant navigation overrides --- src/govoplan_tenancy/backend/api/v1/routes.py | 20 ++++++++++-- .../backend/api/v1/schemas.py | 4 ++- src/govoplan_tenancy/backend/manifest.py | 5 +-- webui/src/api/tenancy.ts | 4 +++ .../features/admin/TenantSettingsPanel.tsx | 31 +++++++++++++++++-- 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index 2220567..6509c5b 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -19,6 +19,10 @@ from govoplan_core.core.access import ( ) from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY +from govoplan_core.core.navigation import ( + navigation_preferences_from_settings, + update_navigation_preferences, +) from govoplan_core.core.principal_cache import invalidate_auth_principals from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session @@ -76,7 +80,7 @@ TENANT_SETTINGS_COLLECTION = "tenancy.tenant_settings" TENANT_SETTINGS_RESOURCE = "tenant_settings_section" ADMIN_MODULE_ID = "admin" ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings" -TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings") +TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "navigation", "settings") TENANT_NON_STATUS_UPDATE_FIELDS = { "name", "description", @@ -251,6 +255,7 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte system_payload = system_i18n_payload(system_settings) system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale) + navigation = navigation_preferences_from_settings(tenant.settings) return TenantSettingsItem( id=tenant.id, slug=tenant.slug, @@ -259,6 +264,7 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte available_languages=system_payload["available_languages"], system_enabled_language_codes=system_enabled, enabled_language_codes=enabled, + navigation=navigation.as_dict() if navigation is not None else None, settings=tenant.settings or {}, ) @@ -273,6 +279,7 @@ def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]: "system_enabled_language_codes": payload["system_enabled_language_codes"], "enabled_language_codes": payload["enabled_language_codes"], }, + "navigation": payload["navigation"], "settings": payload["settings"], } @@ -978,6 +985,11 @@ def update_tenant_settings( ) tenant.default_locale = payload.default_locale.strip() or enabled[0] tenant.settings = update_i18n_settings(tenant.settings, enabled_language_codes=enabled) + if "navigation" in payload.model_fields_set: + tenant.settings = update_navigation_preferences( + tenant.settings, + payload.navigation.model_dump(mode="json") if payload.navigation else None, + ) session.add(tenant) audit_event( session, @@ -986,7 +998,11 @@ def update_tenant_settings( action="tenant.settings.updated", object_type="tenant", object_id=tenant.id, - details={"default_locale": tenant.default_locale, "enabled_language_codes": enabled}, + details={ + "default_locale": tenant.default_locale, + "enabled_language_codes": enabled, + "navigation_updated": "navigation" in payload.model_fields_set, + }, ) after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant)) _record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal) diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py index eab8529..19bf619 100644 --- a/src/govoplan_tenancy/backend/api/v1/schemas.py +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -5,7 +5,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field -from govoplan_core.api.v1.schemas import DeltaDeletedItem +from govoplan_core.api.v1.schemas import DeltaDeletedItem, NavigationPreferencesPayload from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE @@ -137,6 +137,7 @@ class TenantSettingsItem(BaseModel): available_languages: list[dict[str, Any]] = Field(default_factory=list) system_enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list) + navigation: NavigationPreferencesPayload | None = None settings: dict[str, Any] = Field(default_factory=dict) @@ -155,3 +156,4 @@ class TenantSettingsUpdateRequest(BaseModel): default_locale: str = Field(min_length=1, max_length=20) enabled_language_codes: list[str] | None = None + navigation: NavigationPreferencesPayload | None = None diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index 0adaa56..6940b35 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -60,7 +60,7 @@ manifest = ModuleManifest( id="tenancy.lifecycle-and-settings", title="Administer tenant lifecycle and settings", summary="Tenancy adds explicit tenant creation, activation, context resolution, and tenant-owned settings over Core's shared scope storage.", - body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.", + body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenant administrators can inherit or override the system side-rail order and visibility and can lock entries visible for users; system locks remain effective. Personal navigation preferences still take precedence except that they cannot hide locked entries. Navigation changes never grant module entitlement, View visibility, or permissions. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.", documentation_types=("admin",), audience=("system_admin", "tenant_admin", "operator"), related_modules=("access", "admin", "audit"), @@ -83,7 +83,7 @@ manifest = ModuleManifest( id="tenancy.reference.admin-fields", title="Tenant administration fields and consequences", summary="Tenant identity, ownership, locale, governance overrides, and lifecycle state have different mutation and recovery consequences.", - body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. German is the reference and new-tenant default; locale and enabled languages are bounded by system language packages and may be changed explicitly. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it.", + body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. German is the reference and new-tenant default; locale and enabled languages are bounded by system language packages and may be changed explicitly. Tenant navigation inherits the system layer until explicitly saved; tenant locks keep entries visible for users but cannot relax a system lock. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it.", documentation_types=("admin",), audience=("system_admin", "tenant_admin", "operator"), related_modules=("access", "admin", "audit"), @@ -98,6 +98,7 @@ manifest = ModuleManifest( "tenancy.field.initial-owner", "tenancy.field.locale", "tenancy.field.languages", + "tenancy.admin.tenant-settings", "tenancy.field.governance", "tenancy.action.suspend", ], diff --git a/webui/src/api/tenancy.ts b/webui/src/api/tenancy.ts index 903d594..ed030aa 100644 --- a/webui/src/api/tenancy.ts +++ b/webui/src/api/tenancy.ts @@ -1,6 +1,7 @@ import type { ApiSettings, DeltaDeletedItem, + NavigationPreferences, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui"; @@ -41,6 +42,7 @@ export type TenantSettingsItem = { available_languages: LanguagePackage[]; system_enabled_language_codes: string[]; enabled_language_codes: string[]; + navigation?: NavigationPreferences | null; settings: Record; }; @@ -51,6 +53,7 @@ export type TenantSettingsDeltaSections = Partial<{ TenantSettingsItem, "available_languages" | "system_enabled_language_codes" | "enabled_language_codes" >; + navigation: TenantSettingsItem["navigation"]; settings: TenantSettingsItem["settings"]; }>; @@ -147,6 +150,7 @@ export function updateTenantSettings( payload: { default_locale: string; enabled_language_codes?: string[] | null; + navigation?: NavigationPreferences | null; } ): Promise { return apiFetch(settings, "/api/v1/admin/tenant/settings", { diff --git a/webui/src/features/admin/TenantSettingsPanel.tsx b/webui/src/features/admin/TenantSettingsPanel.tsx index 432dee5..462bf43 100644 --- a/webui/src/features/admin/TenantSettingsPanel.tsx +++ b/webui/src/features/admin/TenantSettingsPanel.tsx @@ -1,4 +1,10 @@ -import { DescriptionList } from "@govoplan/core-webui"; +import { + DescriptionList, + NavigationPreferenceEditor, + configurableNavigationItemsForModules, + dispatchPlatformModulesChanged, + usePlatformModules +} from "@govoplan/core-webui"; import { useEffect, useState } from "react"; import { ActionBlockerHint, @@ -35,6 +41,7 @@ const fallback: TenantSettingsItem = { ], system_enabled_language_codes: ["de", "en"], enabled_language_codes: ["de", "en"], + navigation: null, settings: {} }; @@ -48,6 +55,8 @@ export default function TenantSettingsPanel({ }: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise;}) { const { requestDiscard } = useUnsavedChanges(); + const { modules } = usePlatformModules(); + const navigationItems = configurableNavigationItemsForModules(modules); const [draft, setDraft] = useState(fallback); const [savedDraft, setSavedDraft] = useState(fallback); const [loading, setLoading] = useState(true); @@ -103,10 +112,15 @@ export default function TenantSettingsPanel({ setError(""); setSuccess(""); try { - const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes }); + const saved = await updateTenantSettings(settings, { + default_locale: draft.default_locale, + enabled_language_codes: draft.enabled_language_codes, + navigation: draft.navigation + }); setDraft(saved); setSavedDraft(saved); resetDeltaWatermark(DELTA_KEY); + dispatchPlatformModulesChanged(); setSuccess("i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681"); await onAuthRefresh(); return true; @@ -174,6 +188,15 @@ export default function TenantSettingsPanel({
i18n:govoplan-tenancy.available.7c62a142
{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}
+ + setDraft({ ...draft, navigation })} + /> + ); @@ -190,7 +213,8 @@ function localeOptions(current: string, enabled: string[]): string[] { function tenantSettingsDraftKey(item: TenantSettingsItem): string { return JSON.stringify({ default_locale: item.default_locale, - enabled_language_codes: item.enabled_language_codes + enabled_language_codes: item.enabled_language_codes, + navigation: item.navigation }); } @@ -200,6 +224,7 @@ function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantS ...(sections.identity ?? {}), ...(sections.locale ?? {}), ...(sections.languages ?? {}), + ...(sections.navigation !== undefined ? { navigation: sections.navigation } : {}), ...(sections.settings ? { settings: sections.settings } : {}) }; }