Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a53898388 | ||
|
|
4e574f3bdd | ||
|
|
acd0d3bdae | ||
|
|
266f5dae9b | ||
|
|
24dca43e2e | ||
|
|
e49129c3eb | ||
|
|
e87076f86b | ||
|
|
877dced738 | ||
|
|
cdbf2bf80b | ||
|
|
b9a92c79f9 | ||
|
|
013f05839b |
@@ -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.
|
||||
|
||||
+2
-3
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-views"
|
||||
version = "0.1.17"
|
||||
version = "0.1.21"
|
||||
description = "Governed task-focused interface projections for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.17"]
|
||||
dependencies = ["govoplan-core>=0.1.18"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -20,4 +20,3 @@ govoplan_views = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
views = "govoplan_views.backend.manifest:get_manifest"
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Views module."""
|
||||
|
||||
__version__ = "0.1.17"
|
||||
__version__ = "0.1.21"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.17"
|
||||
MODULE_VERSION = "0.1.21"
|
||||
|
||||
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
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""v0.1.18 immutable View presentation metadata.
|
||||
|
||||
Revision ID: c6f2a9d4e7b1
|
||||
Revises: b8e4c1f7a2d9
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c6f2a9d4e7b1"
|
||||
down_revision = "b8e4c1f7a2d9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("view_revisions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"presentation",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("view_revisions") as batch_op:
|
||||
batch_op.drop_column("presentation")
|
||||
@@ -111,6 +111,21 @@ def _catalogue(
|
||||
)
|
||||
|
||||
|
||||
def _product_area_ids(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
) -> frozenset[str]:
|
||||
active_module_ids = {
|
||||
surface.module_id for surface in _catalogue(session, principal)
|
||||
}
|
||||
return frozenset(
|
||||
area.id
|
||||
for manifest in get_registry().manifests()
|
||||
if manifest.id in active_module_ids and manifest.frontend is not None
|
||||
for area in manifest.frontend.product_areas
|
||||
)
|
||||
|
||||
|
||||
def _view_governance_policy():
|
||||
return view_governance_policy(get_registry())
|
||||
|
||||
@@ -507,6 +522,7 @@ def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse:
|
||||
active_revision_id=effective.revision_id,
|
||||
active_view_name=effective.name,
|
||||
visible_surface_ids=sorted(effective.visible_surface_ids),
|
||||
presentation=dict(effective.presentation),
|
||||
projection_active=effective.projection_active,
|
||||
locked=effective.locked,
|
||||
available_views=[
|
||||
@@ -753,6 +769,8 @@ def api_create_definition(
|
||||
visible_surface_ids=payload.visible_surface_ids,
|
||||
catalogue=_catalogue(session, principal),
|
||||
actor_id=_actor_id(principal),
|
||||
presentation=payload.presentation,
|
||||
available_product_area_ids=_product_area_ids(session, principal),
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
@@ -892,6 +910,8 @@ def api_create_revision(
|
||||
visible_surface_ids=payload.visible_surface_ids,
|
||||
catalogue=_catalogue(session, principal),
|
||||
actor_id=_actor_id(principal),
|
||||
presentation=payload.presentation,
|
||||
available_product_area_ids=_product_area_ids(session, principal),
|
||||
)
|
||||
_audit(
|
||||
session,
|
||||
|
||||
@@ -37,6 +37,7 @@ class ViewRevisionResponse(BaseModel):
|
||||
revision: int
|
||||
surface_contract_version: str
|
||||
visible_surface_ids: list[str]
|
||||
presentation: dict[str, Any] = Field(default_factory=dict)
|
||||
content_hash: str
|
||||
created_by: str | None = None
|
||||
created_at: datetime
|
||||
@@ -73,6 +74,7 @@ class ViewDefinitionCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
|
||||
presentation: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ViewDefinitionUpdateRequest(BaseModel):
|
||||
@@ -82,6 +84,7 @@ class ViewDefinitionUpdateRequest(BaseModel):
|
||||
|
||||
class ViewRevisionCreateRequest(BaseModel):
|
||||
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
|
||||
presentation: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ViewAssignmentResponse(BaseModel):
|
||||
@@ -179,6 +182,7 @@ class EffectiveViewResponse(BaseModel):
|
||||
active_revision_id: str | None = None
|
||||
active_view_name: str | None = None
|
||||
visible_surface_ids: list[str] = Field(default_factory=list)
|
||||
presentation: dict[str, Any] = Field(default_factory=dict)
|
||||
projection_active: bool = False
|
||||
locked: bool = False
|
||||
available_views: list[EffectiveViewOptionResponse] = Field(default_factory=list)
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from typing import Any, Literal, Mapping
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
@@ -51,6 +51,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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"./styles/views.css": "./src/styles/views.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+42
-3
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
@@ -677,6 +711,8 @@ export default function ViewsAdminPanel({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
helpContextId="views.action.publish"
|
||||
helpModuleId="views"
|
||||
onClick={() => void publishDraft()}
|
||||
disabled={Boolean(editDisabledReason) || !draft.name.trim()}
|
||||
disabledReason={editDisabledReason ?? (!draft.name.trim() ? VIEWS_INTERFACE_I18N.nameRequiredText : undefined)}
|
||||
@@ -727,6 +763,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 +911,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 +944,7 @@ export default function ViewsAdminPanel({
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-views.first_draft_help
|
||||
</p>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<AssignmentDialog
|
||||
@@ -1200,7 +1286,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 +1447,7 @@ function AssignmentDialog({
|
||||
help="i18n:govoplan-views.pin_revision_help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
{draft.mode === "required" && (
|
||||
<DismissibleAlert
|
||||
tone={requiredMissing.length ? "danger" : "warning"}
|
||||
@@ -1379,6 +1465,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 +1843,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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user