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,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/views
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/views
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/views
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/views
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/views
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

353
.gitignore vendored Normal file
View File

@@ -0,0 +1,353 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.file-drop-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.file-drop-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# Security audit reports
audit-reports/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

12
AGENTS.md Normal file
View File

@@ -0,0 +1,12 @@
# GovOPlaN Views Module
This repository owns view definitions, immutable revisions, scoped
assignments, effective resolution, and the Views administration UI.
- Keep generic surface contracts and shell filtering in `govoplan-core`.
- Keep authorization decisions in Access and governance limits in Policy.
- A View may hide an authorized surface, but must never grant access.
- Required assignments must pass server-side lockout validation.
- Do not import optional module implementations. Consume manifest surfaces and
platform capabilities.

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# govoplan-views
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-views` provides governed, task-focused interface projections for
GovOPlaN. A View selects modules, navigation entries, routes, sections, and
actions that should be shown for a particular task without changing the
underlying authorization decision.
The module owns versioned View definitions, immutable revisions, assignments
at system, tenant, group, and user scope, effective-view resolution, and the
user's current selection. Core owns the surface contract and applies the
effective projection to the shared shell. Modules announce their selectable
surface hierarchy in their manifests and WebUI contributions.
Views are presentation filters, never security boundaries. Hidden routes still
use their normal permission checks. Required Views retain the View selector and
administration surfaces needed to inspect and change the assignment, preventing
administrators from locking an installation out of its own configuration.
## Administration
The module contributes **Views** sections to system and tenant administration.
Administrators can:
- create a draft from the currently installed surface catalogue
- choose modules, navigation entries, routes, sections, and actions
- publish immutable revisions
- make a published View available, default, or required at system, tenant,
group, or user scope
- pin an assignment to its published revision or let it follow later published
revisions
- inspect and remove references to surfaces retired by a module
Required assignments are validated by the backend. They must retain the Views
selector, the Access administration route, and the administration section able
to remove the assignment. If an installed-module change leaves an active View
without a reachable navigation entry and route, Core shows the normal
authorized interface and reports the stale projection instead of trapping the
user.
## Development
Install the module as an editable package alongside Core and include `views` in
`ENABLED_MODULES`. Core's registered-module migration runner applies
the Views migration on startup.
```bash
pip install -e ../govoplan-views
python -m unittest discover -s ../govoplan-views/tests -v
```
Workflow declares the resolver as an optional contract so a future workflow
instance runtime can activate a pinned View revision. Workflow-driven
activation and Policy ceilings remain separate follow-up work; neither is
required for manually assigned and selected Views.

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-views"
version = "0.1.0"
description = "Governed task-focused interface projections for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_views = ["py.typed"]
[project.entry-points."govoplan.modules"]
views = "govoplan_views.backend.manifest:get_manifest"

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 @@

50
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_views.backend.manifest import get_manifest
class ViewsMigrationTests(unittest.TestCase):
def test_migration_creates_views_tables_and_head(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-views-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'views.db'}"
migrate_database(
database_url=url,
enabled_modules=("views",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
self.assertIn(
"b8e4c1f7a2d9",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
{
"view_assignments",
"view_definitions",
"view_preferences",
"view_revisions",
},
{
name
for name in inspect(connection).get_table_names()
if name.startswith("view_")
},
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

467
tests/test_views.py Normal file
View File

@@ -0,0 +1,467 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_views.backend.service import (
ViewsConflictError,
ViewsValidationError,
archive_definition,
create_assignment,
create_definition,
create_revision,
get_revision,
normalize_visible_surface_ids,
publish_revision,
resolve_effective_view,
select_view,
update_assignment,
)
def surface_catalogue() -> tuple[ViewSurface, ...]:
return (
ViewSurface(
id="access.module",
module_id="access",
kind="module",
label="Access",
),
ViewSurface(
id="access.nav.admin",
module_id="access",
kind="navigation",
label="Administration",
parent_id="access.module",
),
ViewSurface(
id="access.route.admin",
module_id="access",
kind="route",
label="Administration",
parent_id="access.module",
),
ViewSurface(
id="views.module",
module_id="views",
kind="module",
label="Views",
),
ViewSurface(
id="views.selector",
module_id="views",
kind="selector",
label="View selector",
parent_id="views.module",
required=True,
),
ViewSurface(
id="views.admin.system",
module_id="views",
kind="section",
label="System Views",
parent_id="views.module",
),
ViewSurface(
id="views.admin.tenant",
module_id="views",
kind="section",
label="Tenant Views",
parent_id="views.module",
),
ViewSurface(
id="files.module",
module_id="files",
kind="module",
label="Files",
),
ViewSurface(
id="files.nav.files",
module_id="files",
kind="navigation",
label="Files",
parent_id="files.module",
),
ViewSurface(
id="files.route.files",
module_id="files",
kind="route",
label="Files",
parent_id="files.module",
),
)
def ordinary_surface_ids() -> list[str]:
return ["files.nav.files", "files.route.files"]
def lockout_safe_surface_ids() -> list[str]:
return [
"access.nav.admin",
"access.route.admin",
"views.admin.system",
"views.admin.tenant",
"files.nav.files",
"files.route.files",
]
class ViewsServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.catalogue = surface_catalogue()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def create_published_definition(
self,
*,
name: str,
scope_type: str = "tenant",
visible_surface_ids: list[str] | None = None,
):
definition = create_definition(
self.session,
tenant_id="tenant-1",
scope_type=scope_type,
definition_key=None,
name=name,
description=None,
visible_surface_ids=visible_surface_ids or ordinary_surface_ids(),
catalogue=self.catalogue,
actor_id="account-admin",
)
revision = get_revision(
self.session,
definition_id=definition.id,
)
publish_revision(
self.session,
definition,
revision,
catalogue=self.catalogue,
actor_id="account-admin",
)
return definition
def test_normalization_adds_required_surfaces_and_ancestors(self) -> None:
normalized = normalize_visible_surface_ids(
ordinary_surface_ids(),
catalogue=self.catalogue,
)
self.assertIn("files.module", normalized)
self.assertIn("views.selector", normalized)
self.assertIn("views.module", normalized)
def test_normalization_requires_navigation_and_route(self) -> None:
with self.assertRaisesRegex(ViewsValidationError, "navigation"):
normalize_visible_surface_ids(
["files.route.files"],
catalogue=self.catalogue,
)
with self.assertRaisesRegex(ViewsValidationError, "route"):
normalize_visible_surface_ids(
["files.nav.files"],
catalogue=self.catalogue,
)
def test_default_assignment_can_be_left_by_the_user(self) -> None:
definition = self.create_published_definition(name="Files task")
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition=definition,
revision_id=None,
mode="default",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
initial = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
)
self.assertEqual(definition.id, initial.effective.view_id)
self.assertFalse(initial.effective.locked)
selected = select_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
group_ids=(),
view_id=None,
)
self.assertIsNone(selected.effective.view_id)
self.assertEqual(
"user_selection",
selected.effective.provenance[0]["source"],
)
def test_tenant_default_precedes_system_default(self) -> None:
system_view = self.create_published_definition(
name="System task",
scope_type="system",
)
tenant_view = self.create_published_definition(name="Tenant task")
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="system",
scope_id=None,
definition=system_view,
revision_id=None,
mode="default",
priority=100,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition=tenant_view,
revision_id=None,
mode="default",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
state = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
)
self.assertEqual(tenant_view.id, state.effective.view_id)
self.assertEqual(
{system_view.id, tenant_view.id},
{option.id for option in state.available_views},
)
def test_more_specific_available_assignment_cannot_override_required_mode(
self,
) -> None:
definition = self.create_published_definition(
name="Required task",
scope_type="system",
visible_surface_ids=lockout_safe_surface_ids(),
)
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="system",
scope_id=None,
definition=definition,
revision_id=None,
mode="required",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="user",
scope_id="account-user",
definition=definition,
revision_id=None,
mode="available",
priority=100,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
state = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
)
self.assertEqual(definition.id, state.effective.view_id)
self.assertTrue(state.effective.locked)
self.assertEqual(
"required_assignment",
state.effective.provenance[0]["source"],
)
def test_required_assignment_rejects_revision_without_escape_surfaces(self) -> None:
definition = self.create_published_definition(name="Unsafe task")
with self.assertRaisesRegex(ViewsValidationError, "administration escape"):
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition=definition,
revision_id=None,
mode="required",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
def test_retired_surfaces_fall_back_to_full_interface_with_diagnostics(
self,
) -> None:
definition = self.create_published_definition(name="Retired task")
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition=definition,
revision_id=None,
mode="default",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
active_catalogue = tuple(
surface for surface in self.catalogue if surface.module_id != "files"
)
state = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
catalogue=active_catalogue,
)
self.assertIsNone(state.effective.view_id)
self.assertEqual(
{"view.stale_surfaces", "view.no_reachable_surface"},
{diagnostic.code for diagnostic in state.diagnostics},
)
self.assertEqual(
"invalid_view_fallback",
state.effective.provenance[-1]["source"],
)
def test_required_assignment_is_locked_and_blocks_archive(self) -> None:
definition = self.create_published_definition(
name="Required task",
visible_surface_ids=lockout_safe_surface_ids(),
)
create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition=definition,
revision_id=None,
mode="required",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
state = resolve_effective_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
)
self.assertEqual(definition.id, state.effective.view_id)
self.assertTrue(state.effective.locked)
with self.assertRaisesRegex(ViewsConflictError, "required assignments"):
archive_definition(
self.session,
definition,
actor_id="account-admin",
)
with self.assertRaisesRegex(ViewsConflictError, "cannot be changed"):
select_view(
self.session,
tenant_id="tenant-1",
account_id="account-user",
group_ids=(),
view_id=None,
)
def test_historical_revision_pin_survives_assignment_edits(self) -> None:
definition = self.create_published_definition(
name="Pinned task",
visible_surface_ids=lockout_safe_surface_ids(),
)
pinned_revision = get_revision(
self.session,
definition_id=definition.id,
)
assignment = create_assignment(
self.session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
definition=definition,
revision_id=pinned_revision.id,
mode="required",
priority=0,
is_active=True,
metadata={},
catalogue=self.catalogue,
actor_id="account-admin",
)
next_revision = create_revision(
self.session,
definition,
visible_surface_ids=ordinary_surface_ids(),
catalogue=self.catalogue,
actor_id="account-admin",
)
publish_revision(
self.session,
definition,
next_revision,
catalogue=self.catalogue,
actor_id="account-admin",
)
updated = update_assignment(
self.session,
assignment,
updates={
"revision_id": pinned_revision.id,
"priority": 25,
},
catalogue=self.catalogue,
actor_id="account-admin",
)
self.assertEqual(pinned_revision.id, updated.revision_id)
self.assertEqual(25, updated.priority)
if __name__ == "__main__":
unittest.main()

29
webui/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@govoplan/views-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/views.css": "./src/styles/views.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

280
webui/src/api/views.ts Normal file
View File

@@ -0,0 +1,280 @@
import {
apiFetch,
apiPath,
type ApiSettings,
type EffectiveViewProjection
} from "@govoplan/core-webui";
export type ViewScopeType = "system" | "tenant";
export type ViewAssignmentScopeType = "system" | "tenant" | "group" | "user";
export type ViewAssignmentMode = "available" | "default" | "required";
export type ViewRevision = {
id: string;
definition_id: string;
revision: number;
surface_contract_version: string;
visible_surface_ids: string[];
content_hash: string;
created_by?: string | null;
created_at: string;
};
export type ViewDefinition = {
id: string;
tenant_id?: string | null;
scope_type: ViewScopeType;
scope_id?: string | null;
definition_key: string;
name: string;
description?: string | null;
status: "draft" | "published" | "archived";
current_revision: number;
latest_revision: ViewRevision;
published_revision?: ViewRevision | null;
readonly: boolean;
stale_surface_ids: string[];
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
};
export type ViewAssignment = {
id: string;
tenant_id?: string | null;
scope_type: ViewAssignmentScopeType;
scope_id?: string | null;
definition_id: string;
revision_id?: string | null;
mode: ViewAssignmentMode;
priority: number;
is_active: boolean;
metadata: Record<string, unknown>;
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
};
type EffectiveViewApiResponse = {
active_view_id?: string | null;
active_revision_id?: string | null;
active_view_name?: string | null;
visible_surface_ids: string[];
locked: boolean;
available_views: Array<{
id: string;
name: string;
description?: string | null;
revision_id: string;
}>;
provenance: Array<{
source: string;
scope_type?: string | null;
scope_id?: string | null;
detail?: string | null;
}>;
diagnostics?: Array<{
severity: "warning" | "error";
code: string;
message: string;
surface_ids: string[];
}>;
};
function jsonBody(payload: unknown): RequestInit {
return {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
};
}
function projection(response: EffectiveViewApiResponse): EffectiveViewProjection {
return {
activeViewId: response.active_view_id ?? null,
activeRevisionId: response.active_revision_id ?? null,
activeViewName: response.active_view_name ?? null,
visibleSurfaceIds: response.visible_surface_ids,
locked: response.locked,
availableViews: response.available_views.map((view) => ({
id: view.id,
name: view.name,
description: view.description,
revisionId: view.revision_id
})),
provenance: response.provenance.map((item) => ({
source: item.source,
scopeType: item.scope_type,
scopeId: item.scope_id,
detail: item.detail
})),
diagnostics: (response.diagnostics ?? []).map((item) => ({
severity: item.severity,
code: item.code,
message: item.message,
surfaceIds: item.surface_ids
}))
};
}
export async function fetchEffectiveView(
settings: ApiSettings
): Promise<EffectiveViewProjection> {
return projection(
await apiFetch<EffectiveViewApiResponse>(
settings,
"/api/v1/views/effective",
{ cache: "no-store" }
)
);
}
export async function selectEffectiveView(
settings: ApiSettings,
viewId: string | null
): Promise<EffectiveViewProjection> {
return projection(
await apiFetch<EffectiveViewApiResponse>(
settings,
"/api/v1/views/selection",
{
method: "PUT",
...jsonBody({ view_id: viewId })
}
)
);
}
export async function fetchViewDefinitions(
settings: ApiSettings,
scopeType: ViewScopeType
): Promise<ViewDefinition[]> {
const response = await apiFetch<{ definitions: ViewDefinition[] }>(
settings,
apiPath("/api/v1/views/definitions", {
scope_type: scopeType,
include_inherited: scopeType === "tenant"
})
);
return response.definitions;
}
export function createViewDefinition(
settings: ApiSettings,
payload: {
scope_type: ViewScopeType;
name: string;
description?: string | null;
visible_surface_ids: string[];
}
): Promise<ViewDefinition> {
return apiFetch(settings, "/api/v1/views/definitions", {
method: "POST",
...jsonBody(payload)
});
}
export function updateViewDefinition(
settings: ApiSettings,
definitionId: string,
payload: { name: string; description?: string | null }
): Promise<ViewDefinition> {
return apiFetch(settings, `/api/v1/views/definitions/${definitionId}`, {
method: "PATCH",
...jsonBody(payload)
});
}
export function createViewRevision(
settings: ApiSettings,
definitionId: string,
visibleSurfaceIds: string[]
): Promise<ViewDefinition> {
return apiFetch(
settings,
`/api/v1/views/definitions/${definitionId}/revisions`,
{
method: "POST",
...jsonBody({ visible_surface_ids: visibleSurfaceIds })
}
);
}
export function publishViewRevision(
settings: ApiSettings,
definitionId: string,
revisionId: string
): Promise<ViewDefinition> {
return apiFetch(
settings,
`/api/v1/views/definitions/${definitionId}/revisions/${revisionId}/publish`,
{ method: "POST" }
);
}
export function archiveViewDefinition(
settings: ApiSettings,
definitionId: string
): Promise<ViewDefinition> {
return apiFetch(settings, `/api/v1/views/definitions/${definitionId}`, {
method: "DELETE"
});
}
export async function fetchViewAssignments(
settings: ApiSettings,
scopeType: ViewScopeType
): Promise<ViewAssignment[]> {
const response = await apiFetch<{ assignments: ViewAssignment[] }>(
settings,
apiPath("/api/v1/views/assignments", {
scope_type: scopeType,
include_inherited: scopeType === "tenant"
})
);
return response.assignments;
}
export function createViewAssignment(
settings: ApiSettings,
payload: {
scope_type: ViewAssignmentScopeType;
scope_id?: string | null;
definition_id: string;
revision_id?: string | null;
mode: ViewAssignmentMode;
priority: number;
is_active: boolean;
}
): Promise<ViewAssignment> {
return apiFetch(settings, "/api/v1/views/assignments", {
method: "POST",
...jsonBody(payload)
});
}
export function updateViewAssignment(
settings: ApiSettings,
assignmentId: string,
payload: {
revision_id?: string | null;
mode?: ViewAssignmentMode;
priority?: number;
is_active?: boolean;
}
): Promise<ViewAssignment> {
return apiFetch(settings, `/api/v1/views/assignments/${assignmentId}`, {
method: "PATCH",
...jsonBody(payload)
});
}
export function deleteViewAssignment(
settings: ApiSettings,
assignmentId: string
): Promise<void> {
return apiFetch(settings, `/api/v1/views/assignments/${assignmentId}`, {
method: "DELETE"
});
}

View File

@@ -0,0 +1,70 @@
import { Eye, LockKeyhole } from "lucide-react";
import { useState } from "react";
import {
dispatchPlatformViewChanged,
useUnsavedChanges,
type ViewSelectorProps
} from "@govoplan/core-webui";
import { selectEffectiveView } from "../api/views";
export default function ViewSelector({
settings,
projection
}: ViewSelectorProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { requestNavigation } = useUnsavedChanges();
const options = projection?.availableViews ?? [];
if (!projection?.activeViewId && options.length === 0) return null;
async function performSelect(viewId: string) {
setBusy(true);
setError("");
try {
await selectEffectiveView(settings, viewId || null);
dispatchPlatformViewChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "View selection failed");
} finally {
setBusy(false);
}
}
function select(viewId: string) {
requestNavigation(() => {
void performSelect(viewId);
});
}
const locked = Boolean(projection?.locked);
const diagnostic = projection?.diagnostics.find((item) => item.severity === "error")
?? projection?.diagnostics[0];
const title = error || diagnostic?.message || (
locked
? "This View is required by an administrator."
: "Choose which parts of GovOPlaN are shown."
);
return (
<label className="titlebar-view-selector" title={title}>
{locked
? <LockKeyhole size={16} aria-hidden="true" />
: <Eye size={16} aria-hidden="true" />}
<span className="sr-only">Current View</span>
<select
value={projection?.activeViewId ?? ""}
disabled={busy || locked}
aria-label="Current View"
aria-invalid={Boolean(error) || undefined}
onChange={(event) => select(event.target.value)}
>
{!locked && <option value="">Full interface</option>}
{options.map((option) => (
<option key={option.id} value={option.id}>{option.name}</option>
))}
</select>
</label>
);
}

File diff suppressed because it is too large Load Diff

3
webui/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default, viewsModule } from "./module";
export * from "./api/views";

102
webui/src/module.ts Normal file
View File

@@ -0,0 +1,102 @@
import { createElement, lazy } from "react";
import {
hasScope,
type AdminSectionsUiCapability,
type PlatformWebModule,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import ViewSelector from "./components/ViewSelector";
import { fetchEffectiveView, selectEffectiveView } from "./api/views";
import "./styles/views.css";
const ViewsAdminPanel = lazy(
() => import("./features/views/ViewsAdminPanel")
);
const viewsAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "system-views",
label: "Views",
group: "SYSTEM",
order: 15,
surfaceId: "views.admin.system",
anyOf: [
"views:system_definition:read",
"views:system_definition:write",
"views:system_assignment:read",
"views:system_assignment:write"
],
render: ({ settings, auth }) => createElement(ViewsAdminPanel, {
settings,
scopeType: "system",
canWriteDefinitions: hasScope(auth, "views:system_definition:write"),
canWriteAssignments: hasScope(auth, "views:system_assignment:write")
})
},
{
id: "tenant-views",
label: "Views",
group: "TENANT",
order: 15,
surfaceId: "views.admin.tenant",
anyOf: [
"views:definition:read",
"views:definition:write",
"views:assignment:read",
"views:assignment:write"
],
render: ({ settings, auth }) => createElement(ViewsAdminPanel, {
settings,
scopeType: "tenant",
canWriteDefinitions: hasScope(auth, "views:definition:write"),
canWriteAssignments: hasScope(auth, "views:assignment:write")
})
}
]
};
const viewsRuntime: ViewsRuntimeUiCapability = {
loadEffectiveView: (settings) => fetchEffectiveView(settings),
activateView: (settings, viewId) => selectEffectiveView(settings, viewId),
Selector: ViewSelector
};
export const viewsModule: PlatformWebModule = {
id: "views",
label: "Views",
version: "0.1.0",
optionalDependencies: ["access", "admin", "policy", "workflow"],
viewSurfaces: [
{
id: "views.selector",
moduleId: "views",
kind: "selector",
label: "View selector",
order: 1,
required: true
},
{
id: "views.admin.system",
moduleId: "views",
kind: "section",
label: "System Views administration",
order: 20
},
{
id: "views.admin.tenant",
moduleId: "views",
kind: "section",
label: "Tenant Views administration",
order: 30
}
],
uiCapabilities: {
"admin.sections": viewsAdminSections,
"views.runtime": viewsRuntime
}
};
export default viewsModule;

364
webui/src/styles/views.css Normal file
View File

@@ -0,0 +1,364 @@
.titlebar-view-selector {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 7px;
color: var(--muted);
}
.titlebar-view-selector select {
width: min(190px, 24vw);
height: 32px;
padding: 0 28px 0 8px;
border: 0;
border-radius: var(--radius-sm);
background-color: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.titlebar-view-selector select:hover,
.titlebar-view-selector select:focus-visible {
background-color: var(--titlebar-hover-bg);
}
.titlebar-view-selector select:disabled {
cursor: default;
opacity: .78;
}
.views-admin-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 620px;
max-height: calc(100vh - 190px);
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.views-definition-pane {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-right: var(--border-line);
background: var(--surface-muted);
}
.views-pane-heading,
.views-editor-heading,
.views-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.views-pane-heading {
align-items: center;
min-height: 50px;
padding: 0 14px;
border-bottom: var(--border-line);
}
.views-pane-heading > span {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.views-definition-list {
min-height: 0;
overflow: auto;
padding: 6px;
}
.views-definition-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 18px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 58px;
padding: 9px 8px 9px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.views-definition-item:hover {
background: var(--hover-tint-soft);
}
.views-definition-item.active {
background: var(--accent-hover-bg);
color: var(--text-strong);
}
.views-definition-item-main {
min-width: 0;
}
.views-definition-item-main strong,
.views-definition-item-main small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.views-definition-item-main small {
margin-top: 3px;
color: var(--muted);
}
.views-editor-pane {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 18px;
}
.views-editor-heading {
min-height: 44px;
}
.views-editor-heading h3,
.views-section-heading h4,
.views-empty-editor h3 {
margin: 0;
font-size: 16px;
letter-spacing: 0;
}
.views-editor-title-row {
display: flex;
align-items: center;
gap: 9px;
}
.views-editor-heading p,
.views-section-heading p {
margin: 4px 0 0;
}
.views-definition-form {
display: grid;
grid-template-columns: minmax(220px, .65fr) minmax(300px, 1.35fr);
gap: 14px;
margin-top: 16px;
}
.views-definition-form textarea {
resize: vertical;
}
.views-surface-section,
.views-assignments-section {
margin-top: 22px;
padding-top: 18px;
border-top: var(--border-line);
}
.views-section-heading {
align-items: center;
margin-bottom: 10px;
}
.views-stale-surface-warning {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.views-surface-selector {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.views-surface-filter {
padding: 8px;
border-bottom: var(--border-line);
background: var(--surface-muted);
}
.views-surface-filter input {
width: 100%;
}
.views-surface-modules {
height: 350px;
overflow: auto;
padding: 6px;
}
.views-surface-modules details {
border-bottom: var(--border-line);
}
.views-surface-modules details:last-child {
border-bottom: 0;
}
.views-surface-modules summary {
list-style: none;
}
.views-surface-modules summary::-webkit-details-marker {
display: none;
}
.views-surface-children {
display: grid;
gap: 2px;
padding: 0 0 6px 24px;
}
.views-surface-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 18px;
align-items: center;
gap: 9px;
min-height: 42px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.views-surface-row:hover {
background: var(--hover-tint-soft);
}
.views-surface-row.disabled {
cursor: default;
opacity: .7;
}
.views-surface-row strong,
.views-surface-row small {
display: block;
letter-spacing: 0;
}
.views-surface-row small {
margin-top: 2px;
overflow-wrap: anywhere;
color: var(--muted);
}
.views-assignment-list {
max-height: 280px;
overflow: auto;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.views-assignment-row {
display: grid;
grid-template-columns: minmax(180px, 1.3fr) auto auto minmax(120px, .7fr) auto auto;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 8px 10px;
border-bottom: var(--border-line);
}
.views-assignment-row:last-child {
border-bottom: 0;
}
.views-assignment-row:hover {
background: var(--hover-tint-soft);
}
.views-assignment-view strong,
.views-assignment-view span {
display: block;
}
.views-assignment-view span,
.views-assignment-priority,
.views-assignment-revision {
color: var(--muted);
font-size: 12px;
}
.views-empty-list,
.views-empty-editor {
padding: 24px 16px;
color: var(--muted);
text-align: center;
}
.views-empty-editor {
display: grid;
place-content: center;
min-height: 420px;
}
.views-assignment-dialog {
width: min(760px, calc(100vw - 40px));
}
.views-assignment-toggles {
display: grid;
align-content: end;
gap: 10px;
min-height: 70px;
}
@media (max-width: 1100px) {
.views-admin-shell {
grid-template-columns: 230px minmax(0, 1fr);
}
.views-assignment-row {
grid-template-columns: minmax(170px, 1fr) auto auto;
}
.views-assignment-priority,
.views-assignment-revision {
display: none;
}
}
@media (max-width: 760px) {
.titlebar-view-selector select {
width: 120px;
}
.views-admin-shell {
display: flex;
flex-direction: column;
max-height: none;
}
.views-definition-pane {
max-height: 220px;
border-right: 0;
border-bottom: var(--border-line);
}
.views-definition-form {
grid-template-columns: 1fr;
}
.views-editor-heading,
.views-section-heading,
.views-stale-surface-warning {
align-items: stretch;
flex-direction: column;
}
.views-assignment-row {
grid-template-columns: minmax(0, 1fr) auto;
}
}