feat: add personal and group view ownership

This commit is contained in:
2026-07-28 22:13:15 +02:00
parent 34221b633b
commit 3de8d4aa5f
12 changed files with 833 additions and 213 deletions

View File

@@ -27,7 +27,7 @@ from govoplan_views.backend.db.models import (
ASSIGNMENT_MODES = frozenset({"available", "default", "required"})
ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"})
DEFINITION_SCOPES = frozenset({"system", "tenant"})
DEFINITION_SCOPES = frozenset({"system", "tenant", "group", "user"})
LOCKOUT_BASE_SURFACE_IDS = (
"access.module",
"access.nav.admin",
@@ -91,11 +91,27 @@ def _scope_values(
scope_type: str,
*,
tenant_id: str,
scope_id: str | None = None,
) -> tuple[str | None, str | None, str]:
if scope_type == "system":
if scope_id:
raise ViewsValidationError(
"System View definitions cannot declare a target id"
)
return None, None, "system"
if scope_type == "tenant":
if scope_id and scope_id != tenant_id:
raise ViewsValidationError(
"Tenant View definitions must target the active tenant"
)
return tenant_id, tenant_id, f"tenant:{tenant_id}"
if scope_type in {"group", "user"}:
target_id = (scope_id or "").strip()
if not target_id:
raise ViewsValidationError(
f"{scope_type.title()} View definitions require a target id"
)
return tenant_id, target_id, f"{scope_type}:{tenant_id}:{target_id}"
raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}")
@@ -158,6 +174,7 @@ def list_definitions(
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
include_inherited: bool = True,
) -> list[ViewDefinition]:
if scope_type not in DEFINITION_SCOPES:
@@ -168,12 +185,45 @@ def list_definitions(
ViewDefinition.scope_type == "system",
ViewDefinition.deleted_at.is_(None),
)
elif scope_type == "tenant":
if not include_inherited:
query = session.query(ViewDefinition).filter(
ViewDefinition.tenant_id == tenant_id,
ViewDefinition.scope_type == "tenant",
ViewDefinition.deleted_at.is_(None),
)
else:
query = _definition_query_for_actor(
session,
tenant_id=tenant_id,
include_system=True,
)
else:
query = _definition_query_for_actor(
session,
tenant_id=tenant_id,
include_system=include_inherited,
target_id = (scope_id or "").strip()
if not target_id:
raise ViewsValidationError(
f"{scope_type.title()} View definition listings require a target id"
)
exact_target = (
(ViewDefinition.tenant_id == tenant_id)
& (ViewDefinition.scope_type == scope_type)
& (ViewDefinition.scope_id == target_id)
)
query = session.query(ViewDefinition).filter(
ViewDefinition.deleted_at.is_(None)
)
if include_inherited:
query = query.filter(
or_(
exact_target,
(ViewDefinition.tenant_id == tenant_id)
& (ViewDefinition.scope_type == "tenant"),
(ViewDefinition.tenant_id.is_(None))
& (ViewDefinition.scope_type == "system"),
)
)
else:
query = query.filter(exact_target)
return query.order_by(
ViewDefinition.scope_type.asc(),
ViewDefinition.name.asc(),
@@ -291,6 +341,7 @@ def create_definition(
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
definition_key: str | None,
name: str,
description: str | None,
@@ -303,6 +354,7 @@ def create_definition(
row_tenant_id, scope_id, scope_key = _scope_values(
scope_type,
tenant_id=tenant_id,
scope_id=scope_id,
)
clean_name = name.strip()
if not clean_name:
@@ -489,9 +541,65 @@ def publish_revision(
definition.updated_by = actor_id
definition.updated_at = _now()
session.flush()
ensure_owner_available_assignment(
session,
definition,
catalogue=catalogue,
actor_id=actor_id,
)
return definition
def ensure_owner_available_assignment(
session: Session,
definition: ViewDefinition,
*,
catalogue: Iterable[ViewSurface],
actor_id: str | None,
) -> ViewAssignment | None:
if definition.scope_type not in {"group", "user"}:
return None
if not definition.tenant_id or not definition.scope_id:
raise ViewsValidationError(
"Owned View definitions require tenant and target identifiers"
)
target_key = f"{definition.scope_type}:{definition.tenant_id}:{definition.scope_id}"
assignment = (
session.query(ViewAssignment)
.filter(
ViewAssignment.target_key == target_key,
ViewAssignment.definition_id == definition.id,
ViewAssignment.mode == "available",
)
.first()
)
if assignment is None:
return create_assignment(
session,
tenant_id=definition.tenant_id,
scope_type=definition.scope_type,
scope_id=definition.scope_id,
definition=definition,
revision_id=None,
mode="available",
priority=0,
is_active=True,
metadata={"source": "definition_owner"},
catalogue=catalogue,
actor_id=actor_id,
)
assignment.revision_id = None
assignment.is_active = True
assignment.metadata_ = {
**dict(assignment.metadata_ or {}),
"source": "definition_owner",
}
assignment.updated_by = actor_id
assignment.updated_at = _now()
session.flush()
return assignment
def archive_definition(
session: Session,
definition: ViewDefinition,
@@ -1181,6 +1289,7 @@ __all__ = [
"get_assignment",
"get_definition",
"get_revision",
"ensure_owner_available_assignment",
"list_assignments",
"list_definitions",
"lockout_required_surface_ids",