Initialize governed Views module
This commit is contained in:
3
src/govoplan_views/__init__.py
Normal file
3
src/govoplan_views/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN Views module."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
src/govoplan_views/backend/__init__.py
Normal file
1
src/govoplan_views/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Views backend."""
|
||||
36
src/govoplan_views/backend/capabilities.py
Normal file
36
src/govoplan_views/backend/capabilities.py
Normal 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"]
|
||||
1
src/govoplan_views/backend/db/__init__.py
Normal file
1
src/govoplan_views/backend/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Views database models."""
|
||||
196
src/govoplan_views/backend/db/models.py
Normal file
196
src/govoplan_views/backend/db/models.py
Normal 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",
|
||||
]
|
||||
282
src/govoplan_views/backend/manifest.py
Normal file
282
src/govoplan_views/backend/manifest.py
Normal 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",
|
||||
]
|
||||
1
src/govoplan_views/backend/migrations/__init__.py
Normal file
1
src/govoplan_views/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Views migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Views migration revisions."""
|
||||
@@ -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")
|
||||
713
src/govoplan_views/backend/router.py
Normal file
713
src/govoplan_views/backend/router.py
Normal 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
|
||||
19
src/govoplan_views/backend/runtime.py
Normal file
19
src/govoplan_views/backend/runtime.py
Normal 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
|
||||
169
src/govoplan_views/backend/schemas.py
Normal file
169
src/govoplan_views/backend/schemas.py
Normal 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)
|
||||
1149
src/govoplan_views/backend/service.py
Normal file
1149
src/govoplan_views/backend/service.py
Normal file
File diff suppressed because it is too large
Load Diff
1
src/govoplan_views/py.typed
Normal file
1
src/govoplan_views/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user