Add governed view revision resolution
This commit is contained in:
@@ -19,6 +19,8 @@ class ViewsResolverCapability(ViewResolver):
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
workflow_view_id: str | None = None,
|
workflow_view_id: str | None = None,
|
||||||
|
workflow_revision_id: str | None = None,
|
||||||
|
workflow_surface_ids: Iterable[str] = (),
|
||||||
) -> EffectiveView:
|
) -> EffectiveView:
|
||||||
state = resolve_effective_view(
|
state = resolve_effective_view(
|
||||||
session,
|
session,
|
||||||
@@ -27,6 +29,8 @@ class ViewsResolverCapability(ViewResolver):
|
|||||||
group_ids=group_ids,
|
group_ids=group_ids,
|
||||||
catalogue=self._registry.view_surfaces(),
|
catalogue=self._registry.view_surfaces(),
|
||||||
workflow_view_id=workflow_view_id,
|
workflow_view_id=workflow_view_id,
|
||||||
|
workflow_revision_id=workflow_revision_id,
|
||||||
|
workflow_surface_ids=workflow_surface_ids,
|
||||||
)
|
)
|
||||||
return state.effective
|
return state.effective
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from govoplan_views.backend.schemas import (
|
|||||||
ViewSelectionRequest,
|
ViewSelectionRequest,
|
||||||
ViewSurfaceCatalogueResponse,
|
ViewSurfaceCatalogueResponse,
|
||||||
ViewSurfaceResponse,
|
ViewSurfaceResponse,
|
||||||
|
WorkflowViewResolutionRequest,
|
||||||
)
|
)
|
||||||
from govoplan_views.backend.service import (
|
from govoplan_views.backend.service import (
|
||||||
EffectiveViewState,
|
EffectiveViewState,
|
||||||
@@ -493,6 +494,31 @@ def api_effective_view(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/effective/workflow", response_model=EffectiveViewResponse)
|
||||||
|
def api_workflow_effective_view(
|
||||||
|
payload: WorkflowViewResolutionRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> EffectiveViewResponse:
|
||||||
|
_require_any_scope(
|
||||||
|
principal,
|
||||||
|
SELECTION_READ_SCOPE,
|
||||||
|
SELECTION_WRITE_SCOPE,
|
||||||
|
)
|
||||||
|
return _effective_response(
|
||||||
|
resolve_effective_view(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
catalogue=_catalogue(),
|
||||||
|
workflow_view_id=payload.view_id,
|
||||||
|
workflow_revision_id=payload.revision_id,
|
||||||
|
workflow_surface_ids=payload.visible_surface_ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/selection", response_model=EffectiveViewResponse)
|
@router.put("/selection", response_model=EffectiveViewResponse)
|
||||||
def api_select_view(
|
def api_select_view(
|
||||||
payload: ViewSelectionRequest,
|
payload: ViewSelectionRequest,
|
||||||
|
|||||||
@@ -185,3 +185,9 @@ class EffectiveViewResponse(BaseModel):
|
|||||||
|
|
||||||
class ViewSelectionRequest(BaseModel):
|
class ViewSelectionRequest(BaseModel):
|
||||||
view_id: str | None = Field(default=None, max_length=36)
|
view_id: str | None = Field(default=None, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowViewResolutionRequest(BaseModel):
|
||||||
|
view_id: str = Field(min_length=1, max_length=36)
|
||||||
|
revision_id: str | None = Field(default=None, max_length=36)
|
||||||
|
visible_surface_ids: list[str] = Field(default_factory=list, max_length=1000)
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class ViewSelection:
|
|||||||
locked: bool
|
locked: bool
|
||||||
provenance: tuple[dict[str, object], ...]
|
provenance: tuple[dict[str, object], ...]
|
||||||
diagnostics: tuple[ViewDiagnostic, ...] = ()
|
diagnostics: tuple[ViewDiagnostic, ...] = ()
|
||||||
|
visible_surface_ids: frozenset[str] | None = None
|
||||||
|
surface_ceiling_ids: frozenset[str] | None = None
|
||||||
|
fallback: ViewSelection | None = None
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -1049,42 +1052,59 @@ def _select_effective_assignment(
|
|||||||
preference: ViewPreference | None,
|
preference: ViewPreference | None,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
workflow_view_id: str | None,
|
workflow_view_id: str | None,
|
||||||
|
initial_diagnostics: Iterable[ViewDiagnostic] = (),
|
||||||
|
fallback: ViewSelection | None = None,
|
||||||
) -> ViewSelection:
|
) -> ViewSelection:
|
||||||
required = [
|
required = [
|
||||||
pair for pair in assignments if pair[0].mode == "required"
|
pair for pair in assignments if pair[0].mode == "required"
|
||||||
]
|
]
|
||||||
if required:
|
required_selection = (
|
||||||
selected = max(
|
max(
|
||||||
required,
|
required,
|
||||||
key=lambda pair: _assignment_order(pair[0]),
|
key=lambda pair: _assignment_order(pair[0]),
|
||||||
)
|
)
|
||||||
return ViewSelection(
|
if required
|
||||||
selected=selected,
|
else None
|
||||||
locked=True,
|
|
||||||
provenance=(
|
|
||||||
_assignment_provenance(
|
|
||||||
"required_assignment",
|
|
||||||
selected[0],
|
|
||||||
f"Required View assignment {selected[0].id}",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
diagnostics = list(initial_diagnostics)
|
||||||
diagnostics: list[ViewDiagnostic] = []
|
|
||||||
if workflow_view_id is not None:
|
if workflow_view_id is not None:
|
||||||
selected = options.get(workflow_view_id)
|
selected = options.get(workflow_view_id)
|
||||||
if selected is not None:
|
if selected is not None:
|
||||||
return ViewSelection(
|
provenance: list[dict[str, object]] = []
|
||||||
selected=selected,
|
if required_selection is not None:
|
||||||
locked=False,
|
provenance.append(
|
||||||
provenance=(
|
_assignment_provenance(
|
||||||
|
"required_assignment_ceiling",
|
||||||
|
required_selection[0],
|
||||||
|
(
|
||||||
|
"Required View assignment "
|
||||||
|
f"{required_selection[0].id} limits the workflow View"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provenance.append(
|
||||||
_assignment_provenance(
|
_assignment_provenance(
|
||||||
"workflow_selection",
|
"workflow_selection",
|
||||||
selected[0],
|
selected[0],
|
||||||
f"Workflow selected View {workflow_view_id}",
|
f"Workflow selected View {workflow_view_id}",
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
return ViewSelection(
|
||||||
|
selected=selected,
|
||||||
|
locked=required_selection is not None,
|
||||||
|
provenance=tuple(provenance),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
surface_ceiling_ids=(
|
||||||
|
frozenset(required_selection[1].visible_surface_ids)
|
||||||
|
if required_selection is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
fallback=fallback,
|
||||||
|
)
|
||||||
|
if not any(
|
||||||
|
item.code == "view.workflow_revision_unavailable"
|
||||||
|
for item in diagnostics
|
||||||
|
):
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
ViewDiagnostic(
|
ViewDiagnostic(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
@@ -1096,6 +1116,20 @@ def _select_effective_assignment(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if required_selection is not None:
|
||||||
|
return ViewSelection(
|
||||||
|
selected=required_selection,
|
||||||
|
locked=True,
|
||||||
|
provenance=(
|
||||||
|
_assignment_provenance(
|
||||||
|
"required_assignment",
|
||||||
|
required_selection[0],
|
||||||
|
f"Required View assignment {required_selection[0].id}",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
if preference and preference.selection_kind == "none":
|
if preference and preference.selection_kind == "none":
|
||||||
return ViewSelection(
|
return ViewSelection(
|
||||||
selected=None,
|
selected=None,
|
||||||
@@ -1162,6 +1196,7 @@ def _evaluate_selected_surfaces(
|
|||||||
selection: ViewSelection,
|
selection: ViewSelection,
|
||||||
*,
|
*,
|
||||||
catalogue: tuple[ViewSurface, ...] | None,
|
catalogue: tuple[ViewSurface, ...] | None,
|
||||||
|
workflow_surface_ids: Iterable[str] = (),
|
||||||
) -> ViewSelection:
|
) -> ViewSelection:
|
||||||
if selection.selected is None or catalogue is None:
|
if selection.selected is None or catalogue is None:
|
||||||
return selection
|
return selection
|
||||||
@@ -1172,6 +1207,95 @@ def _evaluate_selected_surfaces(
|
|||||||
)
|
)
|
||||||
diagnostics = list(selection.diagnostics)
|
diagnostics = list(selection.diagnostics)
|
||||||
provenance = list(selection.provenance)
|
provenance = list(selection.provenance)
|
||||||
|
visible_surface_ids = set(revision.visible_surface_ids)
|
||||||
|
if selection.surface_ceiling_ids is not None:
|
||||||
|
visible_surface_ids.intersection_update(selection.surface_ceiling_ids)
|
||||||
|
requested_overlay = {
|
||||||
|
str(surface_id).strip()
|
||||||
|
for surface_id in workflow_surface_ids
|
||||||
|
if str(surface_id).strip()
|
||||||
|
}
|
||||||
|
if requested_overlay:
|
||||||
|
unknown_overlay_ids = sorted(requested_overlay - set(known_surfaces))
|
||||||
|
bounded_overlay_ids = requested_overlay & visible_surface_ids
|
||||||
|
unavailable_overlay_ids = sorted(
|
||||||
|
requested_overlay
|
||||||
|
- set(unknown_overlay_ids)
|
||||||
|
- bounded_overlay_ids
|
||||||
|
)
|
||||||
|
protected_surface_ids = {
|
||||||
|
surface.id
|
||||||
|
for surface in catalogue
|
||||||
|
if surface.required and surface.id in visible_surface_ids
|
||||||
|
}
|
||||||
|
if selection.locked:
|
||||||
|
ceiling_assignment = (
|
||||||
|
selection.fallback.selected[0]
|
||||||
|
if selection.fallback is not None
|
||||||
|
and selection.fallback.selected is not None
|
||||||
|
and selection.fallback.locked
|
||||||
|
else assignment
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
protected_surface_ids.update(
|
||||||
|
lockout_required_surface_ids(
|
||||||
|
catalogue,
|
||||||
|
assignment_scope_type=ceiling_assignment.scope_type,
|
||||||
|
)
|
||||||
|
& visible_surface_ids
|
||||||
|
)
|
||||||
|
except ViewsValidationError:
|
||||||
|
# The lockout diagnostic below handles an incomplete contract.
|
||||||
|
pass
|
||||||
|
bounded_overlay_ids.update(protected_surface_ids)
|
||||||
|
pending = list(bounded_overlay_ids)
|
||||||
|
while pending:
|
||||||
|
surface = known_surfaces.get(pending.pop())
|
||||||
|
if (
|
||||||
|
surface is not None
|
||||||
|
and surface.parent_id
|
||||||
|
and surface.parent_id in visible_surface_ids
|
||||||
|
and surface.parent_id not in bounded_overlay_ids
|
||||||
|
):
|
||||||
|
bounded_overlay_ids.add(surface.parent_id)
|
||||||
|
pending.append(surface.parent_id)
|
||||||
|
visible_surface_ids = bounded_overlay_ids
|
||||||
|
provenance.append(
|
||||||
|
{
|
||||||
|
"source": "workflow_step_overlay",
|
||||||
|
"scope_type": "workflow",
|
||||||
|
"scope_id": None,
|
||||||
|
"detail": (
|
||||||
|
"Workflow step narrowed the active View to "
|
||||||
|
f"{len(visible_surface_ids)} surfaces"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if unknown_overlay_ids:
|
||||||
|
diagnostics.append(
|
||||||
|
ViewDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="view.workflow_overlay_unknown_surfaces",
|
||||||
|
message=(
|
||||||
|
"The workflow step references surfaces that are not "
|
||||||
|
"announced by the active module graph."
|
||||||
|
),
|
||||||
|
surface_ids=tuple(unknown_overlay_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if unavailable_overlay_ids:
|
||||||
|
diagnostics.append(
|
||||||
|
ViewDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="view.workflow_overlay_bounded",
|
||||||
|
message=(
|
||||||
|
"The workflow step requested surfaces outside its "
|
||||||
|
"effective View or an administrator-required ceiling. "
|
||||||
|
"Those surfaces remain hidden."
|
||||||
|
),
|
||||||
|
surface_ids=tuple(unavailable_overlay_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
if stale_ids:
|
if stale_ids:
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
ViewDiagnostic(
|
ViewDiagnostic(
|
||||||
@@ -1186,9 +1310,16 @@ def _evaluate_selected_surfaces(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if selection.locked:
|
if selection.locked:
|
||||||
|
ceiling_assignment, ceiling_revision = (
|
||||||
|
selection.fallback.selected
|
||||||
|
if selection.fallback is not None
|
||||||
|
and selection.fallback.selected is not None
|
||||||
|
and selection.fallback.locked
|
||||||
|
else selection.selected
|
||||||
|
)
|
||||||
lockout_diagnostic = _required_lockout_diagnostic(
|
lockout_diagnostic = _required_lockout_diagnostic(
|
||||||
assignment,
|
ceiling_assignment,
|
||||||
revision,
|
ceiling_revision,
|
||||||
catalogue=catalogue,
|
catalogue=catalogue,
|
||||||
)
|
)
|
||||||
if lockout_diagnostic is not None:
|
if lockout_diagnostic is not None:
|
||||||
@@ -1211,7 +1342,7 @@ def _evaluate_selected_surfaces(
|
|||||||
|
|
||||||
known_visible = [
|
known_visible = [
|
||||||
known_surfaces[surface_id]
|
known_surfaces[surface_id]
|
||||||
for surface_id in revision.visible_surface_ids
|
for surface_id in visible_surface_ids
|
||||||
if surface_id in known_surfaces
|
if surface_id in known_surfaces
|
||||||
]
|
]
|
||||||
if (
|
if (
|
||||||
@@ -1234,6 +1365,23 @@ def _evaluate_selected_surfaces(
|
|||||||
detail=f"Invalid View revision {revision.id}",
|
detail=f"Invalid View revision {revision.id}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if selection.fallback is not None:
|
||||||
|
return ViewSelection(
|
||||||
|
selected=selection.fallback.selected,
|
||||||
|
locked=selection.fallback.locked,
|
||||||
|
provenance=(
|
||||||
|
*selection.fallback.provenance,
|
||||||
|
{
|
||||||
|
"source": "invalid_view_fallback",
|
||||||
|
"scope_type": "workflow",
|
||||||
|
"scope_id": None,
|
||||||
|
"detail": "Workflow View context was not reachable",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
visible_surface_ids=selection.fallback.visible_surface_ids,
|
||||||
|
surface_ceiling_ids=selection.fallback.surface_ceiling_ids,
|
||||||
|
)
|
||||||
return ViewSelection(
|
return ViewSelection(
|
||||||
selected=None,
|
selected=None,
|
||||||
locked=False,
|
locked=False,
|
||||||
@@ -1245,6 +1393,9 @@ def _evaluate_selected_surfaces(
|
|||||||
locked=selection.locked,
|
locked=selection.locked,
|
||||||
provenance=tuple(provenance),
|
provenance=tuple(provenance),
|
||||||
diagnostics=tuple(diagnostics),
|
diagnostics=tuple(diagnostics),
|
||||||
|
visible_surface_ids=frozenset(visible_surface_ids),
|
||||||
|
surface_ceiling_ids=selection.surface_ceiling_ids,
|
||||||
|
fallback=selection.fallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1310,7 +1461,11 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView:
|
|||||||
view_id=assignment.definition.id,
|
view_id=assignment.definition.id,
|
||||||
revision_id=revision.id,
|
revision_id=revision.id,
|
||||||
name=assignment.definition.name,
|
name=assignment.definition.name,
|
||||||
visible_surface_ids=frozenset(revision.visible_surface_ids),
|
visible_surface_ids=(
|
||||||
|
selection.visible_surface_ids
|
||||||
|
if selection.visible_surface_ids is not None
|
||||||
|
else frozenset(revision.visible_surface_ids)
|
||||||
|
),
|
||||||
locked=selection.locked,
|
locked=selection.locked,
|
||||||
provenance=selection.provenance,
|
provenance=selection.provenance,
|
||||||
)
|
)
|
||||||
@@ -1322,6 +1477,8 @@ def _resolution_invalidation_token(
|
|||||||
preference: ViewPreference | None,
|
preference: ViewPreference | None,
|
||||||
catalogue: tuple[ViewSurface, ...] | None,
|
catalogue: tuple[ViewSurface, ...] | None,
|
||||||
workflow_view_id: str | None,
|
workflow_view_id: str | None,
|
||||||
|
workflow_revision_id: str | None,
|
||||||
|
workflow_surface_ids: Iterable[str],
|
||||||
) -> str:
|
) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"assignments": [
|
"assignments": [
|
||||||
@@ -1361,6 +1518,10 @@ def _resolution_invalidation_token(
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"workflow_view_id": workflow_view_id,
|
"workflow_view_id": workflow_view_id,
|
||||||
|
"workflow_revision_id": workflow_revision_id,
|
||||||
|
"workflow_surface_ids": sorted(
|
||||||
|
str(surface_id) for surface_id in workflow_surface_ids
|
||||||
|
),
|
||||||
}
|
}
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
payload,
|
payload,
|
||||||
@@ -1378,8 +1539,17 @@ def resolve_effective_view(
|
|||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
catalogue: Iterable[ViewSurface] | None = None,
|
catalogue: Iterable[ViewSurface] | None = None,
|
||||||
workflow_view_id: str | None = None,
|
workflow_view_id: str | None = None,
|
||||||
|
workflow_revision_id: str | None = None,
|
||||||
|
workflow_surface_ids: Iterable[str] = (),
|
||||||
) -> EffectiveViewState:
|
) -> EffectiveViewState:
|
||||||
groups = frozenset(str(group_id) for group_id in group_ids)
|
groups = frozenset(str(group_id) for group_id in group_ids)
|
||||||
|
workflow_surfaces = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(surface_id).strip()
|
||||||
|
for surface_id in workflow_surface_ids
|
||||||
|
if str(surface_id).strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
if len(groups) > MAX_EFFECTIVE_VIEW_GROUPS:
|
if len(groups) > MAX_EFFECTIVE_VIEW_GROUPS:
|
||||||
raise ViewsValidationError(
|
raise ViewsValidationError(
|
||||||
"Too many group memberships were supplied for View resolution"
|
"Too many group memberships were supplied for View resolution"
|
||||||
@@ -1424,21 +1594,66 @@ def resolve_effective_view(
|
|||||||
candidates,
|
candidates,
|
||||||
)
|
)
|
||||||
options = _option_assignments(resolved_assignments)
|
options = _option_assignments(resolved_assignments)
|
||||||
|
workflow_options = dict(options)
|
||||||
|
workflow_diagnostics: tuple[ViewDiagnostic, ...] = ()
|
||||||
|
if workflow_view_id is not None and workflow_revision_id is not None:
|
||||||
|
option = options.get(workflow_view_id)
|
||||||
|
pinned_revision = (
|
||||||
|
session.query(ViewRevision)
|
||||||
|
.filter(
|
||||||
|
ViewRevision.id == workflow_revision_id,
|
||||||
|
ViewRevision.definition_id == workflow_view_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
if option is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if option is not None and pinned_revision is not None:
|
||||||
|
workflow_options[workflow_view_id] = (
|
||||||
|
option[0],
|
||||||
|
pinned_revision,
|
||||||
|
)
|
||||||
|
elif option is not None:
|
||||||
|
workflow_options.pop(workflow_view_id, None)
|
||||||
|
workflow_diagnostics = (
|
||||||
|
ViewDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="view.workflow_revision_unavailable",
|
||||||
|
message=(
|
||||||
|
"The workflow-pinned View revision is missing or does "
|
||||||
|
"not belong to the selected View. Normal View selection "
|
||||||
|
"was used."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
preference = _preference(
|
preference = _preference(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
selection = _select_effective_assignment(
|
baseline = _evaluate_selected_surfaces(
|
||||||
|
_select_effective_assignment(
|
||||||
resolved_assignments,
|
resolved_assignments,
|
||||||
options,
|
options,
|
||||||
preference=preference,
|
preference=preference,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
|
workflow_view_id=None,
|
||||||
|
),
|
||||||
|
catalogue=current_catalogue,
|
||||||
|
)
|
||||||
|
selection = _select_effective_assignment(
|
||||||
|
resolved_assignments,
|
||||||
|
workflow_options,
|
||||||
|
preference=preference,
|
||||||
|
account_id=account_id,
|
||||||
workflow_view_id=workflow_view_id,
|
workflow_view_id=workflow_view_id,
|
||||||
|
initial_diagnostics=workflow_diagnostics,
|
||||||
|
fallback=baseline if workflow_view_id is not None else None,
|
||||||
)
|
)
|
||||||
evaluated = _evaluate_selected_surfaces(
|
evaluated = _evaluate_selected_surfaces(
|
||||||
selection,
|
selection,
|
||||||
catalogue=current_catalogue,
|
catalogue=current_catalogue,
|
||||||
|
workflow_surface_ids=workflow_surfaces,
|
||||||
)
|
)
|
||||||
return EffectiveViewState(
|
return EffectiveViewState(
|
||||||
effective=_effective_view_from_selection(evaluated),
|
effective=_effective_view_from_selection(evaluated),
|
||||||
@@ -1449,6 +1664,8 @@ def resolve_effective_view(
|
|||||||
preference=preference,
|
preference=preference,
|
||||||
catalogue=current_catalogue,
|
catalogue=current_catalogue,
|
||||||
workflow_view_id=workflow_view_id,
|
workflow_view_id=workflow_view_id,
|
||||||
|
workflow_revision_id=workflow_revision_id,
|
||||||
|
workflow_surface_ids=workflow_surfaces,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+173
-3
@@ -398,7 +398,7 @@ class ViewsServiceTests(unittest.TestCase):
|
|||||||
state.effective.view_id,
|
state.effective.view_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_required_then_workflow_then_user_and_default_precedence(self) -> None:
|
def test_required_assignment_limits_workflow_selection(self) -> None:
|
||||||
default = self.create_published_definition(name="Default")
|
default = self.create_published_definition(name="Default")
|
||||||
workflow = self.create_published_definition(name="Workflow")
|
workflow = self.create_published_definition(name="Workflow")
|
||||||
required = self.create_published_definition(
|
required = self.create_published_definition(
|
||||||
@@ -443,12 +443,182 @@ class ViewsServiceTests(unittest.TestCase):
|
|||||||
catalogue=self.catalogue,
|
catalogue=self.catalogue,
|
||||||
workflow_view_id=workflow.id,
|
workflow_view_id=workflow.id,
|
||||||
)
|
)
|
||||||
self.assertEqual(required.id, required_state.effective.view_id)
|
workflow_revision = get_revision(
|
||||||
|
self.session,
|
||||||
|
definition_id=workflow.id,
|
||||||
|
)
|
||||||
|
required_revision = get_revision(
|
||||||
|
self.session,
|
||||||
|
definition_id=required.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(workflow.id, required_state.effective.view_id)
|
||||||
self.assertTrue(required_state.effective.locked)
|
self.assertTrue(required_state.effective.locked)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"required_assignment",
|
"required_assignment_ceiling",
|
||||||
required_state.effective.provenance[0]["source"],
|
required_state.effective.provenance[0]["source"],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
set(workflow_revision.visible_surface_ids)
|
||||||
|
& set(required_revision.visible_surface_ids),
|
||||||
|
set(required_state.effective.visible_surface_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workflow_can_pin_a_historical_view_revision(self) -> None:
|
||||||
|
definition = self.create_published_definition(name="Workflow")
|
||||||
|
pinned_revision = get_revision(
|
||||||
|
self.session,
|
||||||
|
definition_id=definition.id,
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
definition,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="available",
|
||||||
|
)
|
||||||
|
current_revision = create_revision(
|
||||||
|
self.session,
|
||||||
|
definition,
|
||||||
|
visible_surface_ids=["access.nav.admin", "access.route.admin"],
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
actor_id="account-admin",
|
||||||
|
)
|
||||||
|
publish_revision(
|
||||||
|
self.session,
|
||||||
|
definition,
|
||||||
|
current_revision,
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
actor_id="account-admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
state = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=definition.id,
|
||||||
|
workflow_revision_id=pinned_revision.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(definition.id, state.effective.view_id)
|
||||||
|
self.assertEqual(pinned_revision.id, state.effective.revision_id)
|
||||||
|
self.assertIn("files.route.files", state.effective.visible_surface_ids)
|
||||||
|
self.assertNotIn("access.route.admin", state.effective.visible_surface_ids)
|
||||||
|
|
||||||
|
def test_workflow_step_overlay_only_narrows_the_selected_view(self) -> None:
|
||||||
|
definition = self.create_published_definition(
|
||||||
|
name="Workflow",
|
||||||
|
visible_surface_ids=[
|
||||||
|
"access.nav.admin",
|
||||||
|
"access.route.admin",
|
||||||
|
"files.nav.files",
|
||||||
|
"files.route.files",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
definition,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="available",
|
||||||
|
)
|
||||||
|
|
||||||
|
state = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=definition.id,
|
||||||
|
workflow_surface_ids=(
|
||||||
|
"files.nav.files",
|
||||||
|
"files.route.files",
|
||||||
|
"unknown.route",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("files.module", state.effective.visible_surface_ids)
|
||||||
|
self.assertIn("files.nav.files", state.effective.visible_surface_ids)
|
||||||
|
self.assertIn("files.route.files", state.effective.visible_surface_ids)
|
||||||
|
self.assertIn("views.selector", state.effective.visible_surface_ids)
|
||||||
|
self.assertNotIn("access.route.admin", state.effective.visible_surface_ids)
|
||||||
|
self.assertIn(
|
||||||
|
"view.workflow_overlay_unknown_surfaces",
|
||||||
|
{diagnostic.code for diagnostic in state.diagnostics},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_workflow_revision_falls_back_to_normal_selection(self) -> None:
|
||||||
|
default = self.create_published_definition(name="Default")
|
||||||
|
workflow = self.create_published_definition(name="Workflow")
|
||||||
|
self.assign(
|
||||||
|
default,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="default",
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
workflow,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="available",
|
||||||
|
)
|
||||||
|
|
||||||
|
state = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=workflow.id,
|
||||||
|
workflow_revision_id="missing-revision",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(default.id, state.effective.view_id)
|
||||||
|
self.assertIn(
|
||||||
|
"view.workflow_revision_unavailable",
|
||||||
|
{diagnostic.code for diagnostic in state.diagnostics},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workflow_view_falls_back_after_group_authorization_is_lost(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
default = self.create_published_definition(name="Default")
|
||||||
|
workflow = self.create_published_definition(name="Group workflow")
|
||||||
|
self.assign(
|
||||||
|
default,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="default",
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
workflow,
|
||||||
|
scope_type="group",
|
||||||
|
scope_id="group-1",
|
||||||
|
mode="available",
|
||||||
|
)
|
||||||
|
|
||||||
|
authorized = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
group_ids=("group-1",),
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=workflow.id,
|
||||||
|
)
|
||||||
|
after_membership_removal = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
group_ids=(),
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=workflow.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(workflow.id, authorized.effective.view_id)
|
||||||
|
self.assertEqual(default.id, after_membership_removal.effective.view_id)
|
||||||
|
self.assertIn(
|
||||||
|
"view.workflow_selection_unavailable",
|
||||||
|
{
|
||||||
|
diagnostic.code
|
||||||
|
for diagnostic in after_membership_removal.diagnostics
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_resolution_invalidation_token_tracks_inputs(self) -> None:
|
def test_resolution_invalidation_token_tracks_inputs(self) -> None:
|
||||||
definition = self.create_published_definition(name="Files")
|
definition = self.create_published_definition(name="Files")
|
||||||
|
|||||||
+3
-3
@@ -16,9 +16,9 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": ">=7.18.2 <8"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -163,6 +163,30 @@ export async function selectEffectiveView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveWorkflowView(
|
||||||
|
settings: ApiSettings,
|
||||||
|
context: {
|
||||||
|
viewId: string;
|
||||||
|
revisionId?: string | null;
|
||||||
|
visibleSurfaceIds?: string[];
|
||||||
|
}
|
||||||
|
): Promise<EffectiveViewProjection> {
|
||||||
|
return projection(
|
||||||
|
await apiFetch<EffectiveViewApiResponse>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/views/effective/workflow",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
...jsonBody({
|
||||||
|
view_id: context.viewId,
|
||||||
|
revision_id: context.revisionId ?? null,
|
||||||
|
visible_surface_ids: context.visibleSurfaceIds ?? []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchViewDefinitions(
|
export async function fetchViewDefinitions(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
scopeType: ViewScopeType,
|
scopeType: ViewScopeType,
|
||||||
|
|||||||
+7
-1
@@ -7,7 +7,11 @@ import {
|
|||||||
type ViewsRuntimeUiCapability
|
type ViewsRuntimeUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import ViewSelector from "./components/ViewSelector";
|
import ViewSelector from "./components/ViewSelector";
|
||||||
import { fetchEffectiveView, selectEffectiveView } from "./api/views";
|
import {
|
||||||
|
fetchEffectiveView,
|
||||||
|
resolveWorkflowView,
|
||||||
|
selectEffectiveView
|
||||||
|
} from "./api/views";
|
||||||
import "./styles/views.css";
|
import "./styles/views.css";
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +72,8 @@ const viewsAdminSections: AdminSectionsUiCapability = {
|
|||||||
const viewsRuntime: ViewsRuntimeUiCapability = {
|
const viewsRuntime: ViewsRuntimeUiCapability = {
|
||||||
loadEffectiveView: (settings) => fetchEffectiveView(settings),
|
loadEffectiveView: (settings) => fetchEffectiveView(settings),
|
||||||
activateView: (settings, viewId) => selectEffectiveView(settings, viewId),
|
activateView: (settings, viewId) => selectEffectiveView(settings, viewId),
|
||||||
|
resolveWorkflowView: (settings, context) =>
|
||||||
|
resolveWorkflowView(settings, context),
|
||||||
Selector: ViewSelector
|
Selector: ViewSelector
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user