feat(core): add layered side rail preferences
This commit is contained in:
@@ -93,6 +93,15 @@ class TenantMembershipInfo(TenantInfo):
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class NavigationPreferencesPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
contract_version: Literal["1"] = "1"
|
||||
order: list[str] = Field(default_factory=list, max_length=256)
|
||||
hidden: list[str] = Field(default_factory=list, max_length=256)
|
||||
locked: list[str] = Field(default_factory=list, max_length=256)
|
||||
|
||||
|
||||
class UserUiPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -101,6 +110,7 @@ class UserUiPreferences(BaseModel):
|
||||
reduce_motion: bool = False
|
||||
sticky_section_sidebars: bool = True
|
||||
theme: Literal["system", "light", "dark"] = "system"
|
||||
navigation: NavigationPreferencesPayload | None = None
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
NAVIGATION_PREFERENCES_KEY = "navigation_preferences"
|
||||
NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
|
||||
_MAX_ITEMS = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavigationPreferences:
|
||||
order: tuple[str, ...] = ()
|
||||
hidden: tuple[str, ...] = ()
|
||||
locked: tuple[str, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": NAVIGATION_PREFERENCES_CONTRACT_VERSION,
|
||||
"order": list(self.order),
|
||||
"hidden": list(self.hidden),
|
||||
"locked": list(self.locked),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveNavigationItem:
|
||||
id: str
|
||||
order: int
|
||||
visible: bool
|
||||
locked: bool
|
||||
order_source: str
|
||||
visibility_source: str
|
||||
lock_source: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"navigation_id": self.id,
|
||||
"order": self.order,
|
||||
"navigation_visible": self.visible,
|
||||
"navigation_locked": self.locked,
|
||||
"navigation_order_source": self.order_source,
|
||||
"navigation_visibility_source": self.visibility_source,
|
||||
"navigation_lock_source": self.lock_source,
|
||||
}
|
||||
|
||||
|
||||
def navigation_preferences_from_settings(
|
||||
settings: object,
|
||||
) -> NavigationPreferences | None:
|
||||
if not isinstance(settings, Mapping):
|
||||
return None
|
||||
raw = settings.get(NAVIGATION_PREFERENCES_KEY)
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
return navigation_preferences_from_mapping(raw)
|
||||
|
||||
|
||||
def navigation_preferences_from_mapping(
|
||||
raw: Mapping[str, Any],
|
||||
) -> NavigationPreferences:
|
||||
return NavigationPreferences(
|
||||
order=_ids(raw.get("order")),
|
||||
hidden=_ids(raw.get("hidden")),
|
||||
locked=_ids(raw.get("locked")),
|
||||
)
|
||||
|
||||
|
||||
def update_navigation_preferences(
|
||||
settings: object,
|
||||
preferences: NavigationPreferences | Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
updated = dict(settings) if isinstance(settings, Mapping) else {}
|
||||
if preferences is None:
|
||||
updated.pop(NAVIGATION_PREFERENCES_KEY, None)
|
||||
else:
|
||||
raw = preferences.as_dict() if isinstance(preferences, NavigationPreferences) else preferences
|
||||
updated[NAVIGATION_PREFERENCES_KEY] = navigation_preferences_from_mapping(
|
||||
raw
|
||||
).as_dict()
|
||||
return updated
|
||||
|
||||
|
||||
def resolve_navigation_preferences(
|
||||
item_ids: Iterable[str],
|
||||
*,
|
||||
system: NavigationPreferences | None = None,
|
||||
tenant: NavigationPreferences | None = None,
|
||||
user: NavigationPreferences | None = None,
|
||||
) -> dict[str, EffectiveNavigationItem]:
|
||||
ordered = list(dict.fromkeys(_clean_id(item_id) for item_id in item_ids))
|
||||
ordered = [item_id for item_id in ordered if item_id]
|
||||
available = set(ordered)
|
||||
order_source = {item_id: "module" for item_id in ordered}
|
||||
visibility = {item_id: True for item_id in ordered}
|
||||
visibility_source = {item_id: "module" for item_id in ordered}
|
||||
locks: dict[str, str] = {}
|
||||
|
||||
for source, preferences, may_lock in (
|
||||
("system", system, True),
|
||||
("tenant", tenant, True),
|
||||
("user", user, False),
|
||||
):
|
||||
if preferences is None:
|
||||
continue
|
||||
requested_order = [item_id for item_id in preferences.order if item_id in available]
|
||||
if requested_order:
|
||||
requested = set(requested_order)
|
||||
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
|
||||
for item_id in requested_order:
|
||||
order_source[item_id] = source
|
||||
|
||||
requested_hidden = set(preferences.hidden).intersection(available)
|
||||
for item_id in available:
|
||||
if item_id in locks:
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = locks[item_id]
|
||||
continue
|
||||
visibility[item_id] = item_id not in requested_hidden
|
||||
visibility_source[item_id] = source
|
||||
|
||||
if may_lock:
|
||||
for item_id in preferences.locked:
|
||||
if item_id not in available:
|
||||
continue
|
||||
locks[item_id] = source
|
||||
visibility[item_id] = True
|
||||
visibility_source[item_id] = source
|
||||
|
||||
return {
|
||||
item_id: EffectiveNavigationItem(
|
||||
id=item_id,
|
||||
order=index,
|
||||
visible=visibility[item_id],
|
||||
locked=item_id in locks,
|
||||
order_source=order_source[item_id],
|
||||
visibility_source=visibility_source[item_id],
|
||||
lock_source=locks.get(item_id),
|
||||
)
|
||||
for index, item_id in enumerate(ordered)
|
||||
}
|
||||
|
||||
|
||||
def _ids(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return ()
|
||||
cleaned = tuple(
|
||||
dict.fromkeys(
|
||||
item_id
|
||||
for item in value[:_MAX_ITEMS]
|
||||
if (item_id := _clean_id(item))
|
||||
)
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _clean_id(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
clean = value.strip()
|
||||
if not clean or len(clean) > 255 or any(ord(character) < 32 for character in clean):
|
||||
return ""
|
||||
return clean
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EffectiveNavigationItem",
|
||||
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
|
||||
"NAVIGATION_PREFERENCES_KEY",
|
||||
"NavigationPreferences",
|
||||
"navigation_preferences_from_mapping",
|
||||
"navigation_preferences_from_settings",
|
||||
"resolve_navigation_preferences",
|
||||
"update_navigation_preferences",
|
||||
]
|
||||
@@ -7,6 +7,12 @@ from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.navigation import (
|
||||
EffectiveNavigationItem,
|
||||
navigation_preferences_from_mapping,
|
||||
navigation_preferences_from_settings,
|
||||
resolve_navigation_preferences,
|
||||
)
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
module_entitlement_payload,
|
||||
tenant_module_entitlement_state,
|
||||
@@ -87,21 +93,116 @@ def _effective_manifest_state(
|
||||
)
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||
def _effective_navigation_state(
|
||||
principal: ApiPrincipal,
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> dict[str, dict[str, EffectiveNavigationItem]]:
|
||||
default_items: list[tuple[int, str, str]] = []
|
||||
for manifest in manifests:
|
||||
frontend_items = (
|
||||
manifest.frontend.nav_items
|
||||
if manifest.frontend is not None and manifest.frontend.nav_items
|
||||
else manifest.nav_items
|
||||
)
|
||||
for item in frontend_items:
|
||||
navigation_id = item.surface_id or navigation_view_surface_id(
|
||||
manifest.id, item.path
|
||||
)
|
||||
default_items.append((item.order, item.label, navigation_id))
|
||||
default_ids = [
|
||||
item_id
|
||||
for _order, _label, item_id in sorted(
|
||||
default_items, key=lambda item: (item[0], item[1], item[2])
|
||||
)
|
||||
]
|
||||
|
||||
principal_ref = getattr(principal, "principal", None)
|
||||
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||
system_preferences = None
|
||||
tenant_preferences = None
|
||||
if tenant_id is not None:
|
||||
try:
|
||||
with get_database().session() as session:
|
||||
system_item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
system_preferences = navigation_preferences_from_settings(
|
||||
system_item.settings if system_item is not None else {}
|
||||
)
|
||||
tenant_preferences = navigation_preferences_from_settings(
|
||||
tenant.settings if tenant is not None else {}
|
||||
)
|
||||
except (RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Navigation preferences could not be resolved.",
|
||||
) from exc
|
||||
|
||||
user_settings = getattr(getattr(principal, "user", None), "settings", {})
|
||||
user_ui = user_settings.get("ui") if isinstance(user_settings, dict) else {}
|
||||
user_navigation = user_ui.get("navigation") if isinstance(user_ui, dict) else None
|
||||
user_preferences = (
|
||||
navigation_preferences_from_mapping(user_navigation)
|
||||
if isinstance(user_navigation, dict)
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"module": resolve_navigation_preferences(default_ids),
|
||||
"system": resolve_navigation_preferences(
|
||||
default_ids,
|
||||
system=system_preferences,
|
||||
),
|
||||
"tenant": resolve_navigation_preferences(
|
||||
default_ids,
|
||||
system=system_preferences,
|
||||
tenant=tenant_preferences,
|
||||
),
|
||||
"user": resolve_navigation_preferences(
|
||||
default_ids,
|
||||
system=system_preferences,
|
||||
tenant=tenant_preferences,
|
||||
user=user_preferences,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _nav_item_payload(
|
||||
item: NavItem,
|
||||
module_id: str | None = None,
|
||||
navigation: dict[str, dict[str, EffectiveNavigationItem]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
navigation_id = (
|
||||
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||
if module_id
|
||||
else item.surface_id
|
||||
)
|
||||
effective = (
|
||||
navigation.get("user", {}).get(navigation_id)
|
||||
if navigation_id and navigation
|
||||
else None
|
||||
)
|
||||
payload = {
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"order": item.order,
|
||||
"surface_id": (
|
||||
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||
if module_id
|
||||
else item.surface_id
|
||||
),
|
||||
"order": effective.order if effective is not None else item.order,
|
||||
"surface_id": navigation_id,
|
||||
}
|
||||
if effective is not None:
|
||||
payload.update(effective.as_dict())
|
||||
payload["navigation_layers"] = {
|
||||
scope: {
|
||||
"order": scoped.order,
|
||||
"visible": scoped.visible,
|
||||
"locked": scoped.locked,
|
||||
"lock_source": scoped.lock_source,
|
||||
}
|
||||
for scope in ("module", "system", "tenant")
|
||||
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
|
||||
@@ -241,7 +342,10 @@ def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, obj
|
||||
return contexts
|
||||
|
||||
|
||||
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
def _frontend_payload(
|
||||
manifest: ModuleManifest,
|
||||
navigation: dict[str, dict[str, EffectiveNavigationItem]] | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return None
|
||||
@@ -257,7 +361,10 @@ def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||
"nav": [
|
||||
_nav_item_payload(item, manifest.id, navigation)
|
||||
for item in frontend.nav_items
|
||||
],
|
||||
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
||||
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
||||
"view_surfaces": _frontend_view_surfaces(manifest),
|
||||
@@ -323,6 +430,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
request,
|
||||
principal,
|
||||
)
|
||||
navigation_state = _effective_navigation_state(principal, manifests)
|
||||
principal_ref = getattr(principal, "principal", None)
|
||||
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||
return {
|
||||
@@ -351,8 +459,11 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
if key != "declarations"
|
||||
},
|
||||
"help_contexts": _documentation_help_contexts(manifest),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
"nav": [
|
||||
_nav_item_payload(item, manifest.id, navigation_state)
|
||||
for item in manifest.nav_items
|
||||
],
|
||||
"frontend": _frontend_payload(manifest, navigation_state),
|
||||
}
|
||||
for manifest in manifests
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user