Add product-area presentation to Views

This commit is contained in:
2026-08-06 19:02:54 +02:00
parent 013f05839b
commit b9a92c79f9
12 changed files with 650 additions and 20 deletions
+3
View File
@@ -100,6 +100,9 @@ class ViewRevision(Base, TimestampMixin):
visible_surface_ids: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
presentation: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
+6 -1
View File
@@ -344,7 +344,7 @@ manifest = ModuleManifest(
"View safeguards, and the difference between visibility and access."
),
body=(
"A View definition owns immutable revisions of visible surface IDs. "
"A View definition owns immutable revisions of visible surface IDs and presentation metadata. "
"Publishing makes the latest revision assignable. Available assignments "
"let users opt in, defaults apply until changed, and required assignments "
"cannot be left. User and group assignments take precedence over tenant "
@@ -356,6 +356,9 @@ manifest = ModuleManifest(
"that system policy made unavailable or the tenant disabled. Saved references "
"to such surfaces remain in immutable revisions and are reported as stale. "
"Inherited definitions or assignments must be changed in their owning scope."
" Product-area grouping, ordering, and labels are presentation metadata in the same revision; "
"they cannot expose a hidden surface or grant authority. Grouped navigation is the sensible default, "
"while flat navigation preserves the complete authorized tool rail."
),
documentation_types=("admin",),
audience=("administrator", "power_user", "workflow_designer"),
@@ -378,6 +381,8 @@ manifest = ModuleManifest(
"views.field.name",
"views.field.description",
"views.field.surfaces",
"views.field.product-areas",
"views.field.navigation-layout",
"views.field.assignment-target",
"views.field.assignment-mode",
"views.field.assignment-priority",
@@ -0,0 +1,33 @@
"""v0.1.18 immutable View presentation metadata.
Revision ID: c6f2a9d4e7b1
Revises: b8e4c1f7a2d9
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c6f2a9d4e7b1"
down_revision = "b8e4c1f7a2d9"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("view_revisions") as batch_op:
batch_op.add_column(
sa.Column(
"presentation",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("view_revisions") as batch_op:
batch_op.drop_column("presentation")
+20
View File
@@ -111,6 +111,21 @@ def _catalogue(
)
def _product_area_ids(
session: Session,
principal: ApiPrincipal,
) -> frozenset[str]:
active_module_ids = {
surface.module_id for surface in _catalogue(session, principal)
}
return frozenset(
area.id
for manifest in get_registry().manifests()
if manifest.id in active_module_ids and manifest.frontend is not None
for area in manifest.frontend.product_areas
)
def _view_governance_policy():
return view_governance_policy(get_registry())
@@ -507,6 +522,7 @@ def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse:
active_revision_id=effective.revision_id,
active_view_name=effective.name,
visible_surface_ids=sorted(effective.visible_surface_ids),
presentation=dict(effective.presentation),
projection_active=effective.projection_active,
locked=effective.locked,
available_views=[
@@ -753,6 +769,8 @@ def api_create_definition(
visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal),
presentation=payload.presentation,
available_product_area_ids=_product_area_ids(session, principal),
)
_audit(
session,
@@ -892,6 +910,8 @@ def api_create_revision(
visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal),
presentation=payload.presentation,
available_product_area_ids=_product_area_ids(session, principal),
)
_audit(
session,
+4
View File
@@ -37,6 +37,7 @@ class ViewRevisionResponse(BaseModel):
revision: int
surface_contract_version: str
visible_surface_ids: list[str]
presentation: dict[str, Any] = Field(default_factory=dict)
content_hash: str
created_by: str | None = None
created_at: datetime
@@ -73,6 +74,7 @@ class ViewDefinitionCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=4000)
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
presentation: dict[str, Any] = Field(default_factory=dict)
class ViewDefinitionUpdateRequest(BaseModel):
@@ -82,6 +84,7 @@ class ViewDefinitionUpdateRequest(BaseModel):
class ViewRevisionCreateRequest(BaseModel):
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
presentation: dict[str, Any] | None = None
class ViewAssignmentResponse(BaseModel):
@@ -179,6 +182,7 @@ class EffectiveViewResponse(BaseModel):
active_revision_id: str | None = None
active_view_name: str | None = None
visible_surface_ids: list[str] = Field(default_factory=list)
presentation: dict[str, Any] = Field(default_factory=dict)
projection_active: bool = False
locked: bool = False
available_views: list[EffectiveViewOptionResponse] = Field(default_factory=list)
+107 -5
View File
@@ -6,7 +6,7 @@ import re
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
from typing import Any, Literal, Mapping
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, joinedload
@@ -51,6 +51,10 @@ LOCKOUT_ADMIN_SURFACE_IDS = {
"user": "views.admin.tenant",
}
_KEY_RE = re.compile(r"[^a-z0-9]+")
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,79}$")
_PRESENTATION_KEYS = frozenset(
{"navigation_mode", "product_area_order", "product_area_labels"}
)
class ViewsError(RuntimeError):
@@ -350,10 +354,92 @@ def normalize_visible_surface_ids(
)
def _revision_hash(surface_ids: list[str]) -> str:
def normalize_view_presentation(
value: Mapping[str, Any] | None,
*,
available_product_area_ids: Iterable[str] | None = None,
) -> dict[str, object]:
if value is None:
return {}
if not isinstance(value, Mapping):
raise ViewsValidationError("View presentation must be an object")
unknown_keys = sorted(set(value) - _PRESENTATION_KEYS)
if unknown_keys:
raise ViewsValidationError(
"Unsupported View presentation fields: " + ", ".join(unknown_keys)
)
normalized: dict[str, object] = {}
mode = value.get("navigation_mode")
if mode is not None:
if mode not in {"grouped", "flat"}:
raise ViewsValidationError(
"View navigation mode must be 'grouped' or 'flat'"
)
normalized["navigation_mode"] = mode
raw_order = value.get("product_area_order")
if raw_order is not None:
if not isinstance(raw_order, list) or len(raw_order) > 100:
raise ViewsValidationError(
"View product area order must be a list of at most 100 ids"
)
order: list[str] = []
for raw_id in raw_order:
area_id = str(raw_id).strip()
if not _PRESENTATION_ID_RE.fullmatch(area_id):
raise ViewsValidationError(
f"Invalid product area id in View presentation: {area_id!r}"
)
if area_id in order:
raise ViewsValidationError(
f"Duplicate product area id in View presentation: {area_id}"
)
order.append(area_id)
normalized["product_area_order"] = order
raw_labels = value.get("product_area_labels")
if raw_labels is not None:
if not isinstance(raw_labels, Mapping) or len(raw_labels) > 100:
raise ViewsValidationError(
"View product area labels must be an object with at most 100 entries"
)
labels: dict[str, str] = {}
for raw_id, raw_label in raw_labels.items():
area_id = str(raw_id).strip()
label = str(raw_label).strip()
if not _PRESENTATION_ID_RE.fullmatch(area_id):
raise ViewsValidationError(
f"Invalid product area id in View presentation: {area_id!r}"
)
if not label or len(label) > 200:
raise ViewsValidationError(
f"Product area label for {area_id} must contain 1 to 200 characters"
)
labels[area_id] = label
normalized["product_area_labels"] = labels
if available_product_area_ids is not None:
available = {str(item) for item in available_product_area_ids}
referenced = set(normalized.get("product_area_order", ())) | set(
normalized.get("product_area_labels", {})
)
unavailable = sorted(referenced - available)
if unavailable:
raise ViewsValidationError(
"View presentation references unavailable product areas: "
+ ", ".join(unavailable)
)
return normalized
def _revision_hash(
surface_ids: list[str], presentation: Mapping[str, object] | None = None
) -> str:
payload = {
"surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
"visible_surface_ids": surface_ids,
"presentation": dict(presentation or {}),
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
@@ -372,6 +458,8 @@ def create_definition(
visible_surface_ids: Iterable[str],
catalogue: Iterable[ViewSurface],
actor_id: str | None,
presentation: Mapping[str, Any] | None = None,
available_product_area_ids: Iterable[str] | None = None,
) -> ViewDefinition:
if scope_type not in DEFINITION_SCOPES:
raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}")
@@ -401,6 +489,10 @@ def create_definition(
visible_surface_ids,
catalogue=catalogue,
)
normalized_presentation = normalize_view_presentation(
presentation,
available_product_area_ids=available_product_area_ids,
)
definition = ViewDefinition(
tenant_id=row_tenant_id,
scope_type=scope_type,
@@ -422,7 +514,8 @@ def create_definition(
revision=1,
surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION,
visible_surface_ids=normalized_surfaces,
content_hash=_revision_hash(normalized_surfaces),
presentation=normalized_presentation,
content_hash=_revision_hash(normalized_surfaces, normalized_presentation),
created_by=actor_id,
)
session.add(revision)
@@ -461,15 +554,21 @@ def create_revision(
visible_surface_ids: Iterable[str],
catalogue: Iterable[ViewSurface],
actor_id: str | None,
presentation: Mapping[str, Any] | None = None,
available_product_area_ids: Iterable[str] | None = None,
) -> ViewRevision:
if definition.status == "archived":
raise ViewsConflictError("Archived Views cannot be revised")
latest = get_revision(session, definition_id=definition.id)
normalized_surfaces = normalize_visible_surface_ids(
visible_surface_ids,
catalogue=catalogue,
)
content_hash = _revision_hash(normalized_surfaces)
latest = get_revision(session, definition_id=definition.id)
normalized_presentation = normalize_view_presentation(
latest.presentation if presentation is None else presentation,
available_product_area_ids=available_product_area_ids,
)
content_hash = _revision_hash(normalized_surfaces, normalized_presentation)
if latest.content_hash == content_hash:
return latest
next_number = definition.current_revision + 1
@@ -479,6 +578,7 @@ def create_revision(
revision=next_number,
surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION,
visible_surface_ids=normalized_surfaces,
presentation=normalized_presentation,
content_hash=content_hash,
created_by=actor_id,
)
@@ -1441,6 +1541,7 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView:
revision_id=None,
name=None,
visible_surface_ids=selection.visible_surface_ids or frozenset(),
presentation={},
locked=False,
projection_active=selection.visible_surface_ids is not None,
provenance=selection.provenance,
@@ -1455,6 +1556,7 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView:
if selection.visible_surface_ids is not None
else frozenset(revision.visible_surface_ids)
),
presentation=dict(revision.presentation or {}),
locked=selection.locked,
projection_active=True,
provenance=selection.provenance,