diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..76ddc43 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# GovOPlaN Forms Codex Guide + +## Scope + +This repository owns immutable reusable form definitions and their designer. +Runtime values, drafts, receipts, attachments, signatures, and handoffs belong +to `govoplan-forms-runtime` or the corresponding domain owner. + +## Working Rules + +- Resolve cross-module behavior through Core contracts and capabilities; do not + import another optional module's tables or WebUI pages. +- Keep definition revisions immutable and tenant-bound. Schema changes create a + new revision and use optimistic concurrency. +- Keep submitted values out of Forms events, logs, and definition storage. +- Every behavior or UI change must update the relevant user/admin documentation + and architecture declaration in the same change. + +## Verification + +```bash +PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ + /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests +``` diff --git a/README.md b/README.md index 179ad40..9f48ac1 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,31 @@ **Repository type:** module (domain). -`govoplan-forms` owns reusable form definitions, validation rules, and form -package fragments. Submission runtime behavior is a separate responsibility -that should live in `govoplan-forms-runtime` when implemented. +`govoplan-forms` owns reusable, immutable form definitions, validation rules, +and form package fragments. Submission runtime behavior remains in +`govoplan-forms-runtime`. -This repository is currently a tag-only scaffold. It should gain package -metadata and module manifests only after the first backend or WebUI slice is -designed. +The module persists exact tenant-bound revisions, exposes bounded catalogue, +history, and write APIs, and provides `forms.definitions` for consumers. A +published revision can be used by Forms Runtime without importing Forms tables; +existing submissions keep their exact revision when a new schema is published. + +The `/forms` designer uses shared application controls to create, revise, +publish, retire, search, and inspect definitions. It edits field order, types, +choices, validation constraints, draft behavior, attachment and signature +requirements, policy references, and permitted handoff kinds. Saving always +creates an exact immutable revision; it never mutates a published schema in +place. + +Definition writes use optimistic concurrency. Replaying the same object and +revision is safe only when the payload is identical. Published definitions may +be retired but not silently returned to draft. + +Focused verification: + +```bash +PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ + /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests +``` See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision. diff --git a/docs/FORMS_BOUNDARY.md b/docs/FORMS_BOUNDARY.md index d308d72..554ec04 100644 --- a/docs/FORMS_BOUNDARY.md +++ b/docs/FORMS_BOUNDARY.md @@ -18,7 +18,7 @@ Forms owns: - schema contracts that let portal, workflow, cases, and reporting understand a form without importing form internals -`govoplan-forms-runtime` owns, when implemented: +`govoplan-forms-runtime` owns: - public/internal submission sessions, drafts, receipts, submitted values, validation evidence, attachment references, and submission status @@ -62,19 +62,25 @@ Form definitions should carry: - localization keys and fallback text - data classification for privacy/retention decisions -## Candidate Capabilities +## Capabilities -- `forms.catalog` -- `forms.schema` -- `forms.validation` -- `forms.packageFragments` -- `forms.runtime` when runtime is installed +- `forms.definitions` resolves an exact, tenant-bound immutable definition. +- The API provides bounded catalogue and history reads plus OCC-guarded writes. +- The designer provides explicit revision, publication, field-order, type, + option, constraint, draft, attachment, signature, policy, and handoff editing. +- Forms Runtime performs value validation against the resolved definition; the + definition owner does not persist submissions. -## First Implementation Slice +## Recovery And Operations -1. Define manifest metadata, permissions, and capability names. -2. Add form definition/version DTOs. -3. Add validation metadata for one reusable form schema. -4. Add package fragment import/export format. -5. Add tests that portal/workflow/reporting can detect form capabilities - without importing form internals. +Definition revisions are append-only. Recovery restores the database and then +verifies that every runtime submission's `form_id` and `form_revision` resolves +to the same payload. Destructive module retirement requires a verified database +snapshot and is blocked while definition rows remain. The transactional event +boundary emits only schema identity, revision, publication state, and field +count; field content remains in the owning database. + +The schema vocabulary covers scalar, choice, structured, attachment, signature, +policy-reference, and handoff constraints. Conditional page layout, +localization authoring, and package-fragment tooling remain product depth that +can deepen this owner without changing the runtime boundary. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..88f76e8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-forms" +version = "0.1.14" +description = "Immutable reusable form definitions for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = ["govoplan-core>=0.1.14", "govoplan-access>=0.1.8"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_forms = ["py.typed"] + +[project.entry-points."govoplan.modules"] +"forms" = "govoplan_forms.backend.manifest:get_manifest" diff --git a/src/govoplan_forms/__init__.py b/src/govoplan_forms/__init__.py new file mode 100644 index 0000000..3a9c821 --- /dev/null +++ b/src/govoplan_forms/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Forms module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_forms/backend/__init__.py b/src/govoplan_forms/backend/__init__.py new file mode 100644 index 0000000..f040f65 --- /dev/null +++ b/src/govoplan_forms/backend/__init__.py @@ -0,0 +1 @@ +"""Forms backend package.""" diff --git a/src/govoplan_forms/backend/db/__init__.py b/src/govoplan_forms/backend/db/__init__.py new file mode 100644 index 0000000..97ea446 --- /dev/null +++ b/src/govoplan_forms/backend/db/__init__.py @@ -0,0 +1 @@ +"""Forms database models.""" diff --git a/src/govoplan_forms/backend/db/models.py b/src/govoplan_forms/backend/db/models.py new file mode 100644 index 0000000..988c75d --- /dev/null +++ b/src/govoplan_forms/backend/db/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import DateTime, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class FormDefinitionRevision(Base, TimestampMixin): + __tablename__ = "form_definition_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "form_id", + "revision", + name="uq_form_definition_revision", + ), + Index( + "ix_form_definition_current", + "tenant_id", + "form_id", + "superseded_at", + ), + Index( + "ix_form_definition_catalog", + "tenant_id", + "publication_state", + "form_key", + ), + ) + + 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) + form_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + form_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + previous_revision_id: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True + ) + publication_state: Mapped[str] = mapped_column( + String(30), nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + recorded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + superseded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + search_text: Mapped[str] = mapped_column(Text, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + changed_by: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + + +__all__ = ["FormDefinitionRevision"] diff --git a/src/govoplan_forms/backend/manifest.py b/src/govoplan_forms/backend/manifest.py new file mode 100644 index 0000000..da9d288 --- /dev/null +++ b/src/govoplan_forms/backend/manifest.py @@ -0,0 +1,214 @@ +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.institutional import CAPABILITY_FORM_DEFINITIONS +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + CapabilityDocumentation, + DocumentationLink, + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_forms.backend.db import models as form_models +from govoplan_forms.backend.service import SqlFormDefinitionProvider + + +MODULE_ID = "forms" +MODULE_NAME = "Forms" +MODULE_VERSION = "0.1.14" +READ_SCOPE = "forms:definition:read" +WRITE_SCOPE = "forms:definition:write" +ADMIN_SCOPE = "forms:definition:admin" + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category=MODULE_NAME, + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +def _router(_context: ModuleContext): + from govoplan_forms.backend.router import router + + return router + + +def _definitions(_context: ModuleContext) -> SqlFormDefinitionProvider: + return SqlFormDefinitionProvider() + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + dependencies=("access",), + optional_dependencies=("forms_runtime", "portal", "workflow_engine", "cases", "policy"), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + provides_interfaces=( + ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"), + ), + permissions=( + _permission(READ_SCOPE, "View form definitions", "Read reusable form definitions and exact revisions."), + _permission(WRITE_SCOPE, "Manage form definitions", "Create and revise reusable form definitions."), + _permission(ADMIN_SCOPE, "Publish form definitions", "Publish and retire form-definition revisions."), + ), + role_templates=( + RoleTemplate( + slug="forms_designer", + name="Forms designer", + description="Design and publish reusable form definitions.", + permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE), + ), + RoleTemplate( + slug="forms_reader", + name="Forms reader", + description="Inspect reusable form definitions.", + permissions=(READ_SCOPE,), + ), + ), + route_factory=_router, + nav_items=( + NavItem( + path="/forms", + label="Form definitions", + icon="list-tree", + required_any=(READ_SCOPE,), + order=36, + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/forms-webui", + routes=( + FrontendRoute( + path="/forms", + component="FormsPage", + required_any=(READ_SCOPE,), + order=36, + ), + ), + nav_items=( + NavItem( + path="/forms", + label="Form definitions", + icon="list-tree", + required_any=(READ_SCOPE,), + order=36, + ), + ), + view_surfaces=( + ViewSurface( + id="forms.navigation", + module_id=MODULE_ID, + kind="navigation", + label="Form definitions navigation", + order=10, + ), + ViewSurface( + id="forms.catalogue", + module_id=MODULE_ID, + kind="route", + label="Form definition catalogue", + order=20, + ), + ), + ), + capability_factories={CAPABILITY_FORM_DEFINITIONS: _definitions}, + capability_documentation={ + CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation( + label="Immutable form definitions", + summary="Resolves exact tenant-bound form schemas without exposing Forms tables.", + contract_version="0.1.0", + ) + }, + 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( + form_models.FormDefinitionRevision, + label=MODULE_NAME, + ), + retirement_notes="Destructive retirement removes immutable form-definition history and requires a database snapshot.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + form_models.FormDefinitionRevision, + label=MODULE_NAME, + ), + ), + documentation=( + DocumentationTopic( + id="forms.definitions", + title="Reusable form definitions", + summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.", + body=( + "Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. " + "Publishing is explicit; existing submissions continue to retain their exact revision." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "product_owner"), + links=( + DocumentationLink( + label="Forms boundary and recovery", + href="govoplan-forms/docs/FORMS_BOUNDARY.md", + kind="repository", + ), + ), + ), + ), + architecture=declared_module_architecture( + layer="human_work_procedure", + kind="domain", + maturity="vertical_slice", + documentation_ref="docs/FORMS_BOUNDARY.md", + test_ref="tests/test_forms.py", + known_limits=( + "Conditional multi-page layout, localization authoring, and package-fragment tooling remain product depth.", + ), + supported_authority_modes=("native_authoritative",), + owned_concepts=("form definition", "form schema", "form definition revision"), + non_owned_concepts=("form submission", "file content", "case", "workflow instance"), + reference_packages=("product.service-to-decision",), + migration_docs=("docs/FORMS_BOUNDARY.md",), + recovery_docs=("docs/FORMS_BOUNDARY.md",), + security_docs=("docs/FORMS_BOUNDARY.md",), + operations_docs=("docs/FORMS_BOUNDARY.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_forms/backend/migrations/__init__.py b/src/govoplan_forms/backend/migrations/__init__.py new file mode 100644 index 0000000..751bb6e --- /dev/null +++ b/src/govoplan_forms/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Forms Alembic revisions.""" diff --git a/src/govoplan_forms/backend/migrations/versions/__init__.py b/src/govoplan_forms/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..80039e7 --- /dev/null +++ b/src/govoplan_forms/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Forms migration versions.""" diff --git a/src/govoplan_forms/backend/migrations/versions/e1f2a3b4c5d6_v0114_forms_definitions.py b/src/govoplan_forms/backend/migrations/versions/e1f2a3b4c5d6_v0114_forms_definitions.py new file mode 100644 index 0000000..6aa411d --- /dev/null +++ b/src/govoplan_forms/backend/migrations/versions/e1f2a3b4c5d6_v0114_forms_definitions.py @@ -0,0 +1,77 @@ +"""v0.1.14 immutable Forms definitions. + +Revision ID: e1f2a3b4c5d6 +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "e1f2a3b4c5d6" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "form_definition_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("form_id", sa.String(length=255), nullable=False), + sa.Column("form_key", sa.String(length=255), nullable=False), + sa.Column("revision", sa.String(length=255), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("publication_state", sa.String(length=30), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("search_text", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("changed_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.PrimaryKeyConstraint("id", name=op.f("pk_form_definition_revisions")), + sa.UniqueConstraint( + "tenant_id", + "form_id", + "revision", + name="uq_form_definition_revision", + ), + ) + for column in ( + "tenant_id", + "form_id", + "form_key", + "revision", + "previous_revision_id", + "publication_state", + "recorded_at", + "superseded_at", + "changed_by", + ): + op.create_index( + op.f(f"ix_form_definition_revisions_{column}"), + "form_definition_revisions", + [column], + unique=False, + ) + op.create_index( + "ix_form_definition_current", + "form_definition_revisions", + ["tenant_id", "form_id", "superseded_at"], + unique=False, + ) + op.create_index( + "ix_form_definition_catalog", + "form_definition_revisions", + ["tenant_id", "publication_state", "form_key"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("form_definition_revisions") diff --git a/src/govoplan_forms/backend/router.py b/src/govoplan_forms/backend/router.py new file mode 100644 index 0000000..331e2f2 --- /dev/null +++ b/src/govoplan_forms/backend/router.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.institutional import InstitutionalContextError +from govoplan_core.db.session import get_session +from govoplan_forms.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE +from govoplan_forms.backend.schemas import ( + FormDefinitionHistoryResponse, + FormDefinitionListResponse, + FormDefinitionWriteRequest, +) +from govoplan_forms.backend.service import ( + FormDefinitionStoreError, + definition_from_mapping, + form_definition_history, + get_form_definition, + list_form_definitions, + record_form_definition, +) + + +router = APIRouter(prefix="/forms", tags=["forms"]) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + code = 409 if any( + word in message.casefold() for word in ("conflict", "already", "stale") + ) else 400 + return HTTPException(status_code=code, detail=message) + + +@router.get("/definitions", response_model=FormDefinitionListResponse) +def api_list_form_definitions( + q: str = Query(default="", max_length=200), + publication_state: list[str] | None = Query(default=None), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> FormDefinitionListResponse: + _require(principal, READ_SCOPE) + try: + items, total = list_form_definitions( + session, + principal, + query=q, + publication_states=publication_state, + offset=offset, + limit=limit, + ) + except FormDefinitionStoreError as exc: + raise _error(exc) from exc + return FormDefinitionListResponse( + definitions=[item.to_dict() for item in items], + total=total, + ) + + +@router.put( + "/definitions/{form_id}", + response_model=dict[str, object], + status_code=status.HTTP_200_OK, +) +def api_record_form_definition( + form_id: str, + payload: FormDefinitionWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, object]: + _require(principal, WRITE_SCOPE) + try: + definition = definition_from_mapping(payload.definition) + if definition.reference.object_id != form_id: + raise FormDefinitionStoreError( + "Form definition path and payload IDs must match." + ) + if definition.publication_state != "draft": + _require(principal, ADMIN_SCOPE) + stored = record_form_definition( + session, + principal, + definition=definition, + expected_revision=payload.expected_revision, + ) + session.commit() + except (FormDefinitionStoreError, InstitutionalContextError) as exc: + session.rollback() + raise _error(exc) from exc + return stored.to_dict() + + +@router.get("/definitions/{form_id}", response_model=dict[str, object]) +def api_get_form_definition( + form_id: str, + revision: str | None = Query(default=None, max_length=255), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, object]: + _require(principal, READ_SCOPE) + item = get_form_definition( + session, + principal, + form_id=form_id, + revision=revision, + ) + if item is None: + raise HTTPException(status_code=404, detail="Form definition not found") + return item.to_dict() + + +@router.get( + "/definitions/{form_id}/history", + response_model=FormDefinitionHistoryResponse, +) +def api_form_definition_history( + form_id: str, + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> FormDefinitionHistoryResponse: + _require(principal, READ_SCOPE) + return FormDefinitionHistoryResponse( + revisions=[ + item.to_dict() + for item in form_definition_history( + session, + principal, + form_id=form_id, + limit=limit, + ) + ] + ) + + +__all__ = ["router"] diff --git a/src/govoplan_forms/backend/schemas.py b/src/govoplan_forms/backend/schemas.py new file mode 100644 index 0000000..6d07e06 --- /dev/null +++ b/src/govoplan_forms/backend/schemas.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class FormDefinitionWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + definition: dict[str, Any] + expected_revision: str | None = Field(default=None, min_length=1, max_length=255) + + +class FormDefinitionListResponse(BaseModel): + definitions: list[dict[str, Any]] + total: int + + +class FormDefinitionHistoryResponse(BaseModel): + revisions: list[dict[str, Any]] + + +__all__ = [ + "FormDefinitionHistoryResponse", + "FormDefinitionListResponse", + "FormDefinitionWriteRequest", +] diff --git a/src/govoplan_forms/backend/service.py b/src/govoplan_forms/backend/service.py new file mode 100644 index 0000000..86666fa --- /dev/null +++ b/src/govoplan_forms/backend/service.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping, Sequence +import uuid + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.core.institutional import ( + FormDefinition, + InstitutionalContextError, + InstitutionalReference, +) +from govoplan_forms.backend.db.models import FormDefinitionRevision + + +_PUBLICATION_TRANSITIONS: dict[str, frozenset[str]] = { + "draft": frozenset({"draft", "published", "retired"}), + "published": frozenset({"published", "retired"}), + "retired": frozenset(), +} + + +class FormDefinitionStoreError(ValueError): + pass + + +def definition_from_mapping(value: Mapping[str, object]) -> FormDefinition: + try: + return FormDefinition.from_mapping(value) + except InstitutionalContextError as exc: + raise FormDefinitionStoreError(str(exc)) from exc + + +def record_form_definition( + session: Session, + principal: object, + *, + definition: FormDefinition, + expected_revision: str | None = None, +) -> FormDefinition: + tenant_id = _principal_tenant(principal) + _validate_definition(definition, tenant_id=tenant_id) + payload = definition.to_dict() + replay = ( + session.query(FormDefinitionRevision) + .filter( + FormDefinitionRevision.tenant_id == tenant_id, + FormDefinitionRevision.form_id == definition.reference.object_id, + FormDefinitionRevision.revision == definition.temporal.revision, + ) + .one_or_none() + ) + if replay is not None: + if replay.payload != payload: + raise FormDefinitionStoreError( + "A different Form definition already uses this revision." + ) + return _definition_from_row(replay) + + current = _current_row( + session, + tenant_id=tenant_id, + form_id=definition.reference.object_id, + lock=True, + ) + if current is None: + if expected_revision is not None: + raise FormDefinitionStoreError( + "Form definition revision conflict: no current revision exists." + ) + key_collision = ( + session.query(FormDefinitionRevision.id) + .filter( + FormDefinitionRevision.tenant_id == tenant_id, + FormDefinitionRevision.form_key == definition.key, + FormDefinitionRevision.superseded_at.is_(None), + ) + .first() + ) + if key_collision is not None: + raise FormDefinitionStoreError( + "Form definition key is already in use in this tenant." + ) + else: + if expected_revision != current.revision: + raise FormDefinitionStoreError( + "Form definition revision conflict: the expected revision is stale." + ) + if definition.key != current.form_key: + raise FormDefinitionStoreError( + "A Form definition key cannot change across revisions." + ) + if definition.publication_state not in _PUBLICATION_TRANSITIONS[ + current.publication_state + ]: + raise FormDefinitionStoreError( + f"Form publication transition {current.publication_state!r} to " + f"{definition.publication_state!r} is not allowed." + ) + current.superseded_at = _recorded_at(definition) + + row = FormDefinitionRevision( + tenant_id=tenant_id, + form_id=definition.reference.object_id, + form_key=definition.key, + revision=definition.temporal.revision, + previous_revision_id=current.id if current is not None else None, + publication_state=definition.publication_state, + title=definition.title, + recorded_at=_recorded_at(definition), + search_text=f"{definition.key} {definition.title} {definition.description or ''}".casefold(), + payload=payload, + changed_by=_principal_actor(principal), + ) + session.add(row) + session.flush() + event_id = str(uuid.uuid4()) + emit_platform_event( + session, + PlatformEvent( + event_id=event_id, + type="forms.definition.recorded", + module_id="forms", + payload={ + "form_id": row.form_id, + "form_key": row.form_key, + "revision": row.revision, + "publication_state": row.publication_state, + "field_count": len(definition.fields), + }, + occurred_at=row.recorded_at, + actor=EventActorRef(type="account", id=_principal_actor(principal)), + tenant=EventTenantRef(id=tenant_id), + resource=EventObjectRef( + type="form_definition", + id=row.form_id, + label=row.title, + ), + classification="internal", + ), + ) + return _definition_from_row(row) + + +def get_form_definition( + session: Session, + principal: object, + *, + form_id: str, + revision: str | None = None, +) -> FormDefinition | None: + tenant_id = _principal_tenant(principal) + query = session.query(FormDefinitionRevision).filter( + FormDefinitionRevision.tenant_id == tenant_id, + FormDefinitionRevision.form_id == form_id, + ) + if revision is None: + query = query.filter(FormDefinitionRevision.superseded_at.is_(None)) + else: + query = query.filter(FormDefinitionRevision.revision == revision) + row = query.order_by(FormDefinitionRevision.recorded_at.desc()).first() + return _definition_from_row(row) if row is not None else None + + +def list_form_definitions( + session: Session, + principal: object, + *, + query: str = "", + publication_states: Sequence[str] | None = None, + offset: int = 0, + limit: int = 100, +) -> tuple[tuple[FormDefinition, ...], int]: + tenant_id = _principal_tenant(principal) + if offset < 0 or not 1 <= limit <= 200: + raise FormDefinitionStoreError( + "Form definition offset must be non-negative and limit between 1 and 200." + ) + statement = session.query(FormDefinitionRevision).filter( + FormDefinitionRevision.tenant_id == tenant_id, + FormDefinitionRevision.superseded_at.is_(None), + ) + if publication_states: + statement = statement.filter( + FormDefinitionRevision.publication_state.in_(tuple(publication_states)) + ) + clean_query = query.strip().casefold() + if clean_query: + statement = statement.filter( + FormDefinitionRevision.search_text.contains(clean_query) + ) + total = int(statement.with_entities(func.count()).scalar() or 0) + rows = ( + statement.order_by( + FormDefinitionRevision.form_key.asc(), + FormDefinitionRevision.recorded_at.desc(), + ) + .offset(offset) + .limit(limit) + .all() + ) + return tuple(_definition_from_row(row) for row in rows), total + + +def form_definition_history( + session: Session, + principal: object, + *, + form_id: str, + limit: int = 100, +) -> tuple[FormDefinition, ...]: + tenant_id = _principal_tenant(principal) + if not 1 <= limit <= 200: + raise FormDefinitionStoreError("Form history limit must be between 1 and 200.") + rows = ( + session.query(FormDefinitionRevision) + .filter( + FormDefinitionRevision.tenant_id == tenant_id, + FormDefinitionRevision.form_id == form_id, + ) + .order_by(FormDefinitionRevision.recorded_at.desc()) + .limit(limit) + .all() + ) + return tuple(_definition_from_row(row) for row in rows) + + +class SqlFormDefinitionProvider: + def get_form_definition( + self, + session: object, + principal: object, + *, + reference: InstitutionalReference, + effective_at: datetime | None = None, + ) -> FormDefinition | None: + tenant_id = _principal_tenant(principal) + if ( + reference.kind != "form" + or reference.owner_module != "forms" + or reference.tenant_id != tenant_id + or not reference.version + ): + raise InstitutionalContextError( + "Form definition lookup requires an exact same-tenant Forms reference." + ) + definition = get_form_definition( + _session(session), + principal, + form_id=reference.object_id, + revision=reference.version, + ) + if definition is None or ( + effective_at is not None + and not definition.temporal.effective_at(effective_at) + ): + return None + return definition + + def list_form_definitions( + self, + session: object, + principal: object, + *, + tenant_id: str, + query: str = "", + limit: int = 100, + ) -> Sequence[FormDefinition]: + if tenant_id != _principal_tenant(principal): + raise InstitutionalContextError( + "Form definition catalogue lookup cannot cross tenants." + ) + items, _ = list_form_definitions( + _session(session), + principal, + query=query, + publication_states=("published",), + limit=limit, + ) + return items + + +def _current_row( + session: Session, + *, + tenant_id: str, + form_id: str, + lock: bool, +) -> FormDefinitionRevision | None: + query = session.query(FormDefinitionRevision).filter( + FormDefinitionRevision.tenant_id == tenant_id, + FormDefinitionRevision.form_id == form_id, + FormDefinitionRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _definition_from_row(row: FormDefinitionRevision) -> FormDefinition: + payload: dict[str, Any] = dict(row.payload) + temporal = dict(payload.get("temporal") or {}) + temporal["superseded_at"] = _datetime_text(row.superseded_at) + payload["temporal"] = temporal + return FormDefinition.from_mapping(payload) + + +def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None: + if definition.reference.owner_module != "forms": + raise FormDefinitionStoreError("Form definitions must be owned by Forms.") + if definition.reference.tenant_id != tenant_id: + raise FormDefinitionStoreError("Form definitions cannot cross tenants.") + if definition.temporal.superseded_at is not None: + raise FormDefinitionStoreError("Clients cannot set Form superseded_at.") + _recorded_at(definition) + if not str(definition.temporal.change_reason or "").strip(): + raise FormDefinitionStoreError( + "A Form definition revision requires a change reason." + ) + + +def _recorded_at(definition: FormDefinition) -> datetime: + if definition.temporal.recorded_at is None: + raise FormDefinitionStoreError( + "A Form definition revision requires recorded_at." + ) + return definition.temporal.recorded_at + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise InstitutionalContextError( + "Form definition operations require a tenant-bound principal." + ) + return tenant_id + + +def _principal_actor(principal: object) -> str | None: + for name in ("account_id", "identity_id", "membership_id"): + value = str(getattr(principal, name, "") or "").strip() + if value: + return value + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise InstitutionalContextError( + "Form definition provider requires a database session." + ) + return value # type: ignore[return-value] + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + + +__all__ = [ + "FormDefinitionStoreError", + "SqlFormDefinitionProvider", + "definition_from_mapping", + "form_definition_history", + "get_form_definition", + "list_form_definitions", + "record_form_definition", +] diff --git a/src/govoplan_forms/py.typed b/src/govoplan_forms/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_forms/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..5371a5c --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + FormDefinition, + FormFieldDefinition, + InstitutionalReference, + TemporalRevision, +) +from govoplan_forms.backend.db.models import FormDefinitionRevision +from govoplan_forms.backend.service import ( + FormDefinitionStoreError, + SqlFormDefinitionProvider, + form_definition_history, + record_form_definition, +) + + +NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + + +def definition( + *, + revision: str = "1", + state: str = "published", + tenant_id: str = "tenant-1", +) -> FormDefinition: + recorded_at = NOW + timedelta(minutes=int(revision) - 1) + return FormDefinition( + reference=InstitutionalReference( + kind="form", + owner_module="forms", + object_id="permit-application", + tenant_id=tenant_id, + version=revision, + ), + key="permit-application", + temporal=TemporalRevision( + revision=revision, + recorded_at=recorded_at, + change_reason="Initial schema." if revision == "1" else "Revise schema.", + ), + title="Permit application", + fields=( + FormFieldDefinition( + key="name", + label="Name", + required=True, + constraints={"min_length": 2, "max_length": 200}, + ), + FormFieldDefinition( + key="delivery", + label="Delivery", + value_type="choice", + options=("portal", "mail"), + ), + ), + publication_state=state, # type: ignore[arg-type] + allow_drafts=True, + max_attachments=2, + handoff_kinds=("case",), + ) + + +class FormsTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + FormDefinitionRevision.__table__.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_provider_returns_exact_published_revision_and_history(self) -> None: + first = record_form_definition( + self.session, + self.principal, + definition=definition(), + ) + provider = SqlFormDefinitionProvider() + + self.assertEqual( + (first,), + tuple( + provider.list_form_definitions( + self.session, + self.principal, + tenant_id="tenant-1", + ) + ), + ) + exact = provider.get_form_definition( + self.session, + self.principal, + reference=first.reference, + effective_at=NOW, + ) + self.assertEqual("1", exact.temporal.revision if exact else None) + + record_form_definition( + self.session, + self.principal, + definition=definition(revision="2"), + expected_revision="1", + ) + self.assertEqual( + ["2", "1"], + [ + item.temporal.revision + for item in form_definition_history( + self.session, + self.principal, + form_id="permit-application", + ) + ], + ) + + def test_replay_occ_and_tenant_boundaries_fail_closed(self) -> None: + first = record_form_definition( + self.session, + self.principal, + definition=definition(), + ) + replay = record_form_definition( + self.session, + self.principal, + definition=definition(), + ) + self.assertEqual(first, replay) + + with self.assertRaisesRegex(FormDefinitionStoreError, "stale"): + record_form_definition( + self.session, + self.principal, + definition=definition(revision="2"), + expected_revision="0", + ) + with self.assertRaisesRegex(FormDefinitionStoreError, "cross tenants"): + record_form_definition( + self.session, + self.principal, + definition=definition(tenant_id="tenant-2"), + ) + with self.assertRaisesRegex(Exception, "cross tenants"): + SqlFormDefinitionProvider().list_form_definitions( + self.session, + Principal("tenant-2"), + tenant_id="tenant-1", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..ad8b050 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_forms.backend.manifest import get_manifest + + +class FormsMigrationTests(unittest.TestCase): + def test_fresh_migration_creates_definition_store_and_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-forms-migration-") as directory: + url = f"sqlite:///{Path(directory) / 'forms.db'}" + migrate_database( + database_url=url, + enabled_modules=("forms",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + self.assertIn( + "form_definition_revisions", + inspect(engine).get_table_names(), + ) + with engine.connect() as connection: + self.assertIn( + "e1f2a3b4c5d6", + set(MigrationContext.configure(connection).get_current_heads()), + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..9c072c1 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@govoplan/forms-webui", + "version": "0.1.14", + "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/forms.css": "./src/styles/forms.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/forms.ts b/webui/src/api/forms.ts new file mode 100644 index 0000000..c1fbbd9 --- /dev/null +++ b/webui/src/api/forms.ts @@ -0,0 +1,71 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + + +export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list"; + +export type FormFieldDefinition = { + key: string; + label: string; + value_type: FormValueType; + required: boolean; + help_text?: string | null; + options: string[]; + constraints: Record; + default_value?: unknown; +}; + +export type FormDefinition = { + reference: { + kind: "form"; + owner_module: "forms"; + object_id: string; + tenant_id: string; + version: string; + }; + key: string; + temporal: { + revision: string; + valid_from?: string | null; + valid_to?: string | null; + recorded_at: string; + superseded_at?: string | null; + change_reason: string; + }; + title: string; + description?: string | null; + fields: FormFieldDefinition[]; + publication_state: "draft" | "published" | "retired"; + allow_drafts: boolean; + max_attachments: number; + signature_requirement: "none" | "optional" | "required"; + policy_refs: string[]; + handoff_kinds: string[]; + metadata: Record; +}; + +export function listFormDefinitions( + settings: ApiSettings, + options: { query?: string; states?: string[]; offset?: number; limit?: number } = {}, + signal?: AbortSignal +): Promise<{ definitions: FormDefinition[]; total: number }> { + return apiFetch(settings, apiPath("/api/v1/forms/definitions", { + q: options.query, + publication_state: options.states, + offset: options.offset ?? 0, + limit: options.limit ?? 200 + }), { signal }); +} + +export function saveFormDefinition( + settings: ApiSettings, + definition: FormDefinition, + expectedRevision?: string | null +): Promise { + return apiFetch(settings, `/api/v1/forms/definitions/${encodeURIComponent(definition.reference.object_id)}`, { + method: "PUT", + body: JSON.stringify({ + definition, + expected_revision: expectedRevision ?? null + }) + }); +} diff --git a/webui/src/features/forms/FormDefinitionDialog.tsx b/webui/src/features/forms/FormDefinitionDialog.tsx new file mode 100644 index 0000000..07d5701 --- /dev/null +++ b/webui/src/features/forms/FormDefinitionDialog.tsx @@ -0,0 +1,330 @@ +import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Dialog, + DismissibleAlert, + FormField as Field, + IconButton, + ToggleSwitch, + type ApiSettings +} from "@govoplan/core-webui"; +import { + saveFormDefinition, + type FormDefinition, + type FormFieldDefinition, + type FormValueType +} from "../../api/forms"; + + +const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [ + { value: "text", label: "Text" }, + { value: "multiline_text", label: "Long text" }, + { value: "email", label: "Email" }, + { value: "integer", label: "Integer" }, + { value: "number", label: "Number" }, + { value: "boolean", label: "Yes / no" }, + { value: "date", label: "Date" }, + { value: "datetime", label: "Date and time" }, + { value: "choice", label: "Single choice" }, + { value: "multi_choice", label: "Multiple choice" }, + { value: "object", label: "Structured object" }, + { value: "list", label: "Structured list" } +]; + +export default function FormDefinitionDialog({ + open, + settings, + tenantId, + definition, + canPublish, + onClose, + onSaved +}: { + open: boolean; + settings: ApiSettings; + tenantId: string; + definition: FormDefinition | null; + canPublish: boolean; + onClose: () => void; + onSaved: (definition: FormDefinition) => void; +}) { + const [draft, setDraft] = useState(() => initialDraft(tenantId, definition)); + const [changeReason, setChangeReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) return; + setDraft(initialDraft(tenantId, definition)); + setChangeReason(""); + setBusy(false); + setError(""); + }, [definition, open, tenantId]); + + const valid = useMemo(() => Boolean( + draft.title.trim() + && draft.key.trim() + && draft.fields.length > 0 + && draft.fields.every((field) => field.key.trim() && field.label.trim()) + && changeReason.trim() + ), [changeReason, draft]); + + async function save() { + if (!valid) return; + setBusy(true); + setError(""); + const revision = crypto.randomUUID(); + const recordedAt = new Date().toISOString(); + const payload: FormDefinition = { + ...draft, + reference: { + ...draft.reference, + version: revision + }, + temporal: { + ...draft.temporal, + revision, + recorded_at: recordedAt, + superseded_at: null, + change_reason: changeReason.trim() + }, + title: draft.title.trim(), + key: draft.key.trim(), + description: draft.description?.trim() || null, + fields: draft.fields.map(normalizeField), + policy_refs: draft.policy_refs.map((item) => item.trim()).filter(Boolean), + metadata: { ...draft.metadata } + }; + try { + const saved = await saveFormDefinition( + settings, + payload, + definition?.reference.version + ); + onSaved(saved); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The Form definition could not be saved."); + } finally { + setBusy(false); + } + } + + function patchField(index: number, patch: Partial) { + setDraft((current) => ({ + ...current, + fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field) + })); + } + + function moveField(index: number, delta: -1 | 1) { + setDraft((current) => { + const target = index + delta; + if (target < 0 || target >= current.fields.length) return current; + const fields = [...current.fields]; + [fields[index], fields[target]] = [fields[target], fields[index]]; + return { ...current, fields }; + }); + } + + return ( + + + + + }> +
+ {error && {error}} +
+ + setDraft({ ...draft, title: event.target.value })} /> + + + setDraft({ ...draft, key: event.target.value })} /> + + +