refactor: stage effective view resolution
This commit is contained in:
@@ -58,7 +58,9 @@ pip install -e ../govoplan-views
|
|||||||
python -m unittest discover -s ../govoplan-views/tests -v
|
python -m unittest discover -s ../govoplan-views/tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Workflow declares the resolver as an optional contract so a future workflow
|
Workflow declares the resolver as an optional contract. The resolver accepts an
|
||||||
instance runtime can activate a pinned View revision. Workflow-driven
|
optional workflow-selected View and applies it below required administrative
|
||||||
activation and Policy ceilings remain separate follow-up work; neither is
|
assignments but above user/default selection. The View must already be
|
||||||
required for manually assigned and selected Views.
|
available to the account, so Workflow cannot bypass assignment policy.
|
||||||
|
Persisting a workflow instance's pinned View revision remains owned by the
|
||||||
|
Workflow module.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class ViewsResolverCapability(ViewResolver):
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
|
workflow_view_id: str | None = None,
|
||||||
) -> EffectiveView:
|
) -> EffectiveView:
|
||||||
state = resolve_effective_view(
|
state = resolve_effective_view(
|
||||||
session,
|
session,
|
||||||
@@ -25,6 +26,7 @@ class ViewsResolverCapability(ViewResolver):
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
group_ids=group_ids,
|
group_ids=group_ids,
|
||||||
catalogue=self._registry.view_surfaces(),
|
catalogue=self._registry.view_surfaces(),
|
||||||
|
workflow_view_id=workflow_view_id,
|
||||||
)
|
)
|
||||||
return state.effective
|
return state.effective
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import and_, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from govoplan_core.core.views import (
|
from govoplan_core.core.views import (
|
||||||
VIEW_SURFACE_CONTRACT_VERSION,
|
VIEW_SURFACE_CONTRACT_VERSION,
|
||||||
@@ -28,6 +28,8 @@ from govoplan_views.backend.db.models import (
|
|||||||
ASSIGNMENT_MODES = frozenset({"available", "default", "required"})
|
ASSIGNMENT_MODES = frozenset({"available", "default", "required"})
|
||||||
ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"})
|
ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"})
|
||||||
DEFINITION_SCOPES = frozenset({"system", "tenant", "group", "user"})
|
DEFINITION_SCOPES = frozenset({"system", "tenant", "group", "user"})
|
||||||
|
MAX_EFFECTIVE_VIEW_ASSIGNMENTS = 1000
|
||||||
|
MAX_EFFECTIVE_VIEW_GROUPS = 500
|
||||||
LOCKOUT_BASE_SURFACE_IDS = (
|
LOCKOUT_BASE_SURFACE_IDS = (
|
||||||
"access.module",
|
"access.module",
|
||||||
"access.nav.admin",
|
"access.nav.admin",
|
||||||
@@ -81,6 +83,18 @@ class EffectiveViewState:
|
|||||||
effective: EffectiveView
|
effective: EffectiveView
|
||||||
available_views: tuple[EffectiveViewOption, ...]
|
available_views: tuple[EffectiveViewOption, ...]
|
||||||
diagnostics: tuple[ViewDiagnostic, ...] = ()
|
diagnostics: tuple[ViewDiagnostic, ...] = ()
|
||||||
|
invalidation_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
ResolvedAssignment = tuple[ViewAssignment, ViewRevision]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ViewSelection:
|
||||||
|
selected: ResolvedAssignment | None
|
||||||
|
locked: bool
|
||||||
|
provenance: tuple[dict[str, object], ...]
|
||||||
|
diagnostics: tuple[ViewDiagnostic, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -660,19 +674,6 @@ def list_assignments(
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
def _assignment_revision(
|
|
||||||
session: Session,
|
|
||||||
assignment: ViewAssignment,
|
|
||||||
) -> ViewRevision | None:
|
|
||||||
revision_id = assignment.revision_id or assignment.definition.published_revision_id
|
|
||||||
if not revision_id:
|
|
||||||
return None
|
|
||||||
revision = session.get(ViewRevision, revision_id)
|
|
||||||
if revision is None or revision.definition_id != assignment.definition_id:
|
|
||||||
return None
|
|
||||||
return revision
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_assignment_revision(
|
def _validate_assignment_revision(
|
||||||
session: Session,
|
session: Session,
|
||||||
definition: ViewDefinition,
|
definition: ViewDefinition,
|
||||||
@@ -874,26 +875,6 @@ def delete_assignment(session: Session, assignment: ViewAssignment) -> None:
|
|||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def _assignment_applies(
|
|
||||||
assignment: ViewAssignment,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
account_id: str,
|
|
||||||
group_ids: frozenset[str],
|
|
||||||
) -> bool:
|
|
||||||
if assignment.scope_type == "system":
|
|
||||||
return True
|
|
||||||
if assignment.tenant_id != tenant_id:
|
|
||||||
return False
|
|
||||||
if assignment.scope_type == "tenant":
|
|
||||||
return assignment.scope_id == tenant_id
|
|
||||||
if assignment.scope_type == "group":
|
|
||||||
return bool(assignment.scope_id and assignment.scope_id in group_ids)
|
|
||||||
if assignment.scope_type == "user":
|
|
||||||
return assignment.scope_id == account_id
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _assignment_order(assignment: ViewAssignment) -> tuple[int, int, float, str]:
|
def _assignment_order(assignment: ViewAssignment) -> tuple[int, int, float, str]:
|
||||||
specificity = {
|
specificity = {
|
||||||
"system": 0,
|
"system": 0,
|
||||||
@@ -923,57 +904,113 @@ def _preference(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_effective_view(
|
def _collect_effective_view_candidates(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: frozenset[str],
|
||||||
catalogue: Iterable[ViewSurface] | None = None,
|
) -> tuple[tuple[ViewAssignment, ...], bool]:
|
||||||
) -> EffectiveViewState:
|
scope_filters = [
|
||||||
groups = frozenset(str(group_id) for group_id in group_ids)
|
and_(
|
||||||
candidates = (
|
ViewAssignment.scope_type == "system",
|
||||||
|
ViewAssignment.tenant_id.is_(None),
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
ViewAssignment.scope_type == "tenant",
|
||||||
|
ViewAssignment.tenant_id == tenant_id,
|
||||||
|
ViewAssignment.scope_id == tenant_id,
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
ViewAssignment.scope_type == "user",
|
||||||
|
ViewAssignment.tenant_id == tenant_id,
|
||||||
|
ViewAssignment.scope_id == account_id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if group_ids:
|
||||||
|
scope_filters.append(
|
||||||
|
and_(
|
||||||
|
ViewAssignment.scope_type == "group",
|
||||||
|
ViewAssignment.tenant_id == tenant_id,
|
||||||
|
ViewAssignment.scope_id.in_(group_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
session.query(ViewAssignment)
|
session.query(ViewAssignment)
|
||||||
.join(ViewDefinition, ViewAssignment.definition_id == ViewDefinition.id)
|
.join(ViewDefinition, ViewAssignment.definition_id == ViewDefinition.id)
|
||||||
|
.options(joinedload(ViewAssignment.definition))
|
||||||
.filter(
|
.filter(
|
||||||
ViewAssignment.is_active.is_(True),
|
ViewAssignment.is_active.is_(True),
|
||||||
ViewDefinition.status == "published",
|
ViewDefinition.status == "published",
|
||||||
ViewDefinition.deleted_at.is_(None),
|
ViewDefinition.deleted_at.is_(None),
|
||||||
or_(
|
or_(*scope_filters),
|
||||||
ViewAssignment.tenant_id == tenant_id,
|
|
||||||
ViewAssignment.tenant_id.is_(None),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
.order_by(ViewAssignment.id.asc())
|
||||||
|
.limit(MAX_EFFECTIVE_VIEW_ASSIGNMENTS + 1)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
applicable = [
|
return (
|
||||||
assignment
|
tuple(rows[:MAX_EFFECTIVE_VIEW_ASSIGNMENTS]),
|
||||||
for assignment in candidates
|
len(rows) > MAX_EFFECTIVE_VIEW_ASSIGNMENTS,
|
||||||
if _assignment_applies(
|
|
||||||
assignment,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
account_id=account_id,
|
|
||||||
group_ids=groups,
|
|
||||||
)
|
)
|
||||||
]
|
|
||||||
|
|
||||||
resolved_assignments: list[tuple[ViewAssignment, ViewRevision]] = []
|
|
||||||
for assignment in applicable:
|
|
||||||
revision = _assignment_revision(session, assignment)
|
|
||||||
if revision is None:
|
|
||||||
continue
|
|
||||||
resolved_assignments.append((assignment, revision))
|
|
||||||
|
|
||||||
option_assignments: dict[str, tuple[ViewAssignment, ViewRevision]] = {}
|
def _resolve_assignment_revisions(
|
||||||
for assignment, revision in resolved_assignments:
|
session: Session,
|
||||||
existing = option_assignments.get(assignment.definition_id)
|
assignments: Iterable[ViewAssignment],
|
||||||
|
) -> tuple[ResolvedAssignment, ...]:
|
||||||
|
candidates = tuple(assignments)
|
||||||
|
revision_ids = {
|
||||||
|
revision_id
|
||||||
|
for assignment in candidates
|
||||||
|
if (
|
||||||
|
revision_id := (
|
||||||
|
assignment.revision_id
|
||||||
|
or assignment.definition.published_revision_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
revisions = (
|
||||||
|
session.query(ViewRevision)
|
||||||
|
.filter(ViewRevision.id.in_(revision_ids))
|
||||||
|
.all()
|
||||||
|
if revision_ids
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
by_id = {revision.id: revision for revision in revisions}
|
||||||
|
resolved: list[ResolvedAssignment] = []
|
||||||
|
for assignment in candidates:
|
||||||
|
revision_id = (
|
||||||
|
assignment.revision_id
|
||||||
|
or assignment.definition.published_revision_id
|
||||||
|
)
|
||||||
|
revision = by_id.get(revision_id)
|
||||||
|
if (
|
||||||
|
revision is not None
|
||||||
|
and revision.definition_id == assignment.definition_id
|
||||||
|
):
|
||||||
|
resolved.append((assignment, revision))
|
||||||
|
return tuple(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def _option_assignments(
|
||||||
|
assignments: Iterable[ResolvedAssignment],
|
||||||
|
) -> dict[str, ResolvedAssignment]:
|
||||||
|
options: dict[str, ResolvedAssignment] = {}
|
||||||
|
for assignment, revision in assignments:
|
||||||
|
existing = options.get(assignment.definition_id)
|
||||||
if existing is None or _assignment_order(assignment) > _assignment_order(
|
if existing is None or _assignment_order(assignment) > _assignment_order(
|
||||||
existing[0]
|
existing[0]
|
||||||
):
|
):
|
||||||
option_assignments[assignment.definition_id] = (assignment, revision)
|
options[assignment.definition_id] = (assignment, revision)
|
||||||
|
return options
|
||||||
|
|
||||||
ordered_options = sorted(
|
|
||||||
option_assignments.values(),
|
def _available_view_options(
|
||||||
|
options: dict[str, ResolvedAssignment],
|
||||||
|
) -> tuple[EffectiveViewOption, ...]:
|
||||||
|
ordered = sorted(
|
||||||
|
options.values(),
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
-_assignment_order(item[0])[0],
|
-_assignment_order(item[0])[0],
|
||||||
-_assignment_order(item[0])[1],
|
-_assignment_order(item[0])[1],
|
||||||
@@ -981,78 +1018,160 @@ def resolve_effective_view(
|
|||||||
item[0].definition_id,
|
item[0].definition_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
available_views = tuple(
|
return tuple(
|
||||||
EffectiveViewOption(
|
EffectiveViewOption(
|
||||||
id=assignment.definition.id,
|
id=assignment.definition.id,
|
||||||
name=assignment.definition.name,
|
name=assignment.definition.name,
|
||||||
description=assignment.definition.description,
|
description=assignment.definition.description,
|
||||||
revision_id=revision.id,
|
revision_id=revision.id,
|
||||||
)
|
)
|
||||||
for assignment, revision in ordered_options
|
for assignment, revision in ordered
|
||||||
)
|
)
|
||||||
|
|
||||||
selected: tuple[ViewAssignment, ViewRevision] | None = None
|
|
||||||
locked = False
|
def _assignment_provenance(
|
||||||
required = [pair for pair in resolved_assignments if pair[0].mode == "required"]
|
source: str,
|
||||||
preference = _preference(
|
assignment: ViewAssignment,
|
||||||
session,
|
detail: str,
|
||||||
tenant_id=tenant_id,
|
) -> dict[str, object]:
|
||||||
account_id=account_id,
|
return {
|
||||||
)
|
"source": source,
|
||||||
provenance: list[dict[str, object]] = []
|
"scope_type": assignment.scope_type,
|
||||||
if required:
|
"scope_id": assignment.scope_id,
|
||||||
selected = max(required, key=lambda pair: _assignment_order(pair[0]))
|
"detail": detail,
|
||||||
locked = True
|
|
||||||
provenance.append(
|
|
||||||
{
|
|
||||||
"source": "required_assignment",
|
|
||||||
"scope_type": selected[0].scope_type,
|
|
||||||
"scope_id": selected[0].scope_id,
|
|
||||||
"detail": f"Required View assignment {selected[0].id}",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_effective_assignment(
|
||||||
|
assignments: tuple[ResolvedAssignment, ...],
|
||||||
|
options: dict[str, ResolvedAssignment],
|
||||||
|
*,
|
||||||
|
preference: ViewPreference | None,
|
||||||
|
account_id: str,
|
||||||
|
workflow_view_id: str | None,
|
||||||
|
) -> ViewSelection:
|
||||||
|
required = [
|
||||||
|
pair for pair in assignments if pair[0].mode == "required"
|
||||||
|
]
|
||||||
|
if required:
|
||||||
|
selected = max(
|
||||||
|
required,
|
||||||
|
key=lambda pair: _assignment_order(pair[0]),
|
||||||
)
|
)
|
||||||
elif preference and preference.selection_kind == "none":
|
return ViewSelection(
|
||||||
provenance.append(
|
selected=selected,
|
||||||
|
locked=True,
|
||||||
|
provenance=(
|
||||||
|
_assignment_provenance(
|
||||||
|
"required_assignment",
|
||||||
|
selected[0],
|
||||||
|
f"Required View assignment {selected[0].id}",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
diagnostics: list[ViewDiagnostic] = []
|
||||||
|
if workflow_view_id is not None:
|
||||||
|
selected = options.get(workflow_view_id)
|
||||||
|
if selected is not None:
|
||||||
|
return ViewSelection(
|
||||||
|
selected=selected,
|
||||||
|
locked=False,
|
||||||
|
provenance=(
|
||||||
|
_assignment_provenance(
|
||||||
|
"workflow_selection",
|
||||||
|
selected[0],
|
||||||
|
f"Workflow selected View {workflow_view_id}",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
diagnostics.append(
|
||||||
|
ViewDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="view.workflow_selection_unavailable",
|
||||||
|
message=(
|
||||||
|
"The workflow-selected View is not available to this "
|
||||||
|
"account. Normal View selection was used."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if preference and preference.selection_kind == "none":
|
||||||
|
return ViewSelection(
|
||||||
|
selected=None,
|
||||||
|
locked=False,
|
||||||
|
provenance=(
|
||||||
{
|
{
|
||||||
"source": "user_selection",
|
"source": "user_selection",
|
||||||
"scope_type": "user",
|
"scope_type": "user",
|
||||||
"scope_id": account_id,
|
"scope_id": account_id,
|
||||||
"detail": "Full interface selected",
|
"detail": "Full interface selected",
|
||||||
}
|
},
|
||||||
|
),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
)
|
)
|
||||||
elif (
|
if (
|
||||||
preference
|
preference
|
||||||
and preference.selection_kind == "view"
|
and preference.selection_kind == "view"
|
||||||
and preference.view_id in option_assignments
|
and preference.view_id in options
|
||||||
):
|
):
|
||||||
selected = option_assignments[preference.view_id]
|
selected = options[preference.view_id]
|
||||||
provenance.append(
|
return ViewSelection(
|
||||||
|
selected=selected,
|
||||||
|
locked=False,
|
||||||
|
provenance=(
|
||||||
{
|
{
|
||||||
"source": "user_selection",
|
"source": "user_selection",
|
||||||
"scope_type": "user",
|
"scope_type": "user",
|
||||||
"scope_id": account_id,
|
"scope_id": account_id,
|
||||||
"detail": f"Selected View {preference.view_id}",
|
"detail": f"Selected View {preference.view_id}",
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
else:
|
diagnostics=tuple(diagnostics),
|
||||||
defaults = [pair for pair in resolved_assignments if pair[0].mode == "default"]
|
|
||||||
if defaults:
|
|
||||||
selected = max(defaults, key=lambda pair: _assignment_order(pair[0]))
|
|
||||||
provenance.append(
|
|
||||||
{
|
|
||||||
"source": "default_assignment",
|
|
||||||
"scope_type": selected[0].scope_type,
|
|
||||||
"scope_id": selected[0].scope_id,
|
|
||||||
"detail": f"Default View assignment {selected[0].id}",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
diagnostics: list[ViewDiagnostic] = []
|
defaults = [
|
||||||
if selected is not None and catalogue is not None:
|
pair for pair in assignments if pair[0].mode == "default"
|
||||||
assignment, revision = selected
|
]
|
||||||
current_catalogue = tuple(catalogue)
|
if defaults:
|
||||||
known_surfaces = {surface.id: surface for surface in current_catalogue}
|
selected = max(
|
||||||
stale_ids = sorted(set(revision.visible_surface_ids) - set(known_surfaces))
|
defaults,
|
||||||
|
key=lambda pair: _assignment_order(pair[0]),
|
||||||
|
)
|
||||||
|
return ViewSelection(
|
||||||
|
selected=selected,
|
||||||
|
locked=False,
|
||||||
|
provenance=(
|
||||||
|
_assignment_provenance(
|
||||||
|
"default_assignment",
|
||||||
|
selected[0],
|
||||||
|
f"Default View assignment {selected[0].id}",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
return ViewSelection(
|
||||||
|
selected=None,
|
||||||
|
locked=False,
|
||||||
|
provenance=(),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_selected_surfaces(
|
||||||
|
selection: ViewSelection,
|
||||||
|
*,
|
||||||
|
catalogue: tuple[ViewSurface, ...] | None,
|
||||||
|
) -> ViewSelection:
|
||||||
|
if selection.selected is None or catalogue is None:
|
||||||
|
return selection
|
||||||
|
assignment, revision = selection.selected
|
||||||
|
known_surfaces = {surface.id: surface for surface in catalogue}
|
||||||
|
stale_ids = sorted(
|
||||||
|
set(revision.visible_surface_ids) - set(known_surfaces)
|
||||||
|
)
|
||||||
|
diagnostics = list(selection.diagnostics)
|
||||||
|
provenance = list(selection.provenance)
|
||||||
if stale_ids:
|
if stale_ids:
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
ViewDiagnostic(
|
ViewDiagnostic(
|
||||||
@@ -1065,55 +1184,37 @@ def resolve_effective_view(
|
|||||||
surface_ids=tuple(stale_ids),
|
surface_ids=tuple(stale_ids),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if locked:
|
|
||||||
try:
|
if selection.locked:
|
||||||
lockout_ids = lockout_required_surface_ids(
|
lockout_diagnostic = _required_lockout_diagnostic(
|
||||||
current_catalogue,
|
assignment,
|
||||||
assignment_scope_type=assignment.scope_type,
|
revision,
|
||||||
)
|
catalogue=catalogue,
|
||||||
unavailable_escape_ids = sorted(
|
|
||||||
lockout_ids - set(revision.visible_surface_ids)
|
|
||||||
)
|
|
||||||
lockout_message = (
|
|
||||||
"The required View no longer retains all administration "
|
|
||||||
"escape surfaces."
|
|
||||||
if unavailable_escape_ids
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
except ViewsValidationError as exc:
|
|
||||||
unavailable_escape_ids = ()
|
|
||||||
lockout_message = str(exc)
|
|
||||||
if lockout_message:
|
|
||||||
diagnostics.append(
|
|
||||||
ViewDiagnostic(
|
|
||||||
severity="error",
|
|
||||||
code="view.lockout_escape_unavailable",
|
|
||||||
message=(
|
|
||||||
f"{lockout_message} The full authorized interface "
|
|
||||||
"is shown until the assignment is repaired."
|
|
||||||
),
|
|
||||||
surface_ids=tuple(unavailable_escape_ids),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if lockout_diagnostic is not None:
|
||||||
|
diagnostics.append(lockout_diagnostic)
|
||||||
provenance.append(
|
provenance.append(
|
||||||
{
|
_fallback_provenance(
|
||||||
"source": "invalid_view_fallback",
|
assignment,
|
||||||
"scope_type": assignment.scope_type,
|
detail=(
|
||||||
"scope_id": assignment.scope_id,
|
|
||||||
"detail": (
|
|
||||||
"Required View administration escape is unavailable "
|
"Required View administration escape is unavailable "
|
||||||
f"for revision {revision.id}"
|
f"for revision {revision.id}"
|
||||||
),
|
),
|
||||||
}
|
|
||||||
)
|
)
|
||||||
selected = None
|
)
|
||||||
locked = False
|
return ViewSelection(
|
||||||
|
selected=None,
|
||||||
|
locked=False,
|
||||||
|
provenance=tuple(provenance),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
known_visible = [
|
known_visible = [
|
||||||
known_surfaces[surface_id]
|
known_surfaces[surface_id]
|
||||||
for surface_id in revision.visible_surface_ids
|
for surface_id in revision.visible_surface_ids
|
||||||
if surface_id in known_surfaces
|
if surface_id in known_surfaces
|
||||||
]
|
]
|
||||||
if selected is not None and (
|
if (
|
||||||
not any(surface.kind == "navigation" for surface in known_visible)
|
not any(surface.kind == "navigation" for surface in known_visible)
|
||||||
or not any(surface.kind == "route" for surface in known_visible)
|
or not any(surface.kind == "route" for surface in known_visible)
|
||||||
):
|
):
|
||||||
@@ -1128,39 +1229,227 @@ def resolve_effective_view(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
provenance.append(
|
provenance.append(
|
||||||
{
|
_fallback_provenance(
|
||||||
"source": "invalid_view_fallback",
|
assignment,
|
||||||
"scope_type": assignment.scope_type,
|
detail=f"Invalid View revision {revision.id}",
|
||||||
"scope_id": assignment.scope_id,
|
)
|
||||||
"detail": f"Invalid View revision {revision.id}",
|
)
|
||||||
}
|
return ViewSelection(
|
||||||
|
selected=None,
|
||||||
|
locked=False,
|
||||||
|
provenance=tuple(provenance),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
return ViewSelection(
|
||||||
|
selected=selection.selected,
|
||||||
|
locked=selection.locked,
|
||||||
|
provenance=tuple(provenance),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
)
|
)
|
||||||
selected = None
|
|
||||||
locked = False
|
|
||||||
|
|
||||||
if selected is None:
|
|
||||||
effective = EffectiveView(
|
def _required_lockout_diagnostic(
|
||||||
|
assignment: ViewAssignment,
|
||||||
|
revision: ViewRevision,
|
||||||
|
*,
|
||||||
|
catalogue: tuple[ViewSurface, ...],
|
||||||
|
) -> ViewDiagnostic | None:
|
||||||
|
try:
|
||||||
|
required_ids = lockout_required_surface_ids(
|
||||||
|
catalogue,
|
||||||
|
assignment_scope_type=assignment.scope_type,
|
||||||
|
)
|
||||||
|
missing_ids = tuple(
|
||||||
|
sorted(required_ids - set(revision.visible_surface_ids))
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"The required View no longer retains all administration escape surfaces."
|
||||||
|
if missing_ids
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except ViewsValidationError as exc:
|
||||||
|
missing_ids = ()
|
||||||
|
message = str(exc)
|
||||||
|
if message is None:
|
||||||
|
return None
|
||||||
|
return ViewDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code="view.lockout_escape_unavailable",
|
||||||
|
message=(
|
||||||
|
f"{message} The full authorized interface is shown until the "
|
||||||
|
"assignment is repaired."
|
||||||
|
),
|
||||||
|
surface_ids=missing_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_provenance(
|
||||||
|
assignment: ViewAssignment,
|
||||||
|
*,
|
||||||
|
detail: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return _assignment_provenance(
|
||||||
|
"invalid_view_fallback",
|
||||||
|
assignment,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView:
|
||||||
|
if selection.selected is None:
|
||||||
|
return EffectiveView(
|
||||||
view_id=None,
|
view_id=None,
|
||||||
revision_id=None,
|
revision_id=None,
|
||||||
name=None,
|
name=None,
|
||||||
visible_surface_ids=frozenset(),
|
visible_surface_ids=frozenset(),
|
||||||
locked=False,
|
locked=False,
|
||||||
provenance=tuple(provenance),
|
provenance=selection.provenance,
|
||||||
)
|
)
|
||||||
else:
|
assignment, revision = selection.selected
|
||||||
assignment, revision = selected
|
return EffectiveView(
|
||||||
effective = 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=frozenset(revision.visible_surface_ids),
|
||||||
locked=locked,
|
locked=selection.locked,
|
||||||
provenance=tuple(provenance),
|
provenance=selection.provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolution_invalidation_token(
|
||||||
|
assignments: Iterable[ResolvedAssignment],
|
||||||
|
*,
|
||||||
|
preference: ViewPreference | None,
|
||||||
|
catalogue: tuple[ViewSurface, ...] | None,
|
||||||
|
workflow_view_id: str | None,
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": assignment.id,
|
||||||
|
"revision_id": revision.id,
|
||||||
|
"mode": assignment.mode,
|
||||||
|
"priority": assignment.priority,
|
||||||
|
"updated_at": assignment.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for assignment, revision in sorted(
|
||||||
|
assignments,
|
||||||
|
key=lambda pair: pair[0].id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"preference": (
|
||||||
|
{
|
||||||
|
"selection_kind": preference.selection_kind,
|
||||||
|
"view_id": preference.view_id,
|
||||||
|
"updated_at": preference.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
if preference is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"surfaces": (
|
||||||
|
sorted(
|
||||||
|
(
|
||||||
|
surface.id,
|
||||||
|
surface.module_id,
|
||||||
|
surface.kind,
|
||||||
|
surface.parent_id,
|
||||||
|
surface.required,
|
||||||
|
)
|
||||||
|
for surface in catalogue
|
||||||
|
)
|
||||||
|
if catalogue is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"workflow_view_id": workflow_view_id,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_effective_view(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
catalogue: Iterable[ViewSurface] | None = None,
|
||||||
|
workflow_view_id: str | None = None,
|
||||||
|
) -> EffectiveViewState:
|
||||||
|
groups = frozenset(str(group_id) for group_id in group_ids)
|
||||||
|
if len(groups) > MAX_EFFECTIVE_VIEW_GROUPS:
|
||||||
|
raise ViewsValidationError(
|
||||||
|
"Too many group memberships were supplied for View resolution"
|
||||||
|
)
|
||||||
|
current_catalogue = tuple(catalogue) if catalogue is not None else None
|
||||||
|
candidates, overflow = _collect_effective_view_candidates(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
group_ids=groups,
|
||||||
|
)
|
||||||
|
if overflow:
|
||||||
|
diagnostic = ViewDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code="view.assignment_limit_exceeded",
|
||||||
|
message=(
|
||||||
|
"View resolution exceeded its assignment safety limit. The "
|
||||||
|
"full authorized interface is shown."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return EffectiveViewState(
|
return EffectiveViewState(
|
||||||
effective=effective,
|
effective=_effective_view_from_selection(
|
||||||
available_views=available_views,
|
ViewSelection(
|
||||||
diagnostics=tuple(diagnostics),
|
selected=None,
|
||||||
|
locked=False,
|
||||||
|
provenance=(
|
||||||
|
{
|
||||||
|
"source": "invalid_view_fallback",
|
||||||
|
"scope_type": "user",
|
||||||
|
"scope_id": account_id,
|
||||||
|
"detail": "View assignment safety limit exceeded",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
diagnostics=(diagnostic,),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
available_views=(),
|
||||||
|
diagnostics=(diagnostic,),
|
||||||
|
)
|
||||||
|
resolved_assignments = _resolve_assignment_revisions(
|
||||||
|
session,
|
||||||
|
candidates,
|
||||||
|
)
|
||||||
|
options = _option_assignments(resolved_assignments)
|
||||||
|
preference = _preference(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
)
|
||||||
|
selection = _select_effective_assignment(
|
||||||
|
resolved_assignments,
|
||||||
|
options,
|
||||||
|
preference=preference,
|
||||||
|
account_id=account_id,
|
||||||
|
workflow_view_id=workflow_view_id,
|
||||||
|
)
|
||||||
|
evaluated = _evaluate_selected_surfaces(
|
||||||
|
selection,
|
||||||
|
catalogue=current_catalogue,
|
||||||
|
)
|
||||||
|
return EffectiveViewState(
|
||||||
|
effective=_effective_view_from_selection(evaluated),
|
||||||
|
available_views=_available_view_options(options),
|
||||||
|
diagnostics=evaluated.diagnostics,
|
||||||
|
invalidation_token=_resolution_invalidation_token(
|
||||||
|
resolved_assignments,
|
||||||
|
preference=preference,
|
||||||
|
catalogue=current_catalogue,
|
||||||
|
workflow_view_id=workflow_view_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -128,12 +128,14 @@ class ViewsServiceTests(unittest.TestCase):
|
|||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
scope_type: str = "tenant",
|
scope_type: str = "tenant",
|
||||||
|
scope_id: str | None = None,
|
||||||
visible_surface_ids: list[str] | None = None,
|
visible_surface_ids: list[str] | None = None,
|
||||||
):
|
):
|
||||||
definition = create_definition(
|
definition = create_definition(
|
||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
scope_type=scope_type,
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
definition_key=None,
|
definition_key=None,
|
||||||
name=name,
|
name=name,
|
||||||
description=None,
|
description=None,
|
||||||
@@ -154,6 +156,30 @@ class ViewsServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return definition
|
return definition
|
||||||
|
|
||||||
|
def assign(
|
||||||
|
self,
|
||||||
|
definition,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
mode: str = "default",
|
||||||
|
priority: int = 0,
|
||||||
|
):
|
||||||
|
return create_assignment(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
definition=definition,
|
||||||
|
revision_id=None,
|
||||||
|
mode=mode,
|
||||||
|
priority=priority,
|
||||||
|
is_active=True,
|
||||||
|
metadata={},
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
actor_id="account-admin",
|
||||||
|
)
|
||||||
|
|
||||||
def test_normalization_adds_required_surfaces_and_ancestors(self) -> None:
|
def test_normalization_adds_required_surfaces_and_ancestors(self) -> None:
|
||||||
normalized = normalize_visible_surface_ids(
|
normalized = normalize_visible_surface_ids(
|
||||||
ordinary_surface_ids(),
|
ordinary_surface_ids(),
|
||||||
@@ -311,6 +337,157 @@ class ViewsServiceTests(unittest.TestCase):
|
|||||||
{option.id for option in state.available_views},
|
{option.id for option in state.available_views},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_scope_precedence_is_system_tenant_group_user(self) -> None:
|
||||||
|
definitions = {
|
||||||
|
"system": self.create_published_definition(
|
||||||
|
name="System",
|
||||||
|
scope_type="system",
|
||||||
|
),
|
||||||
|
"tenant": self.create_published_definition(name="Tenant"),
|
||||||
|
"group": self.create_published_definition(
|
||||||
|
name="Group",
|
||||||
|
scope_type="group",
|
||||||
|
scope_id="group-1",
|
||||||
|
),
|
||||||
|
"user": self.create_published_definition(
|
||||||
|
name="User",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="account-user",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self.assign(
|
||||||
|
definitions["system"],
|
||||||
|
scope_type="system",
|
||||||
|
scope_id=None,
|
||||||
|
priority=1000,
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
definitions["tenant"],
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
priority=100,
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
definitions["group"],
|
||||||
|
scope_type="group",
|
||||||
|
scope_id="group-1",
|
||||||
|
priority=10,
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
definitions["user"],
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="account-user",
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
cases = (
|
||||||
|
("account-outsider", (), "tenant"),
|
||||||
|
("account-member", ("group-1",), "group"),
|
||||||
|
("account-user", ("group-1",), "user"),
|
||||||
|
)
|
||||||
|
for account_id, group_ids, expected_scope in cases:
|
||||||
|
with self.subTest(account_id=account_id):
|
||||||
|
state = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account_id,
|
||||||
|
group_ids=group_ids,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
definitions[expected_scope].id,
|
||||||
|
state.effective.view_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_required_then_workflow_then_user_and_default_precedence(self) -> None:
|
||||||
|
default = self.create_published_definition(name="Default")
|
||||||
|
workflow = self.create_published_definition(name="Workflow")
|
||||||
|
required = self.create_published_definition(
|
||||||
|
name="Required",
|
||||||
|
visible_surface_ids=lockout_safe_surface_ids(),
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
default,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="default",
|
||||||
|
)
|
||||||
|
self.assign(
|
||||||
|
workflow,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="available",
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow_state = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
workflow_view_id=workflow.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(workflow.id, workflow_state.effective.view_id)
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow_selection",
|
||||||
|
workflow_state.effective.provenance[0]["source"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assign(
|
||||||
|
required,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="required",
|
||||||
|
)
|
||||||
|
required_state = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=workflow.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(required.id, required_state.effective.view_id)
|
||||||
|
self.assertTrue(required_state.effective.locked)
|
||||||
|
self.assertEqual(
|
||||||
|
"required_assignment",
|
||||||
|
required_state.effective.provenance[0]["source"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolution_invalidation_token_tracks_inputs(self) -> None:
|
||||||
|
definition = self.create_published_definition(name="Files")
|
||||||
|
self.assign(
|
||||||
|
definition,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=None,
|
||||||
|
mode="available",
|
||||||
|
)
|
||||||
|
baseline = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
)
|
||||||
|
workflow = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue,
|
||||||
|
workflow_view_id=definition.id,
|
||||||
|
)
|
||||||
|
reduced_catalogue = resolve_effective_view(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-user",
|
||||||
|
catalogue=self.catalogue[:-1],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(baseline.invalidation_token)
|
||||||
|
self.assertNotEqual(
|
||||||
|
baseline.invalidation_token,
|
||||||
|
workflow.invalidation_token,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
baseline.invalidation_token,
|
||||||
|
reduced_catalogue.invalidation_token,
|
||||||
|
)
|
||||||
|
|
||||||
def test_more_specific_available_assignment_cannot_override_required_mode(
|
def test_more_specific_available_assignment_cannot_override_required_mode(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user