diff --git a/backend/services/operating_context.py b/backend/services/operating_context.py index 1743bcd..e122ce3 100644 --- a/backend/services/operating_context.py +++ b/backend/services/operating_context.py @@ -47,16 +47,29 @@ def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]: conn.close() -def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]: - db_profile = _load_ui_profile_from_db(archetype_key) - if db_profile: - return db_profile +def _seed_ui_profile(archetype_key: str) -> dict[str, Any]: seed_profile = get_ui_profile_json(archetype_key) if seed_profile: return seed_profile return resolve_ui_profile(archetype_key) +def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]: + seed_profile = _seed_ui_profile(archetype_key) + db_profile = _load_ui_profile_from_db(archetype_key) + if not db_profile: + return seed_profile + + merged = {**seed_profile, **db_profile} + if not (merged.get("omCapabilities") or merged.get("dataSlices")): + seed_slices = seed_profile.get("dataSlices") or [] + merged["dataSlices"] = list(seed_slices) + merged["omCapabilities"] = list( + seed_profile.get("omCapabilities") or seed_slices + ) + return merged + + def _resolve_om_capabilities(ui_profile: dict[str, Any]) -> frozenset[str]: caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or [] return frozenset(caps) diff --git a/backend/tests/test_ap23a_operating_context.py b/backend/tests/test_ap23a_operating_context.py index 7c9bc94..4b3c922 100644 --- a/backend/tests/test_ap23a_operating_context.py +++ b/backend/tests/test_ap23a_operating_context.py @@ -3,6 +3,7 @@ from __future__ import annotations from auth import AUTH_HEADER +from services import operating_context as operating_context_service from tests.factories import provision_user_in_tenant from tests.test_initiatives_actions import _auth, _create_initiative, _login @@ -182,3 +183,19 @@ def test_operating_context_not_found(client): headers=_auth(token), ) assert res.status_code == 404 + + +def test_resolve_ui_profile_merges_seed_slices_when_db_partial(monkeypatch): + monkeypatch.setattr( + operating_context_service, + "_load_ui_profile_from_db", + lambda _key: { + "planOutlineKeys": ["profile", "inbox", "sprint", "gates"], + "planDefaultRoute": "/plan/inbox", + }, + ) + profile = operating_context_service._resolve_ui_profile("initiative.product") + assert profile["planOutlineKeys"] == ["profile", "inbox", "sprint", "gates"] + assert "backlog" in profile["dataSlices"] + assert "work_cycles" in profile["dataSlices"] + assert profile["omCapabilities"] diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 0f423c4..c2f7d12 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -7,6 +7,7 @@ import { useState, } from 'react' import { useParams } from 'react-router-dom' +import { resolveEffectiveDataSlices } from '../registry/resolveOperatingProfile.js' import { getInitiative, getInitiativeSteeringSnapshot, @@ -190,7 +191,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ setOperatingContext(opContext) } - const omSlices = canReadOm ? opContext?.data_slices || [] : [] + const omSlices = canReadOm ? resolveEffectiveDataSlices(opContext) : [] const keysToLoad = sliceKeys || ['initiative', ...omSlices] if (keysToLoad.includes('steering_snapshot')) { @@ -1030,7 +1031,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ steeringSnapshotError, steeringMethods, operatingContext, - dataSlices: operatingContext?.data_slices || [], + dataSlices: resolveEffectiveDataSlices(operatingContext), uiFeatures, steeringElements, operatingProfile, diff --git a/frontend/src/registry/resolveOperatingProfile.js b/frontend/src/registry/resolveOperatingProfile.js index 9cb7501..671d40b 100644 --- a/frontend/src/registry/resolveOperatingProfile.js +++ b/frontend/src/registry/resolveOperatingProfile.js @@ -48,6 +48,23 @@ const EMPTY_PROFILE = { steeringElements: [], } +/** + * API data_slices haben Vorrang; leeres Array gilt als „fehlend“ (JS-Truthy-Falle). + * @param {OperatingContextResponse | null | undefined} context + * @returns {string[]} + */ +export function resolveEffectiveDataSlices(context) { + const apiSlices = context?.data_slices + if (Array.isArray(apiSlices) && apiSlices.length > 0) { + return apiSlices + } + const profileSlices = context?.ui_profile?.dataSlices + if (Array.isArray(profileSlices) && profileSlices.length > 0) { + return profileSlices + } + return Array.isArray(apiSlices) ? apiSlices : [] +} + function resolveUiFeatures(context, profileFeatures) { if (context?.ui_features && Object.keys(context.ui_features).length) { return { ...(context.ui_features || {}) } @@ -85,7 +102,7 @@ export function resolveOperatingProfile(context, options = {}) { profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute, planOutlineKeys: profile.planOutlineKeys ?? null, workNavKeys: profile.workNavKeys ?? null, - dataSlices: context.data_slices || profile.dataSlices || [], + dataSlices: resolveEffectiveDataSlices(context), uiFeatures: resolveUiFeatures(context, profile.uiFeatures), steeringElements: resolveSteeringElements(context), } @@ -98,7 +115,7 @@ export function resolveOperatingProfile(context, options = {}) { }) return { ...fallback, - dataSlices: context?.data_slices || [], + dataSlices: resolveEffectiveDataSlices(context), uiFeatures: resolveUiFeatures(context, {}), steeringElements: resolveSteeringElements(context), } diff --git a/frontend/src/registry/resolveOperatingProfile.test.js b/frontend/src/registry/resolveOperatingProfile.test.js index af1fa1a..805f07f 100644 --- a/frontend/src/registry/resolveOperatingProfile.test.js +++ b/frontend/src/registry/resolveOperatingProfile.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { isRouteAllowedForProfile, + resolveEffectiveDataSlices, resolveOperatingProfile, resolveOperatingProfileFromInput, resolveRedirectForDisallowedRoute, @@ -83,6 +84,26 @@ describe('resolveOperatingProfile', () => { expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true) }) + it('leeres data_slices fällt auf ui_profile.dataSlices zurück', () => { + const context = { + ...PRODUCT_CONTEXT, + data_slices: [], + ui_profile: { + ...PRODUCT_CONTEXT.ui_profile, + dataSlices: ['backlog', 'roadmap', 'work_cycles', 'actions'], + }, + } + expect(resolveEffectiveDataSlices(context)).toEqual([ + 'backlog', + 'roadmap', + 'work_cycles', + 'actions', + ]) + expect(isRouteAllowedForProfile('/plan/inbox', { operatingContext: context })).toBe(true) + expect(isRouteAllowedForProfile('/plan/gates', { operatingContext: context })).toBe(true) + expect(isRouteAllowedForProfile('/plan/sprint', { operatingContext: context })).toBe(true) + }) + it('liefert uiFeatures aus Methoden-Vertrag (API)', () => { const profile = resolveOperatingProfile(PRODUCT_CONTEXT) expect(profile.uiFeatures.continuousProductWorkMode).toBe(true)