diff --git a/docs/QUICK_ACCESS.md b/docs/QUICK_ACCESS.md index a0f86c2..5b0513e 100644 --- a/docs/QUICK_ACCESS.md +++ b/docs/QUICK_ACCESS.md @@ -25,6 +25,12 @@ override that decision, but may configure any still-available item. Stale preferences are retained and diagnosed so uninstalling and reinstalling a contributing module does not silently discard a user's arrangement. +The effective API reports each item's `availability_state`, +`availability_source`, and `order_source`. It also returns every unavailable +stored id as a `stale_preferences` entry with its system, tenant, or user +source. Administration and support tooling can therefore explain a result +without reading or copying any contributing module's state. + ## Categories The initial stable categories are Work, Calendar, Messages, and Files. Messages diff --git a/src/govoplan_quick_access/backend/manifest.py b/src/govoplan_quick_access/backend/manifest.py index ff1cedc..1414876 100644 --- a/src/govoplan_quick_access/backend/manifest.py +++ b/src/govoplan_quick_access/backend/manifest.py @@ -174,6 +174,8 @@ DOCUMENTATION = ( body=( "The catalogue follows installed module registrations. System settings constrain tenants; " "tenant settings constrain users. An item may remain available, be blocked, or be forced. " + "Effective entries identify the system, tenant, user, or module source of availability and ordering; " + "preferences for retired entries retain their scope provenance. " "Views and permissions form additional ceilings and Quick Access never grants access to domain data. " "A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized " "tools participate. The All available tools escape only restores that permission-derived set for the current session; " @@ -191,6 +193,8 @@ DOCUMENTATION = ( "body": ( "Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, " "Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. " + "Effektive Eintraege nennen System, Mandant, Benutzer oder Modul als Quelle fuer Verfuegbarkeit und Reihenfolge; " + "Einstellungen fuer entfernte Eintraege behalten ihren Ebenennachweis. " "Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. " "Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb " "der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Alle verfuegbaren Werkzeuge stellt nur diese " diff --git a/src/govoplan_quick_access/backend/schemas.py b/src/govoplan_quick_access/backend/schemas.py index 07af5a9..61f1d21 100644 --- a/src/govoplan_quick_access/backend/schemas.py +++ b/src/govoplan_quick_access/backend/schemas.py @@ -71,17 +71,30 @@ class EffectiveToolResponse(CatalogueToolResponse): enabled: bool forced: bool locked_by: str | None = None + availability_state: Literal["available", "forced", "blocked"] + availability_source: Literal["module", "system", "tenant", "user"] + order_source: Literal["module", "system", "tenant", "user"] class EffectiveCategoryResponse(CatalogueCategoryResponse): enabled: bool forced: bool locked_by: str | None = None + availability_state: Literal["available", "forced", "blocked"] + availability_source: Literal["module", "system", "tenant", "user"] + order_source: Literal["module", "system", "tenant", "user"] tools: list[EffectiveToolResponse] +class EffectiveStalePreferenceResponse(BaseModel): + id: str + kind: Literal["category", "tool"] + source: Literal["system", "tenant", "user"] + + class EffectiveQuickAccessResponse(BaseModel): categories: list[EffectiveCategoryResponse] + stale_preferences: list[EffectiveStalePreferenceResponse] = Field(default_factory=list) diagnostics: list[str] = Field(default_factory=list) diff --git a/src/govoplan_quick_access/backend/service.py b/src/govoplan_quick_access/backend/service.py index 0378a1f..d0e2eb9 100644 --- a/src/govoplan_quick_access/backend/service.py +++ b/src/govoplan_quick_access/backend/service.py @@ -18,6 +18,7 @@ from govoplan_quick_access.backend.schemas import ( CatalogueToolResponse, EffectiveCategoryResponse, EffectiveQuickAccessResponse, + EffectiveStalePreferenceResponse, EffectiveToolResponse, PreferenceEntry, ProfileResponse, @@ -241,6 +242,7 @@ def resolve_effective( ("user", get_profile(session, scope_type="user", tenant_id=tenant_id, scope_id=account_id)), ) diagnostics: list[str] = [] + stale_preferences = _stale_preferences(profiles, catalogue) category_states: dict[str, _EffectiveState] = {} for category in catalogue.categories: state = _EffectiveState(enabled=True, order=category.order) @@ -259,6 +261,11 @@ def resolve_effective( state.apply(entry, source=source) category_state = category_states.get(tool.category_id) enabled = state.enabled and bool(category_state and category_state.enabled) + availability_source = ( + category_state.availability_source + if state.enabled and category_state and not category_state.enabled + else state.availability_source + ) tool_payload = tool.model_dump() tool_payload["order"] = state.order tools_by_category.setdefault(tool.category_id, []).append( @@ -267,6 +274,9 @@ def resolve_effective( enabled=enabled, forced=state.forced, locked_by=state.locked_by, + availability_state=_availability_state(enabled, state.forced), + availability_source=availability_source, + order_source=state.order_source, ) ) @@ -286,12 +296,16 @@ def resolve_effective( enabled=enabled, forced=state.forced, locked_by=state.locked_by, + availability_state=_availability_state(enabled, state.forced), + availability_source=state.availability_source, + order_source=state.order_source, tools=tools, ) ) categories.sort(key=lambda item: (item.order, item.id)) return EffectiveQuickAccessResponse( categories=categories, + stale_preferences=stale_preferences, diagnostics=list(dict.fromkeys(diagnostics)), ) @@ -302,16 +316,20 @@ class _EffectiveState: order: int forced: bool = False locked_by: str | None = None + availability_source: str = "module" + order_source: str = "module" def apply(self, entry: PreferenceEntry | None, *, source: str) -> None: if entry is None: return if entry.order is not None: self.order = entry.order + self.order_source = source if self.locked_by is not None: return if entry.enabled is not None: self.enabled = entry.enabled + self.availability_source = source if source != "user" and entry.enabled is False: self.forced = False self.locked_by = source @@ -319,6 +337,38 @@ class _EffectiveState: self.enabled = True self.forced = True self.locked_by = source + self.availability_source = source + + +def _availability_state(enabled: bool, forced: bool) -> str: + if not enabled: + return "blocked" + return "forced" if forced else "available" + + +def _stale_preferences( + profiles: tuple[tuple[str, QuickAccessProfile | None], ...], + catalogue: CatalogueResponse, +) -> list[EffectiveStalePreferenceResponse]: + category_ids = {item.id for item in catalogue.categories} + tool_ids = {item.id for item in catalogue.tools} + stale: list[EffectiveStalePreferenceResponse] = [] + for source, profile in profiles: + if profile is None: + continue + for kind, field, known_ids in ( + ("category", "category_preferences", category_ids), + ("tool", "tool_preferences", tool_ids), + ): + values = getattr(profile, field, {}) + if not isinstance(values, Mapping): + continue + stale.extend( + EffectiveStalePreferenceResponse(id=str(item_id), kind=kind, source=source) + for item_id in values + if str(item_id) not in known_ids + ) + return sorted(stale, key=lambda item: (item.source, item.kind, item.id)) def _profile_entry( diff --git a/tests/test_quick_access.py b/tests/test_quick_access.py index f717d26..caa4320 100644 --- a/tests/test_quick_access.py +++ b/tests/test_quick_access.py @@ -136,6 +136,91 @@ class QuickAccessTests(unittest.TestCase): self.assertTrue(work.enabled) self.assertTrue(work.forced) self.assertEqual("tenant", work.locked_by) + self.assertEqual("forced", work.availability_state) + self.assertEqual("tenant", work.availability_source) + + def test_effective_resolution_reports_availability_and_order_provenance(self) -> None: + with Session(self.engine) as session: + session.add_all( + ( + QuickAccessProfile( + scope_type="system", + tenant_id=None, + scope_id=None, + scope_key="system:*", + category_preferences={}, + tool_preferences={"example.work": {"enabled": True, "order": 40}}, + revision=2, + ), + QuickAccessProfile( + scope_type="user", + tenant_id="tenant-1", + scope_id="account-1", + scope_key="user:tenant-1:account-1", + category_preferences={}, + tool_preferences={"example.work": {"order": 5}}, + revision=2, + ), + ) + ) + session.commit() + + effective = resolve_effective( + session, + registry=registry_with_tools(), + tenant_id="tenant-1", + account_id="account-1", + permission_checker=lambda _scope: True, + ) + + tool = next(item for item in effective.categories if item.id == "work").tools[0] + self.assertEqual("available", tool.availability_state) + self.assertEqual("system", tool.availability_source) + self.assertEqual("user", tool.order_source) + self.assertEqual(5, tool.order) + + def test_effective_resolution_retains_stale_preferences_with_scope_provenance(self) -> None: + with Session(self.engine) as session: + session.add_all( + ( + QuickAccessProfile( + scope_type="tenant", + tenant_id="tenant-1", + scope_id="tenant-1", + scope_key="tenant:tenant-1", + category_preferences={"retired.category": {"enabled": False}}, + tool_preferences={"retired.tool": {"order": 5}}, + revision=2, + ), + QuickAccessProfile( + scope_type="user", + tenant_id="tenant-1", + scope_id="account-1", + scope_key="user:tenant-1:account-1", + category_preferences={}, + tool_preferences={"missing.user-tool": {"enabled": True}}, + revision=2, + ), + ) + ) + session.commit() + + effective = resolve_effective( + session, + registry=registry_with_tools(), + tenant_id="tenant-1", + account_id="account-1", + permission_checker=lambda _scope: True, + ) + + self.assertEqual( + [ + ("retired.category", "category", "tenant"), + ("retired.tool", "tool", "tenant"), + ("missing.user-tool", "tool", "user"), + ], + [(item.id, item.kind, item.source) for item in effective.stale_preferences], + ) if __name__ == "__main__": diff --git a/webui/src/api/quickAccess.ts b/webui/src/api/quickAccess.ts index c653276..9b9bd2b 100644 --- a/webui/src/api/quickAccess.ts +++ b/webui/src/api/quickAccess.ts @@ -56,17 +56,28 @@ export type EffectiveQuickAccessTool = QuickAccessTool & { enabled: boolean; forced: boolean; locked_by?: string | null; + availability_state: "available" | "forced" | "blocked"; + availability_source: "module" | "system" | "tenant" | "user"; + order_source: "module" | "system" | "tenant" | "user"; }; export type EffectiveQuickAccessCategory = QuickAccessCategory & { enabled: boolean; forced: boolean; locked_by?: string | null; + availability_state: "available" | "forced" | "blocked"; + availability_source: "module" | "system" | "tenant" | "user"; + order_source: "module" | "system" | "tenant" | "user"; tools: EffectiveQuickAccessTool[]; }; export type EffectiveQuickAccess = { categories: EffectiveQuickAccessCategory[]; + stale_preferences: Array<{ + id: string; + kind: "category" | "tool"; + source: "system" | "tenant" | "user"; + }>; diagnostics: string[]; }; diff --git a/webui/src/features/settings/QuickAccessSettingsPanel.tsx b/webui/src/features/settings/QuickAccessSettingsPanel.tsx index 9e62df0..54a5e43 100644 --- a/webui/src/features/settings/QuickAccessSettingsPanel.tsx +++ b/webui/src/features/settings/QuickAccessSettingsPanel.tsx @@ -32,6 +32,11 @@ type Draft = { tools: Record; }; type AvailabilityMode = "inherit" | "available" | "blocked" | "forced"; +type EffectiveProvenance = { + state: "available" | "forced" | "blocked"; + availabilitySource: "module" | "system" | "tenant" | "user"; + orderSource: "module" | "system" | "tenant" | "user"; +}; const EMPTY_DRAFT: Draft = { categories: {}, tools: {} }; @@ -186,6 +191,14 @@ export default function QuickAccessSettingsPanel({ const lockedTools = new Map( (effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by]) ); + const categoryProvenance = new Map( + (effective?.categories ?? []).map((item) => [item.id, effectiveProvenance(item)]) + ); + const toolProvenance = new Map( + (effective?.categories ?? []).flatMap((category) => + category.tools.map((item) => [item.id, effectiveProvenance(item)] as const) + ) + ); const categoryIds = categories.map((item) => item.id); return ( @@ -219,6 +232,7 @@ export default function QuickAccessSettingsPanel({ preference={draft.categories[category.id]} isPersonal={isPersonal} lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))} + provenance={categoryProvenance.get(category.id)} disabled={!canWrite || saving} onChange={(value) => setPreference("categories", category.id, value)} onMoveUp={() => move("categories", categoryIds, category.id, -1)} @@ -243,6 +257,7 @@ export default function QuickAccessSettingsPanel({ defaultEnabled={tool.default_enabled} isPersonal={isPersonal} lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))} + provenance={toolProvenance.get(tool.id)} disabled={!canWrite || saving} onChange={(value) => setPreference("tools", tool.id, value)} onMoveUp={() => move("tools", toolIds, tool.id, -1)} @@ -268,6 +283,7 @@ function PreferenceRow({ defaultEnabled = true, isPersonal, lockedBy, + provenance, disabled, onChange, onMoveUp, @@ -282,6 +298,7 @@ function PreferenceRow({ defaultEnabled?: boolean; isPersonal: boolean; lockedBy?: string | null; + provenance?: EffectiveProvenance; disabled: boolean; onChange: (value: QuickAccessPreference | null) => void; onMoveUp: () => void; @@ -303,6 +320,15 @@ function PreferenceRow({ })} ) : null} + {provenance ? ( + + {i18nMessage("i18n:govoplan-quick-access.effective_provenance", { + value0: provenance.state, + value1: provenance.availabilitySource, + value2: provenance.orderSource + })} + + ) : null}
{isPersonal ? ( @@ -338,6 +364,18 @@ function PreferenceRow({ ); } +function effectiveProvenance(item: { + availability_state: "available" | "forced" | "blocked"; + availability_source: "module" | "system" | "tenant" | "user"; + order_source: "module" | "system" | "tenant" | "user"; +}): EffectiveProvenance { + return { + state: item.availability_state, + availabilitySource: item.availability_source, + orderSource: item.order_source + }; +} + function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode { if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit"; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 26686ad..769cda9 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -44,7 +44,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-quick-access.category.messages_description": "Mail, Postfach und künftige Gesprächskanäle in einer Einblendung.", "i18n:govoplan-quick-access.category.files": "Dateien", "i18n:govoplan-quick-access.category.files_description": "Aktuelle und kontextbezogene Dateien, ohne die laufende Aufgabe zu verlassen.", - "i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt" + "i18n:govoplan-quick-access.locked_by_value": "Durch {value0} festgelegt", + "i18n:govoplan-quick-access.effective_provenance": "Effektiv {value0}; Verfügbarkeit von {value1}, Reihenfolge von {value2}" }, en: { "i18n:govoplan-quick-access.quick_access": "Quick Access", @@ -89,6 +90,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-quick-access.category.messages_description": "Mail, Postbox, and future conversational channels in one overlay.", "i18n:govoplan-quick-access.category.files": "Files", "i18n:govoplan-quick-access.category.files_description": "Recent and contextual files without leaving the current task.", - "i18n:govoplan-quick-access.locked_by_value": "Set by {value0}" + "i18n:govoplan-quick-access.locked_by_value": "Set by {value0}", + "i18n:govoplan-quick-access.effective_provenance": "Effective {value0}; availability from {value1}, order from {value2}" } };