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