9 Commits
Author SHA1 Message Date
zemion 4e574f3bdd docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:42 +02:00
zemion acd0d3bdae docs: complete public documentation baseline
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 07:23:31 +02:00
zemion 266f5dae9b feat(views): add governed DSAR coverage 2026-08-21 04:02:21 +02:00
zemion 24dca43e2e feat: contribute bounded policy impact subjects 2026-08-20 20:27:17 +02:00
zemion e49129c3eb test(views): cover canonical module visibility 2026-08-19 22:55:19 +02:00
zemion e87076f86b docs: explain View Quick Access focus behavior 2026-08-19 20:14:02 +02:00
zemion 877dced738 feat: add View-scoped quick access presentation 2026-08-19 18:47:46 +02:00
zemion cdbf2bf80b Adopt shared WebUI layout primitives 2026-08-18 10:42:54 +02:00
zemion b9a92c79f9 Add product-area presentation to Views 2026-08-06 19:02:54 +02:00
24 changed files with 2793 additions and 39 deletions
+6
View File
@@ -79,3 +79,9 @@ assignments but above user/default selection. The View must already be
available to the account, so Workflow cannot bypass assignment policy.
Persisting a workflow instance's pinned View revision remains owned by the
Workflow module.
A View may also recommend or focus Quick Access tools. Focus narrows only the
already effective, authorized catalogue. The rail identifies the active View,
explains a focus whose tools are no longer available, and exposes **All
available tools** as a temporary permission-derived escape; that action neither
changes the View nor stores an override.
+1 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-views"
version = "0.1.18"
version = "0.1.20"
description = "Governed task-focused interface projections for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
@@ -20,4 +20,3 @@ govoplan_views = ["py.typed"]
[project.entry-points."govoplan.modules"]
views = "govoplan_views.backend.manifest:get_manifest"
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Views module."""
__version__ = "0.1.18"
__version__ = "0.1.20"
+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
+834
View File
@@ -0,0 +1,834 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_views.backend.db.models import (
ViewAssignment,
ViewDefinition,
ViewPreference,
ViewRevision,
)
VIEWS_DSAR_CAPABILITY = dsar_capability_name("views")
_MAX_RECORDS = 5_000
_MAX_REVISIONS_PER_DEFINITION = 1_000
_MAX_PERSONAL_REVISIONS = 5_000
_CONFLICT = object()
_ATTRIBUTION_TYPES = frozenset(
{
"view_definition_attribution",
"view_revision_attribution",
"view_assignment_attribution",
}
)
_PERSONAL_TYPES = frozenset(
{
"personal_view_assignment",
"personal_view_preference",
"personal_view_definition",
}
)
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str
definition_id: str | None
assignment_id: str | None
preference_id: str | None
class ViewsDsarProvider:
provider_id = "views"
module_id = "views"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
personal_revision_count = 0
if selectors.preference_id is None and selectors.assignment_id is None:
definitions = db.query(ViewDefinition).filter(
ViewDefinition.tenant_id == tenant_id,
ViewDefinition.scope_type == "user",
ViewDefinition.scope_id == selectors.account_id,
)
if selectors.definition_id:
definitions = definitions.filter(
ViewDefinition.id == selectors.definition_id
)
for definition in _limited(definitions, label="personal definitions"):
record = _personal_definition_record(db, definition)
revisions = record.data["revisions"]
personal_revision_count += len(revisions) # type: ignore[arg-type]
if personal_revision_count > _MAX_PERSONAL_REVISIONS:
raise ValueError(
"Views DSAR personal revision limit exceeded; narrow the selectors."
)
records.append(record)
if selectors.preference_id is None:
assignments = db.query(ViewAssignment).filter(
ViewAssignment.tenant_id == tenant_id,
ViewAssignment.scope_type == "user",
ViewAssignment.scope_id == selectors.account_id,
)
if selectors.assignment_id:
assignments = assignments.filter(
ViewAssignment.id == selectors.assignment_id
)
if selectors.definition_id:
assignments = assignments.filter(
ViewAssignment.definition_id == selectors.definition_id
)
for assignment in _limited(assignments, label="personal assignments"):
records.append(_personal_assignment_record(assignment))
if selectors.assignment_id is None:
preferences = db.query(ViewPreference).filter(
ViewPreference.tenant_id == tenant_id,
ViewPreference.account_id == selectors.account_id,
)
if selectors.preference_id:
preferences = preferences.filter(
ViewPreference.id == selectors.preference_id
)
if selectors.definition_id:
preferences = preferences.filter(
ViewPreference.view_id == selectors.definition_id
)
for preference in _limited(preferences, label="personal preferences"):
records.append(_personal_preference_record(preference))
if selectors.preference_id is None and selectors.assignment_id is None:
definitions = db.query(ViewDefinition).filter(
ViewDefinition.tenant_id == tenant_id,
or_(
ViewDefinition.scope_type != "user",
ViewDefinition.scope_id != selectors.account_id,
),
or_(
ViewDefinition.created_by == selectors.account_id,
ViewDefinition.updated_by == selectors.account_id,
),
)
if selectors.definition_id:
definitions = definitions.filter(
ViewDefinition.id == selectors.definition_id
)
for definition in _limited(
definitions,
label="definition attribution",
):
records.append(
_definition_attribution_record(definition, selectors.account_id)
)
revisions = (
db.query(ViewRevision)
.join(ViewDefinition, ViewRevision.definition_id == ViewDefinition.id)
.filter(
ViewRevision.tenant_id == tenant_id,
ViewRevision.created_by == selectors.account_id,
or_(
ViewDefinition.scope_type != "user",
ViewDefinition.scope_id != selectors.account_id,
),
)
)
if selectors.definition_id:
revisions = revisions.filter(
ViewRevision.definition_id == selectors.definition_id
)
for revision in _limited(revisions, label="revision attribution"):
records.append(_revision_attribution_record(revision))
if selectors.preference_id is None:
assignments = db.query(ViewAssignment).filter(
ViewAssignment.tenant_id == tenant_id,
or_(
ViewAssignment.scope_type != "user",
ViewAssignment.scope_id != selectors.account_id,
),
or_(
ViewAssignment.created_by == selectors.account_id,
ViewAssignment.updated_by == selectors.account_id,
),
)
if selectors.assignment_id:
assignments = assignments.filter(
ViewAssignment.id == selectors.assignment_id
)
if selectors.definition_id:
assignments = assignments.filter(
ViewAssignment.definition_id == selectors.definition_id
)
for assignment in _limited(
assignments,
label="assignment attribution",
):
records.append(
_assignment_attribution_record(assignment, selectors.account_id)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Views DSAR result limit exceeded; narrow the selectors.")
order = {
"personal_view_assignment": 10,
"personal_view_preference": 20,
"personal_view_definition": 30,
"view_assignment_attribution": 40,
"view_definition_attribution": 50,
"view_revision_attribution": 60,
}
return tuple(
sorted(
records,
key=lambda item: (order[item.resource_type], item.resource_id),
)
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Views DSAR requires one corroborated account.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type in _ATTRIBUTION_TYPES:
actions.append(_retain_action(record))
continue
row = _personal_row(
db,
tenant_id=tenant_id,
account_id=selectors.account_id,
resource_type=record.resource_type,
resource_id=record.resource_id,
)
if row is None or not _row_matches_selectors(row, selectors):
actions.append(_manual_action(record, "ownership or selector mismatch"))
continue
if isinstance(row, ViewDefinition) and _has_foreign_dependents(
db,
row,
account_id=selectors.account_id,
):
actions.append(
_manual_action(
record,
"the personal definition has assignments or selections owned by another account",
)
)
continue
version = _row_version(row)
actions.append(
DsarErasureActionRef(
action_id=(
f"views:delete:{record.resource_type}:{record.resource_id}:"
f"{version['token']}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="delete",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Delete {record.title}",
rationale=(
"The record is an exact account-owned presentation "
"preference; deleting it does not change domain data."
),
executable=True,
irreversible=True,
metadata={"account_id": selectors.account_id, **version},
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Views DSAR requires one corroborated account.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Institutional View configuration attribution remains "
"accountability evidence."
),
)
)
continue
if action.resource_type not in _PERSONAL_TYPES:
raise ValueError("Unsupported executable Views DSAR action.")
row = _personal_row(
db,
tenant_id=tenant_id,
account_id=selectors.account_id,
resource_type=action.resource_type,
resource_id=action.resource_id,
)
if row is None:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary="The personal View record was already absent.",
evidence={"request_id": request_id},
)
)
continue
if str(
action.metadata.get("account_id") or ""
) != selectors.account_id or not _row_matches_selectors(row, selectors):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The View record does not match the corroborated "
"account and resource selectors."
),
)
)
continue
if not _version_matches(row, action.metadata):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The View record changed after the erasure plan; "
"create a new plan before deleting it."
),
)
)
continue
if isinstance(row, ViewDefinition) and _has_foreign_dependents(
db,
row,
account_id=selectors.account_id,
):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The personal View now affects another account; "
"review its assignments and selections manually."
),
)
)
continue
resource_id = str(row.id)
db.delete(row)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal View record without changing any "
"institutional View or domain data."
),
evidence={
"request_id": request_id,
"resource_type": action.resource_type,
"resource_id": resource_id,
},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("views.account"),
references.get("access.account"),
),
"definition_id": _coalesce(
references.get("views.definition"),
references.get("views.view"),
),
"assignment_id": _coalesce(
references.get("views.assignment"),
references.get("views.assignment_id"),
),
"preference_id": _coalesce(
references.get("views.preference"),
references.get("views.preference_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
account_id = _optional_string(values["account_id"])
if account_id is None:
return None
return _SubjectSelectors(
account_id=account_id,
definition_id=_optional_string(values["definition_id"]),
assignment_id=_optional_string(values["assignment_id"]),
preference_id=_optional_string(values["preference_id"]),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _limited(query, *, label: str):
rows = (
query.order_by(query.column_descriptions[0]["entity"].id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Views DSAR {label} limit exceeded; narrow the selectors.")
return rows
def _personal_definition_record(
session: Session,
definition: ViewDefinition,
) -> DsarRecordRef:
revisions = (
session.query(ViewRevision)
.filter(ViewRevision.definition_id == definition.id)
.order_by(ViewRevision.revision, ViewRevision.id)
.limit(_MAX_REVISIONS_PER_DEFINITION + 1)
.all()
)
if len(revisions) > _MAX_REVISIONS_PER_DEFINITION:
raise ValueError(
"Views DSAR personal definition revision limit exceeded; narrow the selectors."
)
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="personal_view_definition",
resource_id=definition.id,
category="personal_interface_preference",
title=f"Personal View: {definition.name[:200]}",
data={
"definition_key": definition.definition_key,
"name": definition.name[:200],
"description": (definition.description or "")[:2_000] or None,
"status": definition.status,
"current_revision": definition.current_revision,
"published_revision_id": definition.published_revision_id,
"deleted_at": _iso(definition.deleted_at),
"revisions": [
{
"id": revision.id,
"revision": revision.revision,
"surface_contract_version": revision.surface_contract_version,
"visible_surface_ids": _string_list(
revision.visible_surface_ids,
limit=1_000,
),
"presentation": _presentation_projection(revision.presentation),
"created_at": _iso(revision.created_at),
}
for revision in revisions
],
},
observed_at=_aware(definition.updated_at),
)
def _personal_assignment_record(assignment: ViewAssignment) -> DsarRecordRef:
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="personal_view_assignment",
resource_id=assignment.id,
category="personal_interface_preference",
title="Personal View assignment",
data={
"definition_id": assignment.definition_id,
"revision_id": assignment.revision_id,
"mode": assignment.mode,
"priority": assignment.priority,
"is_active": assignment.is_active,
},
observed_at=_aware(assignment.updated_at),
)
def _personal_preference_record(preference: ViewPreference) -> DsarRecordRef:
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="personal_view_preference",
resource_id=preference.id,
category="personal_interface_preference",
title="Personal active-View selection",
data={
"selection_kind": preference.selection_kind,
"view_id": preference.view_id,
},
observed_at=_aware(preference.updated_at),
)
def _definition_attribution_record(
definition: ViewDefinition,
account_id: str,
) -> DsarRecordRef:
activities = []
if definition.created_by == account_id:
activities.append("created_view_definition")
if definition.updated_by == account_id:
activities.append("updated_view_definition")
return _attribution_record(
"view_definition_attribution",
definition.id,
"View-definition attribution",
{
"activities": activities,
"scope_type": definition.scope_type,
"current_revision": definition.current_revision,
"status": definition.status,
},
observed_at=definition.updated_at,
)
def _revision_attribution_record(revision: ViewRevision) -> DsarRecordRef:
return _attribution_record(
"view_revision_attribution",
revision.id,
"View-revision attribution",
{
"activity": "created_view_revision",
"definition_id": revision.definition_id,
"revision": revision.revision,
"surface_contract_version": revision.surface_contract_version,
},
observed_at=revision.created_at,
)
def _assignment_attribution_record(
assignment: ViewAssignment,
account_id: str,
) -> DsarRecordRef:
activities = []
if assignment.created_by == account_id:
activities.append("created_view_assignment")
if assignment.updated_by == account_id:
activities.append("updated_view_assignment")
return _attribution_record(
"view_assignment_attribution",
assignment.id,
"View-assignment attribution",
{
"activities": activities,
"scope_type": assignment.scope_type,
"definition_id": assignment.definition_id,
"mode": assignment.mode,
"is_active": assignment.is_active,
},
observed_at=assignment.updated_at,
)
def _attribution_record(
resource_type: str,
resource_id: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type=resource_type,
resource_id=resource_id,
category="operator_accountability_evidence",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Institutional View configuration attribution is retained for "
"accountability; definition, presentation, and metadata payloads are excluded."
),
)
def _personal_row(
session: Session,
*,
tenant_id: str,
account_id: str,
resource_type: str,
resource_id: str,
) -> ViewAssignment | ViewPreference | ViewDefinition | None:
if resource_type == "personal_view_assignment":
return (
session.query(ViewAssignment)
.filter(
ViewAssignment.id == resource_id,
ViewAssignment.tenant_id == tenant_id,
ViewAssignment.scope_type == "user",
ViewAssignment.scope_id == account_id,
)
.one_or_none()
)
if resource_type == "personal_view_preference":
return (
session.query(ViewPreference)
.filter(
ViewPreference.id == resource_id,
ViewPreference.tenant_id == tenant_id,
ViewPreference.account_id == account_id,
)
.one_or_none()
)
if resource_type == "personal_view_definition":
return (
session.query(ViewDefinition)
.filter(
ViewDefinition.id == resource_id,
ViewDefinition.tenant_id == tenant_id,
ViewDefinition.scope_type == "user",
ViewDefinition.scope_id == account_id,
)
.one_or_none()
)
raise ValueError("Unsupported personal Views DSAR record type.")
def _row_matches_selectors(
row: ViewAssignment | ViewPreference | ViewDefinition,
selectors: _SubjectSelectors,
) -> bool:
if selectors.assignment_id and (
not isinstance(row, ViewAssignment) or row.id != selectors.assignment_id
):
return False
if selectors.preference_id and (
not isinstance(row, ViewPreference) or row.id != selectors.preference_id
):
return False
if selectors.definition_id:
if isinstance(row, ViewDefinition):
return row.id == selectors.definition_id
return (
row.view_id == selectors.definition_id
if isinstance(row, ViewPreference)
else row.definition_id == selectors.definition_id
)
return True
def _has_foreign_dependents(
session: Session,
definition: ViewDefinition,
*,
account_id: str,
) -> bool:
foreign_assignment = (
session.query(ViewAssignment.id)
.filter(
ViewAssignment.definition_id == definition.id,
or_(
ViewAssignment.tenant_id != definition.tenant_id,
ViewAssignment.scope_type != "user",
ViewAssignment.scope_id != account_id,
),
)
.first()
)
foreign_preference = (
session.query(ViewPreference.id)
.filter(
ViewPreference.view_id == definition.id,
or_(
ViewPreference.tenant_id != definition.tenant_id,
ViewPreference.account_id != account_id,
),
)
.first()
)
return foreign_assignment is not None or foreign_preference is not None
def _row_version(
row: ViewAssignment | ViewPreference | ViewDefinition,
) -> dict[str, object]:
updated_at = _iso(row.updated_at)
if isinstance(row, ViewDefinition):
return {
"token": f"r{row.current_revision}",
"current_revision": row.current_revision,
"updated_at": updated_at,
}
return {"token": "timestamp", "updated_at": updated_at}
def _version_matches(
row: ViewAssignment | ViewPreference | ViewDefinition,
metadata: Mapping[str, object],
) -> bool:
if metadata.get("updated_at") != _iso(row.updated_at):
return False
if isinstance(row, ViewDefinition):
return metadata.get("current_revision") == row.current_revision
return True
def _presentation_projection(value: object) -> dict[str, object]:
if not isinstance(value, Mapping):
raise ValueError("View presentation must be an object.")
projected: dict[str, object] = {}
mode = value.get("navigation_mode")
if isinstance(mode, str):
projected["navigation_mode"] = mode[:20]
for key in (
"product_area_order",
"quick_access_recommended_tool_ids",
"quick_access_focused_tool_ids",
):
raw = value.get(key)
if raw is not None:
projected[key] = _string_list(raw, limit=100)
labels = value.get("product_area_labels")
if labels is not None:
if not isinstance(labels, Mapping) or len(labels) > 100:
raise ValueError("View presentation labels exceed the DSAR bound.")
projected["product_area_labels"] = {
str(key)[:80]: str(label)[:200] for key, label in labels.items()
}
return projected
def _string_list(value: object, *, limit: int) -> list[str]:
if not isinstance(value, list) or len(value) > limit:
raise ValueError("View list payload exceeds the DSAR bound.")
return [str(item)[:255] for item in value]
def _retain_action(record: DsarRecordRef) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=f"views:retain:{record.resource_type}:{record.resource_id}",
provider_id="views",
module_id="views",
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Institutional View attribution is accountability evidence.",
executable=False,
)
def _manual_action(record: DsarRecordRef, reason: str) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=f"views:manual_review:{record.resource_type}:{record.resource_id}",
provider_id="views",
module_id="views",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=f"Automatic deletion stopped because of {reason}.",
executable=False,
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Views DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "views" or record.module_id != "views":
raise ValueError("Views DSAR cannot plan a foreign provider record.")
if record.resource_type not in _PERSONAL_TYPES | _ATTRIBUTION_TYPES:
raise ValueError("Views DSAR record type is invalid.")
if not record.resource_id:
raise ValueError("Views DSAR record identity is incomplete.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "views" or action.module_id != "views":
raise ValueError("Views DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("views:"):
raise ValueError("Views DSAR action identity is invalid.")
__all__ = ["VIEWS_DSAR_CAPABILITY", "ViewsDsarProvider"]
@@ -0,0 +1,71 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'views.data-subject-requests': {'consequence_classes': {'delete_assignment': 'Entfernt nur die '
'persönliche '
'Zuordnung, nicht '
'die '
'View-Definition.',
'delete_personal_definition': 'Entfernt '
'die '
'persönliche '
'Definition '
'und '
'Überarbeitungen '
'nur, wenn '
'kein '
'anderes '
'Konto '
'davon '
'abhängt.',
'delete_selection': 'Entfernt die '
'Kontoauswahl; '
'effektive Ausfälle '
'gelten erneut.',
'retain_attribution': 'Reserviert '
'minimierte '
'Mandant- / '
'Gruppenkonfigurationsverantwortung.'}},
'views.interface-projections': {'steps': ['Öffnen Sie den View-Selektor und überprüfen Sie den '
'effektiven View und seine Quelle.',
'Wählen Sie eine verfügbare View oder kehren Sie zum '
'berechtigungsabgeleiteten Standard zurück.',
'Verwenden Sie die Administrationsflucht, wenn eine '
'erforderliche View inspiziert oder geändert werden '
'muss.']},
'views.reference.fields-and-consequences': {'consequence_classes': {'archive': 'Deaktiviert '
'optionale '
'Zuweisungen; '
'erforderliche '
'Zuweisungen '
'müssen zuerst '
'entfernt werden.',
'publish': 'Erstellt die '
'zuordenbare '
'unveränderliche '
'Revision, die von '
'nicht gepinnten '
'Zuweisungen '
'verwendet wird.',
'remove_assignment': 'Stoppt, '
'dass '
'das '
'Ziel '
'diese '
'Zuweisung '
'erbt, '
'ohne '
'View zu '
'löschen.',
'required': 'Schränkt '
'betroffene '
'Benutzer ein, '
'während '
'Selektor- und '
'Administrations-Escape-Oberflächen '
'beibehalten '
'werden.'}}}
@@ -0,0 +1,217 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.policy import (
POLICY_SCOPE_TYPES,
PolicyImpactPopulationRequest,
PolicyImpactSubject,
PolicyImpactSubjectBatch,
PolicyScopeType,
)
from govoplan_views.backend.db.models import ViewDefinition
VIEW_IMPACT_ACTIONS = (
"view",
"select",
"assign",
"edit",
"derive",
"workflow_activate",
)
class ViewsPolicyImpactSubjectProvider:
provider_id = "views"
supported_policy_families = ("view",)
def __init__(self, registry: object) -> None:
self._registry = registry
def collect_policy_impact_subjects(
self,
session: object | None = None,
*,
request: PolicyImpactPopulationRequest,
) -> PolicyImpactSubjectBatch:
if request.policy_family != "view":
return PolicyImpactSubjectBatch(
provider_id=self.provider_id,
state="unavailable",
explanation="Views only contributes subjects for the View policy family.",
)
if not isinstance(session, Session):
return PolicyImpactSubjectBatch(
provider_id=self.provider_id,
state="unavailable",
explanation="The Views impact population requires a database session.",
)
selector = request.selector
_validate_selector_keys(selector)
actions = _actions(selector)
include_views = _selector_flag(selector, "include_views", default=True)
include_surfaces = _selector_flag(
selector,
"include_surfaces",
default=True,
)
explicit_view_ids = _selector_ids(selector, "view_ids")
explicit_surface_ids = _selector_ids(selector, "surface_ids")
subjects: list[PolicyImpactSubject] = []
total_available = 0
if include_views:
query = session.query(ViewDefinition).filter(
ViewDefinition.deleted_at.is_(None),
or_(
ViewDefinition.tenant_id == request.tenant_id,
ViewDefinition.tenant_id.is_(None),
),
)
if explicit_view_ids is not None:
query = query.filter(ViewDefinition.id.in_(explicit_view_ids))
definition_count = query.count()
definition_limit = max(
1,
(request.limit + len(actions) - 1) // len(actions),
)
definitions = query.order_by(
ViewDefinition.scope_type.asc(),
ViewDefinition.name.asc(),
ViewDefinition.id.asc(),
).limit(definition_limit).all()
total_available += definition_count * len(actions)
for definition in definitions:
scope_type = (
cast(PolicyScopeType, definition.scope_type)
if definition.scope_type in POLICY_SCOPE_TYPES
else None
)
for action in actions:
subjects.append(
PolicyImpactSubject(
module_id="views",
resource_type="view",
resource_id=definition.id,
action=action,
label=(
definition.name
if request.allow_sensitive_details
else None
),
scope_type=scope_type,
scope_id=definition.scope_id,
attributes={"definition_scope": definition.scope_type},
)
)
if include_surfaces:
surfaces = _surfaces(self._registry)
if explicit_surface_ids is not None:
allowed_surface_ids = set(explicit_surface_ids)
surfaces = tuple(
surface
for surface in surfaces
if str(getattr(surface, "id", "")) in allowed_surface_ids
)
total_available += len(surfaces)
for surface in surfaces:
subjects.append(
PolicyImpactSubject(
module_id=str(getattr(surface, "module_id", "core")),
resource_type="surface",
resource_id=str(getattr(surface, "id", "")),
action="view",
label=(
str(getattr(surface, "label", "")) or None
if request.allow_sensitive_details
else None
),
attributes={"surface_kind": str(getattr(surface, "kind", ""))},
)
)
subjects = subjects[: request.limit]
truncated = total_available > len(subjects)
return PolicyImpactSubjectBatch(
provider_id=self.provider_id,
subjects=tuple(subjects),
state="truncated" if truncated else "complete",
total_available=total_available,
explanation=(
f"The bounded preview returned {len(subjects)} of "
f"{total_available} matching View subjects."
if truncated
else "The explicit Views catalogue population was evaluated completely."
),
)
def _actions(selector: Mapping[str, Any]) -> tuple[str, ...]:
value = selector.get("actions")
if value is None:
return VIEW_IMPACT_ACTIONS
if not isinstance(value, (list, tuple)):
raise ValueError("View impact actions must be a list")
actions = tuple(dict.fromkeys(str(item).strip() for item in value))
if not actions or any(action not in VIEW_IMPACT_ACTIONS for action in actions):
raise ValueError("View impact actions contain an unsupported action")
return actions
def _validate_selector_keys(selector: Mapping[str, Any]) -> None:
supported = {
"actions",
"include_views",
"include_surfaces",
"view_ids",
"surface_ids",
}
unknown = sorted(str(key) for key in selector if str(key) not in supported)
if unknown:
raise ValueError(
"View impact selector contains unsupported fields: " + ", ".join(unknown)
)
def _selector_flag(
selector: Mapping[str, Any],
key: str,
*,
default: bool,
) -> bool:
value = selector.get(key, default)
if not isinstance(value, bool):
raise ValueError(f"View impact selector {key} must be boolean")
return value
def _selector_ids(
selector: Mapping[str, Any],
key: str,
) -> tuple[str, ...] | None:
if key not in selector:
return None
value = selector.get(key)
if not isinstance(value, (list, tuple)) or len(value) > 500:
raise ValueError(f"View impact selector {key} must contain at most 500 IDs")
result = tuple(dict.fromkeys(str(item).strip() for item in value))
if any(not item or len(item) > 240 for item in result):
raise ValueError(f"View impact selector {key} contains an invalid ID")
return result
def _surfaces(registry: object) -> tuple[object, ...]:
if not hasattr(registry, "view_surfaces"):
return ()
value = registry.view_surfaces()
return tuple(value) if isinstance(value, (list, tuple)) else tuple(value or ())
__all__ = ["VIEW_IMPACT_ACTIONS", "ViewsPolicyImpactSubjectProvider"]
+177 -7
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_views.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.access import (
@@ -11,6 +14,8 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -22,14 +27,19 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.policy import CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER, ViewSurface
from govoplan_core.db.base import Base
from govoplan_views.backend.db import models as view_models
from govoplan_views.backend.dsar_provider import (
VIEWS_DSAR_CAPABILITY,
ViewsDsarProvider,
)
MODULE_ID = "views"
MODULE_NAME = "Views"
MODULE_VERSION = "0.1.18"
MODULE_VERSION = "0.1.20"
DEFINITION_READ_SCOPE = "views:definition:read"
DEFINITION_WRITE_SCOPE = "views:definition:write"
@@ -207,6 +217,18 @@ def _resolver(context: ModuleContext):
return resolver_capability(context)
def _policy_impact_subjects(context: ModuleContext):
from govoplan_views.backend.impact_subjects import (
ViewsPolicyImpactSubjectProvider,
)
return ViewsPolicyImpactSubjectProvider(context.registry)
def _dsar_provider(_context: ModuleContext) -> ViewsDsarProvider:
return ViewsDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -219,6 +241,7 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="views.surface_contract", version="1.0.0"),
ModuleInterfaceProvider(name="views.resolver", version="0.1.0"),
ModuleInterfaceProvider(name=VIEWS_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -288,8 +311,83 @@ manifest = ModuleManifest(
),
capability_factories={
CAPABILITY_VIEWS_RESOLVER: _resolver,
f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}views": _policy_impact_subjects,
VIEWS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
VIEWS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Views data-subject request provider",
summary=(
"Exports and deletes account-owned View preferences while "
"retaining minimized institutional configuration attribution."
),
contract_version="0.1.0",
),
},
documentation=(
DocumentationTopic(
id="views.data-subject-requests",
title="Views data-subject requests",
summary=(
"Export or delete personal Views, assignments, and selections "
"without changing institutional projections or domain data."
),
body=(
"Views correlates one exact account in the active tenant and can "
"narrow the request to a definition, assignment, or preference. "
"The access package includes bounded personal definition and "
"revision presentation, user assignments, and the active-View "
"selection. Arbitrary assignment metadata is excluded and View "
"surfaces are identifiers only; the provider never traverses them "
"into feature data. Tenant and group configuration authored or "
"updated by the subject contributes minimized attribution only and "
"is retained as institutional accountability evidence. System-wide "
"Views are outside tenant-scoped requests. Erasure can delete an "
"exact personal selection, user assignment, or user-owned definition "
"after timestamp, revision, ownership, and dependent-record checks. "
"A personal definition that affects another account is sent to "
"manual review. Deleting a selection or assignment never deletes "
"the referenced institutional View. Repeated execution is unchanged."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
translations={
"de": {
"title": "Datenschutzanfragen für Ansichten",
"summary": "Persönliche Ansichten, Zuordnungen und Auswahlen exportieren oder löschen, ohne institutionelle Projektionen oder Fachdaten zu verändern.",
"body": (
"Views ordnet genau ein Konto im aktiven Mandanten zu und kann die Anfrage auf eine Definition, Zuordnung oder Einstellung begrenzen. Das Auskunftspaket enthält die begrenzte Darstellung persönlicher Definitionen und Revisionen, Benutzerzuordnungen sowie die aktive Ansichtsauswahl. "
"Beliebige Zuordnungsmetadaten sind ausgeschlossen; Oberflächen werden nur als Kennungen ausgegeben und niemals bis in Fachdaten verfolgt. Von der betroffenen Person erstellte oder aktualisierte Mandanten- und Gruppenkonfiguration trägt nur minimierte Zuordnungsdaten bei, die als institutioneller Verantwortungsnachweis erhalten bleiben. Systemweite Ansichten liegen außerhalb mandantenbezogener Anfragen. "
"Eine Löschung kann nach Prüfung von Zeitstempel, Revision, Eigentum und abhängigen Datensätzen eine genaue persönliche Auswahl, Benutzerzuordnung oder benutzereigene Definition entfernen. Eine persönliche Definition, von der ein anderes Konto abhängt, wird zur manuellen Prüfung weitergeleitet. Das Löschen einer Auswahl oder Zuordnung löscht niemals die referenzierte institutionelle Ansicht; wiederholte Ausführung verändert nichts."
),
}
},
related_modules=("core", "access", "quick_access"),
metadata={
"help_contexts": [
"views.selector",
"views.settings.personal",
"views.admin.tenant",
"privacy.data-subject-requests",
],
"consequence_classes": {
"delete_selection": (
"Removes the account selection; effective defaults apply again."
),
"delete_assignment": (
"Removes only the personal assignment, not its View definition."
),
"delete_personal_definition": (
"Removes the personal definition and revisions only when no "
"other account depends on it."
),
"retain_attribution": (
"Preserves minimized tenant/group configuration accountability."
),
},
},
),
DocumentationTopic(
id="views.interface-projections",
title="Task-focused Views",
@@ -306,12 +404,43 @@ manifest = ModuleManifest(
"so they can always be inspected and changed. The titlebar eye "
"opens the selector and is accented while a specialized View is "
"active. Hidden functions remain protected by their normal "
"permission checks."
"permission checks. A revision may recommend Quick Access tools or "
"focus the rail to a task-specific subset. These fields affect presentation "
"only: unavailable, context-incompatible, or unauthorized tools stay absent, "
"and an unusable focus falls back to the normal effective rail with an explanation. "
"While a focus is active, All available tools temporarily restores that same permission-derived "
"rail without changing the View or saving an override. Workflow uses "
"the same behavior by resolving the exact immutable View revision."
" When Policy is enabled, Views contributes a bounded catalogue of "
"View definitions, actions, and registered surfaces to policy-impact "
"previews. The provider is tenant-filtered, honors an explicit limit, "
"and never grants Policy access to View implementation internals."
),
layer="available",
documentation_types=("admin", "user"),
audience=("administrator", "power_user", "workflow_designer"),
related_modules=("access", "admin", "policy", "workflow_engine"),
conditions=(
DocumentationCondition(
required_modules=("views", "access"),
any_scopes=(
SELECTION_READ_SCOPE,
DEFINITION_READ_SCOPE,
PERSONAL_DEFINITION_READ_SCOPE,
),
),
),
translations={
"de": {
"title": "Aufgabenbezogene Ansichten",
"summary": "Die sichtbare Oberfläche auf die für eine Aufgabe benötigten Module und Funktionen begrenzen, ohne Berechtigungen zu ändern.",
"body": (
"Ansichten sind versionierte Darstellungsprojektionen. Module melden ihre wählbaren Oberflächen über den Plattformvertrag. System- und Mandantenadministrationen können Ansichten veröffentlichen und sie auf System-, Mandanten-, Gruppen- und Benutzerebene als verfügbar, voreingestellt oder verpflichtend zuordnen. Verpflichtende Ansichten behalten Auswahl- und Administrationsauswege, damit sie stets geprüft und geändert werden können. "
"Das Augensymbol in der Titelleiste öffnet die Auswahl und wird hervorgehoben, solange eine spezialisierte Ansicht aktiv ist. Ausgeblendete Funktionen bleiben durch ihre normalen Berechtigungsprüfungen geschützt. Eine Revision darf Schnellzugriffswerkzeuge empfehlen oder die Leiste auf eine aufgabenbezogene Teilmenge fokussieren. Das ändert nur die Darstellung: nicht verfügbare, unpassende oder unberechtigte Werkzeuge bleiben verborgen; ein unbrauchbarer Fokus fällt mit Erklärung auf die normale wirksame Leiste zurück. "
"Alle verfügbaren Werkzeuge stellt vorübergehend dieselbe berechtigungsabgeleitete Leiste wieder her, ohne die Ansicht zu ändern oder eine Umgehung zu speichern. Workflow erhält dasselbe Verhalten durch Auflösung der genauen unveränderlichen Ansichtsrevision. Ist Policy aktiviert, liefert Views einen mandantenbezogenen und begrenzten Katalog von Definitionen, Aktionen und registrierten Oberflächen für Wirkungsvorschauen, ohne Zugriff auf Implementierungsdetails zu erteilen."
),
}
},
links=(
DocumentationLink(
label="Views administration",
@@ -325,7 +454,7 @@ manifest = ModuleManifest(
),
),
metadata={
"kind": "guide",
"kind": "workflow",
"help_contexts": [
"views.selector",
"views.admin.system",
@@ -333,6 +462,11 @@ manifest = ModuleManifest(
"views.settings.personal",
"views.admin.blocked",
],
"steps": [
"Open the View selector and review the effective View and its source.",
"Choose an available View or return to the permission-derived default.",
"Use the administration escape when a required View must be inspected or changed.",
],
},
order=18,
),
@@ -344,7 +478,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,10 +490,27 @@ 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. Quick Access recommendation and "
"focus ids are likewise revisioned presentation metadata and never authorize a contribution. The runtime's "
"All available tools escape can only reveal contributions that already survived entitlement, policy, preference, "
"surface, context, and permission checks."
),
documentation_types=("admin",),
audience=("administrator", "power_user", "workflow_designer"),
related_modules=("access", "admin", "policy", "workflow_engine"),
translations={
"de": {
"title": "Felder, Zuordnungen und Folgen von Ansichten",
"summary": "Unveränderliche Revisionen, Zuordnungsrangfolge, Schutzvorgaben verpflichtender Ansichten und den Unterschied zwischen Sichtbarkeit und Zugriff verstehen.",
"body": (
"Eine Ansichtsdefinition enthält unveränderliche Revisionen sichtbarer Oberflächenkennungen und Darstellungsmetadaten. Durch Veröffentlichung wird die neueste Revision zuordenbar. Verfügbare Zuordnungen erlauben eine freiwillige Auswahl, Voreinstellungen gelten bis zu einer Änderung und verpflichtende Zuordnungen können nicht verlassen werden. Benutzer- und Gruppenzuordnungen haben Vorrang vor Mandanten- und Systemzuordnungen. "
"Eine Fixierung bewahrt genau eine veröffentlichte Revision; eine nicht fixierte Zuordnung folgt späteren Veröffentlichungen. Verpflichtende Ansichten müssen Auswahl- und Administrationsauswege erhalten. Das Ausblenden einer Oberfläche erteilt oder entzieht niemals eine Berechtigung. Der Oberflächenkatalog ist durch die Modulfreigabe des aktiven Mandanten begrenzt; eine Ansicht kann daher kein systemseitig oder mandantenseitig deaktiviertes Modul freigeben. Gespeicherte Verweise bleiben in unveränderlichen Revisionen erhalten und werden als veraltet gemeldet. Geerbte Definitionen und Zuordnungen müssen in ihrer besitzenden Ebene geändert werden. "
"Produktbereichsgruppen, Reihenfolge und Beschriftungen sind Darstellungsmetadaten derselben Revision und können weder verborgene Oberflächen freigeben noch Berechtigungen erteilen. Gruppierte Navigation ist die sinnvolle Voreinstellung; flache Navigation erhält die vollständige berechtigte Werkzeugleiste. Empfehlungen und Fokuskennungen für Schnellzugriff sind ebenfalls versionierte Darstellungsmetadaten. Der Ausweg Alle verfügbaren Werkzeuge kann nur Beiträge zeigen, die bereits Modulfreigabe, Richtlinie, Einstellung, Oberfläche, Kontext und Berechtigungsprüfung bestanden haben."
),
}
},
links=(
DocumentationLink(
label="Views administration",
@@ -378,6 +529,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",
@@ -400,15 +553,32 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="README.md",
test_ref="tests/test_views.py",
known_limits=("A View filters presentation only; modules still vary in the granularity of announced surfaces.",),
owned_concepts=("view definition", "view revision", "view assignment", "view selection"),
non_owned_concepts=("authorization", "module navigation", "workflow definition", "dashboard layout"),
known_limits=(
"A View filters presentation only; modules still vary in the granularity of announced surfaces.",
),
owned_concepts=(
"view definition",
"view revision",
"view assignment",
"view selection",
),
non_owned_concepts=(
"authorization",
"module navigation",
"workflow definition",
"dashboard layout",
),
recovery_docs=("README.md",),
security_docs=("README.md",),
),
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -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)
+139 -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,17 @@ 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}$")
_QUICK_ACCESS_TOOL_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)+$")
_PRESENTATION_KEYS = frozenset(
{
"navigation_mode",
"product_area_order",
"product_area_labels",
"quick_access_recommended_tool_ids",
"quick_access_focused_tool_ids",
}
)
class ViewsError(RuntimeError):
@@ -350,10 +361,117 @@ 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
for field_name, label in (
("quick_access_recommended_tool_ids", "recommended Quick Access tools"),
("quick_access_focused_tool_ids", "focused Quick Access tools"),
):
raw_tool_ids = value.get(field_name)
if raw_tool_ids is None:
continue
if not isinstance(raw_tool_ids, list) or len(raw_tool_ids) > 100:
raise ViewsValidationError(
f"View {label} must be a list of at most 100 ids"
)
tool_ids: list[str] = []
for raw_id in raw_tool_ids:
tool_id = str(raw_id).strip()
if not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(tool_id):
raise ViewsValidationError(
f"Invalid Quick Access tool id in View presentation: {tool_id!r}"
)
if tool_id in tool_ids:
raise ViewsValidationError(
f"Duplicate Quick Access tool id in View presentation: {tool_id}"
)
tool_ids.append(tool_id)
normalized[field_name] = tool_ids
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 +490,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 +521,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 +546,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 +586,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 +610,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 +1573,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 +1588,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,
+522
View File
@@ -0,0 +1,522 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_views.backend.db.models import (
ViewAssignment,
ViewDefinition,
ViewPreference,
ViewRevision,
)
from govoplan_views.backend.dsar_provider import (
VIEWS_DSAR_CAPABILITY,
ViewsDsarProvider,
)
from govoplan_views.backend.manifest import manifest
class _Registry:
def __init__(self, provider: ViewsDsarProvider, *, active: bool = True) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (VIEWS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "views"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("views",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "views"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != VIEWS_DSAR_CAPABILITY:
raise KeyError(name)
class ViewsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = ViewsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _definition(
self,
definition_id: str,
*,
tenant_id: str,
scope_type: str,
scope_id: str,
actor_id: str,
name: str,
) -> ViewDefinition:
definition = ViewDefinition(
id=definition_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
scope_key=f"{scope_type}:{tenant_id}:{scope_id}",
definition_key=definition_id,
name=name,
description=f"Description for {name}",
status="published",
current_revision=1,
published_revision_id=f"revision-{definition_id}",
created_by=actor_id,
updated_by=actor_id,
)
definition.revisions.append(
ViewRevision(
id=f"revision-{definition_id}",
tenant_id=tenant_id,
revision=1,
surface_contract_version="1.0.0",
visible_surface_ids=["files.route.files"],
presentation={
"navigation_mode": "flat",
"quick_access_focused_tool_ids": ["files.recent"],
"private_payload_do_not_export": f"private-{definition_id}",
},
content_hash=("a" * 64),
created_by=actor_id,
)
)
return definition
def _seed(self) -> None:
personal = self._definition(
"definition-personal",
tenant_id="tenant-1",
scope_type="user",
scope_id="account-1",
actor_id="account-1",
name="My personal work",
)
tenant = self._definition(
"definition-tenant",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
actor_id="account-1",
name="private-tenant-definition-do-not-export",
)
other_account = self._definition(
"definition-other-account",
tenant_id="tenant-1",
scope_type="user",
scope_id="account-other",
actor_id="account-other",
name="Other account",
)
other_tenant = self._definition(
"definition-other-tenant",
tenant_id="tenant-2",
scope_type="user",
scope_id="account-1",
actor_id="account-1",
name="Other tenant",
)
self.session.add_all((personal, tenant, other_account, other_tenant))
self.session.flush()
self.session.add_all(
(
ViewAssignment(
id="assignment-personal",
tenant_id="tenant-1",
scope_type="user",
scope_id="account-1",
target_key="user:tenant-1:account-1",
definition_id=personal.id,
revision_id=None,
mode="available",
priority=1,
is_active=True,
metadata_={"private": "personal-metadata-do-not-export"},
created_by="account-1",
updated_by="account-1",
),
ViewAssignment(
id="assignment-tenant",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
target_key="tenant:tenant-1",
definition_id=tenant.id,
revision_id=None,
mode="default",
priority=2,
is_active=True,
metadata_={"private": "tenant-metadata-do-not-export"},
created_by="account-1",
updated_by="account-other",
),
ViewPreference(
id="preference-personal",
tenant_id="tenant-1",
account_id="account-1",
selection_kind="selected",
view_id=personal.id,
),
ViewPreference(
id="preference-other-account",
tenant_id="tenant-1",
account_id="account-other",
selection_kind="auto",
view_id=None,
),
ViewPreference(
id="preference-other-tenant",
tenant_id="tenant-2",
account_id="account-1",
selection_kind="auto",
view_id=None,
),
)
)
def test_search_exports_personal_records_and_minimized_attribution(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-1"),
)
self.assertEqual(
[
"personal_view_assignment",
"personal_view_preference",
"personal_view_definition",
"view_assignment_attribution",
"view_definition_attribution",
"view_revision_attribution",
],
[record.resource_type for record in records],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("My personal work", exported)
self.assertIn("files.recent", exported)
self.assertNotIn("personal-metadata-do-not-export", exported)
self.assertNotIn("tenant-metadata-do-not-export", exported)
self.assertNotIn("private-tenant-definition-do-not-export", exported)
self.assertNotIn("definition-other-account", exported)
self.assertNotIn("definition-other-tenant", exported)
def test_resource_references_narrow_and_conflicts_fail_closed(self) -> None:
definition = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"views.definition": "definition-personal"},
),
)
assignment = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"views.assignment": "assignment-personal"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"views.account": "account-other"},
),
)
no_account = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"views.preference": "preference-personal"}
),
)
self.assertEqual(
{
"personal_view_assignment",
"personal_view_preference",
"personal_view_definition",
},
{
item.resource_type
for item in definition
if item.resource_type.startswith("personal_")
},
)
self.assertEqual(
["personal_view_assignment"],
[item.resource_type for item in assignment],
)
self.assertEqual((), conflict)
self.assertEqual((), no_account)
def test_erasure_deletes_personal_records_and_retains_institutional_ones(
self,
) -> None:
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(
["delete", "delete", "delete", "retain", "retain", "retain"],
[action.kind for action in actions],
)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual(
["executed", "executed", "executed", "blocked", "blocked", "blocked"],
[result.status for result in first],
)
self.assertEqual(
["unchanged", "unchanged", "unchanged", "blocked", "blocked", "blocked"],
[result.status for result in second],
)
self.assertIsNone(self.session.get(ViewAssignment, "assignment-personal"))
self.assertIsNone(self.session.get(ViewPreference, "preference-personal"))
self.assertIsNone(self.session.get(ViewDefinition, "definition-personal"))
self.assertIsNone(
self.session.get(ViewRevision, "revision-definition-personal")
)
self.assertIsNotNone(self.session.get(ViewDefinition, "definition-tenant"))
self.assertIsNotNone(self.session.get(ViewAssignment, "assignment-tenant"))
def test_foreign_dependency_and_changed_definition_block_deletion(self) -> None:
subject = DsarSubjectRef(
account_id="account-1",
external_references={"views.definition": "definition-personal"},
)
definition_record = next(
item
for item in self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
if item.resource_type == "personal_view_definition"
)
definition = self.session.get(ViewDefinition, "definition-personal")
definition.current_revision += 1
stale_action = DsarErasureActionRef(
action_id="views:delete:personal_view_definition:definition-personal:r1",
provider_id="views",
module_id="views",
kind="delete",
resource_type="personal_view_definition",
resource_id="definition-personal",
title="Delete personal View",
rationale="Personal preference",
executable=True,
metadata={
"account_id": "account-1",
"current_revision": 1,
"updated_at": definition_record.observed_at.isoformat(),
},
)
changed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(stale_action,),
request_id="dsar-1",
)
self.assertEqual("blocked", changed[0].status)
definition.current_revision = 1
self.session.add(
ViewAssignment(
id="assignment-foreign-dependent",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
target_key="tenant:tenant-1:foreign-dependent",
definition_id=definition.id,
revision_id=None,
mode="available",
priority=0,
is_active=True,
metadata_={},
created_by="account-other",
updated_by="account-other",
)
)
self.session.flush()
manual = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(definition_record,),
)
self.assertEqual("manual_review", manual[0].kind)
self.assertFalse(manual[0].executable)
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="dashboard",
module_id="dashboard",
resource_type="personal_view_preference",
resource_id="preference-personal",
category="preference",
title="Foreign preference",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="dashboard:delete:view:preference-personal",
provider_id="dashboard",
module_id="dashboard",
kind="delete",
resource_type="personal_view_preference",
resource_id="preference-personal",
title="Delete preference",
rationale="Foreign action",
executable=True,
),
),
request_id="dsar-1",
)
def test_core_workflow_and_manifest_register_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-VIEWS-1",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual([VIEWS_DSAR_CAPABILITY], row.coverage["provider_capabilities"])
self.assertEqual(6, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-VIEWS-2",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual(
[VIEWS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertIn(VIEWS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(VIEWS_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
VIEWS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "views.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
@@ -37,6 +37,26 @@ class ViewsInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("views.action.publish", reference.metadata["help_contexts"])
self.assertIn("required", reference.metadata["consequence_classes"])
self.assertTrue(
all(
all(
topic.translations.get("de", {}).get(field)
for field in ("title", "summary", "body")
)
for topic in topics.values()
)
)
workflow = topics["views.interface-projections"]
self.assertEqual("workflow", workflow.metadata["kind"])
self.assertTrue(workflow.conditions)
self.assertTrue(
all(
condition.required_scopes or condition.any_scopes
for condition in workflow.conditions
)
)
self.assertEqual("reference", reference.metadata["kind"])
if __name__ == "__main__":
unittest.main()
+10 -1
View File
@@ -26,7 +26,7 @@ class ViewsMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"b8e4c1f7a2d9",
"c6f2a9d4e7b1",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
@@ -42,6 +42,15 @@ class ViewsMigrationTests(unittest.TestCase):
if name.startswith("view_")
},
)
self.assertIn(
"presentation",
{
column["name"]
for column in inspect(connection).get_columns(
"view_revisions"
)
},
)
finally:
engine.dispose()
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.policy import PolicyImpactPopulationRequest
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_views.backend.db.models import ViewDefinition
from govoplan_views.backend.impact_subjects import (
ViewsPolicyImpactSubjectProvider,
)
class _Registry:
def view_surfaces(self) -> tuple[ViewSurface, ...]:
return (
ViewSurface(
id="views.selector",
module_id="views",
kind="selector",
label="View selector",
),
)
class ViewsPolicyImpactSubjectTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = ViewsPolicyImpactSubjectProvider(_Registry())
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _definition(self, *, tenant_id: str, name: str) -> ViewDefinition:
definition = ViewDefinition(
tenant_id=tenant_id,
scope_type="tenant",
scope_id=None,
scope_key=f"tenant:{tenant_id}",
definition_key=name.casefold(),
name=name,
status="published",
)
self.session.add(definition)
self.session.flush()
return definition
def test_provider_is_tenant_filtered_and_returns_bounded_actions(self) -> None:
included = self._definition(tenant_id="tenant-1", name="Included")
self._definition(tenant_id="tenant-2", name="Hidden")
batch = self.provider.collect_policy_impact_subjects(
self.session,
request=PolicyImpactPopulationRequest(
tenant_id="tenant-1",
policy_family="view",
selector={
"actions": ["view", "edit"],
"include_surfaces": False,
},
limit=10,
allow_sensitive_details=True,
),
)
self.assertEqual("complete", batch.state)
self.assertEqual(2, batch.total_available)
self.assertEqual({included.id}, {item.resource_id for item in batch.subjects})
self.assertEqual({"view", "edit"}, {item.action for item in batch.subjects})
self.assertEqual({"Included"}, {item.label for item in batch.subjects})
def test_provider_reports_truncation_and_hides_labels(self) -> None:
self._definition(tenant_id="tenant-1", name="One")
self._definition(tenant_id="tenant-1", name="Two")
batch = self.provider.collect_policy_impact_subjects(
self.session,
request=PolicyImpactPopulationRequest(
tenant_id="tenant-1",
policy_family="view",
selector={"include_surfaces": True},
limit=2,
),
)
self.assertEqual("truncated", batch.state)
self.assertGreater(batch.total_available or 0, len(batch.subjects))
self.assertEqual(2, len(batch.subjects))
self.assertTrue(all(subject.label is None for subject in batch.subjects))
def test_explicit_empty_population_never_falls_back_to_catalogue_scan(self) -> None:
self._definition(tenant_id="tenant-1", name="Not requested")
batch = self.provider.collect_policy_impact_subjects(
self.session,
request=PolicyImpactPopulationRequest(
tenant_id="tenant-1",
policy_family="view",
selector={
"view_ids": [],
"include_surfaces": False,
},
limit=10,
),
)
self.assertEqual("complete", batch.state)
self.assertEqual(0, batch.total_available)
self.assertEqual((), batch.subjects)
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -20,6 +20,7 @@ from govoplan_views.backend.service import (
create_revision,
get_revision,
list_definitions,
normalize_view_presentation,
normalize_visible_surface_ids,
publish_revision,
resolve_effective_view,
@@ -193,6 +194,80 @@ class ViewsServiceTests(unittest.TestCase):
self.session.close()
self.engine.dispose()
def test_view_presentation_is_normalized_and_versioned(self) -> None:
definition = create_definition(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition_key=None,
name="Product navigation",
description=None,
visible_surface_ids=ordinary_surface_ids(),
catalogue=self.catalogue,
actor_id="account-admin",
presentation={
"navigation_mode": "grouped",
"product_area_order": ["work", "records-documents"],
"product_area_labels": {"work": "My work"},
"quick_access_recommended_tool_ids": ["tasks.work"],
"quick_access_focused_tool_ids": ["tasks.work", "mail.messages"],
},
available_product_area_ids=("work", "records-documents"),
)
revision = get_revision(self.session, definition_id=definition.id)
self.assertEqual("grouped", revision.presentation["navigation_mode"])
self.assertEqual(
["tasks.work"],
revision.presentation["quick_access_recommended_tool_ids"],
)
compatible_revision = create_revision(
self.session,
definition,
visible_surface_ids=lockout_safe_surface_ids(),
catalogue=self.catalogue,
actor_id="legacy-client",
available_product_area_ids=("work", "records-documents"),
)
self.assertEqual(
revision.presentation,
compatible_revision.presentation,
"omitting presentation must preserve the previous revision contract",
)
revision = compatible_revision
publish_revision(
self.session,
definition,
revision,
catalogue=self.catalogue,
actor_id="account-admin",
)
self.assign(
definition,
scope_type="tenant",
scope_id=None,
)
state = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-1",
catalogue=self.catalogue,
)
self.assertEqual("My work", state.effective.presentation["product_area_labels"]["work"])
def test_view_presentation_rejects_unknown_or_unavailable_fields(self) -> None:
with self.assertRaises(ViewsValidationError):
normalize_view_presentation({"unknown": True})
with self.assertRaises(ViewsValidationError):
normalize_view_presentation(
{"product_area_order": ["unavailable"]},
available_product_area_ids=("work",),
)
with self.assertRaises(ViewsValidationError):
normalize_view_presentation(
{"quick_access_recommended_tool_ids": ["not namespaced"]}
)
def create_published_definition(
self,
*,
@@ -426,6 +501,79 @@ class ViewsServiceTests(unittest.TestCase):
],
)
def test_user_and_group_views_hide_tenant_effective_module_roots(self) -> None:
group_view = self.create_published_definition(
name="Group files",
scope_type="group",
scope_id="group-1",
visible_surface_ids=ordinary_surface_ids(),
)
user_view = self.create_published_definition(
name="Personal files",
scope_type="user",
scope_id="account-user",
visible_surface_ids=ordinary_surface_ids(),
)
self.assign(
group_view,
scope_type="group",
scope_id="group-1",
)
self.assign(
user_view,
scope_type="user",
scope_id="account-user",
)
cases = (
("account-member", ("group-1",), group_view.id),
("account-user", (), user_view.id),
)
for account_id, group_ids, expected_view_id 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,
catalogue=self.catalogue,
)
self.assertEqual(expected_view_id, state.effective.view_id)
self.assertIn("files.module", state.effective.visible_surface_ids)
self.assertNotIn("access.module", state.effective.visible_surface_ids)
def test_group_view_cannot_restore_a_tenant_unavailable_module(self) -> None:
definition = self.create_published_definition(
name="Group files",
scope_type="group",
scope_id="group-1",
visible_surface_ids=ordinary_surface_ids(),
)
self.assign(
definition,
scope_type="group",
scope_id="group-1",
)
active_catalogue = tuple(
surface for surface in self.catalogue if surface.module_id != "files"
)
state = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-member",
group_ids=("group-1",),
catalogue=active_catalogue,
)
self.assertIsNone(state.effective.view_id)
self.assertNotIn("files.module", state.effective.visible_surface_ids)
self.assertIn(
"view.stale_surfaces",
{diagnostic.code for diagnostic in state.diagnostics},
)
def test_normalization_requires_navigation_and_route(self) -> None:
with self.assertRaisesRegex(ViewsValidationError, "navigation"):
normalize_visible_surface_ids(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/views-webui",
"version": "0.1.18",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
+42 -3
View File
@@ -2,7 +2,8 @@ import {
apiFetch,
apiPath,
type ApiSettings,
type EffectiveViewProjection
type EffectiveViewProjection,
type ViewPresentation
} from "@govoplan/core-webui";
export type ViewScopeType = "system" | "tenant" | "group" | "user";
@@ -15,6 +16,13 @@ export type ViewRevision = {
revision: number;
surface_contract_version: string;
visible_surface_ids: string[];
presentation: {
navigation_mode?: "grouped" | "flat";
product_area_order?: string[];
product_area_labels?: Record<string, string>;
quick_access_recommended_tool_ids?: string[];
quick_access_focused_tool_ids?: string[];
};
content_hash: string;
created_by?: string | null;
created_at: string;
@@ -79,6 +87,7 @@ type EffectiveViewApiResponse = {
active_revision_id?: string | null;
active_view_name?: string | null;
visible_surface_ids: string[];
presentation?: ViewRevision["presentation"];
locked: boolean;
available_views: Array<{
id: string;
@@ -113,6 +122,7 @@ function projection(response: EffectiveViewApiResponse): EffectiveViewProjection
activeRevisionId: response.active_revision_id ?? null,
activeViewName: response.active_view_name ?? null,
visibleSurfaceIds: response.visible_surface_ids,
presentation: presentationFromApi(response.presentation),
locked: response.locked,
availableViews: response.available_views.map((view) => ({
id: view.id,
@@ -211,6 +221,7 @@ export function createViewDefinition(
name: string;
description?: string | null;
visible_surface_ids: string[];
presentation?: ViewRevision["presentation"];
}
): Promise<ViewDefinition> {
return apiFetch(settings, "/api/v1/views/definitions", {
@@ -233,18 +244,46 @@ export function updateViewDefinition(
export function createViewRevision(
settings: ApiSettings,
definitionId: string,
visibleSurfaceIds: string[]
visibleSurfaceIds: string[],
presentation: ViewPresentation
): Promise<ViewDefinition> {
return apiFetch(
settings,
`/api/v1/views/definitions/${definitionId}/revisions`,
{
method: "POST",
...jsonBody({ visible_surface_ids: visibleSurfaceIds })
...jsonBody({
visible_surface_ids: visibleSurfaceIds,
presentation: presentationToApi(presentation)
})
}
);
}
function presentationFromApi(
value: ViewRevision["presentation"] | undefined
): ViewPresentation {
return {
navigationMode: value?.navigation_mode,
productAreaOrder: value?.product_area_order ?? [],
productAreaLabels: value?.product_area_labels ?? {},
quickAccessRecommendedToolIds: value?.quick_access_recommended_tool_ids ?? [],
quickAccessFocusedToolIds: value?.quick_access_focused_tool_ids ?? []
};
}
export function presentationToApi(
value: ViewPresentation
): ViewRevision["presentation"] {
return {
navigation_mode: value.navigationMode ?? "grouped",
product_area_order: value.productAreaOrder ?? [],
product_area_labels: value.productAreaLabels ?? {},
quick_access_recommended_tool_ids: value.quickAccessRecommendedToolIds ?? [],
quick_access_focused_tool_ids: value.quickAccessFocusedToolIds ?? []
};
}
export function publishViewRevision(
settings: ApiSettings,
definitionId: string,
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import {
import { ActionToolbar,
ActionBlockerHint,
FormField,
hasScope,
@@ -65,7 +65,7 @@ export default function PersonalViewsPanel({
return (
<div className="views-personal-panel">
{options.length > 1 && (
<div className="views-owner-toolbar">
<ActionToolbar className="views-owner-toolbar">
<FormField
label="i18n:govoplan-views.view_owner"
documentation={VIEWS_FIELD_DOCUMENTATION}
@@ -81,7 +81,7 @@ export default function PersonalViewsPanel({
))}
</select>
</FormField>
</div>
</ActionToolbar>
)}
<ViewsAdminPanel
key={owner.key}
+341 -13
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
ArrowDown,
ArrowUp,
CheckSquare2,
ChevronDown,
ChevronRight,
@@ -13,7 +15,7 @@ import {
Square,
Trash2
} from "lucide-react";
import {
import { FormGrid,
ActionBlockerHint,
AdminPageLayout,
Button,
@@ -25,6 +27,7 @@ import {
FormField,
IconButton,
SearchableSelect,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
@@ -33,12 +36,15 @@ import {
dispatchPlatformViewChanged,
i18nMessage,
usePlatformLanguage,
usePlatformModules,
useUnsavedChanges,
useUnsavedDraftGuard,
useViewSurfaces,
type ApiSettings,
type PlatformWebModule,
type PlatformViewSurface,
type SearchableSelectOption
type SearchableSelectOption,
type ViewPresentation
} from "@govoplan/core-webui";
import {
archiveViewDefinition,
@@ -50,6 +56,7 @@ import {
fetchViewAssignments,
fetchViewDefinitions,
publishViewRevision,
presentationToApi,
updateViewAssignment,
updateViewDefinition,
type ViewAssignment,
@@ -70,6 +77,15 @@ type DefinitionDraft = {
name: string;
description: string;
surfaceIds: string[];
presentation: ViewPresentation;
};
type ViewProductArea = {
id: string;
label: string;
description?: string | null;
order: number;
surfaceIds: string[];
};
type AssignmentDraft = {
@@ -117,6 +133,8 @@ export default function ViewsAdminPanel({
description?: string;
}) {
const surfaces = useViewSurfaces();
const modules = usePlatformModules();
const productAreas = useMemo(() => aggregateProductAreas(modules), [modules]);
const { requestDiscard } = useUnsavedChanges();
const { translateText } = usePlatformLanguage();
const [definitions, setDefinitions] = useState<ViewDefinition[]>([]);
@@ -125,7 +143,8 @@ export default function ViewsAdminPanel({
const [draft, setDraft] = useState<DefinitionDraft>({
name: "",
description: "",
surfaceIds: []
surfaceIds: [],
presentation: defaultPresentation([])
});
const [savedDraftKey, setSavedDraftKey] = useState("");
const [loading, setLoading] = useState(true);
@@ -225,9 +244,18 @@ export default function ViewsAdminPanel({
? {
name: definition.name,
description: definition.description ?? "",
surfaceIds: definition.latest_revision.visible_surface_ids
surfaceIds: definition.latest_revision.visible_surface_ids,
presentation: revisionPresentation(
definition.latest_revision.presentation,
productAreas
)
}
: { name: "", description: "", surfaceIds: [] };
: {
name: "",
description: "",
surfaceIds: [],
presentation: defaultPresentation(productAreas)
};
setDraft(next);
setSavedDraftKey(definitionDraftKey(next));
}
@@ -267,12 +295,17 @@ export default function ViewsAdminPanel({
}
if (
surfaceSetKey(draft.surfaceIds) !==
surfaceSetKey(next.latest_revision.visible_surface_ids)
surfaceSetKey(next.latest_revision.visible_surface_ids) ||
presentationKey(draft.presentation) !==
presentationKey(
revisionPresentation(next.latest_revision.presentation, productAreas)
)
) {
next = await createViewRevision(
settings,
selected.id,
draft.surfaceIds
draft.surfaceIds,
draft.presentation
);
}
await load(selected.id);
@@ -328,7 +361,8 @@ export default function ViewsAdminPanel({
scope_id: scopeId || null,
name: createDraft.name.trim(),
description: createDraft.description.trim() || null,
visible_surface_ids: visibleSurfaceIds
visible_surface_ids: visibleSurfaceIds,
presentation: presentationToApi(defaultPresentation(productAreas))
});
closeCreate();
setSuccess("i18n:govoplan-views.draft_created");
@@ -727,6 +761,56 @@ export default function ViewsAdminPanel({
</FormField>
</div>
{productAreas.length > 0 && (
<ProductAreaEditor
areas={productAreas}
surfaces={surfaces}
draft={draft}
disabled={!definitionEditable || busy}
onChange={setDraft}
/>
)}
<section className="views-product-area-section">
<div className="views-section-heading">
<div>
<h4>Quick Access focus</h4>
<p className="muted small-note">
Recommend or focus namespaced tool IDs for this View. The
active account still needs the tool's permissions and visible surface.
</p>
</div>
</div>
<FormGrid columns={2} collapseAt="standard">
<FormField label="Recommended tool IDs" help="Comma-separated, for example tasks.work, files.recent">
<input
value={(draft.presentation.quickAccessRecommendedToolIds ?? []).join(", ")}
disabled={!definitionEditable || busy}
onChange={(event) => setDraft({
...draft,
presentation: {
...draft.presentation,
quickAccessRecommendedToolIds: commaSeparatedToolIds(event.target.value)
}
})}
/>
</FormField>
<FormField label="Focused tool IDs" help="When at least one listed tool is available, other Quick Access tools are hidden for this View.">
<input
value={(draft.presentation.quickAccessFocusedToolIds ?? []).join(", ")}
disabled={!definitionEditable || busy}
onChange={(event) => setDraft({
...draft,
presentation: {
...draft.presentation,
quickAccessFocusedToolIds: commaSeparatedToolIds(event.target.value)
}
})}
/>
</FormField>
</FormGrid>
</section>
<section className="views-surface-section">
<div className="views-section-heading">
<div>
@@ -825,7 +909,7 @@ export default function ViewsAdminPanel({
</>
}
>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField
label="i18n:govoplan-views.name"
documentation={VIEWS_FIELD_DOCUMENTATION}
@@ -858,7 +942,7 @@ export default function ViewsAdminPanel({
<p className="muted small-note">
i18n:govoplan-views.first_draft_help
</p>
</div>
</FormGrid>
</Dialog>
<AssignmentDialog
@@ -1200,7 +1284,7 @@ function AssignmentDialog({
</>
}
>
<div className="form-grid two responsive-form-grid">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField
label="i18n:govoplan-views.target_level"
help={editing !== "new" ? "i18n:govoplan-views.assignment_target_immutable" : undefined}
@@ -1361,7 +1445,7 @@ function AssignmentDialog({
help="i18n:govoplan-views.pin_revision_help"
/>
</div>
</div>
</FormGrid>
{draft.mode === "required" && (
<DismissibleAlert
tone={requiredMissing.length ? "danger" : "warning"}
@@ -1379,6 +1463,136 @@ function AssignmentDialog({
}
function ProductAreaEditor({
areas,
surfaces,
draft,
disabled,
onChange
}: {
areas: ViewProductArea[];
surfaces: PlatformViewSurface[];
draft: DefinitionDraft;
disabled: boolean;
onChange: (draft: DefinitionDraft) => void;
}) {
const { translateText } = usePlatformLanguage();
const ordered = orderedProductAreas(areas, draft.presentation.productAreaOrder);
const requiredSurfaceIds = new Set(
surfaces.filter((surface) => surface.required).map((surface) => surface.id)
);
function updatePresentation(presentation: ViewPresentation) {
onChange({ ...draft, presentation });
}
function move(areaId: string, direction: -1 | 1) {
const order = ordered.map((area) => area.id);
const index = order.indexOf(areaId);
const target = index + direction;
if (index < 0 || target < 0 || target >= order.length) return;
[order[index], order[target]] = [order[target], order[index]];
updatePresentation({ ...draft.presentation, productAreaOrder: order });
}
function setLabel(areaId: string, label: string) {
const labels = { ...(draft.presentation.productAreaLabels ?? {}) };
if (label.trim()) labels[areaId] = label;
else delete labels[areaId];
updatePresentation({ ...draft.presentation, productAreaLabels: labels });
}
function setAreaVisible(area: ViewProductArea, visible: boolean) {
const selected = new Set(draft.surfaceIds);
const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces);
if (visible) affectedSurfaceIds.forEach((id) => selected.add(id));
else affectedSurfaceIds.forEach((id) => selected.delete(id));
onChange({ ...draft, surfaceIds: [...selected] });
}
return (
<section className="views-product-area-section">
<div className="views-section-heading">
<div>
<h4>i18n:govoplan-views.product_areas</h4>
<p className="muted small-note">
i18n:govoplan-views.product_areas_help
</p>
</div>
<SegmentedControl<"grouped" | "flat">
ariaLabel={translateText("i18n:govoplan-views.navigation_layout")}
role="group"
value={draft.presentation.navigationMode ?? "grouped"}
disabled={disabled}
onChange={(navigationMode) =>
updatePresentation({ ...draft.presentation, navigationMode })
}
options={[
{ id: "grouped", label: "i18n:govoplan-views.grouped" },
{ id: "flat", label: "i18n:govoplan-views.flat" }
]}
/>
</div>
<div className="views-product-area-list">
{ordered.map((area, index) => {
const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces);
const visible = affectedSurfaceIds.some((id) =>
draft.surfaceIds.includes(id)
);
const required = affectedSurfaceIds.some((id) =>
requiredSurfaceIds.has(id)
);
return (
<div className="views-product-area-row" key={area.id}>
<div className="views-product-area-copy">
<strong>{translateText(area.label)}</strong>
<small>{translateText(area.description ?? area.id)}</small>
</div>
<input
value={draft.presentation.productAreaLabels?.[area.id] ?? ""}
placeholder={translateText(area.label)}
aria-label={i18nMessage(
"i18n:govoplan-views.custom_area_label_value",
{ value0: translateText(area.label) }
)}
maxLength={200}
disabled={disabled}
onChange={(event) => setLabel(area.id, event.target.value)}
/>
<ToggleSwitch
label={area.label}
inactiveLabel="i18n:govoplan-views.hidden"
activeLabel="i18n:govoplan-views.visible"
checked={visible}
disabled={disabled || required}
help={required ? "i18n:govoplan-views.required_area_help" : undefined}
onChange={(checked) => setAreaVisible(area, checked)}
/>
<div className="views-product-area-order">
<IconButton
label="i18n:govoplan-views.move_up"
icon={<ArrowUp size={16} />}
variant="ghost"
disabled={disabled || index === 0}
onClick={() => move(area.id, -1)}
/>
<IconButton
label="i18n:govoplan-views.move_down"
icon={<ArrowDown size={16} />}
variant="ghost"
disabled={disabled || index === ordered.length - 1}
onClick={() => move(area.id, 1)}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
function SurfaceSelector({
surfaces,
selected,
@@ -1627,11 +1841,125 @@ function definitionDraftKey(draft: DefinitionDraft): string {
return JSON.stringify({
name: draft.name.trim(),
description: draft.description.trim(),
surfaces: [...new Set(draft.surfaceIds)].sort()
surfaces: [...new Set(draft.surfaceIds)].sort(),
presentation: presentationKey(draft.presentation)
});
}
function aggregateProductAreas(modules: PlatformWebModule[]): ViewProductArea[] {
const result = new Map<string, ViewProductArea>();
for (const contribution of modules.flatMap(
(module) => module.productAreas ?? []
)) {
const existing = result.get(contribution.id);
if (existing) {
existing.surfaceIds = [
...new Set([...existing.surfaceIds, ...contribution.surfaceIds])
];
existing.order = Math.min(existing.order, contribution.order ?? 100);
continue;
}
result.set(contribution.id, {
id: contribution.id,
label: contribution.label,
description: contribution.description,
order: contribution.order ?? 100,
surfaceIds: [...new Set(contribution.surfaceIds)]
});
}
return [...result.values()].sort(
(left, right) =>
left.order - right.order || left.label.localeCompare(right.label)
);
}
function defaultPresentation(areas: ViewProductArea[]): ViewPresentation {
return {
navigationMode: "grouped",
productAreaOrder: areas.map((area) => area.id),
productAreaLabels: {},
quickAccessRecommendedToolIds: [],
quickAccessFocusedToolIds: []
};
}
function productAreaSurfaceIds(
area: ViewProductArea,
surfaces: PlatformViewSurface[]
): string[] {
const affected = new Set(area.surfaceIds);
let changed = true;
while (changed) {
changed = false;
for (const surface of surfaces) {
if (
surface.parentId &&
affected.has(surface.parentId) &&
!affected.has(surface.id)
) {
affected.add(surface.id);
changed = true;
}
}
}
return [...affected];
}
function revisionPresentation(
value: ViewDefinition["latest_revision"]["presentation"] | undefined,
areas: ViewProductArea[]
): ViewPresentation {
const defaults = defaultPresentation(areas);
return {
navigationMode: value?.navigation_mode ?? defaults.navigationMode,
productAreaOrder:
value?.product_area_order?.length
? value.product_area_order
: defaults.productAreaOrder,
productAreaLabels: value?.product_area_labels ?? {},
quickAccessRecommendedToolIds: value?.quick_access_recommended_tool_ids ?? [],
quickAccessFocusedToolIds: value?.quick_access_focused_tool_ids ?? []
};
}
function orderedProductAreas(
areas: ViewProductArea[],
configuredOrder: string[] | undefined
): ViewProductArea[] {
const rank = new Map((configuredOrder ?? []).map((id, index) => [id, index]));
return [...areas].sort(
(left, right) =>
(rank.get(left.id) ?? 10_000) - (rank.get(right.id) ?? 10_000) ||
left.order - right.order ||
left.label.localeCompare(right.label)
);
}
function presentationKey(value: ViewPresentation): string {
return JSON.stringify({
navigationMode: value.navigationMode ?? "grouped",
productAreaOrder: value.productAreaOrder ?? [],
productAreaLabels: Object.fromEntries(
Object.entries(value.productAreaLabels ?? {})
.filter(([, label]) => label.trim())
.sort(([left], [right]) => left.localeCompare(right))
),
quickAccessRecommendedToolIds: value.quickAccessRecommendedToolIds ?? [],
quickAccessFocusedToolIds: value.quickAccessFocusedToolIds ?? []
});
}
function commaSeparatedToolIds(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}
function assignmentDraftKey(draft: AssignmentDraft): string {
return JSON.stringify({
scopeType: draft.scopeType,
+24 -2
View File
@@ -129,7 +129,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-views.filter_view_surfaces": "Filter View surfaces",
"i18n:govoplan-views.surface_count": "{value0}/{value1} surfaces",
"i18n:govoplan-views.surface_detail": "{value0} · {value1}",
"i18n:govoplan-views.no_matching_surfaces": "No matching surfaces."
"i18n:govoplan-views.no_matching_surfaces": "No matching surfaces.",
"i18n:govoplan-views.product_areas": "Product areas",
"i18n:govoplan-views.product_areas_help": "Choose the outcome-based navigation groups, their order, and optional labels for this View.",
"i18n:govoplan-views.navigation_layout": "Navigation layout",
"i18n:govoplan-views.grouped": "Grouped",
"i18n:govoplan-views.flat": "Flat",
"i18n:govoplan-views.hidden": "Hidden",
"i18n:govoplan-views.visible": "Visible",
"i18n:govoplan-views.required_area_help": "This area contains a required surface and cannot be hidden.",
"i18n:govoplan-views.custom_area_label_value": "Custom label for {value0}",
"i18n:govoplan-views.move_up": "Move up",
"i18n:govoplan-views.move_down": "Move down"
},
de: {
"i18n:govoplan-views.views": "Ansichten",
@@ -259,6 +270,17 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-views.filter_view_surfaces": "Ansichtsoberflächen filtern",
"i18n:govoplan-views.surface_count": "{value0}/{value1} Oberflächen",
"i18n:govoplan-views.surface_detail": "{value0} · {value1}",
"i18n:govoplan-views.no_matching_surfaces": "Keine passenden Oberflächen."
"i18n:govoplan-views.no_matching_surfaces": "Keine passenden Oberflächen.",
"i18n:govoplan-views.product_areas": "Produktbereiche",
"i18n:govoplan-views.product_areas_help": "Ergebnisorientierte Navigationsgruppen, ihre Reihenfolge und optionale Bezeichnungen für diese Ansicht festlegen.",
"i18n:govoplan-views.navigation_layout": "Navigationsdarstellung",
"i18n:govoplan-views.grouped": "Gruppiert",
"i18n:govoplan-views.flat": "Flach",
"i18n:govoplan-views.hidden": "Ausgeblendet",
"i18n:govoplan-views.visible": "Sichtbar",
"i18n:govoplan-views.required_area_help": "Dieser Bereich enthält eine vorgeschriebene Oberfläche und kann nicht ausgeblendet werden.",
"i18n:govoplan-views.custom_area_label_value": "Eigene Bezeichnung für {value0}",
"i18n:govoplan-views.move_up": "Nach oben",
"i18n:govoplan-views.move_down": "Nach unten"
}
};
+1 -1
View File
@@ -103,7 +103,7 @@ const viewsSettingsSections: SettingsSectionsUiCapability = {
export const viewsModule: PlatformWebModule = {
id: "views",
label: "i18n:govoplan-views.views",
version: "0.1.0",
version: "0.1.19",
optionalDependencies: ["access", "admin", "policy", "workflow"],
translations: {
en: generatedTranslations.en,
+56
View File
@@ -175,12 +175,59 @@
resize: vertical;
}
.views-product-area-section,
.views-surface-section {
margin-top: 22px;
padding-top: 18px;
border-top: var(--border-line);
}
.views-product-area-list {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.views-product-area-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(180px, .8fr) auto auto;
align-items: center;
gap: 12px;
min-height: 62px;
padding: 8px 10px;
border-bottom: var(--border-line);
}
.views-product-area-row:last-child {
border-bottom: 0;
}
.views-product-area-row:hover {
background: var(--hover-tint-soft);
}
.views-product-area-copy {
min-width: 0;
}
.views-product-area-copy strong,
.views-product-area-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-product-area-copy small {
margin-top: 3px;
color: var(--muted);
}
.views-product-area-order {
display: flex;
align-items: center;
}
.views-assignments-section {
min-width: 0;
padding-top: 18px;
@@ -337,6 +384,15 @@
grid-template-columns: 1fr;
}
.views-product-area-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.views-product-area-row > input {
grid-column: 1 / -1;
grid-row: 2;
}
.views-editor-heading,
.views-section-heading,
.views-stale-surface-warning {