commit e14432db73701600f51166ca5e8c02a883df2049 Author: Albrecht Degering Date: Tue Jul 28 21:04:44 2026 +0200 Initialize governed Views module diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4578562 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..f66e07c --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -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? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6533b8a --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..d5dcdac --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..3ed16a6 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -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: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..252e52a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30dae3e --- /dev/null +++ b/AGENTS.md @@ -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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd90c9a --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# govoplan-views + + +**Repository type:** module (platform). + + +`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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2a09d08 --- /dev/null +++ b/pyproject.toml @@ -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" + diff --git a/src/govoplan_views/__init__.py b/src/govoplan_views/__init__.py new file mode 100644 index 0000000..00d4100 --- /dev/null +++ b/src/govoplan_views/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Views module.""" + +__version__ = "0.1.0" diff --git a/src/govoplan_views/backend/__init__.py b/src/govoplan_views/backend/__init__.py new file mode 100644 index 0000000..3cff484 --- /dev/null +++ b/src/govoplan_views/backend/__init__.py @@ -0,0 +1 @@ +"""Views backend.""" diff --git a/src/govoplan_views/backend/capabilities.py b/src/govoplan_views/backend/capabilities.py new file mode 100644 index 0000000..91467f0 --- /dev/null +++ b/src/govoplan_views/backend/capabilities.py @@ -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"] diff --git a/src/govoplan_views/backend/db/__init__.py b/src/govoplan_views/backend/db/__init__.py new file mode 100644 index 0000000..b133c48 --- /dev/null +++ b/src/govoplan_views/backend/db/__init__.py @@ -0,0 +1 @@ +"""Views database models.""" diff --git a/src/govoplan_views/backend/db/models.py b/src/govoplan_views/backend/db/models.py new file mode 100644 index 0000000..d38ba52 --- /dev/null +++ b/src/govoplan_views/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py new file mode 100644 index 0000000..e28b8dc --- /dev/null +++ b/src/govoplan_views/backend/manifest.py @@ -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", +] diff --git a/src/govoplan_views/backend/migrations/__init__.py b/src/govoplan_views/backend/migrations/__init__.py new file mode 100644 index 0000000..b67b328 --- /dev/null +++ b/src/govoplan_views/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Views migrations.""" diff --git a/src/govoplan_views/backend/migrations/versions/__init__.py b/src/govoplan_views/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..40ca9ee --- /dev/null +++ b/src/govoplan_views/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Views migration revisions.""" diff --git a/src/govoplan_views/backend/migrations/versions/b8e4c1f7a2d9_v010_views.py b/src/govoplan_views/backend/migrations/versions/b8e4c1f7a2d9_v010_views.py new file mode 100644 index 0000000..a8e2354 --- /dev/null +++ b/src/govoplan_views/backend/migrations/versions/b8e4c1f7a2d9_v010_views.py @@ -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") diff --git a/src/govoplan_views/backend/router.py b/src/govoplan_views/backend/router.py new file mode 100644 index 0000000..0f5ccab --- /dev/null +++ b/src/govoplan_views/backend/router.py @@ -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 diff --git a/src/govoplan_views/backend/runtime.py b/src/govoplan_views/backend/runtime.py new file mode 100644 index 0000000..a1b8605 --- /dev/null +++ b/src/govoplan_views/backend/runtime.py @@ -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 diff --git a/src/govoplan_views/backend/schemas.py b/src/govoplan_views/backend/schemas.py new file mode 100644 index 0000000..d3ee562 --- /dev/null +++ b/src/govoplan_views/backend/schemas.py @@ -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) diff --git a/src/govoplan_views/backend/service.py b/src/govoplan_views/backend/service.py new file mode 100644 index 0000000..4ce953f --- /dev/null +++ b/src/govoplan_views/backend/service.py @@ -0,0 +1,1149 @@ +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Literal + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from govoplan_core.core.views import ( + VIEW_SURFACE_CONTRACT_VERSION, + EffectiveView, + ViewSurface, +) +from govoplan_core.db.base import utcnow +from govoplan_views.backend.db.models import ( + ViewAssignment, + ViewDefinition, + ViewPreference, + ViewRevision, +) + + +ASSIGNMENT_MODES = frozenset({"available", "default", "required"}) +ASSIGNMENT_SCOPES = frozenset({"system", "tenant", "group", "user"}) +DEFINITION_SCOPES = frozenset({"system", "tenant"}) +LOCKOUT_BASE_SURFACE_IDS = ( + "access.module", + "access.nav.admin", + "access.route.admin", + "views.module", + "views.selector", +) +LOCKOUT_ADMIN_SURFACE_IDS = { + "system": "views.admin.system", + "tenant": "views.admin.tenant", + "group": "views.admin.tenant", + "user": "views.admin.tenant", +} +_KEY_RE = re.compile(r"[^a-z0-9]+") + + +class ViewsError(RuntimeError): + pass + + +class ViewsNotFoundError(ViewsError): + pass + + +class ViewsConflictError(ViewsError): + pass + + +class ViewsValidationError(ViewsError): + pass + + +@dataclass(frozen=True, slots=True) +class EffectiveViewOption: + id: str + name: str + description: str | None + revision_id: str + + +@dataclass(frozen=True, slots=True) +class ViewDiagnostic: + severity: Literal["warning", "error"] + code: str + message: str + surface_ids: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class EffectiveViewState: + effective: EffectiveView + available_views: tuple[EffectiveViewOption, ...] + diagnostics: tuple[ViewDiagnostic, ...] = () + + +def _now() -> datetime: + return utcnow() + + +def _scope_values( + scope_type: str, + *, + tenant_id: str, +) -> tuple[str | None, str | None, str]: + if scope_type == "system": + return None, None, "system" + if scope_type == "tenant": + return tenant_id, tenant_id, f"tenant:{tenant_id}" + raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}") + + +def _assignment_target( + scope_type: str, + scope_id: str | None, + *, + tenant_id: str, +) -> tuple[str | None, str | None, str]: + normalized_scope = scope_type.strip().lower() + if normalized_scope not in ASSIGNMENT_SCOPES: + raise ViewsValidationError(f"Unsupported View assignment scope: {scope_type}") + if normalized_scope == "system": + if scope_id: + raise ViewsValidationError( + "System View assignments cannot declare a target id" + ) + return None, None, "system" + if normalized_scope == "tenant": + if scope_id and scope_id != tenant_id: + raise ViewsValidationError( + "Tenant View assignments must target the active tenant" + ) + return tenant_id, tenant_id, f"tenant:{tenant_id}" + target_id = (scope_id or "").strip() + if not target_id: + raise ViewsValidationError( + f"{normalized_scope.title()} View assignments require a target id" + ) + return tenant_id, target_id, f"{normalized_scope}:{tenant_id}:{target_id}" + + +def _definition_key(value: str | None, name: str) -> str: + source = (value or name).strip().lower() + normalized = _KEY_RE.sub("-", source).strip("-") + if not normalized: + raise ViewsValidationError("View key must contain letters or numbers") + return normalized[:120] + + +def _definition_query_for_actor( + session: Session, + *, + tenant_id: str, + include_system: bool, +): + query = session.query(ViewDefinition).filter(ViewDefinition.deleted_at.is_(None)) + if include_system: + return query.filter( + or_( + ViewDefinition.tenant_id == tenant_id, + ViewDefinition.tenant_id.is_(None), + ) + ) + return query.filter(ViewDefinition.tenant_id == tenant_id) + + +def list_definitions( + session: Session, + *, + tenant_id: str, + scope_type: str, + include_inherited: bool = True, +) -> list[ViewDefinition]: + if scope_type not in DEFINITION_SCOPES: + raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}") + if scope_type == "system": + query = session.query(ViewDefinition).filter( + ViewDefinition.tenant_id.is_(None), + ViewDefinition.scope_type == "system", + ViewDefinition.deleted_at.is_(None), + ) + else: + query = _definition_query_for_actor( + session, + tenant_id=tenant_id, + include_system=include_inherited, + ) + return query.order_by( + ViewDefinition.scope_type.asc(), + ViewDefinition.name.asc(), + ViewDefinition.id.asc(), + ).all() + + +def get_definition( + session: Session, + *, + tenant_id: str, + definition_id: str, + include_system: bool = True, +) -> ViewDefinition: + definition = ( + _definition_query_for_actor( + session, + tenant_id=tenant_id, + include_system=include_system, + ) + .filter(ViewDefinition.id == definition_id) + .first() + ) + if definition is None: + raise ViewsNotFoundError("View definition not found") + return definition + + +def get_revision( + session: Session, + *, + definition_id: str, + revision_id: str | None = None, + revision: int | None = None, +) -> ViewRevision: + query = session.query(ViewRevision).filter( + ViewRevision.definition_id == definition_id + ) + if revision_id is not None: + query = query.filter(ViewRevision.id == revision_id) + elif revision is not None: + query = query.filter(ViewRevision.revision == revision) + else: + query = query.order_by(ViewRevision.revision.desc()) + result = query.first() + if result is None: + raise ViewsNotFoundError("View revision not found") + return result + + +def definition_revisions( + session: Session, + *, + definition_id: str, +) -> list[ViewRevision]: + return ( + session.query(ViewRevision) + .filter(ViewRevision.definition_id == definition_id) + .order_by(ViewRevision.revision.desc()) + .all() + ) + + +def normalize_visible_surface_ids( + values: Iterable[str], + *, + catalogue: Iterable[ViewSurface], +) -> list[str]: + surfaces = tuple(catalogue) + by_id = {surface.id: surface for surface in surfaces} + selected = {str(value).strip() for value in values if str(value).strip()} + unknown = sorted(selected - set(by_id)) + if unknown: + preview = ", ".join(unknown[:5]) + suffix = "" if len(unknown) <= 5 else f" and {len(unknown) - 5} more" + raise ViewsValidationError(f"Unknown View surfaces: {preview}{suffix}") + + selected.update(surface.id for surface in surfaces if surface.required) + pending = list(selected) + while pending: + surface_id = pending.pop() + surface = by_id[surface_id] + if surface.parent_id and surface.parent_id not in selected: + if surface.parent_id not in by_id: + raise ViewsValidationError( + f"View surface {surface.id} has unavailable parent {surface.parent_id}" + ) + selected.add(surface.parent_id) + pending.append(surface.parent_id) + + selected_surfaces = [by_id[surface_id] for surface_id in selected] + if not any(surface.kind == "navigation" for surface in selected_surfaces): + raise ViewsValidationError("A View must keep at least one navigation entry") + if not any(surface.kind == "route" for surface in selected_surfaces): + raise ViewsValidationError("A View must keep at least one route") + + order = {surface.id: index for index, surface in enumerate(surfaces)} + return sorted( + selected, key=lambda surface_id: (order.get(surface_id, 10_000), surface_id) + ) + + +def _revision_hash(surface_ids: list[str]) -> str: + payload = { + "surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION, + "visible_surface_ids": surface_ids, + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def create_definition( + session: Session, + *, + tenant_id: str, + scope_type: str, + definition_key: str | None, + name: str, + description: str | None, + visible_surface_ids: Iterable[str], + catalogue: Iterable[ViewSurface], + actor_id: str | None, +) -> ViewDefinition: + if scope_type not in DEFINITION_SCOPES: + raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}") + row_tenant_id, scope_id, scope_key = _scope_values( + scope_type, + tenant_id=tenant_id, + ) + clean_name = name.strip() + if not clean_name: + raise ViewsValidationError("View name is required") + clean_key = _definition_key(definition_key, clean_name) + duplicate = ( + session.query(ViewDefinition.id) + .filter( + ViewDefinition.scope_key == scope_key, + ViewDefinition.definition_key == clean_key, + ViewDefinition.deleted_at.is_(None), + ) + .first() + ) + if duplicate is not None: + raise ViewsConflictError( + f"A View with key {clean_key!r} already exists in this scope" + ) + normalized_surfaces = normalize_visible_surface_ids( + visible_surface_ids, + catalogue=catalogue, + ) + definition = ViewDefinition( + tenant_id=row_tenant_id, + scope_type=scope_type, + scope_id=scope_id, + scope_key=scope_key, + definition_key=clean_key, + name=clean_name, + description=(description or "").strip() or None, + status="draft", + current_revision=1, + created_by=actor_id, + updated_by=actor_id, + ) + session.add(definition) + session.flush() + revision = ViewRevision( + tenant_id=row_tenant_id, + definition_id=definition.id, + revision=1, + surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION, + visible_surface_ids=normalized_surfaces, + content_hash=_revision_hash(normalized_surfaces), + created_by=actor_id, + ) + session.add(revision) + session.flush() + return definition + + +def update_definition( + session: Session, + definition: ViewDefinition, + *, + name: str | None, + description: str | None, + actor_id: str | None, + fields_set: set[str], +) -> ViewDefinition: + if definition.status == "archived": + raise ViewsConflictError("Archived Views cannot be edited") + if "name" in fields_set: + clean_name = (name or "").strip() + if not clean_name: + raise ViewsValidationError("View name is required") + definition.name = clean_name + if "description" in fields_set: + definition.description = (description or "").strip() or None + definition.updated_by = actor_id + definition.updated_at = _now() + session.flush() + return definition + + +def create_revision( + session: Session, + definition: ViewDefinition, + *, + visible_surface_ids: Iterable[str], + catalogue: Iterable[ViewSurface], + actor_id: str | None, +) -> ViewRevision: + if definition.status == "archived": + raise ViewsConflictError("Archived Views cannot be revised") + normalized_surfaces = normalize_visible_surface_ids( + visible_surface_ids, + catalogue=catalogue, + ) + content_hash = _revision_hash(normalized_surfaces) + latest = get_revision(session, definition_id=definition.id) + if latest.content_hash == content_hash: + return latest + next_number = definition.current_revision + 1 + revision = ViewRevision( + tenant_id=definition.tenant_id, + definition_id=definition.id, + revision=next_number, + surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION, + visible_surface_ids=normalized_surfaces, + content_hash=content_hash, + created_by=actor_id, + ) + session.add(revision) + definition.current_revision = next_number + definition.updated_by = actor_id + definition.updated_at = _now() + session.flush() + return revision + + +def lockout_required_surface_ids( + catalogue: Iterable[ViewSurface], + *, + assignment_scope_type: str, +) -> frozenset[str]: + known = {surface.id for surface in catalogue} + required = {surface.id for surface in catalogue if surface.required} + admin_surface = LOCKOUT_ADMIN_SURFACE_IDS.get(assignment_scope_type) + escape_ids = set(LOCKOUT_BASE_SURFACE_IDS) + if admin_surface: + escape_ids.add(admin_surface) + missing_contract = { + "access.nav.admin", + "access.route.admin", + admin_surface, + } - known + missing_contract.discard(None) + if missing_contract: + raise ViewsValidationError( + "Required View assignments are unavailable because the active module " + "graph does not expose all administration escape surfaces: " + + ", ".join(sorted(missing_contract)) + ) + return frozenset(required | (escape_ids & known)) + + +def validate_required_assignment_revision( + revision: ViewRevision, + *, + assignment_scope_type: str, + catalogue: Iterable[ViewSurface], +) -> None: + required_ids = lockout_required_surface_ids( + catalogue, + assignment_scope_type=assignment_scope_type, + ) + missing = sorted(required_ids - set(revision.visible_surface_ids)) + if missing: + raise ViewsValidationError( + "A required View must retain its selector and administration escape " + f"surfaces: {', '.join(missing)}" + ) + + +def publish_revision( + session: Session, + definition: ViewDefinition, + revision: ViewRevision, + *, + catalogue: Iterable[ViewSurface], + actor_id: str | None, +) -> ViewDefinition: + if definition.status == "archived": + raise ViewsConflictError("Archived Views cannot be published") + tracking_required_assignments = ( + session.query(ViewAssignment) + .filter( + ViewAssignment.definition_id == definition.id, + ViewAssignment.mode == "required", + ViewAssignment.is_active.is_(True), + ViewAssignment.revision_id.is_(None), + ) + .all() + ) + for assignment in tracking_required_assignments: + validate_required_assignment_revision( + revision, + assignment_scope_type=assignment.scope_type, + catalogue=catalogue, + ) + definition.published_revision_id = revision.id + definition.status = "published" + definition.updated_by = actor_id + definition.updated_at = _now() + session.flush() + return definition + + +def archive_definition( + session: Session, + definition: ViewDefinition, + *, + actor_id: str | None, +) -> ViewDefinition: + required_count = ( + session.query(ViewAssignment) + .filter( + ViewAssignment.definition_id == definition.id, + ViewAssignment.mode == "required", + ViewAssignment.is_active.is_(True), + ) + .count() + ) + if required_count: + raise ViewsConflictError( + "Remove or replace required assignments before archiving this View" + ) + session.query(ViewAssignment).filter( + ViewAssignment.definition_id == definition.id + ).update({"is_active": False}, synchronize_session=False) + definition.status = "archived" + definition.updated_by = actor_id + definition.updated_at = _now() + session.flush() + return definition + + +def list_assignments( + session: Session, + *, + tenant_id: str, + scope_type: str, + include_inherited: bool = True, +) -> list[ViewAssignment]: + if scope_type == "system": + query = session.query(ViewAssignment).filter(ViewAssignment.tenant_id.is_(None)) + elif scope_type == "tenant": + query = session.query(ViewAssignment).filter( + ViewAssignment.tenant_id == tenant_id + ) + if include_inherited: + query = session.query(ViewAssignment).filter( + or_( + ViewAssignment.tenant_id == tenant_id, + ViewAssignment.tenant_id.is_(None), + ) + ) + else: + raise ViewsValidationError( + f"Unsupported assignment listing scope: {scope_type}" + ) + return query.order_by( + ViewAssignment.scope_type.asc(), + ViewAssignment.priority.desc(), + ViewAssignment.created_at.asc(), + ).all() + + +def _assignment_revision( + session: Session, + assignment: ViewAssignment, +) -> ViewRevision | None: + revision_id = assignment.revision_id or assignment.definition.published_revision_id + if not revision_id: + return None + revision = session.get(ViewRevision, revision_id) + if revision is None or revision.definition_id != assignment.definition_id: + return None + return revision + + +def _validate_assignment_revision( + session: Session, + definition: ViewDefinition, + revision_id: str | None, + *, + allow_historical_pin: bool = False, +) -> ViewRevision: + if definition.status != "published" or not definition.published_revision_id: + raise ViewsConflictError("Publish the View before assigning it") + selected_revision_id = revision_id or definition.published_revision_id + revision = session.get(ViewRevision, selected_revision_id) + if revision is None or revision.definition_id != definition.id: + raise ViewsValidationError("The selected revision does not belong to this View") + if ( + revision_id + and revision_id != definition.published_revision_id + and not allow_historical_pin + ): + raise ViewsValidationError( + "Only the currently published revision can be pinned to a new assignment" + ) + return revision + + +def _deactivate_competing_assignment( + session: Session, + *, + assignment: ViewAssignment, +) -> None: + if assignment.mode not in {"default", "required"} or not assignment.is_active: + return + ( + session.query(ViewAssignment) + .filter( + ViewAssignment.target_key == assignment.target_key, + ViewAssignment.mode == assignment.mode, + ViewAssignment.is_active.is_(True), + ViewAssignment.id != assignment.id, + ) + .update({"is_active": False}, synchronize_session=False) + ) + + +def create_assignment( + session: Session, + *, + tenant_id: str, + scope_type: str, + scope_id: str | None, + definition: ViewDefinition, + revision_id: str | None, + mode: str, + priority: int, + is_active: bool, + metadata: dict[str, object], + catalogue: Iterable[ViewSurface], + actor_id: str | None, +) -> ViewAssignment: + if mode not in ASSIGNMENT_MODES: + raise ViewsValidationError(f"Unsupported View assignment mode: {mode}") + row_tenant_id, target_id, target_key = _assignment_target( + scope_type, + scope_id, + tenant_id=tenant_id, + ) + if scope_type == "system" and definition.scope_type != "system": + raise ViewsValidationError("System assignments can only use system Views") + if definition.tenant_id not in {None, tenant_id}: + raise ViewsNotFoundError("View definition not found") + revision = _validate_assignment_revision(session, definition, revision_id) + if mode == "required" and is_active: + validate_required_assignment_revision( + revision, + assignment_scope_type=scope_type, + catalogue=catalogue, + ) + duplicate = ( + session.query(ViewAssignment.id) + .filter( + ViewAssignment.target_key == target_key, + ViewAssignment.definition_id == definition.id, + ViewAssignment.mode == mode, + ) + .first() + ) + if duplicate is not None: + raise ViewsConflictError( + "This View already has an assignment with the same target and mode" + ) + assignment = ViewAssignment( + tenant_id=row_tenant_id, + scope_type=scope_type, + scope_id=target_id, + target_key=target_key, + definition_id=definition.id, + revision_id=revision_id, + mode=mode, + priority=priority, + is_active=is_active, + metadata_=metadata, + created_by=actor_id, + updated_by=actor_id, + ) + session.add(assignment) + session.flush() + _deactivate_competing_assignment(session, assignment=assignment) + session.flush() + return assignment + + +def get_assignment( + session: Session, + *, + tenant_id: str, + assignment_id: str, + include_system: bool = True, +) -> ViewAssignment: + query = session.query(ViewAssignment).filter(ViewAssignment.id == assignment_id) + if include_system: + query = query.filter( + or_( + ViewAssignment.tenant_id == tenant_id, + ViewAssignment.tenant_id.is_(None), + ) + ) + else: + query = query.filter(ViewAssignment.tenant_id == tenant_id) + assignment = query.first() + if assignment is None: + raise ViewsNotFoundError("View assignment not found") + return assignment + + +def update_assignment( + session: Session, + assignment: ViewAssignment, + *, + updates: dict[str, object], + catalogue: Iterable[ViewSurface], + actor_id: str | None, +) -> ViewAssignment: + next_mode = str(updates.get("mode", assignment.mode)) + next_revision_id = ( + updates["revision_id"] if "revision_id" in updates else assignment.revision_id + ) + next_active = bool(updates.get("is_active", assignment.is_active)) + if next_mode not in ASSIGNMENT_MODES: + raise ViewsValidationError(f"Unsupported View assignment mode: {next_mode}") + revision = _validate_assignment_revision( + session, + assignment.definition, + next_revision_id if isinstance(next_revision_id, str) else None, + allow_historical_pin=( + isinstance(next_revision_id, str) + and next_revision_id == assignment.revision_id + ), + ) + if next_mode == "required" and next_active: + validate_required_assignment_revision( + revision, + assignment_scope_type=assignment.scope_type, + catalogue=catalogue, + ) + duplicate = ( + session.query(ViewAssignment.id) + .filter( + ViewAssignment.target_key == assignment.target_key, + ViewAssignment.definition_id == assignment.definition_id, + ViewAssignment.mode == next_mode, + ViewAssignment.id != assignment.id, + ) + .first() + ) + if duplicate is not None: + raise ViewsConflictError( + "This View already has an assignment with the same target and mode" + ) + assignment.mode = next_mode + if "revision_id" in updates: + assignment.revision_id = ( + str(next_revision_id) if next_revision_id is not None else None + ) + if "priority" in updates: + assignment.priority = int(updates["priority"]) + if "is_active" in updates: + assignment.is_active = next_active + if "metadata" in updates: + assignment.metadata_ = dict(updates["metadata"] or {}) + assignment.updated_by = actor_id + assignment.updated_at = _now() + session.flush() + _deactivate_competing_assignment(session, assignment=assignment) + session.flush() + return assignment + + +def delete_assignment(session: Session, assignment: ViewAssignment) -> None: + session.delete(assignment) + session.flush() + + +def _assignment_applies( + assignment: ViewAssignment, + *, + tenant_id: str, + account_id: str, + group_ids: frozenset[str], +) -> bool: + if assignment.scope_type == "system": + return True + if assignment.tenant_id != tenant_id: + return False + if assignment.scope_type == "tenant": + return assignment.scope_id == tenant_id + if assignment.scope_type == "group": + return bool(assignment.scope_id and assignment.scope_id in group_ids) + if assignment.scope_type == "user": + return assignment.scope_id == account_id + return False + + +def _assignment_order(assignment: ViewAssignment) -> tuple[int, int, float, str]: + specificity = { + "system": 0, + "tenant": 1, + "group": 2, + "user": 3, + }.get(assignment.scope_type, -1) + updated_at = assignment.updated_at + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=timezone.utc) + return specificity, assignment.priority, updated_at.timestamp(), assignment.id + + +def _preference( + session: Session, + *, + tenant_id: str, + account_id: str, +) -> ViewPreference | None: + return ( + session.query(ViewPreference) + .filter( + ViewPreference.tenant_id == tenant_id, + ViewPreference.account_id == account_id, + ) + .first() + ) + + +def resolve_effective_view( + session: Session, + *, + tenant_id: str, + account_id: str, + group_ids: Iterable[str] = (), + catalogue: Iterable[ViewSurface] | None = None, +) -> EffectiveViewState: + groups = frozenset(str(group_id) for group_id in group_ids) + candidates = ( + session.query(ViewAssignment) + .join(ViewDefinition, ViewAssignment.definition_id == ViewDefinition.id) + .filter( + ViewAssignment.is_active.is_(True), + ViewDefinition.status == "published", + ViewDefinition.deleted_at.is_(None), + or_( + ViewAssignment.tenant_id == tenant_id, + ViewAssignment.tenant_id.is_(None), + ), + ) + .all() + ) + applicable = [ + assignment + for assignment in candidates + if _assignment_applies( + assignment, + tenant_id=tenant_id, + account_id=account_id, + group_ids=groups, + ) + ] + + resolved_assignments: list[tuple[ViewAssignment, ViewRevision]] = [] + for assignment in applicable: + revision = _assignment_revision(session, assignment) + if revision is None: + continue + resolved_assignments.append((assignment, revision)) + + option_assignments: dict[str, tuple[ViewAssignment, ViewRevision]] = {} + for assignment, revision in resolved_assignments: + existing = option_assignments.get(assignment.definition_id) + if existing is None or _assignment_order(assignment) > _assignment_order( + existing[0] + ): + option_assignments[assignment.definition_id] = (assignment, revision) + + ordered_options = sorted( + option_assignments.values(), + key=lambda item: ( + -_assignment_order(item[0])[0], + -_assignment_order(item[0])[1], + item[0].definition.name.lower(), + item[0].definition_id, + ), + ) + available_views = tuple( + EffectiveViewOption( + id=assignment.definition.id, + name=assignment.definition.name, + description=assignment.definition.description, + revision_id=revision.id, + ) + for assignment, revision in ordered_options + ) + + selected: tuple[ViewAssignment, ViewRevision] | None = None + locked = False + required = [pair for pair in resolved_assignments if pair[0].mode == "required"] + preference = _preference( + session, + tenant_id=tenant_id, + account_id=account_id, + ) + provenance: list[dict[str, object]] = [] + if required: + selected = max(required, key=lambda pair: _assignment_order(pair[0])) + locked = True + provenance.append( + { + "source": "required_assignment", + "scope_type": selected[0].scope_type, + "scope_id": selected[0].scope_id, + "detail": f"Required View assignment {selected[0].id}", + } + ) + elif preference and preference.selection_kind == "none": + provenance.append( + { + "source": "user_selection", + "scope_type": "user", + "scope_id": account_id, + "detail": "Full interface selected", + } + ) + elif ( + preference + and preference.selection_kind == "view" + and preference.view_id in option_assignments + ): + selected = option_assignments[preference.view_id] + provenance.append( + { + "source": "user_selection", + "scope_type": "user", + "scope_id": account_id, + "detail": f"Selected View {preference.view_id}", + } + ) + else: + defaults = [pair for pair in resolved_assignments if pair[0].mode == "default"] + if defaults: + selected = max(defaults, key=lambda pair: _assignment_order(pair[0])) + provenance.append( + { + "source": "default_assignment", + "scope_type": selected[0].scope_type, + "scope_id": selected[0].scope_id, + "detail": f"Default View assignment {selected[0].id}", + } + ) + + diagnostics: list[ViewDiagnostic] = [] + if selected is not None and catalogue is not None: + assignment, revision = selected + known_surfaces = {surface.id: surface for surface in catalogue} + stale_ids = sorted(set(revision.visible_surface_ids) - set(known_surfaces)) + if stale_ids: + diagnostics.append( + ViewDiagnostic( + severity="warning", + code="view.stale_surfaces", + message=( + "The active View references surfaces that are no longer " + "announced by the active module graph." + ), + surface_ids=tuple(stale_ids), + ) + ) + known_visible = [ + known_surfaces[surface_id] + for surface_id in revision.visible_surface_ids + if surface_id in known_surfaces + ] + if not any( + surface.kind == "navigation" for surface in known_visible + ) or not any(surface.kind == "route" for surface in known_visible): + diagnostics.append( + ViewDiagnostic( + severity="error", + code="view.no_reachable_surface", + message=( + "The active View no longer contains a reachable navigation " + "entry and route. The full authorized interface is shown." + ), + ) + ) + provenance.append( + { + "source": "invalid_view_fallback", + "scope_type": assignment.scope_type, + "scope_id": assignment.scope_id, + "detail": f"Invalid View revision {revision.id}", + } + ) + selected = None + locked = False + + if selected is None: + effective = EffectiveView( + view_id=None, + revision_id=None, + name=None, + visible_surface_ids=frozenset(), + locked=False, + provenance=tuple(provenance), + ) + else: + assignment, revision = selected + effective = EffectiveView( + view_id=assignment.definition.id, + revision_id=revision.id, + name=assignment.definition.name, + visible_surface_ids=frozenset(revision.visible_surface_ids), + locked=locked, + provenance=tuple(provenance), + ) + return EffectiveViewState( + effective=effective, + available_views=available_views, + diagnostics=tuple(diagnostics), + ) + + +def select_view( + session: Session, + *, + tenant_id: str, + account_id: str, + group_ids: Iterable[str], + view_id: str | None, + catalogue: Iterable[ViewSurface] | None = None, +) -> EffectiveViewState: + current = resolve_effective_view( + session, + tenant_id=tenant_id, + account_id=account_id, + group_ids=group_ids, + catalogue=catalogue, + ) + if current.effective.locked and view_id != current.effective.view_id: + raise ViewsConflictError( + "The effective View is required by an administrator and cannot be changed" + ) + available_ids = {option.id for option in current.available_views} + if view_id is not None and view_id not in available_ids: + raise ViewsValidationError("The selected View is not available to this account") + + preference = _preference( + session, + tenant_id=tenant_id, + account_id=account_id, + ) + if preference is None: + preference = ViewPreference( + tenant_id=tenant_id, + account_id=account_id, + ) + session.add(preference) + preference.selection_kind = "view" if view_id is not None else "none" + preference.view_id = view_id + preference.updated_at = _now() + session.flush() + return resolve_effective_view( + session, + tenant_id=tenant_id, + account_id=account_id, + group_ids=group_ids, + catalogue=catalogue, + ) + + +def definition_payload( + session: Session, + definition: ViewDefinition, + *, + readonly: bool, + catalogue: Iterable[ViewSurface] | None = None, +) -> dict[str, object]: + latest = get_revision(session, definition_id=definition.id) + published = ( + session.get(ViewRevision, definition.published_revision_id) + if definition.published_revision_id + else None + ) + stale_surface_ids = ( + sorted(set(latest.visible_surface_ids) - {surface.id for surface in catalogue}) + if catalogue is not None + else [] + ) + return { + "id": definition.id, + "tenant_id": definition.tenant_id, + "scope_type": definition.scope_type, + "scope_id": definition.scope_id, + "definition_key": definition.definition_key, + "name": definition.name, + "description": definition.description, + "status": definition.status, + "current_revision": definition.current_revision, + "latest_revision": latest, + "published_revision": published, + "readonly": readonly, + "stale_surface_ids": stale_surface_ids, + "created_by": definition.created_by, + "updated_by": definition.updated_by, + "created_at": definition.created_at, + "updated_at": definition.updated_at, + } + + +def assignment_payload(assignment: ViewAssignment) -> dict[str, object]: + return { + "id": assignment.id, + "tenant_id": assignment.tenant_id, + "scope_type": assignment.scope_type, + "scope_id": assignment.scope_id, + "definition_id": assignment.definition_id, + "revision_id": assignment.revision_id, + "mode": assignment.mode, + "priority": assignment.priority, + "is_active": assignment.is_active, + "metadata": assignment.metadata_, + "created_by": assignment.created_by, + "updated_by": assignment.updated_by, + "created_at": assignment.created_at, + "updated_at": assignment.updated_at, + } + + +__all__ = [ + "EffectiveViewOption", + "EffectiveViewState", + "ViewDiagnostic", + "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", + "lockout_required_surface_ids", + "normalize_visible_surface_ids", + "publish_revision", + "resolve_effective_view", + "select_view", + "update_assignment", + "update_definition", + "validate_required_assignment_revision", +] diff --git a/src/govoplan_views/py.typed b/src/govoplan_views/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_views/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..667f539 --- /dev/null +++ b/tests/test_migrations.py @@ -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() diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..b025d79 --- /dev/null +++ b/tests/test_views.py @@ -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() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..c267427 --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} + diff --git a/webui/src/api/views.ts b/webui/src/api/views.ts new file mode 100644 index 0000000..a7e93d7 --- /dev/null +++ b/webui/src/api/views.ts @@ -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; + 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 { + return projection( + await apiFetch( + settings, + "/api/v1/views/effective", + { cache: "no-store" } + ) + ); +} + +export async function selectEffectiveView( + settings: ApiSettings, + viewId: string | null +): Promise { + return projection( + await apiFetch( + settings, + "/api/v1/views/selection", + { + method: "PUT", + ...jsonBody({ view_id: viewId }) + } + ) + ); +} + +export async function fetchViewDefinitions( + settings: ApiSettings, + scopeType: ViewScopeType +): Promise { + 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 { + 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 { + return apiFetch(settings, `/api/v1/views/definitions/${definitionId}`, { + method: "PATCH", + ...jsonBody(payload) + }); +} + +export function createViewRevision( + settings: ApiSettings, + definitionId: string, + visibleSurfaceIds: string[] +): Promise { + 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 { + return apiFetch( + settings, + `/api/v1/views/definitions/${definitionId}/revisions/${revisionId}/publish`, + { method: "POST" } + ); +} + +export function archiveViewDefinition( + settings: ApiSettings, + definitionId: string +): Promise { + return apiFetch(settings, `/api/v1/views/definitions/${definitionId}`, { + method: "DELETE" + }); +} + +export async function fetchViewAssignments( + settings: ApiSettings, + scopeType: ViewScopeType +): Promise { + 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 { + 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 { + return apiFetch(settings, `/api/v1/views/assignments/${assignmentId}`, { + method: "PATCH", + ...jsonBody(payload) + }); +} + +export function deleteViewAssignment( + settings: ApiSettings, + assignmentId: string +): Promise { + return apiFetch(settings, `/api/v1/views/assignments/${assignmentId}`, { + method: "DELETE" + }); +} diff --git a/webui/src/components/ViewSelector.tsx b/webui/src/components/ViewSelector.tsx new file mode 100644 index 0000000..b87fbd7 --- /dev/null +++ b/webui/src/components/ViewSelector.tsx @@ -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 ( + + ); +} diff --git a/webui/src/features/views/ViewsAdminPanel.tsx b/webui/src/features/views/ViewsAdminPanel.tsx new file mode 100644 index 0000000..4f88f71 --- /dev/null +++ b/webui/src/features/views/ViewsAdminPanel.tsx @@ -0,0 +1,1232 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Archive, + Check, + ChevronRight, + Pencil, + Plus, + RefreshCw, + Save, + Send, + Trash2 +} from "lucide-react"; +import { + AdminPageLayout, + Button, + ConfirmDialog, + Dialog, + DismissibleAlert, + FormField, + IconButton, + StatusBadge, + ToggleSwitch, + adminErrorMessage, + dispatchPlatformViewChanged, + useUnsavedChanges, + useUnsavedDraftGuard, + useViewSurfaces, + type ApiSettings, + type PlatformViewSurface +} from "@govoplan/core-webui"; +import { + archiveViewDefinition, + createViewAssignment, + createViewDefinition, + createViewRevision, + deleteViewAssignment, + fetchViewAssignments, + fetchViewDefinitions, + publishViewRevision, + updateViewAssignment, + updateViewDefinition, + type ViewAssignment, + type ViewAssignmentMode, + type ViewAssignmentScopeType, + type ViewDefinition, + type ViewScopeType +} from "../../api/views"; + + +type DefinitionDraft = { + name: string; + description: string; + surfaceIds: string[]; +}; + +type AssignmentDraft = { + scopeType: ViewAssignmentScopeType; + scopeId: string; + definitionId: string; + mode: ViewAssignmentMode; + priority: number; + active: boolean; + pinRevision: boolean; +}; + +const LOCKOUT_SURFACES = new Set([ + "access.module", + "access.nav.admin", + "access.route.admin", + "views.module", + "views.selector", + "views.admin.system", + "views.admin.tenant" +]); + + +export default function ViewsAdminPanel({ + settings, + scopeType, + canWriteDefinitions, + canWriteAssignments +}: { + settings: ApiSettings; + scopeType: ViewScopeType; + canWriteDefinitions: boolean; + canWriteAssignments: boolean; +}) { + const surfaces = useViewSurfaces(); + const { requestDiscard } = useUnsavedChanges(); + const [definitions, setDefinitions] = useState([]); + const [assignments, setAssignments] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [draft, setDraft] = useState({ + name: "", + description: "", + surfaceIds: [] + }); + const [savedDraftKey, setSavedDraftKey] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [createDraft, setCreateDraft] = useState({ + name: "", + description: "" + }); + const [assignmentEditor, setAssignmentEditor] = useState< + ViewAssignment | "new" | null + >(null); + const [assignmentDraft, setAssignmentDraft] = useState( + emptyAssignmentDraft(scopeType) + ); + const [archiveTarget, setArchiveTarget] = useState(null); + const [deleteAssignmentTarget, setDeleteAssignmentTarget] = + useState(null); + + const selected = definitions.find((item) => item.id === selectedId) ?? null; + const dirty = Boolean( + selected && + !selected.readonly && + definitionDraftKey(draft) !== savedDraftKey + ); + + useUnsavedDraftGuard({ + dirty, + onSave: saveDraft, + onDiscard: resetDraft + }); + + async function load(preferredId?: string) { + setLoading(true); + setError(""); + try { + const [nextDefinitions, nextAssignments] = await Promise.all([ + fetchViewDefinitions(settings, scopeType), + fetchViewAssignments(settings, scopeType) + ]); + setDefinitions(nextDefinitions); + setAssignments(nextAssignments); + const nextSelectedId = + preferredId && nextDefinitions.some((item) => item.id === preferredId) + ? preferredId + : nextDefinitions.some((item) => item.id === selectedId) + ? selectedId + : nextDefinitions[0]?.id ?? ""; + setSelectedId(nextSelectedId); + const nextSelected = + nextDefinitions.find((item) => item.id === nextSelectedId) ?? null; + applyDefinitionDraft(nextSelected); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + setSelectedId(""); + setAssignmentDraft(emptyAssignmentDraft(scopeType)); + void load(); + }, [ + scopeType, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey + ]); + + function applyDefinitionDraft(definition: ViewDefinition | null) { + const next = definition + ? { + name: definition.name, + description: definition.description ?? "", + surfaceIds: definition.latest_revision.visible_surface_ids + } + : { name: "", description: "", surfaceIds: [] }; + setDraft(next); + setSavedDraftKey(definitionDraftKey(next)); + } + + function resetDraft() { + applyDefinitionDraft(selected); + } + + function selectDefinition(definitionId: string) { + if (definitionId === selectedId) return; + requestDiscard(() => { + const definition = + definitions.find((item) => item.id === definitionId) ?? null; + setSelectedId(definitionId); + applyDefinitionDraft(definition); + setError(""); + setSuccess(""); + }); + } + + async function persistDraft(): Promise { + if (!selected || selected.readonly || selected.status === "archived") { + return selected; + } + setBusy(true); + setError(""); + try { + let next = selected; + if ( + draft.name.trim() !== selected.name || + draft.description.trim() !== (selected.description ?? "") + ) { + next = await updateViewDefinition(settings, selected.id, { + name: draft.name.trim(), + description: draft.description.trim() || null + }); + } + if ( + surfaceSetKey(draft.surfaceIds) !== + surfaceSetKey(next.latest_revision.visible_surface_ids) + ) { + next = await createViewRevision( + settings, + selected.id, + draft.surfaceIds + ); + } + await load(selected.id); + return next; + } catch (caught) { + setError(adminErrorMessage(caught)); + return null; + } finally { + setBusy(false); + } + } + + async function saveDraft(): Promise { + const next = await persistDraft(); + if (!next) return false; + setSuccess("View draft saved."); + return true; + } + + async function publishDraft() { + const persisted = await persistDraft(); + if (!persisted) return; + setBusy(true); + setError(""); + try { + await publishViewRevision( + settings, + persisted.id, + persisted.latest_revision.id + ); + setSuccess(`Revision ${persisted.latest_revision.revision} published.`); + await load(persisted.id); + dispatchPlatformViewChanged(); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setBusy(false); + } + } + + async function createDefinition() { + setBusy(true); + setError(""); + try { + const visibleSurfaceIds = surfaces + .filter((surface) => surface.defaultVisible !== false) + .map((surface) => surface.id); + const created = await createViewDefinition(settings, { + scope_type: scopeType, + name: createDraft.name.trim(), + description: createDraft.description.trim() || null, + visible_surface_ids: visibleSurfaceIds + }); + setCreateOpen(false); + setCreateDraft({ name: "", description: "" }); + setSuccess("View draft created with all currently available surfaces."); + await load(created.id); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setBusy(false); + } + } + + async function archiveDefinition() { + if (!archiveTarget) return; + setBusy(true); + setError(""); + try { + await archiveViewDefinition(settings, archiveTarget.id); + setArchiveTarget(null); + setSuccess("View archived."); + await load(); + dispatchPlatformViewChanged(); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setBusy(false); + } + } + + function openCreateAssignment() { + const firstPublished = definitions.find( + (definition) => definition.status === "published" + ); + setAssignmentDraft({ + ...emptyAssignmentDraft(scopeType), + definitionId: firstPublished?.id ?? "" + }); + setAssignmentEditor("new"); + setError(""); + } + + function openEditAssignment(assignment: ViewAssignment) { + setAssignmentDraft({ + scopeType: assignment.scope_type, + scopeId: assignment.scope_id ?? "", + definitionId: assignment.definition_id, + mode: assignment.mode, + priority: assignment.priority, + active: assignment.is_active, + pinRevision: Boolean(assignment.revision_id) + }); + setAssignmentEditor(assignment); + setError(""); + } + + async function saveAssignment() { + const definition = definitions.find( + (item) => item.id === assignmentDraft.definitionId + ); + if (!definition) return; + setBusy(true); + setError(""); + try { + const revisionId = assignmentDraft.pinRevision + ? ( + assignmentEditor !== "new" && + assignmentEditor?.revision_id + ? assignmentEditor.revision_id + : definition.published_revision?.id ?? null + ) + : null; + if (assignmentEditor === "new") { + await createViewAssignment(settings, { + scope_type: assignmentDraft.scopeType, + scope_id: assignmentDraft.scopeId.trim() || null, + definition_id: definition.id, + revision_id: revisionId, + mode: assignmentDraft.mode, + priority: assignmentDraft.priority, + is_active: assignmentDraft.active + }); + } else if (assignmentEditor) { + await updateViewAssignment(settings, assignmentEditor.id, { + revision_id: revisionId, + mode: assignmentDraft.mode, + priority: assignmentDraft.priority, + is_active: assignmentDraft.active + }); + } + setAssignmentEditor(null); + setSuccess( + assignmentEditor === "new" + ? "View assignment created." + : "View assignment updated." + ); + await load(selectedId); + dispatchPlatformViewChanged(); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setBusy(false); + } + } + + async function toggleAssignment( + assignment: ViewAssignment, + active: boolean + ) { + setBusy(true); + setError(""); + try { + await updateViewAssignment(settings, assignment.id, { + is_active: active + }); + await load(selectedId); + dispatchPlatformViewChanged(); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setBusy(false); + } + } + + async function removeAssignment() { + if (!deleteAssignmentTarget) return; + setBusy(true); + setError(""); + try { + await deleteViewAssignment(settings, deleteAssignmentTarget.id); + setDeleteAssignmentTarget(null); + setSuccess("View assignment removed."); + await load(selectedId); + dispatchPlatformViewChanged(); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setBusy(false); + } + } + + const title = scopeType === "system" ? "System Views" : "Tenant Views"; + const description = + scopeType === "system" + ? "Publish reusable interface projections and assign instance-wide defaults or requirements." + : "Tailor the visible interface for this tenant, its groups, and individual users."; + const canCreate = + canWriteDefinitions && + surfaces.some((surface) => surface.kind === "navigation") && + surfaces.some((surface) => surface.kind === "route"); + + return ( + <> + + } + onClick={() => requestDiscard(() => void load(selectedId))} + disabled={loading || busy} + /> + + + } + > +
+ + +
+ {selected ? ( + <> +
+
+
+

{selected.name}

+ {selected.readonly && ( + + )} +
+

+ Draft revision {selected.latest_revision.revision} + {selected.published_revision + ? ` · published revision ${selected.published_revision.revision}` + : " · not published"} +

+
+ {!selected.readonly && selected.status !== "archived" && ( +
+ + + + } + variant="danger" + onClick={() => setArchiveTarget(selected)} + disabled={busy} + /> +
+ )} +
+ +
+ + + setDraft({ ...draft, name: event.target.value }) + } + /> + + +