Add product-area presentation to Views
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+33
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user