From 3cdab599ff0c4db9124dcfdcb3dfbee16fd39067 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 16:06:17 +0200 Subject: [PATCH] Implement unified work inbox module --- .gitea/workflows/module-package-release.yml | 270 +++++++++ README.md | 7 + docs/TASKS_DOMAIN.md | 65 +++ package.json | 8 + pyproject.toml | 24 + src/govoplan_tasks/__init__.py | 3 + src/govoplan_tasks/backend/__init__.py | 1 + src/govoplan_tasks/backend/aggregation.py | 102 ++++ src/govoplan_tasks/backend/db/__init__.py | 3 + src/govoplan_tasks/backend/db/models.py | 140 +++++ src/govoplan_tasks/backend/manifest.py | 341 ++++++++++++ .../backend/migrations/__init__.py | 1 + .../7c4d9a2e1f30_v0118_tasks_kernel.py | 110 ++++ .../backend/migrations/versions/__init__.py | 1 + src/govoplan_tasks/backend/router.py | 343 ++++++++++++ src/govoplan_tasks/backend/schemas.py | 118 ++++ src/govoplan_tasks/backend/service.py | 516 ++++++++++++++++++ src/govoplan_tasks/py.typed | 1 + tests/__init__.py | 1 + tests/test_migrations.py | 41 ++ tests/test_tasks.py | 293 ++++++++++ webui/package.json | 28 + webui/src/api/tasks.ts | 146 +++++ webui/src/features/tasks/TasksPage.tsx | 385 +++++++++++++ .../src/features/tasks/TasksSummaryWidget.tsx | 46 ++ webui/src/i18n/generatedTranslations.ts | 134 +++++ webui/src/index.ts | 2 + webui/src/module.ts | 71 +++ webui/src/styles/tasks.css | 308 +++++++++++ webui/src/vite-env.d.ts | 1 + webui/tsconfig.json | 30 + 31 files changed, 3540 insertions(+) create mode 100644 .gitea/workflows/module-package-release.yml create mode 100644 docs/TASKS_DOMAIN.md create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/govoplan_tasks/__init__.py create mode 100644 src/govoplan_tasks/backend/__init__.py create mode 100644 src/govoplan_tasks/backend/aggregation.py create mode 100644 src/govoplan_tasks/backend/db/__init__.py create mode 100644 src/govoplan_tasks/backend/db/models.py create mode 100644 src/govoplan_tasks/backend/manifest.py create mode 100644 src/govoplan_tasks/backend/migrations/__init__.py create mode 100644 src/govoplan_tasks/backend/migrations/versions/7c4d9a2e1f30_v0118_tasks_kernel.py create mode 100644 src/govoplan_tasks/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_tasks/backend/router.py create mode 100644 src/govoplan_tasks/backend/schemas.py create mode 100644 src/govoplan_tasks/backend/service.py create mode 100644 src/govoplan_tasks/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/test_migrations.py create mode 100644 tests/test_tasks.py create mode 100644 webui/package.json create mode 100644 webui/src/api/tasks.ts create mode 100644 webui/src/features/tasks/TasksPage.tsx create mode 100644 webui/src/features/tasks/TasksSummaryWidget.tsx create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/tasks.css create mode 100644 webui/src/vite-env.d.ts create mode 100644 webui/tsconfig.json diff --git a/.gitea/workflows/module-package-release.yml b/.gitea/workflows/module-package-release.yml new file mode 100644 index 0000000..ef7ae89 --- /dev/null +++ b/.gitea/workflows/module-package-release.yml @@ -0,0 +1,270 @@ +name: Module Package Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_tag: + description: Existing protected version tag to publish + required: true + type: string + +jobs: + publish-packages: + runs-on: ubuntu-latest + env: + GITEA_REPOSITORY: ${{ gitea.repository }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Select and validate protected release tag + shell: bash + env: + REQUESTED_TAG: ${{ inputs.release_tag }} + TRIGGER_TAG: ${{ gitea.ref_name }} + run: | + set -euo pipefail + tag="${REQUESTED_TAG:-$TRIGGER_TAG}" + case "$tag" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;; + esac + git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main + tag_commit="$(git rev-list -n 1 "$tag")" + git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || { + echo "Release tag is not contained in main" >&2 + exit 1 + } + git checkout --detach "$tag" + printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV" + printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV" + - name: Validate package versions + run: | + python - <<'PY' + import json + from pathlib import Path + import os + import re + import tomllib + + tag = os.environ["RELEASE_TAG"] + expected = tag.removeprefix("v") + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + if project.get("version") != expected: + raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}") + if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None: + raise SystemExit("Python distribution name must use the govoplan-* namespace") + webui = Path("webui/package.json") + if webui.is_file(): + package = json.loads(webui.read_text(encoding="utf-8")) + if package.get("version") != expected: + raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}") + if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None: + raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace") + release = Path("webui/package.release.json") + if release.is_file(): + release_package = json.loads(release.read_text(encoding="utf-8")) + if ( + release_package.get("name") != package.get("name") + or release_package.get("version") != expected + ): + raise SystemExit("WebUI release package identity does not match package.json and the release tag") + PY + - name: Build immutable package artifacts + shell: bash + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0 + rm -rf dist .package-webui + python -m build --wheel --outdir dist + python -m twine check dist/*.whl + if [[ -f webui/package.json ]]; then + mkdir .package-webui + cp -a webui/. .package-webui/ + rm -rf .package-webui/node_modules .package-webui/dist + if [[ -f .package-webui/package.release.json ]]; then + cp .package-webui/package.release.json .package-webui/package.json + fi + node <<'NODE' + const fs = require("node:fs"); + const path = ".package-webui/package.json"; + const packageJson = JSON.parse(fs.readFileSync(path, "utf8")); + const groups = ["dependencies", "optionalDependencies", "peerDependencies"]; + for (const group of groups) { + for (const [name, specifier] of Object.entries(packageJson[group] || {})) { + if (!name.startsWith("@govoplan/")) continue; + if (typeof specifier !== "string") { + throw new Error(`${group}.${name} must use a string version`); + } + const packageSlug = name.slice("@govoplan/".length); + if (!packageSlug.endsWith("-webui")) { + throw new Error(`${group}.${name} is outside the WebUI package namespace`); + } + const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`; + const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const gitTag = specifier.match( + new RegExp( + `^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`, + ), + ); + if (gitTag) { + packageJson[group][name] = gitTag[1]; + continue; + } + if (specifier.startsWith("file:") || specifier.startsWith("git+")) { + throw new Error( + `${group}.${name} must resolve to an exact registry version for publication`, + ); + } + } + } + delete packageJson.private; + fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`); + NODE + npm pkg delete private --prefix .package-webui + (cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist) + fi + python - <<'PY' + import hashlib + import json + from pathlib import Path + import os + import subprocess + + artifacts = [] + for path in sorted(Path("dist").iterdir()): + if path.suffix not in {".whl", ".tgz"}: + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size}) + payload = { + "schema_version": "1", + "repository": os.environ["GITEA_REPOSITORY"], + "tag": os.environ["RELEASE_TAG"], + "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "artifacts": artifacts, + } + Path("dist/package-artifacts.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Retain package hash evidence + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 + with: + name: module-packages-${{ gitea.ref_name }} + path: dist/package-artifacts.json + - name: Check immutable registry state + shell: bash + env: + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_TOKEN" + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import tomllib + from urllib.error import HTTPError + from urllib.parse import quote + from urllib.request import Request, urlopen + + api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN" + token = os.environ["PACKAGE_TOKEN"] + + def should_publish(kind, name, version, path): + package_url = "/".join( + (api_root, kind, quote(name, safe=""), quote(version, safe=""), "files") + ) + request = Request( + package_url, + headers={"Accept": "application/json", "Authorization": f"token {token}"}, + ) + try: + with urlopen(request, timeout=30) as response: + files = json.load(response) + except HTTPError as exc: + if exc.code == 404: + print(f"{kind} package {name}=={version} is not published yet") + return True + raise + if not isinstance(files, list) or len(files) != 1: + raise SystemExit( + f"immutable {kind} package {name}=={version} has an unexpected file set" + ) + expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + if files[0].get("sha256") != expected_sha256: + raise SystemExit( + f"immutable {kind} package {name}=={version} already exists with a different SHA-256" + ) + print(f"verified existing {kind} package {name}=={version} ({expected_sha256})") + return False + + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + wheels = tuple(Path("dist").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit("release build must contain exactly one wheel") + publish_pypi = should_publish( + "pypi", str(project["name"]), str(project["version"]), wheels[0] + ) + + tarballs = tuple(Path("dist").glob("*.tgz")) + if len(tarballs) > 1: + raise SystemExit("release build must contain at most one npm package") + publish_npm = False + if tarballs: + webui = json.loads( + Path(".package-webui/package.json").read_text(encoding="utf-8") + ) + publish_npm = should_publish( + "npm", str(webui["name"]), str(webui["version"]), tarballs[0] + ) + + with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n") + env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n") + PY + - name: Publish wheel and WebUI package + shell: bash + env: + PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" + test -n "$PACKAGE_TOKEN" + if [[ "$PUBLISH_PYPI" == 1 ]]; then + TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ + python -m twine upload --non-interactive \ + --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ + dist/*.whl + else + echo "Exact wheel is already present; skipping immutable retry." + fi + shopt -s nullglob + webui_packages=(dist/*.tgz) + if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then + npmrc="$(mktemp)" + trap 'rm -f "$npmrc"' EXIT + chmod 600 "$npmrc" + printf '%s\n' \ + '@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \ + "//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \ + > "$npmrc" + NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \ + --ignore-scripts --access public \ + --registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/ + elif (( ${#webui_packages[@]} )); then + echo "Exact WebUI package is already present; skipping immutable retry." + fi diff --git a/README.md b/README.md index 9bcbd23..4b8b03a 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,10 @@ **Repository type:** module (domain). + +GovOPlaN Tasks owns replay-safe explicit work items and presents a unified work +inbox over module-owned attention items. Workflow Engine keeps process state, +Notifications keeps delivery state, and each domain module keeps its business +objects and commands. + +See [Tasks Domain And Unified Work Inbox](docs/TASKS_DOMAIN.md). diff --git a/docs/TASKS_DOMAIN.md b/docs/TASKS_DOMAIN.md new file mode 100644 index 0000000..8dfe0db --- /dev/null +++ b/docs/TASKS_DOMAIN.md @@ -0,0 +1,65 @@ +# Tasks Domain And Unified Work Inbox + +## Boundary + +Tasks owns explicit work items, their typed assignments, due dates, priorities, +state transitions, source references, and the unified work-inbox presentation. +It does not own the state of a Workflow instance, approval, notification, +Postbox message, Case, Campaign, record, or any other contributed object. + +Modules contribute currently authorized `WorkItem` projections through Core's +versioned provider-registration contract. The projection links back to the +source owner, which remains responsible for commands, validation, concurrency, +audit, retention, and recovery. A failing provider is isolated and shown as an +unavailable work source; it cannot make other work disappear. + +## Explicit Tasks + +An explicit task has: + +- a tenant and replay-safe idempotency key; +- title, optional summary, priority, due date, and required action; +- one or more account, group, role, function, function-assignment, or broad + tenant assignments; +- typed source references and provenance; +- an optimistic-concurrency revision and immutable transition evidence. + +Function assignments are resolved at read time through the optional IDM +directory. This lets work assigned to an institutional function follow current +responsibility without turning an incumbent into the permanent owner. When IDM +is absent, account, group, role, and explicit assignment references continue to +work; unresolved function work fails closed. + +## State And Recovery + +Supported states are `open`, `in_progress`, `deferred`, `blocked`, `completed`, +and `cancelled`. Consequential transitions require a strong `If-Match` +precondition and compare-and-set the revision. Replaying task creation with the +same idempotency key returns the same task only when the canonical request is +identical. + +Task changes emit Core change-sequence events so optional Notifications, +Workflow Engine, Search, Audit, or reporting consumers can react transactionally. +Database backup and restore is the recovery unit. Domain effects linked from a +task remain subject to the recovery rules of their owner. + +## Permissions + +- `tasks:item:read`: discover assigned explicit and contributed work; +- `tasks:item:write`: create explicit tasks and advance visible task state; +- `tasks:item:admin`: inspect and recover tenant-wide explicit work. + +Current authorization is always rechecked, including for historical or deferred +work. The inbox never widens the permissions of its sources. + +## User And Administrator Guidance + +Users open **Work**, filter the current inbox, inspect the responsible source, +and either open the source-owned action or advance an explicit task. Completed +and cancelled work is hidden from the default view but remains available through +the history filter. + +Administrators assign the Work participant or Work supervisor role and ensure +that IDM is enabled when function-bound work is required. Disabling Tasks +preserves explicit task state. Contributed source work remains with each source +module and becomes visible again when Tasks is re-enabled. diff --git a/package.json b/package.json new file mode 100644 index 0000000..b844827 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "@govoplan/tasks", + "version": "0.1.18", + "private": true, + "description": "Governed work items and unified work inbox for GovOPlaN.", + "type": "module", + "peerDependencies": {} +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ed742a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-tasks" +version = "0.1.18" +description = "Governed work items and unified work inbox for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.18", + "govoplan-access>=0.1.18", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_tasks = ["py.typed"] + +[project.entry-points."govoplan.modules"] +tasks = "govoplan_tasks.backend.manifest:get_manifest" diff --git a/src/govoplan_tasks/__init__.py b/src/govoplan_tasks/__init__.py new file mode 100644 index 0000000..eb78898 --- /dev/null +++ b/src/govoplan_tasks/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Tasks module.""" + +__version__ = "0.1.18" diff --git a/src/govoplan_tasks/backend/__init__.py b/src/govoplan_tasks/backend/__init__.py new file mode 100644 index 0000000..517474f --- /dev/null +++ b/src/govoplan_tasks/backend/__init__.py @@ -0,0 +1 @@ +"""Tasks backend.""" diff --git a/src/govoplan_tasks/backend/aggregation.py b/src/govoplan_tasks/backend/aggregation.py new file mode 100644 index 0000000..54e8d45 --- /dev/null +++ b/src/govoplan_tasks/backend/aggregation.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.tasks import WorkItem, WorkItemPage, WorkItemQuery +from govoplan_tasks.backend.schemas import WorkProviderDiagnostic + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class WorkAggregation: + items: tuple[WorkItem, ...] + total: int + truncated: bool = False + diagnostics: tuple[WorkProviderDiagnostic, ...] = () + + +def aggregate_work_items( + registry: PlatformRegistry, + session: Session, + principal: ApiPrincipal, + *, + query: WorkItemQuery, +) -> WorkAggregation: + """Aggregate current work while isolating optional provider failures.""" + + items: list[WorkItem] = [] + total = 0 + truncated = False + diagnostics: list[WorkProviderDiagnostic] = [] + for registered, provider in registry.work_item_providers(): + provider_id = registered.registration.id + if query.provider_ids and provider_id not in query.provider_ids: + continue + if query.owner_modules and registered.module_id not in query.owner_modules: + continue + try: + page = provider.list_items(session, principal, query=query) + _validate_page(registered.module_id, provider_id, query, page) + except Exception: # provider isolation is part of the aggregation contract + logger.exception("Work-item provider failed provider=%s", provider_id) + diagnostics.append( + WorkProviderDiagnostic( + provider_id=provider_id, + owner_module=registered.module_id, + code="provider_unavailable", + message="This work source is temporarily unavailable.", + ) + ) + continue + items.extend(page.items) + total += page.total + truncated = truncated or page.truncated + items.sort(key=_sort_key) + if len(items) > query.limit: + truncated = True + items = items[: query.limit] + return WorkAggregation( + items=tuple(items), + total=total, + truncated=truncated or total > len(items), + diagnostics=tuple(diagnostics), + ) + + +def _validate_page( + owner_module: str, + provider_id: str, + query: WorkItemQuery, + page: WorkItemPage, +) -> None: + if len(page.items) > query.limit: + raise ValueError("Work-item provider exceeded the requested limit.") + for item in page.items: + if item.provider_id != provider_id: + raise ValueError("Work-item provider returned another provider id.") + if item.owner_module != owner_module: + raise ValueError("Work-item provider returned another owner module.") + if item.tenant_id != query.tenant_id: + raise ValueError("Work-item provider returned another tenant.") + + +def _sort_key(item: WorkItem) -> tuple[object, ...]: + priorities = {"urgent": 0, "high": 1, "normal": 2, "low": 3} + due = _aware(item.due_at) if item.due_at else datetime.max.replace(tzinfo=UTC) + updated_rank = -_aware(item.updated_at).timestamp() if item.updated_at else 0.0 + return (priorities[item.priority], due, updated_rank, item.provider_id, item.id) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +__all__ = ["WorkAggregation", "aggregate_work_items"] diff --git a/src/govoplan_tasks/backend/db/__init__.py b/src/govoplan_tasks/backend/db/__init__.py new file mode 100644 index 0000000..77cf238 --- /dev/null +++ b/src/govoplan_tasks/backend/db/__init__.py @@ -0,0 +1,3 @@ +from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem + +__all__ = ["TaskAssignment", "TaskItem"] diff --git a/src/govoplan_tasks/backend/db/models.py b/src/govoplan_tasks/backend/db/models.py new file mode 100644 index 0000000..1ce849f --- /dev/null +++ b/src/govoplan_tasks/backend/db/models.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + 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 TaskItem(Base, TimestampMixin): + __tablename__ = "task_items" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_task_item_idempotency", + ), + Index("ix_task_items_tenant_status", "tenant_id", "status"), + Index("ix_task_items_tenant_due", "tenant_id", "due_at", "status"), + Index( + "ix_task_items_source", + "tenant_id", + "source_module", + "source_resource_type", + "source_resource_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) + title: Mapped[str] = mapped_column(String(500), nullable=False) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(30), nullable=False, default="open", index=True + ) + priority: Mapped[str] = mapped_column( + String(20), nullable=False, default="normal", index=True + ) + required_action: Mapped[str | None] = mapped_column(String(500), nullable=True) + action_url: Mapped[str | None] = mapped_column(String(1500), nullable=True) + due_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + deferred_until: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + source_module: Mapped[str | None] = mapped_column( + String(100), nullable=True, index=True + ) + source_resource_type: Mapped[str | None] = mapped_column( + String(100), nullable=True, index=True + ) + source_resource_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True) + sources: Mapped[list[dict[str, Any]]] = mapped_column( + JSON, default=list, nullable=False + ) + provenance: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", JSON, default=dict, nullable=False + ) + revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + idempotency_key: Mapped[str] = mapped_column( + String(255), nullable=False, index=True + ) + request_sha256: Mapped[str] = mapped_column(String(64), 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 + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + assignments: Mapped[list["TaskAssignment"]] = relationship( + back_populates="task", + cascade="all, delete-orphan", + order_by="TaskAssignment.created_at", + ) + + +class TaskAssignment(Base, TimestampMixin): + __tablename__ = "task_assignments" + __table_args__ = ( + UniqueConstraint( + "task_id", + "assignment_kind", + "assignment_id", + name="uq_task_assignment_target", + ), + Index( + "ix_task_assignment_lookup", + "tenant_id", + "assignment_kind", + "assignment_id", + "task_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) + task_id: Mapped[str] = mapped_column( + ForeignKey("task_items.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + assignment_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + assignment_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + assignment_label: Mapped[str | None] = mapped_column(String(500), nullable=True) + + task: Mapped[TaskItem] = relationship(back_populates="assignments") + + +__all__ = ["TaskAssignment", "TaskItem", "new_uuid"] diff --git a/src/govoplan_tasks/backend/manifest.py b/src/govoplan_tasks/backend/manifest.py new file mode 100644 index 0000000..f1bf917 --- /dev/null +++ b/src/govoplan_tasks/backend/manifest.py @@ -0,0 +1,341 @@ +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 ( + 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.tasks import ( + CAPABILITY_TASK_COMMANDS, + WorkItemProviderRegistration, +) +from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_tasks.backend.db import models as task_models +from govoplan_tasks.backend.service import SqlTaskService + + +MODULE_ID = "tasks" +MODULE_NAME = "Tasks" +MODULE_VERSION = "0.1.18" +READ_SCOPE = "tasks:item:read" +WRITE_SCOPE = "tasks:item:write" +ADMIN_SCOPE = "tasks:item: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="Tasks", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +def _router(context: ModuleContext): + from govoplan_tasks.backend.router import create_router + + return create_router(context.registry) + + +def _service(context: ModuleContext) -> SqlTaskService: + return SqlTaskService(context.registry) + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + total = ( + session.query(task_models.TaskItem) + .filter(task_models.TaskItem.tenant_id == tenant_id) + .count() + ) + open_items = ( + session.query(task_models.TaskItem) + .filter( + task_models.TaskItem.tenant_id == tenant_id, + task_models.TaskItem.status.in_( + ("open", "in_progress", "deferred", "blocked") + ), + ) + .count() + ) + return {"tasks": total, "open_tasks": open_items} + + +PERMISSIONS = ( + _permission( + READ_SCOPE, + "View assigned work", + "Read explicit and contributed work visible to the current account, group, role, or function.", + ), + _permission( + WRITE_SCOPE, + "Manage assigned work", + "Create explicit tasks and advance visible task state.", + ), + _permission( + ADMIN_SCOPE, + "Administer tenant work", + "Read and recover all explicit tasks in the tenant.", + ), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="work_participant", + name="Work participant", + description="Read and advance assigned work and create explicit tasks.", + permissions=(READ_SCOPE, WRITE_SCOPE), + ), + RoleTemplate( + slug="work_supervisor", + name="Work supervisor", + description="Inspect and recover tenant-wide work in addition to participating.", + permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE), + ), +) + +DOCUMENTATION = ( + DocumentationTopic( + id="tasks.work-inbox", + title="Unified work inbox", + summary="Resume explicit tasks and module-owned work requiring attention.", + body=( + "The Work inbox combines explicit Tasks with work contributed by enabled modules. " + "Each source keeps ownership of its commands and completion state. Tasks does not turn a " + "Workflow handoff, Postbox message, approval, or notification into a copied task. Filters, " + "due dates, priorities, and source links help the current actor resume work safely." + ), + layer="configured", + documentation_types=("user", "admin"), + audience=("user", "operator", "tenant_admin", "module_admin"), + related_modules=( + "workflow_engine", + "notifications", + "postbox", + "approvals", + "views", + "dashboard", + ), + links=( + DocumentationLink( + label="Tasks domain", + href="govoplan-tasks/docs/TASKS_DOMAIN.md", + kind="repository", + ), + ), + translations={ + "de": { + "title": "Gemeinsamer Arbeitsvorrat", + "summary": "Explizite Aufgaben und Arbeitsvorgaenge anderer Module sicher fortsetzen.", + "body": ( + "Der Arbeitsvorrat verbindet explizite Aufgaben mit Arbeitsobjekten aktivierter Module. " + "Jede Quelle behaelt die Verantwortung fuer Befehle und Abschlussstatus. Tasks kopiert " + "keine Workflow-Uebergabe, Postfachnachricht, Freigabe oder Benachrichtigung in einen " + "zweiten Fachzustand. Filter, Fristen, Prioritaeten und Quellverweise helfen beim sicheren Fortsetzen." + ), + } + }, + metadata={ + "help_contexts": [ + "tasks.route.work", + "tasks.page.inbox", + "tasks.page.detail", + "tasks.action.create", + "tasks.action.advance", + "tasks.field.assignment", + "tasks.field.due-at", + "tasks.field.priority", + ] + }, + ), +) + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + dependencies=("access",), + optional_dependencies=( + "idm", + "organizations", + "workflow_engine", + "workflow", + "notifications", + "postbox", + "approvals", + "views", + "dashboard", + "search", + ), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + provides_interfaces=( + ModuleInterfaceProvider(name=CAPABILITY_TASK_COMMANDS, version="1.0.0"), + ModuleInterfaceProvider(name="tasks.work_items", version="1.0.0"), + ), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + route_factory=_router, + nav_items=( + NavItem( + path="/tasks", + label="Work", + icon="list-checks", + required_any=(READ_SCOPE,), + order=21, + surface_id="tasks.route.work", + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/tasks-webui", + routes=( + FrontendRoute( + path="/tasks", + component="TasksPage", + required_any=(READ_SCOPE,), + order=21, + surface_id="tasks.route.work", + ), + ), + view_surfaces=( + ViewSurface( + id="tasks.page.inbox", + module_id=MODULE_ID, + kind="section", + label="Work inbox", + parent_id="tasks.route.work", + order=20, + ), + ViewSurface( + id="tasks.page.detail", + module_id=MODULE_ID, + kind="section", + label="Work details", + parent_id="tasks.route.work", + order=30, + ), + ViewSurface( + id="tasks.action.create", + module_id=MODULE_ID, + kind="action", + label="Create task", + parent_id="tasks.page.inbox", + order=40, + ), + ViewSurface( + id="tasks.action.advance", + module_id=MODULE_ID, + kind="action", + label="Advance task", + parent_id="tasks.page.detail", + order=50, + ), + ViewSurface( + id="tasks.widget.open-work", + module_id=MODULE_ID, + kind="section", + label="Open work widget", + order=60, + ), + ), + ), + tenant_summary_providers=(_tenant_summary,), + capability_factories={CAPABILITY_TASK_COMMANDS: _service}, + capability_documentation={ + CAPABILITY_TASK_COMMANDS: CapabilityDocumentation( + label="Task commands", + summary="Creates replay-safe explicit tasks without importing the Tasks implementation.", + contract_version="1.0.0", + ) + }, + work_item_providers=( + WorkItemProviderRegistration(id="tasks.explicit", factory=_service, order=10), + ), + 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( + task_models.TaskAssignment, task_models.TaskItem, label="Tasks" + ), + retirement_notes="Destructive retirement removes explicit task state after a database snapshot; contributed work remains with its owner.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + task_models.TaskItem, task_models.TaskAssignment, label="Tasks" + ), + ), + documentation=DOCUMENTATION, + architecture=declared_module_architecture( + layer="human_work_procedure", + kind="domain", + maturity="vertical_slice", + documentation_ref="docs/TASKS_DOMAIN.md", + test_ref="tests/test_tasks.py", + known_limits=( + "Function assignment resolution depends on the optional IDM directory; source-owned inline commands remain deep links in this first slice.", + ), + supported_authority_modes=("native_authoritative", "linked_reference"), + owned_concepts=("explicit task", "task assignment", "unified work inbox"), + non_owned_concepts=( + "workflow instance", + "notification", + "postbox message", + "approval request", + "domain object", + ), + reference_packages=( + "product.service-to-decision", + "product.governed-communication", + "product.governed-data-assurance", + ), + migration_docs=("docs/TASKS_DOMAIN.md",), + recovery_docs=("docs/TASKS_DOMAIN.md",), + security_docs=("docs/TASKS_DOMAIN.md",), + operations_docs=("docs/TASKS_DOMAIN.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest + + +__all__ = [ + "ADMIN_SCOPE", + "MODULE_ID", + "MODULE_VERSION", + "READ_SCOPE", + "WRITE_SCOPE", + "get_manifest", + "manifest", +] diff --git a/src/govoplan_tasks/backend/migrations/__init__.py b/src/govoplan_tasks/backend/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tasks/backend/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/src/govoplan_tasks/backend/migrations/versions/7c4d9a2e1f30_v0118_tasks_kernel.py b/src/govoplan_tasks/backend/migrations/versions/7c4d9a2e1f30_v0118_tasks_kernel.py new file mode 100644 index 0000000..745ea8f --- /dev/null +++ b/src/govoplan_tasks/backend/migrations/versions/7c4d9a2e1f30_v0118_tasks_kernel.py @@ -0,0 +1,110 @@ +"""v0.1.18 Tasks kernel. + +Revision ID: 7c4d9a2e1f30 +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "7c4d9a2e1f30" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "task_items", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("priority", sa.String(length=20), nullable=False), + sa.Column("required_action", sa.String(length=500), nullable=True), + sa.Column("action_url", sa.String(length=1500), nullable=True), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deferred_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("source_module", sa.String(length=100), nullable=True), + sa.Column("source_resource_type", sa.String(length=100), nullable=True), + sa.Column("source_resource_id", sa.String(length=255), nullable=True), + sa.Column("source_revision", sa.String(length=255), nullable=True), + sa.Column("sources", sa.JSON(), nullable=False), + sa.Column("provenance", sa.JSON(), nullable=False), + sa.Column("metadata", sa.JSON(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_by", sa.String(length=255), nullable=True), + sa.Column("cancelled_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"), + sa.UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_task_item_idempotency" + ), + ) + for column in ( + "tenant_id", + "status", + "priority", + "due_at", + "deferred_until", + "source_module", + "source_resource_type", + "source_resource_id", + "idempotency_key", + "created_by", + "updated_by", + ): + op.create_index(f"ix_task_items_{column}", "task_items", [column]) + op.create_index( + "ix_task_items_tenant_status", "task_items", ["tenant_id", "status"] + ) + op.create_index( + "ix_task_items_tenant_due", "task_items", ["tenant_id", "due_at", "status"] + ) + op.create_index( + "ix_task_items_source", + "task_items", + ["tenant_id", "source_module", "source_resource_type", "source_resource_id"], + ) + + op.create_table( + "task_assignments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("task_id", sa.String(length=36), nullable=False), + sa.Column("assignment_kind", sa.String(length=40), nullable=False), + sa.Column("assignment_id", sa.String(length=255), nullable=False), + sa.Column("assignment_label", sa.String(length=500), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["task_id"], ["task_items.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "task_id", + "assignment_kind", + "assignment_id", + name="uq_task_assignment_target", + ), + ) + for column in ("tenant_id", "task_id", "assignment_kind", "assignment_id"): + op.create_index(f"ix_task_assignments_{column}", "task_assignments", [column]) + op.create_index( + "ix_task_assignment_lookup", + "task_assignments", + ["tenant_id", "assignment_kind", "assignment_id", "task_id"], + ) + + +def downgrade() -> None: + op.drop_table("task_assignments") + op.drop_table("task_items") diff --git a/src/govoplan_tasks/backend/migrations/versions/__init__.py b/src/govoplan_tasks/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tasks/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ + diff --git a/src/govoplan_tasks/backend/router.py b/src/govoplan_tasks/backend/router.py new file mode 100644 index 0000000..580ed51 --- /dev/null +++ b/src/govoplan_tasks/backend/router.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.concurrency import ( + ConcurrencyError, + MissingPreconditionError, + RevisionConflictError, + assert_revision_precondition, +) +from govoplan_core.core.tasks import ( + TaskCreateCommand, + WorkAssignmentRef, + WorkItem, + WorkItemQuery, + WorkSourceRef, +) +from govoplan_core.db.session import get_session +from govoplan_tasks.backend.aggregation import aggregate_work_items +from govoplan_tasks.backend.schemas import ( + TaskActionPayload, + TaskCreatePayload, + WorkItemListResponse, + WorkItemResponse, + WorkSummaryResponse, +) +from govoplan_tasks.backend.service import ( + ACTIVE_STATUSES, + SqlTaskService, + TaskConflict, + TaskError, + TaskForbidden, + TaskNotFound, + task_etag, +) + + +READ_SCOPE = "tasks:item:read" +WRITE_SCOPE = "tasks:item:write" +ADMIN_SCOPE = "tasks:item:admin" + + +def create_router(registry: object) -> APIRouter: + router = APIRouter(prefix="/tasks", tags=["tasks"]) + explicit_tasks = SqlTaskService(registry) + + @router.get("", response_model=WorkItemListResponse) + def list_work( + status_filter: list[str] | None = Query(default=None, alias="status"), + priority: list[str] | None = Query(default=None), + provider: list[str] | None = Query(default=None), + owner_module: list[str] | None = Query(default=None), + due_before: datetime | None = Query(default=None), + query_text: str = Query(default="", alias="q", max_length=500), + limit: int = Query(default=100, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> WorkItemListResponse: + _require(principal, READ_SCOPE) + query = _query( + principal, + statuses=status_filter, + priorities=priority, + provider_ids=provider, + owner_modules=owner_module, + due_before=due_before, + text=query_text, + limit=limit, + ) + aggregation = aggregate_work_items( + registry, + session, + principal, + query=query, + ) + return WorkItemListResponse( + items=[_response(item) for item in aggregation.items], + total=aggregation.total, + truncated=aggregation.truncated, + diagnostics=list(aggregation.diagnostics), + ) + + @router.get("/summary", response_model=WorkSummaryResponse) + def work_summary( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> WorkSummaryResponse: + _require(principal, READ_SCOPE) + query = _query(principal, limit=500) + aggregation = aggregate_work_items( + registry, + session, + principal, + query=query, + ) + now = datetime.now(UTC) + return WorkSummaryResponse( + total=aggregation.total, + open=sum(item.status == "open" for item in aggregation.items), + in_progress=sum(item.status == "in_progress" for item in aggregation.items), + deferred=sum(item.status == "deferred" for item in aggregation.items), + blocked=sum(item.status == "blocked" for item in aggregation.items), + overdue=sum( + item.due_at is not None + and _aware(item.due_at) < now + and item.status in ACTIVE_STATUSES + for item in aggregation.items + ), + urgent=sum(item.priority == "urgent" for item in aggregation.items), + truncated=aggregation.truncated, + diagnostics=list(aggregation.diagnostics), + ) + + @router.post( + "", response_model=WorkItemResponse, status_code=status.HTTP_201_CREATED + ) + def create_task( + payload: TaskCreatePayload, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> WorkItemResponse: + _require(principal, WRITE_SCOPE) + try: + item = explicit_tasks.create_task( + session, + principal, + command=TaskCreateCommand( + tenant_id=principal.tenant_id, + title=payload.title, + summary=payload.summary, + priority=payload.priority, + due_at=payload.due_at, + required_action=payload.required_action, + action_url=payload.action_url, + assignments=tuple( + WorkAssignmentRef(**value.model_dump()) + for value in payload.assignments + ), + sources=tuple( + WorkSourceRef(**value.model_dump()) for value in payload.sources + ), + provenance=payload.provenance, + metadata=payload.metadata, + idempotency_key=payload.idempotency_key, + ), + ) + session.commit() + except (TaskError, ValueError) as exc: + session.rollback() + raise _task_error(exc) from exc + _set_etag(response, item) + return _response(item) + + @router.get("/{task_id}", response_model=WorkItemResponse) + def get_task( + task_id: str, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> WorkItemResponse: + _require(principal, READ_SCOPE) + try: + row = explicit_tasks.get_task( + session, + principal, + tenant_id=principal.tenant_id, + task_id=task_id, + ) + item = explicit_tasks.to_item(row) + except TaskError as exc: + raise _task_error(exc) from exc + _set_etag(response, item) + return _response(item) + + @router.post("/{task_id}/actions", response_model=WorkItemResponse) + def transition_task( + task_id: str, + payload: TaskActionPayload, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), + ) -> WorkItemResponse: + _require_any(principal, WRITE_SCOPE, ADMIN_SCOPE) + try: + assert_revision_precondition( + if_match, + resource_type="task", + resource_id=task_id, + submitted_base_revision=payload.expected_revision, + ) + item = explicit_tasks.transition_task( + session, + principal, + tenant_id=principal.tenant_id, + task_id=task_id, + action=payload.action, + expected_revision=payload.expected_revision, + deferred_until=payload.deferred_until, + comment=payload.comment, + ) + session.commit() + except (TaskError, ConcurrencyError) as exc: + session.rollback() + raise _mutation_error(exc) from exc + _set_etag(response, item) + return _response(item) + + return router + + +def _query( + principal: ApiPrincipal, + *, + statuses: list[str] | None = None, + priorities: list[str] | None = None, + provider_ids: list[str] | None = None, + owner_modules: list[str] | None = None, + due_before: datetime | None = None, + text: str = "", + limit: int = 100, +) -> WorkItemQuery: + allowed_statuses = { + "open", + "in_progress", + "deferred", + "blocked", + "completed", + "cancelled", + } + allowed_priorities = {"low", "normal", "high", "urgent"} + normalized_statuses = tuple(statuses or ACTIVE_STATUSES) + normalized_priorities = tuple(priorities or ()) + if any(value not in allowed_statuses for value in normalized_statuses): + raise HTTPException(status_code=422, detail="Unsupported task status filter.") + if any(value not in allowed_priorities for value in normalized_priorities): + raise HTTPException(status_code=422, detail="Unsupported task priority filter.") + return WorkItemQuery( + tenant_id=principal.tenant_id, + statuses=normalized_statuses, # type: ignore[arg-type] + priorities=normalized_priorities, # type: ignore[arg-type] + provider_ids=tuple(provider_ids or ()), + owner_modules=tuple(owner_modules or ()), + due_before=due_before, + text=text, + limit=limit, + ) + + +def _response(item: WorkItem) -> WorkItemResponse: + return WorkItemResponse( + id=item.id, + provider_id=item.provider_id, + owner_module=item.owner_module, + tenant_id=item.tenant_id, + title=item.title, + status=item.status, + priority=item.priority, + summary=item.summary, + required_action=item.required_action, + action_url=item.action_url, + due_at=item.due_at, + deferred_until=item.deferred_until, + assignments=[ + {"kind": value.kind, "id": value.id, "label": value.label} + for value in item.assignments + ], + sources=[ + { + "module_id": value.module_id, + "resource_type": value.resource_type, + "resource_id": value.resource_id, + "revision": value.revision, + "url": value.url, + "label": value.label, + } + for value in item.sources + ], + provenance=dict(item.provenance), + metadata=dict(item.metadata), + revision=item.revision, + etag=task_etag(item) if item.provider_id == "tasks.explicit" else None, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _require_any(principal: ApiPrincipal, *scopes: str) -> None: + if not any(has_scope(principal, scope) for scope in scopes): + raise HTTPException( + status_code=403, detail=f"Requires one of: {', '.join(scopes)}" + ) + + +def _task_error(exc: TaskError | ValueError) -> HTTPException: + if isinstance(exc, TaskNotFound): + code = status.HTTP_404_NOT_FOUND + elif isinstance(exc, TaskForbidden): + code = status.HTTP_403_FORBIDDEN + elif isinstance(exc, TaskConflict): + code = status.HTTP_409_CONFLICT + else: + code = status.HTTP_422_UNPROCESSABLE_CONTENT + return HTTPException( + status_code=code, + detail={"code": getattr(exc, "code", "invalid_task"), "message": str(exc)}, + ) + + +def _mutation_error(exc: TaskError | ConcurrencyError) -> HTTPException: + if isinstance(exc, MissingPreconditionError): + return HTTPException(status_code=428, detail=exc.as_dict()) + if isinstance(exc, RevisionConflictError): + return HTTPException(status_code=412, detail=exc.as_dict()) + if isinstance(exc, ConcurrencyError): + return HTTPException( + status_code=400, + detail={"code": "invalid_precondition", "message": str(exc)}, + ) + return _task_error(exc) + + +def _set_etag(response: Response, item: WorkItem) -> None: + etag = task_etag(item) + if etag: + response.headers["ETag"] = etag + + +__all__ = ["create_router"] diff --git a/src/govoplan_tasks/backend/schemas.py b/src/govoplan_tasks/backend/schemas.py new file mode 100644 index 0000000..e34cfe4 --- /dev/null +++ b/src/govoplan_tasks/backend/schemas.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +class WorkAssignmentPayload(BaseModel): + kind: Literal[ + "account", + "group", + "role", + "function", + "function_assignment", + "anyone", + ] + id: str = Field(min_length=1, max_length=255) + label: str | None = Field(default=None, max_length=500) + + +class WorkSourcePayload(BaseModel): + module_id: str = Field(min_length=1, max_length=100) + resource_type: str = Field(min_length=1, max_length=100) + resource_id: str = Field(min_length=1, max_length=255) + revision: str | None = Field(default=None, max_length=255) + url: str | None = Field(default=None, max_length=1500) + label: str | None = Field(default=None, max_length=500) + + +class WorkItemResponse(BaseModel): + id: str + provider_id: str + owner_module: str + tenant_id: str + title: str + status: str + priority: str + summary: str | None = None + required_action: str | None = None + action_url: str | None = None + due_at: datetime | None = None + deferred_until: datetime | None = None + assignments: list[WorkAssignmentPayload] = Field(default_factory=list) + sources: list[WorkSourcePayload] = Field(default_factory=list) + provenance: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + revision: str + etag: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class WorkProviderDiagnostic(BaseModel): + provider_id: str + owner_module: str + code: str + message: str + + +class WorkItemListResponse(BaseModel): + items: list[WorkItemResponse] + total: int + truncated: bool = False + diagnostics: list[WorkProviderDiagnostic] = Field(default_factory=list) + + +class WorkSummaryResponse(BaseModel): + total: int + open: int + in_progress: int + deferred: int + blocked: int + overdue: int + urgent: int + truncated: bool = False + diagnostics: list[WorkProviderDiagnostic] = Field(default_factory=list) + + +class TaskCreatePayload(BaseModel): + title: str = Field(min_length=1, max_length=500) + summary: str | None = Field(default=None, max_length=4000) + priority: Literal["low", "normal", "high", "urgent"] = "normal" + due_at: datetime | None = None + required_action: str | None = Field(default=None, max_length=500) + action_url: str | None = Field(default=None, max_length=1500) + assignments: list[WorkAssignmentPayload] = Field(min_length=1, max_length=100) + sources: list[WorkSourcePayload] = Field(default_factory=list, max_length=100) + provenance: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + idempotency_key: str = Field(min_length=1, max_length=255) + + +class TaskActionPayload(BaseModel): + action: Literal["start", "complete", "defer", "reopen", "cancel"] + expected_revision: int = Field(ge=1) + deferred_until: datetime | None = None + comment: str | None = Field(default=None, max_length=4000) + + @model_validator(mode="after") + def validate_action(self) -> "TaskActionPayload": + if self.action == "defer" and self.deferred_until is None: + raise ValueError("Deferring a task requires a date and time.") + if self.action != "defer" and self.deferred_until is not None: + raise ValueError("Only a defer action accepts deferred_until.") + return self + + +__all__ = [ + "TaskActionPayload", + "TaskCreatePayload", + "WorkAssignmentPayload", + "WorkItemListResponse", + "WorkItemResponse", + "WorkProviderDiagnostic", + "WorkSourcePayload", + "WorkSummaryResponse", +] diff --git a/src/govoplan_tasks/backend/service.py b/src/govoplan_tasks/backend/service.py new file mode 100644 index 0000000..40651ba --- /dev/null +++ b/src/govoplan_tasks/backend/service.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from govoplan_core.core.change_sequence import record_change +from govoplan_core.core.concurrency import ( + RevisionConflictError, + claim_revision, + strong_resource_etag, +) +from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory +from govoplan_core.core.tasks import ( + TaskCreateCommand, + WorkAssignmentRef, + WorkItem, + WorkItemPage, + WorkItemQuery, + WorkSourceRef, +) +from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem + + +READ_SCOPE = "tasks:item:read" +WRITE_SCOPE = "tasks:item:write" +ADMIN_SCOPE = "tasks:item:admin" +PROVIDER_ID = "tasks.explicit" +ACTIVE_STATUSES = ("open", "in_progress", "deferred", "blocked") + + +class TaskError(RuntimeError): + code = "task_error" + + +class TaskNotFound(TaskError): + code = "task_not_found" + + +class TaskForbidden(TaskError): + code = "task_forbidden" + + +class TaskConflict(TaskError): + code = "task_conflict" + + +class SqlTaskService: + def __init__(self, registry: object | None = None) -> None: + self.registry = registry + + def list_items( + self, + session: object, + principal: object, + *, + query: WorkItemQuery, + ) -> WorkItemPage: + if not isinstance(session, Session): + raise TypeError("Tasks requires a SQLAlchemy session.") + self._require_tenant(principal, query.tenant_id) + if not _has(principal, READ_SCOPE) and not _has(principal, ADMIN_SCOPE): + raise TaskForbidden("The current principal may not read tasks.") + statement = self._visible_statement(principal, query.tenant_id) + if query.statuses: + statement = statement.where(TaskItem.status.in_(query.statuses)) + if query.priorities: + statement = statement.where(TaskItem.priority.in_(query.priorities)) + if query.due_before is not None: + statement = statement.where(TaskItem.due_at <= query.due_before) + if query.text: + pattern = f"%{_escape_like(query.text)}%" + statement = statement.where( + or_( + TaskItem.title.ilike(pattern, escape="\\"), + TaskItem.summary.ilike(pattern, escape="\\"), + TaskItem.required_action.ilike(pattern, escape="\\"), + ) + ) + count_statement = select(func.count()).select_from( + statement.order_by(None).subquery() + ) + total = int(session.scalar(count_statement) or 0) + rows = list( + session.scalars( + statement.order_by( + _priority_rank(), + TaskItem.due_at.is_(None), + TaskItem.due_at.asc(), + TaskItem.updated_at.desc(), + TaskItem.id.desc(), + ).limit(query.limit) + ).unique() + ) + return WorkItemPage( + items=tuple(self.to_item(row) for row in rows), + total=total, + truncated=total > len(rows), + ) + + def get_task( + self, + session: Session, + principal: object, + *, + tenant_id: str, + task_id: str, + for_update: bool = False, + ) -> TaskItem: + self._require_tenant(principal, tenant_id) + statement = self._visible_statement(principal, tenant_id).where( + TaskItem.id == task_id + ) + if for_update: + statement = statement.with_for_update() + task = session.scalar(statement) + if task is None: + raise TaskNotFound("Task not found or not visible.") + return task + + def create_task( + self, + session: object, + principal: object, + *, + command: TaskCreateCommand, + ) -> WorkItem: + if not isinstance(session, Session): + raise TypeError("Tasks requires a SQLAlchemy session.") + self._require_tenant(principal, command.tenant_id) + if not _has(principal, WRITE_SCOPE) and not _has(principal, ADMIN_SCOPE): + raise TaskForbidden("The current principal may not create tasks.") + digest = _command_digest(command) + existing = session.scalar( + select(TaskItem) + .where( + TaskItem.tenant_id == command.tenant_id, + TaskItem.idempotency_key == command.idempotency_key, + ) + .options(selectinload(TaskItem.assignments)) + ) + if existing is not None: + if existing.request_sha256 != digest: + raise TaskConflict( + "The idempotency key already identifies a different task request." + ) + return self.to_item(existing) + + sources = [_source_dict(item) for item in command.sources] + primary = command.sources[0] if command.sources else None + actor_id = _account_id(principal) + task = TaskItem( + tenant_id=command.tenant_id, + title=command.title.strip(), + summary=_optional(command.summary), + status="open", + priority=command.priority, + due_at=command.due_at, + required_action=_optional(command.required_action), + action_url=_optional(command.action_url), + source_module=primary.module_id if primary else None, + source_resource_type=primary.resource_type if primary else None, + source_resource_id=primary.resource_id if primary else None, + source_revision=primary.revision if primary else None, + sources=sources, + provenance=dict(command.provenance), + metadata_=dict(command.metadata), + idempotency_key=command.idempotency_key.strip(), + request_sha256=digest, + created_by=actor_id, + updated_by=actor_id, + ) + task.assignments = [ + TaskAssignment( + tenant_id=command.tenant_id, + assignment_kind=item.kind, + assignment_id=item.id, + assignment_label=item.label, + ) + for item in _deduplicate_assignments(command.assignments) + ] + session.add(task) + session.flush() + record_change( + session, + module_id="tasks", + collection="work_items", + resource_type="task", + resource_id=task.id, + operation="created", + tenant_id=task.tenant_id, + actor_type="account" if actor_id else None, + actor_id=actor_id, + payload={ + "status": task.status, + "priority": task.priority, + "assignment_count": len(task.assignments), + "source_module": task.source_module, + }, + ) + return self.to_item(task) + + def transition_task( + self, + session: Session, + principal: object, + *, + tenant_id: str, + task_id: str, + action: str, + expected_revision: int, + deferred_until: datetime | None = None, + comment: str | None = None, + ) -> WorkItem: + if not _has(principal, WRITE_SCOPE) and not _has(principal, ADMIN_SCOPE): + raise TaskForbidden("The current principal may not update tasks.") + task = self.get_task( + session, + principal, + tenant_id=tenant_id, + task_id=task_id, + for_update=True, + ) + previous_status = task.status + next_status = _next_status(task.status, action) + if action == "defer": + if deferred_until is None or _utc(deferred_until) <= datetime.now(UTC): + raise TaskConflict("Deferred tasks require a future date and time.") + try: + next_revision = claim_revision( + session, + model=TaskItem, + filters=(TaskItem.id == task.id, TaskItem.tenant_id == tenant_id), + revision_attribute="revision", + expected_revision=expected_revision, + resource_type="task", + resource_id=task.id, + refresh_path=f"/api/v1/tasks/{task.id}", + ) + except RevisionConflictError: + raise + session.refresh(task) + actor_id = _account_id(principal) + now = datetime.now(UTC) + task.revision = next_revision + task.status = next_status + task.updated_by = actor_id + task.deferred_until = _utc(deferred_until) if action == "defer" else None + if action == "complete": + task.completed_at = now + task.completed_by = actor_id + elif action == "reopen": + task.completed_at = None + task.completed_by = None + task.cancelled_at = None + elif action == "cancel": + task.cancelled_at = now + metadata = dict(task.metadata_ or {}) + history = list(metadata.get("transition_history") or []) + history.append( + { + "action": action, + "from_status": previous_status, + "to_status": next_status, + "actor_id": actor_id, + "recorded_at": now.isoformat(), + "comment": _optional(comment), + } + ) + metadata["transition_history"] = history[-100:] + task.metadata_ = metadata + session.flush() + record_change( + session, + module_id="tasks", + collection="work_items", + resource_type="task", + resource_id=task.id, + operation="updated", + tenant_id=tenant_id, + actor_type="account" if actor_id else None, + actor_id=actor_id, + payload={ + "action": action, + "status": next_status, + "revision": next_revision, + }, + ) + return self.to_item(task) + + def _visible_statement(self, principal: object, tenant_id: str): + statement = ( + select(TaskItem) + .where(TaskItem.tenant_id == tenant_id) + .options(selectinload(TaskItem.assignments)) + ) + if _has(principal, ADMIN_SCOPE): + return statement + targets = self._assignment_targets(principal, tenant_id) + conditions = [ + and_( + TaskAssignment.assignment_kind == kind, + TaskAssignment.assignment_id.in_(tuple(ids)), + ) + for kind, ids in targets.items() + if ids + ] + if not conditions: + return statement.where(False) + return statement.join(TaskAssignment).where(or_(*conditions)).distinct() + + def _assignment_targets( + self, principal: object, tenant_id: str + ) -> dict[str, set[str]]: + targets = { + "account": {_account_id(principal)} if _account_id(principal) else set(), + "group": set(getattr(principal, "group_ids", ()) or ()), + "role": set(getattr(principal, "role_ids", ()) or ()), + "function_assignment": set( + getattr(principal, "function_assignment_ids", ()) or () + ), + "function": set(), + "anyone": {"*"}, + } + directory = self._idm_directory() + if directory is not None and _account_id(principal): + assignments = directory.organization_function_assignments_for_account( + _account_id(principal), + tenant_id=tenant_id, + ) + targets["function"].update( + item.function_id + for item in assignments + if item.status == "active" and item.tenant_id == tenant_id + ) + return targets + + def _idm_directory(self) -> IdmDirectory | None: + registry = self.registry + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(CAPABILITY_IDM_DIRECTORY) + ): + return None + provider = registry.capability(CAPABILITY_IDM_DIRECTORY) + return provider if isinstance(provider, IdmDirectory) else None + + @staticmethod + def _require_tenant(principal: object, tenant_id: str) -> None: + if str(getattr(principal, "tenant_id", "") or "") != tenant_id: + raise TaskForbidden("Task access is limited to the active tenant.") + + @staticmethod + def to_item(task: TaskItem) -> WorkItem: + return WorkItem( + id=task.id, + provider_id=PROVIDER_ID, + owner_module="tasks", + tenant_id=task.tenant_id, + title=task.title, + summary=task.summary, + status=task.status, # type: ignore[arg-type] + priority=task.priority, # type: ignore[arg-type] + required_action=task.required_action, + action_url=task.action_url, + due_at=task.due_at, + deferred_until=task.deferred_until, + assignments=tuple( + WorkAssignmentRef( + kind=item.assignment_kind, # type: ignore[arg-type] + id=item.assignment_id, + label=item.assignment_label, + ) + for item in task.assignments + ), + sources=tuple(WorkSourceRef(**item) for item in task.sources), + provenance=dict(task.provenance or {}), + metadata=dict(task.metadata_ or {}), + revision=str(task.revision), + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +def task_etag(task: WorkItem) -> str | None: + try: + return strong_resource_etag("task", task.id, int(task.revision)) + except (TypeError, ValueError): + return None + + +def _has(principal: object, scope: str) -> bool: + checker = getattr(principal, "has", None) + return bool(callable(checker) and checker(scope)) + + +def _account_id(principal: object) -> str: + return str(getattr(principal, "account_id", "") or "") + + +def _optional(value: str | None) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _source_dict(value: WorkSourceRef) -> dict[str, str | None]: + return { + "module_id": value.module_id, + "resource_type": value.resource_type, + "resource_id": value.resource_id, + "revision": value.revision, + "url": value.url, + "label": value.label, + } + + +def _deduplicate_assignments( + assignments: Sequence[WorkAssignmentRef], +) -> tuple[WorkAssignmentRef, ...]: + by_key: dict[tuple[str, str], WorkAssignmentRef] = {} + for item in assignments: + by_key.setdefault((item.kind, item.id), item) + return tuple(by_key.values()) + + +def _command_digest(command: TaskCreateCommand) -> str: + payload = { + "tenant_id": command.tenant_id, + "title": command.title.strip(), + "summary": _optional(command.summary), + "priority": command.priority, + "due_at": command.due_at.isoformat() if command.due_at else None, + "required_action": _optional(command.required_action), + "action_url": _optional(command.action_url), + "assignments": [ + {"kind": item.kind, "id": item.id, "label": item.label} + for item in _deduplicate_assignments(command.assignments) + ], + "sources": [_source_dict(item) for item in command.sources], + "provenance": dict(command.provenance), + "metadata": dict(command.metadata), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _next_status(current: str, action: str) -> str: + allowed: Mapping[str, Mapping[str, str]] = { + "open": { + "start": "in_progress", + "complete": "completed", + "defer": "deferred", + "cancel": "cancelled", + }, + "in_progress": { + "complete": "completed", + "defer": "deferred", + "cancel": "cancelled", + }, + "deferred": { + "start": "in_progress", + "complete": "completed", + "reopen": "open", + "cancel": "cancelled", + }, + "blocked": { + "reopen": "open", + "cancel": "cancelled", + }, + "completed": {"reopen": "open"}, + "cancelled": {"reopen": "open"}, + } + next_status = allowed.get(current, {}).get(action) + if next_status is None: + raise TaskConflict( + f"Action {action!r} is not available for a {current!r} task." + ) + return next_status + + +def _escape_like(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def _priority_rank(): + from sqlalchemy import case + + return case( + (TaskItem.priority == "urgent", 0), + (TaskItem.priority == "high", 1), + (TaskItem.priority == "normal", 2), + else_=3, + ) + + +__all__ = [ + "ACTIVE_STATUSES", + "PROVIDER_ID", + "SqlTaskService", + "TaskConflict", + "TaskError", + "TaskForbidden", + "TaskNotFound", + "task_etag", +] diff --git a/src/govoplan_tasks/py.typed b/src/govoplan_tasks/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tasks/py.typed @@ -0,0 +1 @@ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..ebf77c4 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,41 @@ +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_access.backend.manifest import get_manifest as get_access_manifest +from govoplan_core.db.migrations import migrate_database +from govoplan_tasks.backend.manifest import get_manifest as get_tasks_manifest + + +class TasksMigrationTests(unittest.TestCase): + def test_fresh_migration_creates_task_kernel_and_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-tasks-migration-") as directory: + url = f"sqlite:///{Path(directory) / 'tasks.db'}" + migrate_database( + database_url=url, + enabled_modules=("access", "tasks"), + manifest_factories=(get_access_manifest, get_tasks_manifest), + ) + engine = create_engine(url) + try: + self.assertTrue( + {"task_items", "task_assignments"}.issubset( + inspect(engine).get_table_names() + ) + ) + with engine.connect() as connection: + self.assertIn( + "7c4d9a2e1f30", + set(MigrationContext.configure(connection).get_current_heads()), + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..34b1835 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.core.concurrency import RevisionConflictError +from govoplan_core.core.tasks import ( + TaskCreateCommand, + WorkAssignmentRef, + WorkItemQuery, + WorkSourceRef, +) +from govoplan_core.db.base import Base +from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem +from govoplan_tasks.backend.manifest import get_manifest +from govoplan_tasks.backend.service import ( + ADMIN_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + SqlTaskService, + TaskConflict, + TaskForbidden, + TaskNotFound, +) + + +class TaskServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + self.tables = ( + ChangeSequenceEntry.__table__, + TaskItem.__table__, + TaskAssignment.__table__, + ) + Base.metadata.create_all(self.engine, tables=self.tables) + self.Session = sessionmaker(bind=self.engine) + self.service = SqlTaskService() + + def tearDown(self) -> None: + Base.metadata.drop_all(self.engine, tables=reversed(self.tables)) + self.engine.dispose() + + @staticmethod + def principal( + account_id: str, + *, + tenant_id: str = "tenant-1", + scopes: set[str] | None = None, + group_ids: set[str] | None = None, + role_ids: set[str] | None = None, + function_assignment_ids: set[str] | None = None, + ) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id=account_id, + membership_id=f"membership-{account_id}", + tenant_id=tenant_id, + scopes=frozenset( + {READ_SCOPE, WRITE_SCOPE} if scopes is None else scopes + ), + group_ids=frozenset(group_ids or ()), + role_ids=frozenset(role_ids or ()), + function_assignment_ids=frozenset(function_assignment_ids or ()), + ), + account=SimpleNamespace(id=account_id), + user=SimpleNamespace(id=f"membership-{account_id}"), + ) + + @staticmethod + def command( + *, + idempotency_key: str = "request-1", + title: str = "Review the submission", + assignments: tuple[WorkAssignmentRef, ...] | None = None, + ) -> TaskCreateCommand: + return TaskCreateCommand( + tenant_id="tenant-1", + title=title, + summary="Resolve the governed handoff.", + priority="high", + due_at=datetime.now(UTC) + timedelta(days=2), + required_action="Review and decide", + action_url="/cases/case-1", + assignments=assignments + or (WorkAssignmentRef(kind="account", id="account-1"),), + sources=( + WorkSourceRef( + module_id="cases", + resource_type="case", + resource_id="case-1", + revision="3", + url="/cases/case-1", + ), + ), + provenance={"workflow_instance_id": "workflow-1"}, + idempotency_key=idempotency_key, + ) + + def test_replayed_create_is_idempotent_and_mismatch_is_rejected(self) -> None: + principal = self.principal("account-1") + with self.Session() as session: + command = self.command() + first = self.service.create_task(session, principal, command=command) + replay = self.service.create_task(session, principal, command=command) + self.assertEqual(first.id, replay.id) + self.assertEqual(1, len(session.scalars(select(TaskItem)).all())) + self.assertEqual( + 1, + len(session.scalars(select(ChangeSequenceEntry)).all()), + ) + + with self.assertRaises(TaskConflict): + self.service.create_task( + session, + principal, + command=self.command(title="A different task"), + ) + + def test_visibility_uses_typed_assignments_and_fails_closed(self) -> None: + creator = self.principal( + "account-1", + scopes={READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, + ) + with self.Session() as session: + account_task = self.service.create_task( + session, + creator, + command=self.command(idempotency_key="account"), + ) + group_task = self.service.create_task( + session, + creator, + command=self.command( + idempotency_key="group", + assignments=(WorkAssignmentRef(kind="group", id="group-1"),), + ), + ) + function_assignment_task = self.service.create_task( + session, + creator, + command=self.command( + idempotency_key="function-assignment", + assignments=( + WorkAssignmentRef( + kind="function_assignment", + id="assignment-1", + ), + ), + ), + ) + session.flush() + + group_reader = self.principal( + "account-2", + group_ids={"group-1"}, + function_assignment_ids={"assignment-1"}, + ) + page = self.service.list_items( + session, + group_reader, + query=WorkItemQuery(tenant_id="tenant-1"), + ) + self.assertEqual( + {group_task.id, function_assignment_task.id}, + {item.id for item in page.items}, + ) + self.assertNotIn(account_task.id, {item.id for item in page.items}) + + hidden = self.principal("account-3") + empty = self.service.list_items( + session, + hidden, + query=WorkItemQuery(tenant_id="tenant-1"), + ) + self.assertEqual(0, empty.total) + + def test_admin_can_read_all_tenant_work_but_not_another_tenant(self) -> None: + admin = self.principal( + "admin", + scopes={READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, + ) + with self.Session() as session: + task = self.service.create_task( + session, + admin, + command=self.command( + assignments=(WorkAssignmentRef(kind="account", id="someone-else"),), + ), + ) + page = self.service.list_items( + session, + admin, + query=WorkItemQuery(tenant_id="tenant-1"), + ) + self.assertEqual([task.id], [item.id for item in page.items]) + + with self.assertRaises(TaskForbidden): + self.service.list_items( + session, + admin, + query=WorkItemQuery(tenant_id="tenant-2"), + ) + + def test_capability_enforces_read_and_write_scopes(self) -> None: + without_scopes = self.principal("account-1", scopes=set()) + with self.Session() as session: + with self.assertRaises(TaskForbidden): + self.service.create_task( + session, + without_scopes, + command=self.command(), + ) + with self.assertRaises(TaskForbidden): + self.service.list_items( + session, + without_scopes, + query=WorkItemQuery(tenant_id="tenant-1"), + ) + + def test_transition_uses_revision_and_records_exact_history(self) -> None: + principal = self.principal("account-1") + with self.Session() as session: + created = self.service.create_task( + session, + principal, + command=self.command(), + ) + started = self.service.transition_task( + session, + principal, + tenant_id="tenant-1", + task_id=created.id, + action="start", + expected_revision=1, + comment="Taking responsibility", + ) + self.assertEqual("in_progress", started.status) + self.assertEqual("2", started.revision) + history = started.metadata["transition_history"] + self.assertEqual("open", history[0]["from_status"]) + self.assertEqual("in_progress", history[0]["to_status"]) + + with self.assertRaises(RevisionConflictError): + self.service.transition_task( + session, + principal, + tenant_id="tenant-1", + task_id=created.id, + action="complete", + expected_revision=1, + ) + + def test_invalid_transition_and_hidden_task_are_indistinguishable(self) -> None: + owner = self.principal("account-1") + other = self.principal("account-2") + with self.Session() as session: + task = self.service.create_task(session, owner, command=self.command()) + with self.assertRaises(TaskConflict): + self.service.transition_task( + session, + owner, + tenant_id="tenant-1", + task_id=task.id, + action="reopen", + expected_revision=1, + ) + with self.assertRaises(TaskNotFound): + self.service.get_task( + session, + other, + tenant_id="tenant-1", + task_id=task.id, + ) + + +class TaskManifestTests(unittest.TestCase): + def test_manifest_exposes_static_docs_and_work_provider(self) -> None: + manifest = get_manifest() + self.assertEqual("tasks", manifest.id) + self.assertEqual("tasks.explicit", manifest.work_item_providers[0].id) + self.assertTrue(manifest.documentation) + self.assertEqual("@govoplan/tasks-webui", manifest.frontend.package_name) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..553fee2 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/tasks-webui", + "version": "0.1.18", + "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/tasks.css": "./src/styles/tasks.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/tasks.ts b/webui/src/api/tasks.ts new file mode 100644 index 0000000..2f90547 --- /dev/null +++ b/webui/src/api/tasks.ts @@ -0,0 +1,146 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type WorkStatus = + | "open" + | "in_progress" + | "deferred" + | "blocked" + | "completed" + | "cancelled"; + +export type WorkPriority = "low" | "normal" | "high" | "urgent"; + +export type WorkAssignment = { + kind: "account" | "group" | "role" | "function" | "function_assignment" | "anyone"; + id: string; + label?: string | null; +}; + +export type WorkSource = { + module_id: string; + resource_type: string; + resource_id: string; + revision?: string | null; + url?: string | null; + label?: string | null; +}; + +export type WorkItem = { + id: string; + provider_id: string; + owner_module: string; + tenant_id: string; + title: string; + status: WorkStatus; + priority: WorkPriority; + summary?: string | null; + required_action?: string | null; + action_url?: string | null; + due_at?: string | null; + deferred_until?: string | null; + assignments: WorkAssignment[]; + sources: WorkSource[]; + provenance: Record; + metadata: Record; + revision: string; + etag?: string | null; + created_at?: string | null; + updated_at?: string | null; +}; + +export type WorkProviderDiagnostic = { + provider_id: string; + owner_module: string; + code: string; + message: string; +}; + +export type WorkListResponse = { + items: WorkItem[]; + total: number; + truncated: boolean; + diagnostics: WorkProviderDiagnostic[]; +}; + +export type WorkSummary = { + total: number; + open: number; + in_progress: number; + deferred: number; + blocked: number; + overdue: number; + urgent: number; + truncated: boolean; + diagnostics: WorkProviderDiagnostic[]; +}; + +export type TaskCreatePayload = { + title: string; + summary?: string | null; + priority: WorkPriority; + due_at?: string | null; + required_action?: string | null; + action_url?: string | null; + assignments: WorkAssignment[]; + sources?: WorkSource[]; + provenance?: Record; + metadata?: Record; + idempotency_key: string; +}; + +export function listWork( + settings: ApiSettings, + filters: { + statuses?: WorkStatus[]; + priorities?: WorkPriority[]; + providers?: string[]; + modules?: string[]; + q?: string; + limit?: number; + } = {} +): Promise { + const params = new URLSearchParams(); + filters.statuses?.forEach((value) => params.append("status", value)); + filters.priorities?.forEach((value) => params.append("priority", value)); + filters.providers?.forEach((value) => params.append("provider", value)); + filters.modules?.forEach((value) => params.append("owner_module", value)); + if (filters.q) params.set("q", filters.q); + if (filters.limit) params.set("limit", String(filters.limit)); + const query = params.toString(); + return apiFetch( + settings, + `/api/v1/tasks${query ? `?${query}` : ""}` + ); +} + +export function loadWorkSummary(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/tasks/summary"); +} + +export function createTask( + settings: ApiSettings, + payload: TaskCreatePayload +): Promise { + return apiFetch(settings, "/api/v1/tasks", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function transitionTask( + settings: ApiSettings, + task: WorkItem, + action: "start" | "complete" | "defer" | "reopen" | "cancel", + deferredUntil?: string | null +): Promise { + if (!task.etag) throw new Error("i18n:govoplan-tasks.reason.refresh_required"); + return apiFetch(settings, `/api/v1/tasks/${task.id}/actions`, { + method: "POST", + headers: { "If-Match": task.etag }, + body: JSON.stringify({ + action, + expected_revision: Number(task.revision), + deferred_until: deferredUntil || null + }) + }); +} diff --git a/webui/src/features/tasks/TasksPage.tsx b/webui/src/features/tasks/TasksPage.tsx new file mode 100644 index 0000000..d8f0434 --- /dev/null +++ b/webui/src/features/tasks/TasksPage.tsx @@ -0,0 +1,385 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { + CalendarClock, + Check, + CirclePlay, + ExternalLink, + ListChecks, + Plus, + RefreshCw, + RotateCcw, + Search, + XCircle +} from "lucide-react"; +import { Link } from "react-router"; +import { + AdminIconButton, + Button, + DateTimeField, + Dialog, + DismissibleAlert, + DocumentationHelpLink, + FormField, + SegmentedControl, + SelectionList, + SelectionListItem, + StatusBadge, + hasScope, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + createTask, + listWork, + transitionTask, + type TaskCreatePayload, + type WorkItem, + type WorkPriority, + type WorkStatus +} from "../../api/tasks"; + +type StatusView = "active" | "completed" | "all"; + +const ACTIVE_STATUSES: WorkStatus[] = ["open", "in_progress", "deferred", "blocked"]; +const CLOSED_STATUSES: WorkStatus[] = ["completed", "cancelled"]; +const ALL_STATUSES: WorkStatus[] = [...ACTIVE_STATUSES, ...CLOSED_STATUSES]; +const DOCUMENTATION = { + contextId: "tasks.page.inbox", + topicId: "tasks.work-inbox", + documentationType: "user" as const +}; + +export default function TasksPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + const [items, setItems] = useState([]); + const [selectedKey, setSelectedKey] = useState(""); + const [statusView, setStatusView] = useState("active"); + const [searchDraft, setSearchDraft] = useState(""); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [diagnostics, setDiagnostics] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + const [deferOpen, setDeferOpen] = useState(false); + const [deferredUntil, setDeferredUntil] = useState(""); + + const canWrite = hasScope(auth, "tasks:item:write"); + const selected = useMemo( + () => items.find((item) => workKey(item) === selectedKey) ?? items[0] ?? null, + [items, selectedKey] + ); + + useEffect(() => { + void load(); + }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusView, query]); + + async function load() { + setLoading(true); + setError(""); + try { + const response = await listWork(settings, { + statuses: statusView === "active" ? ACTIVE_STATUSES : statusView === "completed" ? CLOSED_STATUSES : ALL_STATUSES, + q: query, + limit: 500 + }); + setItems(response.items); + setDiagnostics(response.diagnostics.map((item) => `${item.owner_module}: ${item.message}`)); + setSelectedKey((current) => response.items.some((item) => workKey(item) === current) ? current : response.items[0] ? workKey(response.items[0]) : ""); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setLoading(false); + } + } + + async function runAction(action: "start" | "complete" | "defer" | "reopen" | "cancel") { + if (!selected || selected.provider_id !== "tasks.explicit") return; + setBusy(true); + setError(""); + try { + const next = await transitionTask( + settings, + selected, + action, + action === "defer" ? localDateTimeToIso(deferredUntil) : null + ); + setItems((current) => current.map((item) => item.id === next.id ? next : item)); + setDeferOpen(false); + setDeferredUntil(""); + await load(); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setQuery(searchDraft.trim()); + } + + return ( +
+
+ + +
+
+ {selected?.title ?? "i18n:govoplan-tasks.work_details"} + + + {selected?.provider_id === "tasks.explicit" && canWrite ? action === "defer" ? setDeferOpen(true) : void runAction(action)} /> : null} + +
+ + {error ? {error} : null} + {diagnostics.map((message) => {message})} + + {selected ? : ( +

i18n:govoplan-tasks.work

i18n:govoplan-tasks.select_help

+ )} +
+
+ + setCreateOpen(false)} + onCreated={async (item) => { + setCreateOpen(false); + setSelectedKey(workKey(item)); + await load(); + }} + onError={setError} + setBusy={setBusy} + /> + setDeferOpen(false)} + closeDisabled={busy} + portal + helpContextId="tasks.action.advance" + footer={<>} + > + + + + +
+ ); +} + +function TaskActions({ item, busy, onAction }: { item: WorkItem; busy: boolean; onAction: (action: "start" | "complete" | "defer" | "reopen" | "cancel") => void }) { + if (["completed", "cancelled"].includes(item.status)) { + return ; + } + return ( + <> + {["open", "deferred"].includes(item.status) ? : null} + + + + + ); +} + +function TaskDetails({ item }: { item: WorkItem }) { + const safeAction = safeActionUrl(item.action_url); + return ( +
+
+
{moduleLabel(item.owner_module)}{item.due_at ? {dueLabel(item.due_at)} : null}
+

{item.title}

+ {item.summary ?

{item.summary}

: null} + {item.required_action ?
i18n:govoplan-tasks.required_action{item.required_action}
: null} + {safeAction ? i18n:govoplan-tasks.open_work : null} +
+
+

i18n:govoplan-tasks.context

+
+
i18n:govoplan-tasks.source_module
{moduleLabel(item.owner_module)}
+
i18n:govoplan-tasks.provider
{item.provider_id}
+
i18n:govoplan-tasks.assigned_to
{item.assignments.map(assignmentLabel).join(", ") || "i18n:govoplan-tasks.not_set"}
+
i18n:govoplan-tasks.updated
{formatDate(item.updated_at)}
+
+
+ {item.sources.length ?

i18n:govoplan-tasks.sources

{item.sources.map((source) =>
{source.label || `${source.resource_type} ${source.resource_id}`}{moduleLabel(source.module_id)}{source.revision ? ` · ${source.revision}` : ""}
)}
: null} +
+ ); +} + +function CreateTaskDialog({ open, busy, settings, auth, onClose, onCreated, onError, setBusy }: { open: boolean; busy: boolean; settings: ApiSettings; auth: AuthInfo; onClose: () => void; onCreated: (item: WorkItem) => Promise; onError: (message: string) => void; setBusy: (value: boolean) => void }) { + const [title, setTitle] = useState(""); + const [summary, setSummary] = useState(""); + const [requiredAction, setRequiredAction] = useState(""); + const [priority, setPriority] = useState("normal"); + const [dueAt, setDueAt] = useState(""); + const accountId = auth.principal?.account_id || auth.user.account_id; + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!title.trim() || !accountId) return; + setBusy(true); + onError(""); + const payload: TaskCreatePayload = { + title: title.trim(), + summary: summary.trim() || null, + required_action: requiredAction.trim() || null, + priority, + due_at: dueAt ? localDateTimeToIso(dueAt) : null, + assignments: [{ kind: "account", id: accountId, label: auth.user.display_name || auth.user.email }], + idempotency_key: crypto.randomUUID() + }; + try { + const item = await createTask(settings, payload); + setTitle(""); + setSummary(""); + setRequiredAction(""); + setPriority("normal"); + setDueAt(""); + await onCreated(item); + } catch (reason) { + onError(errorMessage(reason)); + } finally { + setBusy(false); + } + } + + return ( + } + > +
+ setTitle(event.target.value)} maxLength={500} autoFocus required /> +