Initialize governed Views module

This commit is contained in:
2026-07-28 21:04:44 +02:00
commit e14432db73
34 changed files with 6013 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
"""GovOPlaN Views module."""
__version__ = "0.1.0"

View File

@@ -0,0 +1 @@
"""Views backend."""

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Iterable
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.views import EffectiveView, ViewResolver
from govoplan_views.backend.service import resolve_effective_view
class ViewsResolverCapability(ViewResolver):
def __init__(self, registry: object) -> None:
self._registry = registry
def resolve_effective_view(
self,
session: object,
*,
tenant_id: str,
account_id: str,
group_ids: Iterable[str] = (),
) -> EffectiveView:
state = resolve_effective_view(
session,
tenant_id=tenant_id,
account_id=account_id,
group_ids=group_ids,
catalogue=self._registry.view_surfaces(),
)
return state.effective
def resolver_capability(context: ModuleContext) -> ViewsResolverCapability:
return ViewsResolverCapability(context.registry)
__all__ = ["ViewsResolverCapability", "resolver_capability"]

View File

@@ -0,0 +1 @@
"""Views database models."""

View File

@@ -0,0 +1,196 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class ViewDefinition(Base, TimestampMixin):
__tablename__ = "view_definitions"
__table_args__ = (
UniqueConstraint(
"scope_key",
"definition_key",
name="uq_view_definition_scope_key",
),
Index("ix_view_definitions_tenant_status", "tenant_id", "status"),
Index("ix_view_definitions_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
scope_key: Mapped[str] = mapped_column(String(300), nullable=False, index=True)
definition_key: Mapped[str] = mapped_column(String(120), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(24), default="draft", nullable=False, index=True
)
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
published_revision_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
revisions: Mapped[list["ViewRevision"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="ViewRevision.revision",
)
assignments: Mapped[list["ViewAssignment"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
)
class ViewRevision(Base, TimestampMixin):
__tablename__ = "view_revisions"
__table_args__ = (
UniqueConstraint(
"definition_id",
"revision",
name="uq_view_revision_number",
),
Index("ix_view_revisions_definition", "definition_id", "revision"),
Index("ix_view_revisions_contract", "surface_contract_version"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
definition_id: Mapped[str] = mapped_column(
ForeignKey("view_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
surface_contract_version: Mapped[str] = mapped_column(String(20), nullable=False)
visible_surface_ids: Mapped[list[str]] = mapped_column(
JSON, default=list, 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
)
definition: Mapped[ViewDefinition] = relationship(back_populates="revisions")
class ViewAssignment(Base, TimestampMixin):
__tablename__ = "view_assignments"
__table_args__ = (
UniqueConstraint(
"target_key",
"definition_id",
"mode",
name="uq_view_assignment_target_definition_mode",
),
Index("ix_view_assignments_tenant_active", "tenant_id", "is_active"),
Index("ix_view_assignments_target", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
target_key: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(
ForeignKey("view_definitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision_id: Mapped[str | None] = mapped_column(
ForeignKey("view_revisions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
mode: Mapped[str] = mapped_column(
String(20), default="available", nullable=False, index=True
)
priority: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, index=True
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
definition: Mapped[ViewDefinition] = relationship(back_populates="assignments")
revision: Mapped[ViewRevision | None] = relationship()
class ViewPreference(Base, TimestampMixin):
__tablename__ = "view_preferences"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"account_id",
name="uq_view_preference_tenant_account",
),
Index("ix_view_preferences_tenant_account", "tenant_id", "account_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
selection_kind: Mapped[str] = mapped_column(
String(20),
default="auto",
nullable=False,
index=True,
)
view_id: Mapped[str | None] = mapped_column(
ForeignKey("view_definitions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
__all__ = [
"ViewAssignment",
"ViewDefinition",
"ViewPreference",
"ViewRevision",
"new_uuid",
]

View File

@@ -0,0 +1,282 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
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
MODULE_ID = "views"
MODULE_NAME = "Views"
MODULE_VERSION = "0.1.0"
DEFINITION_READ_SCOPE = "views:definition:read"
DEFINITION_WRITE_SCOPE = "views:definition:write"
ASSIGNMENT_READ_SCOPE = "views:assignment:read"
ASSIGNMENT_WRITE_SCOPE = "views:assignment:write"
SELECTION_READ_SCOPE = "views:selection:read"
SELECTION_WRITE_SCOPE = "views:selection:write"
SYSTEM_DEFINITION_READ_SCOPE = "views:system_definition:read"
SYSTEM_DEFINITION_WRITE_SCOPE = "views:system_definition:write"
SYSTEM_ASSIGNMENT_READ_SCOPE = "views:system_assignment:read"
SYSTEM_ASSIGNMENT_WRITE_SCOPE = "views:system_assignment:write"
def _permission(
scope: str,
label: str,
description: str,
*,
level: str = "tenant",
) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Views",
level=level,
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
DEFINITION_READ_SCOPE,
"View tenant Views",
"Read tenant and inherited View definitions and immutable revisions.",
),
_permission(
DEFINITION_WRITE_SCOPE,
"Manage tenant Views",
"Create, revise, publish, and archive tenant View definitions.",
),
_permission(
ASSIGNMENT_READ_SCOPE,
"View tenant View assignments",
"Read tenant, group, and user View assignments.",
),
_permission(
ASSIGNMENT_WRITE_SCOPE,
"Manage tenant View assignments",
"Assign available, default, and required Views within a tenant.",
),
_permission(
SELECTION_READ_SCOPE,
"View effective View",
"Read the effective View projection for the current account.",
),
_permission(
SELECTION_WRITE_SCOPE,
"Select available Views",
"Select or leave an available View unless an administrator requires it.",
),
_permission(
SYSTEM_DEFINITION_READ_SCOPE,
"View system Views",
"Read system-wide View definitions and immutable revisions.",
level="system",
),
_permission(
SYSTEM_DEFINITION_WRITE_SCOPE,
"Manage system Views",
"Create, revise, publish, and archive system-wide View definitions.",
level="system",
),
_permission(
SYSTEM_ASSIGNMENT_READ_SCOPE,
"View system View assignments",
"Read system-wide View assignments.",
level="system",
),
_permission(
SYSTEM_ASSIGNMENT_WRITE_SCOPE,
"Manage system View assignments",
"Assign available, default, and required Views system-wide.",
level="system",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="view_manager",
name="View manager",
description="Design tenant Views and manage their assignments.",
permissions=(
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
ASSIGNMENT_READ_SCOPE,
ASSIGNMENT_WRITE_SCOPE,
SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE,
),
),
RoleTemplate(
slug="view_user",
name="View user",
description="Use and select Views made available to the account.",
permissions=(SELECTION_READ_SCOPE, SELECTION_WRITE_SCOPE),
default_authenticated=True,
),
)
def _router(context: ModuleContext):
from govoplan_views.backend.runtime import configure_runtime
configure_runtime(registry=context.registry)
from govoplan_views.backend.router import router
return router
def _resolver(context: ModuleContext):
from govoplan_views.backend.capabilities import resolver_capability
from govoplan_views.backend.runtime import configure_runtime
configure_runtime(registry=context.registry)
return resolver_capability(context)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
optional_dependencies=("access", "admin", "policy", "workflow"),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name="views.surface_contract", version="1.0.0"),
ModuleInterfaceProvider(name="views.resolver", version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/views-webui",
view_surfaces=(
ViewSurface(
id="views.selector",
module_id=MODULE_ID,
kind="selector",
label="View selector",
description="Always-available selector for leaving optional Views.",
order=1,
required=True,
),
ViewSurface(
id="views.admin.system",
module_id=MODULE_ID,
kind="section",
label="System Views administration",
description="Edit system-wide Views and assignments.",
order=20,
),
ViewSurface(
id="views.admin.tenant",
module_id=MODULE_ID,
kind="section",
label="Tenant Views administration",
description="Edit tenant, group, and user Views and assignments.",
order=30,
),
),
),
route_factory=_router,
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
view_models.ViewPreference,
view_models.ViewAssignment,
view_models.ViewRevision,
view_models.ViewDefinition,
label="Views",
),
retirement_notes=(
"Destructive retirement removes View definitions, revisions, "
"assignments, and user selections after a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
view_models.ViewDefinition,
view_models.ViewAssignment,
label="Views",
),
),
capability_factories={
CAPABILITY_VIEWS_RESOLVER: _resolver,
},
documentation=(
DocumentationTopic(
id="views.interface-projections",
title="Task-focused Views",
summary=(
"Reduce the visible interface to the modules and functions needed "
"for a task without changing authorization."
),
body=(
"Views are versioned presentation projections. Modules announce "
"their selectable surfaces through the platform contract. System "
"and tenant administrators can publish Views and make them "
"available, default, or required at system, tenant, group, and "
"user scope. Required Views retain administration escape surfaces "
"so they can always be inspected and changed. Hidden functions "
"remain protected by their normal permission checks."
),
layer="available",
documentation_types=("admin", "user"),
audience=("administrator", "power_user", "workflow_designer"),
related_modules=("access", "admin", "policy", "workflow"),
order=18,
),
),
)
def get_manifest() -> ModuleManifest:
return manifest
__all__ = [
"ASSIGNMENT_READ_SCOPE",
"ASSIGNMENT_WRITE_SCOPE",
"DEFINITION_READ_SCOPE",
"DEFINITION_WRITE_SCOPE",
"MODULE_ID",
"MODULE_VERSION",
"SELECTION_READ_SCOPE",
"SELECTION_WRITE_SCOPE",
"SYSTEM_ASSIGNMENT_READ_SCOPE",
"SYSTEM_ASSIGNMENT_WRITE_SCOPE",
"SYSTEM_DEFINITION_READ_SCOPE",
"SYSTEM_DEFINITION_WRITE_SCOPE",
"get_manifest",
"manifest",
]

View File

@@ -0,0 +1 @@
"""Views migrations."""

View File

@@ -0,0 +1 @@
"""Views migration revisions."""

View File

@@ -0,0 +1,235 @@
"""v0.1.0 Views definitions and assignments
Revision ID: b8e4c1f7a2d9
Revises: None
Create Date: 2026-07-28 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b8e4c1f7a2d9"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"view_definitions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("scope_key", sa.String(length=300), nullable=False),
sa.Column("definition_key", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_view_definitions")),
sa.UniqueConstraint(
"scope_key",
"definition_key",
name="uq_view_definition_scope_key",
),
)
for column in (
"tenant_id",
"scope_type",
"scope_id",
"scope_key",
"status",
"published_revision_id",
"created_by",
"updated_by",
"deleted_at",
):
op.create_index(
op.f(f"ix_view_definitions_{column}"),
"view_definitions",
[column],
unique=False,
)
op.create_index(
"ix_view_definitions_tenant_status",
"view_definitions",
["tenant_id", "status"],
unique=False,
)
op.create_index(
"ix_view_definitions_scope",
"view_definitions",
["scope_type", "scope_id"],
unique=False,
)
op.create_table(
"view_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("definition_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("surface_contract_version", sa.String(length=20), nullable=False),
sa.Column("visible_surface_ids", sa.JSON(), nullable=False),
sa.Column("content_hash", sa.String(length=64), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["definition_id"],
["view_definitions.id"],
name=op.f("fk_view_revisions_definition_id_view_definitions"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_view_revisions")),
sa.UniqueConstraint(
"definition_id",
"revision",
name="uq_view_revision_number",
),
)
for column in (
"tenant_id",
"definition_id",
"content_hash",
"created_by",
):
op.create_index(
op.f(f"ix_view_revisions_{column}"),
"view_revisions",
[column],
unique=False,
)
op.create_index(
"ix_view_revisions_definition",
"view_revisions",
["definition_id", "revision"],
unique=False,
)
op.create_index(
"ix_view_revisions_contract",
"view_revisions",
["surface_contract_version"],
unique=False,
)
op.create_table(
"view_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("target_key", sa.String(length=320), nullable=False),
sa.Column("definition_id", sa.String(length=36), nullable=False),
sa.Column("revision_id", sa.String(length=36), nullable=True),
sa.Column("mode", sa.String(length=20), nullable=False),
sa.Column("priority", sa.Integer(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["definition_id"],
["view_definitions.id"],
name=op.f("fk_view_assignments_definition_id_view_definitions"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["revision_id"],
["view_revisions.id"],
name=op.f("fk_view_assignments_revision_id_view_revisions"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_view_assignments")),
sa.UniqueConstraint(
"target_key",
"definition_id",
"mode",
name="uq_view_assignment_target_definition_mode",
),
)
for column in (
"tenant_id",
"scope_type",
"scope_id",
"target_key",
"definition_id",
"revision_id",
"mode",
"is_active",
"created_by",
"updated_by",
):
op.create_index(
op.f(f"ix_view_assignments_{column}"),
"view_assignments",
[column],
unique=False,
)
op.create_index(
"ix_view_assignments_tenant_active",
"view_assignments",
["tenant_id", "is_active"],
unique=False,
)
op.create_index(
"ix_view_assignments_target",
"view_assignments",
["scope_type", "scope_id"],
unique=False,
)
op.create_table(
"view_preferences",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=255), nullable=False),
sa.Column("selection_kind", sa.String(length=20), nullable=False),
sa.Column("view_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["view_id"],
["view_definitions.id"],
name=op.f("fk_view_preferences_view_id_view_definitions"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_view_preferences")),
sa.UniqueConstraint(
"tenant_id",
"account_id",
name="uq_view_preference_tenant_account",
),
)
for column in ("tenant_id", "account_id", "selection_kind", "view_id"):
op.create_index(
op.f(f"ix_view_preferences_{column}"),
"view_preferences",
[column],
unique=False,
)
op.create_index(
"ix_view_preferences_tenant_account",
"view_preferences",
["tenant_id", "account_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("view_preferences")
op.drop_table("view_assignments")
op.drop_table("view_revisions")
op.drop_table("view_definitions")

View File

@@ -0,0 +1,713 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface
from govoplan_core.db.session import get_session
from govoplan_views.backend.manifest import (
ASSIGNMENT_READ_SCOPE,
ASSIGNMENT_WRITE_SCOPE,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE,
SYSTEM_ASSIGNMENT_READ_SCOPE,
SYSTEM_ASSIGNMENT_WRITE_SCOPE,
SYSTEM_DEFINITION_READ_SCOPE,
SYSTEM_DEFINITION_WRITE_SCOPE,
)
from govoplan_views.backend.runtime import get_registry
from govoplan_views.backend.schemas import (
EffectiveViewOptionResponse,
EffectiveViewResponse,
ViewDiagnosticResponse,
ViewAssignmentCreateRequest,
ViewAssignmentListResponse,
ViewAssignmentResponse,
ViewAssignmentUpdateRequest,
ViewDefinitionCreateRequest,
ViewDefinitionListResponse,
ViewDefinitionResponse,
ViewDefinitionUpdateRequest,
ViewProvenanceResponse,
ViewRevisionCreateRequest,
ViewRevisionResponse,
ViewSelectionRequest,
ViewSurfaceCatalogueResponse,
ViewSurfaceResponse,
)
from govoplan_views.backend.service import (
EffectiveViewState,
ViewsConflictError,
ViewsError,
ViewsNotFoundError,
ViewsValidationError,
archive_definition,
assignment_payload,
create_assignment,
create_definition,
create_revision,
definition_payload,
definition_revisions,
delete_assignment,
get_assignment,
get_definition,
get_revision,
list_assignments,
list_definitions,
publish_revision,
resolve_effective_view,
select_view,
update_assignment,
update_definition,
)
router = APIRouter(prefix="/views", tags=["views"])
def _catalogue() -> tuple[ViewSurface, ...]:
return get_registry().view_surfaces()
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of: {', '.join(scopes)}",
)
def _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_any_scope(
principal,
SYSTEM_DEFINITION_READ_SCOPE,
SYSTEM_DEFINITION_WRITE_SCOPE,
)
return
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
)
def _require_definition_write(principal: ApiPrincipal, scope_type: str) -> None:
_require_any_scope(
principal,
SYSTEM_DEFINITION_WRITE_SCOPE
if scope_type == "system"
else DEFINITION_WRITE_SCOPE,
)
def _require_assignment_read(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_any_scope(
principal,
SYSTEM_ASSIGNMENT_READ_SCOPE,
SYSTEM_ASSIGNMENT_WRITE_SCOPE,
)
return
_require_any_scope(
principal,
ASSIGNMENT_READ_SCOPE,
ASSIGNMENT_WRITE_SCOPE,
)
def _require_assignment_write(principal: ApiPrincipal, scope_type: str) -> None:
_require_any_scope(
principal,
SYSTEM_ASSIGNMENT_WRITE_SCOPE
if scope_type == "system"
else ASSIGNMENT_WRITE_SCOPE,
)
def _actor_id(principal: ApiPrincipal) -> str:
return principal.account_id
def _http_error(exc: ViewsError) -> HTTPException:
if isinstance(exc, ViewsNotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if isinstance(exc, ViewsConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, ViewsValidationError):
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
def _audit(
session: Session,
principal: ApiPrincipal,
*,
action: str,
object_type: str,
object_id: str,
details: dict[str, object],
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action=action,
object_type=object_type,
object_id=object_id,
details=details,
)
def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse:
effective = state.effective
return EffectiveViewResponse(
active_view_id=effective.view_id,
active_revision_id=effective.revision_id,
active_view_name=effective.name,
visible_surface_ids=sorted(effective.visible_surface_ids),
locked=effective.locked,
available_views=[
EffectiveViewOptionResponse(
id=option.id,
name=option.name,
description=option.description,
revision_id=option.revision_id,
)
for option in state.available_views
],
provenance=[
ViewProvenanceResponse.model_validate(item) for item in effective.provenance
],
diagnostics=[
ViewDiagnosticResponse(
severity=item.severity,
code=item.code,
message=item.message,
surface_ids=list(item.surface_ids),
)
for item in state.diagnostics
],
)
def _definition_response(
session: Session,
definition,
*,
readonly: bool,
) -> ViewDefinitionResponse:
return ViewDefinitionResponse.model_validate(
definition_payload(
session,
definition,
readonly=readonly,
catalogue=_catalogue(),
)
)
@router.get("/effective", response_model=EffectiveViewResponse)
def api_effective_view(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> EffectiveViewResponse:
_require_any_scope(
principal,
SELECTION_READ_SCOPE,
SELECTION_WRITE_SCOPE,
)
return _effective_response(
resolve_effective_view(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
group_ids=principal.group_ids,
catalogue=_catalogue(),
)
)
@router.put("/selection", response_model=EffectiveViewResponse)
def api_select_view(
payload: ViewSelectionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> EffectiveViewResponse:
_require_any_scope(principal, SELECTION_WRITE_SCOPE)
try:
state = select_view(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
group_ids=principal.group_ids,
view_id=payload.view_id,
catalogue=_catalogue(),
)
_audit(
session,
principal,
action="views.selection.update",
object_type="view_preference",
object_id=principal.account_id,
details={"view_id": payload.view_id},
)
session.commit()
return _effective_response(state)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.get("/surfaces", response_model=ViewSurfaceCatalogueResponse)
def api_view_surfaces(
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewSurfaceCatalogueResponse:
_require_any_scope(
principal,
DEFINITION_READ_SCOPE,
DEFINITION_WRITE_SCOPE,
SYSTEM_DEFINITION_READ_SCOPE,
SYSTEM_DEFINITION_WRITE_SCOPE,
)
lockout_ids = {
"access.module",
"access.nav.admin",
"access.route.admin",
"views.module",
"views.selector",
"views.admin.system",
"views.admin.tenant",
}
return ViewSurfaceCatalogueResponse(
contract_version=VIEW_SURFACE_CONTRACT_VERSION,
surfaces=[
ViewSurfaceResponse(
id=surface.id,
module_id=surface.module_id,
kind=surface.kind,
label=surface.label,
parent_id=surface.parent_id,
description=surface.description,
order=surface.order,
default_visible=surface.default_visible,
required=surface.required,
required_for_locked_view=surface.id in lockout_ids,
)
for surface in _catalogue()
],
)
@router.get("/definitions", response_model=ViewDefinitionListResponse)
def api_list_definitions(
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
include_inherited: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionListResponse:
_require_definition_read(principal, scope_type)
definitions = list_definitions(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
include_inherited=include_inherited,
)
return ViewDefinitionListResponse(
definitions=[
_definition_response(
session,
definition,
readonly=(scope_type == "tenant" and definition.scope_type == "system"),
)
for definition in definitions
]
)
@router.post(
"/definitions",
response_model=ViewDefinitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_definition(
payload: ViewDefinitionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse:
_require_definition_write(principal, payload.scope_type)
try:
definition = create_definition(
session,
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
definition_key=payload.definition_key,
name=payload.name,
description=payload.description,
visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(),
actor_id=_actor_id(principal),
)
_audit(
session,
principal,
action="views.definition.create",
object_type="view_definition",
object_id=definition.id,
details={"scope_type": definition.scope_type},
)
session.commit()
return _definition_response(session, definition, readonly=False)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.patch(
"/definitions/{definition_id}",
response_model=ViewDefinitionResponse,
)
def api_update_definition(
definition_id: str,
payload: ViewDefinitionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse:
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
update_definition(
session,
definition,
name=payload.name,
description=payload.description,
actor_id=_actor_id(principal),
fields_set=payload.model_fields_set,
)
_audit(
session,
principal,
action="views.definition.update",
object_type="view_definition",
object_id=definition.id,
details={"fields": sorted(payload.model_fields_set)},
)
session.commit()
return _definition_response(session, definition, readonly=False)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.get(
"/definitions/{definition_id}/revisions",
response_model=list[ViewRevisionResponse],
)
def api_list_revisions(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> list[ViewRevisionResponse]:
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_read(principal, definition.scope_type)
return [
ViewRevisionResponse.model_validate(revision)
for revision in definition_revisions(
session,
definition_id=definition.id,
)
]
except ViewsError as exc:
raise _http_error(exc) from exc
@router.post(
"/definitions/{definition_id}/revisions",
response_model=ViewDefinitionResponse,
)
def api_create_revision(
definition_id: str,
payload: ViewRevisionCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse:
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
revision = create_revision(
session,
definition,
visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(),
actor_id=_actor_id(principal),
)
_audit(
session,
principal,
action="views.revision.create",
object_type="view_definition",
object_id=definition.id,
details={
"revision": revision.revision,
"content_hash": revision.content_hash,
},
)
session.commit()
return _definition_response(session, definition, readonly=False)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.post(
"/definitions/{definition_id}/revisions/{revision_id}/publish",
response_model=ViewDefinitionResponse,
)
def api_publish_revision(
definition_id: str,
revision_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse:
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
revision = get_revision(
session,
definition_id=definition.id,
revision_id=revision_id,
)
publish_revision(
session,
definition,
revision,
catalogue=_catalogue(),
actor_id=_actor_id(principal),
)
_audit(
session,
principal,
action="views.revision.publish",
object_type="view_definition",
object_id=definition.id,
details={"revision": revision.revision, "revision_id": revision.id},
)
session.commit()
return _definition_response(session, definition, readonly=False)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.delete(
"/definitions/{definition_id}",
response_model=ViewDefinitionResponse,
)
def api_archive_definition(
definition_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewDefinitionResponse:
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=definition_id,
)
_require_definition_write(principal, definition.scope_type)
archive_definition(
session,
definition,
actor_id=_actor_id(principal),
)
_audit(
session,
principal,
action="views.definition.archive",
object_type="view_definition",
object_id=definition.id,
details={},
)
session.commit()
return _definition_response(session, definition, readonly=False)
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.get("/assignments", response_model=ViewAssignmentListResponse)
def api_list_assignments(
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
include_inherited: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewAssignmentListResponse:
_require_assignment_read(principal, scope_type)
assignments = list_assignments(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
include_inherited=include_inherited,
)
return ViewAssignmentListResponse(
assignments=[
ViewAssignmentResponse.model_validate(assignment_payload(assignment))
for assignment in assignments
]
)
@router.post(
"/assignments",
response_model=ViewAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_assignment(
payload: ViewAssignmentCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewAssignmentResponse:
_require_assignment_write(principal, payload.scope_type)
try:
definition = get_definition(
session,
tenant_id=principal.tenant_id,
definition_id=payload.definition_id,
)
assignment = create_assignment(
session,
tenant_id=principal.tenant_id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
definition=definition,
revision_id=payload.revision_id,
mode=payload.mode,
priority=payload.priority,
is_active=payload.is_active,
metadata=payload.metadata,
catalogue=_catalogue(),
actor_id=_actor_id(principal),
)
_audit(
session,
principal,
action="views.assignment.create",
object_type="view_assignment",
object_id=assignment.id,
details={
"scope_type": assignment.scope_type,
"scope_id": assignment.scope_id,
"definition_id": assignment.definition_id,
"mode": assignment.mode,
},
)
session.commit()
return ViewAssignmentResponse.model_validate(assignment_payload(assignment))
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.patch(
"/assignments/{assignment_id}",
response_model=ViewAssignmentResponse,
)
def api_update_assignment(
assignment_id: str,
payload: ViewAssignmentUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ViewAssignmentResponse:
try:
assignment = get_assignment(
session,
tenant_id=principal.tenant_id,
assignment_id=assignment_id,
)
_require_assignment_write(principal, assignment.scope_type)
update_assignment(
session,
assignment,
updates=payload.model_dump(exclude_unset=True),
catalogue=_catalogue(),
actor_id=_actor_id(principal),
)
_audit(
session,
principal,
action="views.assignment.update",
object_type="view_assignment",
object_id=assignment.id,
details={"fields": sorted(payload.model_fields_set)},
)
session.commit()
return ViewAssignmentResponse.model_validate(assignment_payload(assignment))
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.delete(
"/assignments/{assignment_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def api_delete_assignment(
assignment_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
try:
assignment = get_assignment(
session,
tenant_id=principal.tenant_id,
assignment_id=assignment_id,
)
_require_assignment_write(principal, assignment.scope_type)
assignment_details = {
"scope_type": assignment.scope_type,
"scope_id": assignment.scope_id,
"definition_id": assignment.definition_id,
"mode": assignment.mode,
}
delete_assignment(session, assignment)
_audit(
session,
principal,
action="views.assignment.delete",
object_type="view_assignment",
object_id=assignment_id,
details=assignment_details,
)
session.commit()
except ViewsError as exc:
session.rollback()
raise _http_error(exc) from exc

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from govoplan_core.core.registry import PlatformRegistry
_registry: PlatformRegistry | None = None
def configure_runtime(*, registry: object) -> None:
global _registry
if not isinstance(registry, PlatformRegistry):
raise RuntimeError("Views requires the GovOPlaN platform registry")
_registry = registry
def get_registry() -> PlatformRegistry:
if _registry is None:
raise RuntimeError("Views runtime is not configured")
return _registry

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
ViewScopeType = Literal["system", "tenant"]
AssignmentScopeType = Literal["system", "tenant", "group", "user"]
AssignmentMode = Literal["available", "default", "required"]
class ViewSurfaceResponse(BaseModel):
id: str
module_id: str
kind: str
label: str
parent_id: str | None = None
description: str | None = None
order: int = 100
default_visible: bool = True
required: bool = False
required_for_locked_view: bool = False
class ViewSurfaceCatalogueResponse(BaseModel):
contract_version: str
surfaces: list[ViewSurfaceResponse]
class ViewRevisionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
definition_id: str
revision: int
surface_contract_version: str
visible_surface_ids: list[str]
content_hash: str
created_by: str | None = None
created_at: datetime
class ViewDefinitionResponse(BaseModel):
id: str
tenant_id: str | None = None
scope_type: ViewScopeType
scope_id: str | None = None
definition_key: str
name: str
description: str | None = None
status: Literal["draft", "published", "archived"]
current_revision: int
latest_revision: ViewRevisionResponse
published_revision: ViewRevisionResponse | None = None
readonly: bool = False
stale_surface_ids: list[str] = Field(default_factory=list)
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
class ViewDefinitionListResponse(BaseModel):
definitions: list[ViewDefinitionResponse]
class ViewDefinitionCreateRequest(BaseModel):
scope_type: ViewScopeType = "tenant"
definition_key: str | None = Field(default=None, max_length=120)
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)
class ViewDefinitionUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=4000)
class ViewRevisionCreateRequest(BaseModel):
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
class ViewAssignmentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
scope_type: AssignmentScopeType
scope_id: str | None = None
definition_id: str
revision_id: str | None = None
mode: AssignmentMode
priority: int
is_active: bool
metadata: dict[str, Any] = Field(default_factory=dict)
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
class ViewAssignmentListResponse(BaseModel):
assignments: list[ViewAssignmentResponse]
class ViewAssignmentCreateRequest(BaseModel):
scope_type: AssignmentScopeType
scope_id: str | None = Field(default=None, max_length=255)
definition_id: str = Field(min_length=1, max_length=36)
revision_id: str | None = Field(default=None, max_length=36)
mode: AssignmentMode = "available"
priority: int = Field(default=0, ge=-1000, le=1000)
is_active: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_target(self):
if self.scope_type in {"group", "user"} and not self.scope_id:
raise ValueError(f"{self.scope_type} assignments require scope_id")
if self.scope_type == "system" and self.scope_id:
raise ValueError("system assignments cannot declare scope_id")
return self
class ViewAssignmentUpdateRequest(BaseModel):
revision_id: str | None = Field(default=None, max_length=36)
mode: AssignmentMode | None = None
priority: int | None = Field(default=None, ge=-1000, le=1000)
is_active: bool | None = None
metadata: dict[str, Any] | None = None
class EffectiveViewOptionResponse(BaseModel):
id: str
name: str
description: str | None = None
revision_id: str
class ViewProvenanceResponse(BaseModel):
source: str
scope_type: str | None = None
scope_id: str | None = None
detail: str | None = None
class ViewDiagnosticResponse(BaseModel):
severity: Literal["warning", "error"]
code: str
message: str
surface_ids: list[str] = Field(default_factory=list)
class EffectiveViewResponse(BaseModel):
active_view_id: str | None = None
active_revision_id: str | None = None
active_view_name: str | None = None
visible_surface_ids: list[str] = Field(default_factory=list)
locked: bool = False
available_views: list[EffectiveViewOptionResponse] = Field(default_factory=list)
provenance: list[ViewProvenanceResponse] = Field(default_factory=list)
diagnostics: list[ViewDiagnosticResponse] = Field(default_factory=list)
class ViewSelectionRequest(BaseModel):
view_id: str | None = Field(default=None, max_length=36)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@