feat: govern tenant appearance overrides

This commit is contained in:
2026-08-20 10:50:55 +02:00
parent c5119ab868
commit c558621550
6 changed files with 103 additions and 9 deletions
+40 -4
View File
@@ -18,7 +18,14 @@ from govoplan_core.core.access import (
TenantContextSwitcher,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired
from govoplan_core.core.appearance import APPEARANCE_SETTINGS_KEY, appearance_settings, resolve_effective_appearance, update_appearance_settings
from govoplan_core.core.appearance import (
APPEARANCE_SETTINGS_KEY,
appearance_custom_overrides_policy,
appearance_settings,
resolve_effective_appearance,
update_appearance_custom_overrides_policy,
update_appearance_settings,
)
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
from govoplan_core.core.navigation import (
navigation_preferences_from_settings,
@@ -259,6 +266,8 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte
navigation = navigation_preferences_from_settings(tenant.settings)
system_palette, system_locked = appearance_settings(system_settings.settings)
tenant_palette, tenant_locked = appearance_settings(tenant.settings)
system_custom_overrides_allowed = appearance_custom_overrides_policy(system_settings.settings) is True
tenant_custom_overrides_allowed = appearance_custom_overrides_policy(tenant.settings)
effective_appearance = resolve_effective_appearance(
system_settings=system_settings.settings,
tenant_settings=tenant.settings,
@@ -279,6 +288,9 @@ def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsIte
system_appearance_palette_locked=system_locked,
effective_appearance_palette=effective_appearance.palette,
effective_appearance_source=effective_appearance.source,
appearance_custom_overrides_allowed=tenant_custom_overrides_allowed,
system_appearance_custom_overrides_allowed=system_custom_overrides_allowed,
effective_appearance_custom_overrides_allowed=effective_appearance.custom_overrides_allowed,
settings=tenant.settings or {},
)
@@ -301,6 +313,9 @@ def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]:
"system_appearance_palette_locked": payload["system_appearance_palette_locked"],
"effective_appearance_palette": payload["effective_appearance_palette"],
"effective_appearance_source": payload["effective_appearance_source"],
"appearance_custom_overrides_allowed": payload["appearance_custom_overrides_allowed"],
"system_appearance_custom_overrides_allowed": payload["system_appearance_custom_overrides_allowed"],
"effective_appearance_custom_overrides_allowed": payload["effective_appearance_custom_overrides_allowed"],
},
"settings": payload["settings"],
}
@@ -997,6 +1012,8 @@ def update_tenant_settings(
system_settings = get_system_settings(session)
tenant_palette, tenant_locked = appearance_settings(tenant.settings)
system_palette, system_locked = appearance_settings(system_settings.settings)
system_custom_overrides_allowed = appearance_custom_overrides_policy(system_settings.settings) is True
tenant_custom_overrides_allowed = appearance_custom_overrides_policy(tenant.settings)
appearance_palette_changed = (
"appearance_palette" in payload.model_fields_set
and payload.appearance_palette != tenant_palette
@@ -1005,17 +1022,26 @@ def update_tenant_settings(
"appearance_palette_locked" in payload.model_fields_set
and payload.appearance_palette_locked != tenant_locked
)
custom_overrides_policy_changed = (
"appearance_custom_overrides_allowed" in payload.model_fields_set
and payload.appearance_custom_overrides_allowed != tenant_custom_overrides_allowed
)
if payload.appearance_custom_overrides_allowed is True and not system_custom_overrides_allowed:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The system appearance policy does not allow personal custom overrides.",
)
if system_locked and (appearance_palette_changed or appearance_lock_changed):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"The system appearance policy locks palette {system_palette or 'default'}.",
)
if (
appearance_lock_changed or (tenant_locked and appearance_palette_changed)
appearance_lock_changed or (tenant_locked and appearance_palette_changed) or custom_overrides_policy_changed
) and not has_scope(principal, "admin:policies:write"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Changing the tenant appearance lock or its locked value requires admin:policies:write.",
detail="Changing the tenant appearance policy requires admin:policies:write.",
)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
current_i18n = i18n_settings(tenant.settings)
@@ -1035,6 +1061,11 @@ def update_tenant_settings(
default_palette=payload.appearance_palette if "appearance_palette" in payload.model_fields_set else current_palette,
palette_locked=payload.appearance_palette_locked if payload.appearance_palette_locked is not None else current_locked,
)
if "appearance_custom_overrides_allowed" in payload.model_fields_set:
tenant.settings = update_appearance_custom_overrides_policy(
tenant.settings,
allowed=payload.appearance_custom_overrides_allowed,
)
if "navigation" in payload.model_fields_set:
tenant.settings = update_navigation_preferences(
tenant.settings,
@@ -1052,9 +1083,14 @@ def update_tenant_settings(
"default_locale": tenant.default_locale,
"enabled_language_codes": enabled,
"navigation_updated": "navigation" in payload.model_fields_set,
"appearance_updated": bool({"appearance_palette", "appearance_palette_locked"}.intersection(payload.model_fields_set)),
"appearance_updated": bool(
{"appearance_palette", "appearance_palette_locked", "appearance_custom_overrides_allowed"}.intersection(
payload.model_fields_set
)
),
"appearance_palette": appearance_settings(tenant.settings)[0],
"appearance_palette_locked": appearance_settings(tenant.settings)[1],
"appearance_custom_overrides_allowed": appearance_custom_overrides_policy(tenant.settings),
},
)
after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
@@ -144,6 +144,9 @@ class TenantSettingsItem(BaseModel):
system_appearance_palette_locked: bool = False
effective_appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
effective_appearance_source: Literal["tenant", "system", "tenant_lock", "system_lock"] = "system"
appearance_custom_overrides_allowed: bool | None = None
system_appearance_custom_overrides_allowed: bool = False
effective_appearance_custom_overrides_allowed: bool = False
settings: dict[str, Any] = Field(default_factory=dict)
@@ -165,3 +168,4 @@ class TenantSettingsUpdateRequest(BaseModel):
navigation: NavigationPreferencesPayload | None = None
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
appearance_palette_locked: bool | None = None
appearance_custom_overrides_allowed: bool | None = None
+1 -1
View File
@@ -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. 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. Tenant appearance likewise inherits the system palette until explicitly selected; an unlocked tenant default permits a personal palette, while a policy-authorized tenant lock suppresses it and a system lock always wins. Resetting the tenant palette restores inheritance rather than copying the current system value. Navigation and appearance 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.",
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. Tenant appearance likewise inherits the system palette until explicitly selected; an unlocked tenant default permits a personal palette, while a policy-authorized tenant lock suppresses it and a system lock always wins. Resetting the tenant palette restores inheritance rather than copying the current system value. When the system permits advanced personal color overrides, a policy-authorized tenant administrator may inherit, allow, or block them; the tenant cannot enable a system-denied policy, and palette locks still suppress the editor. Advanced documents cover both light and dark modes and are validated atomically by Core. Navigation and appearance 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"),