Initialize governed Views module
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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:
|
||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal 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?
|
||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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:
|
||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal 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:
|
||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal 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:
|
||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
353
.gitignore
vendored
Normal file
353
.gitignore
vendored
Normal 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
12
AGENTS.md
Normal 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
58
README.md
Normal 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
23
pyproject.toml
Normal 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"
|
||||
|
||||
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 @@
|
||||
|
||||
50
tests/test_migrations.py
Normal file
50
tests/test_migrations.py
Normal 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
467
tests/test_views.py
Normal 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
29
webui/package.json
Normal 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
280
webui/src/api/views.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
70
webui/src/components/ViewSelector.tsx
Normal file
70
webui/src/components/ViewSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1232
webui/src/features/views/ViewsAdminPanel.tsx
Normal file
1232
webui/src/features/views/ViewsAdminPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
3
webui/src/index.ts
Normal file
3
webui/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default, viewsModule } from "./module";
|
||||
export * from "./api/views";
|
||||
|
||||
102
webui/src/module.ts
Normal file
102
webui/src/module.ts
Normal 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
364
webui/src/styles/views.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user