11 Commits
Author SHA1 Message Date
zemion faf6b3305a docs(tasks): complete German workflow coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 06:50:34 +02:00
zemion 88bd0e6aae feat(tasks): add governed DSAR coverage 2026-08-21 04:21:01 +02:00
zemion 39bb6c0d18 feat(webui): complete bounded work quick access 2026-08-19 19:28:14 +02:00
zemion a8646e76a8 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion e8076700b0 style: use shared WebUI foundation tokens 2026-08-18 21:32:50 +02:00
zemion f4739efd86 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion b9c2d061e5 Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 4ec5d56055 Adopt shared WebUI layout primitives 2026-08-18 10:42:54 +02:00
zemion db76260010 Release Tasks package v0.1.19
Module Package Release / publish-packages (push) Successful in 11s
2026-08-06 20:11:12 +02:00
zemion 8e4a247308 Contribute Tasks to product presentation 2026-08-06 19:02:54 +02:00
zemion 3cdab599ff Implement unified work inbox module 2026-08-06 16:06:17 +02:00
35 changed files with 4734 additions and 0 deletions
+270
View File
@@ -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
+7
View File
@@ -3,3 +3,10 @@
<!-- govoplan-repository-type:start --> <!-- govoplan-repository-type:start -->
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
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).
+65
View File
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@govoplan/tasks",
"version": "0.1.20",
"private": true,
"description": "Governed work items and unified work inbox for GovOPlaN.",
"type": "module",
"peerDependencies": {}
}
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-tasks"
version = "0.1.20"
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"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Tasks module."""
__version__ = "0.1.20"
+1
View File
@@ -0,0 +1 @@
"""Tasks backend."""
+102
View File
@@ -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"]
@@ -0,0 +1,3 @@
from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem
__all__ = ["TaskAssignment", "TaskItem"]
+140
View File
@@ -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"]
+410
View File
@@ -0,0 +1,410 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem
TASKS_DSAR_CAPABILITY = dsar_capability_name("tasks")
_MAX_RECORDS = 5_000
_MAX_SOURCES = 100
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str | None
actor_ids: tuple[str, ...]
task_id: str | None
class TasksDsarProvider:
provider_id = "tasks"
module_id = "tasks"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
assigned_ids: set[str] = set()
if selectors.account_id:
assigned = (
db.query(TaskAssignment.task_id)
.join(TaskItem, TaskAssignment.task_id == TaskItem.id)
.filter(
TaskItem.tenant_id == tenant_id,
TaskAssignment.tenant_id == tenant_id,
TaskAssignment.assignment_kind == "account",
TaskAssignment.assignment_id == selectors.account_id,
)
)
if selectors.task_id:
assigned = assigned.filter(TaskItem.id == selectors.task_id)
rows = assigned.limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Tasks DSAR assignment result limit exceeded; narrow the selectors."
)
assigned_ids = {str(task_id) for (task_id,) in rows}
actor_query = db.query(TaskItem.id).filter(
TaskItem.tenant_id == tenant_id,
or_(
TaskItem.created_by.in_(selectors.actor_ids),
TaskItem.updated_by.in_(selectors.actor_ids),
TaskItem.completed_by.in_(selectors.actor_ids),
),
)
if selectors.task_id:
actor_query = actor_query.filter(TaskItem.id == selectors.task_id)
actor_rows = actor_query.limit(_MAX_RECORDS + 1).all()
if len(actor_rows) > _MAX_RECORDS:
raise ValueError(
"Tasks DSAR actor result limit exceeded; narrow the selectors."
)
actor_ids = {str(task_id) for (task_id,) in actor_rows}
task_ids = assigned_ids | actor_ids
if len(task_ids) > _MAX_RECORDS:
raise ValueError("Tasks DSAR result limit exceeded; narrow the selectors.")
if not task_ids:
return ()
tasks = (
db.query(TaskItem)
.filter(
TaskItem.tenant_id == tenant_id,
TaskItem.id.in_(task_ids),
)
.order_by(TaskItem.created_at, TaskItem.id)
.all()
)
return tuple(
_assigned_task_record(
task,
account_id=selectors.account_id,
actor_ids=selectors.actor_ids,
)
if task.id in assigned_ids
else _actor_attribution_record(task, selectors.actor_ids)
for task in tasks
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Tasks DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type == "assigned_task":
actions.append(
DsarErasureActionRef(
action_id=f"tasks:manual_review:assigned_task:{record.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=(
"The account assignment and task content may be shared "
"institutional work. Its source owner and retention state "
"must be reviewed before detachment or minimization."
),
executable=False,
)
)
else:
actions.append(
DsarErasureActionRef(
action_id=(
f"tasks:retain:task_actor_attribution:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Task lifecycle attribution is accountability evidence.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Tasks DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"retain", "manual_review"}:
raise ValueError("Tasks DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The Task remains unchanged pending its institutional "
"retention and source-owner review."
if action.kind == "manual_review"
else "Task lifecycle attribution remains immutable evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("tasks.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("tasks.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("tasks.identity"),
references.get("identity.id"),
),
"task_id": _coalesce(
references.get("tasks.task"),
references.get("tasks.item"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for key in ("account_id", "membership_id", "identity_id")
if (value := _optional_string(values[key]))
)
)
if not actor_ids:
return None
return _SubjectSelectors(
account_id=_optional_string(values["account_id"]),
actor_ids=actor_ids,
task_id=_optional_string(values["task_id"]),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _assigned_task_record(
task: TaskItem,
*,
account_id: str | None,
actor_ids: Sequence[str],
) -> DsarRecordRef:
assignments = [
{
"id": assignment.id,
"kind": assignment.assignment_kind,
"assignment_id": assignment.assignment_id,
"label": (assignment.assignment_label or "")[:500] or None,
}
for assignment in task.assignments
if account_id
and assignment.assignment_kind == "account"
and assignment.assignment_id == account_id
]
if len(assignments) > 100:
raise ValueError("Task account assignments exceed the DSAR bound.")
return DsarRecordRef(
provider_id="tasks",
module_id="tasks",
resource_type="assigned_task",
resource_id=task.id,
category="assigned_institutional_work",
title=f"Assigned task: {task.title[:500]}",
data={
"title": task.title[:500],
"summary": (task.summary or "")[:4_000] or None,
"status": task.status,
"priority": task.priority,
"required_action": (task.required_action or "")[:500] or None,
"action_url": (task.action_url or "")[:1_500] or None,
"due_at": _iso(task.due_at),
"deferred_until": _iso(task.deferred_until),
"completed_at": _iso(task.completed_at),
"cancelled_at": _iso(task.cancelled_at),
"revision": task.revision,
"assignments": assignments,
"sources": _source_projection(task.sources),
"actor_activities": _actor_activities(task, actor_ids),
"created_at": _iso(task.created_at),
"updated_at": _iso(task.updated_at),
},
observed_at=_aware(task.updated_at),
retention_reason=(
"The task may be shared institutional work and requires source-owner "
"and retention review before its account assignment can be changed."
),
)
def _actor_attribution_record(
task: TaskItem,
actor_ids: Sequence[str],
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="tasks",
module_id="tasks",
resource_type="task_actor_attribution",
resource_id=task.id,
category="operator_accountability_evidence",
title="Task lifecycle attribution",
data={
"activities": _actor_activities(task, actor_ids),
"status": task.status,
"priority": task.priority,
"source_module": task.source_module,
"source_resource_type": task.source_resource_type,
"source_resource_id": task.source_resource_id,
"source_revision": task.source_revision,
"revision": task.revision,
"created_at": _iso(task.created_at),
"updated_at": _iso(task.updated_at),
"completed_at": _iso(task.completed_at),
},
observed_at=_aware(task.updated_at),
immutable_evidence=True,
retention_reason=(
"Task creation, update, and completion attribution is immutable "
"accountability evidence; task content and metadata are excluded."
),
)
def _actor_activities(task: TaskItem, actor_ids: Sequence[str]) -> list[str]:
actor_set = set(actor_ids)
activities = []
if task.created_by in actor_set:
activities.append("created_task")
if task.updated_by in actor_set:
activities.append("updated_task")
if task.completed_by in actor_set:
activities.append("completed_task")
return activities
def _source_projection(value: object) -> list[dict[str, str | None]]:
if not isinstance(value, list) or len(value) > _MAX_SOURCES:
raise ValueError("Tasks source references exceed the DSAR bound.")
projected: list[dict[str, str | None]] = []
for item in value:
if not isinstance(item, Mapping):
raise ValueError("Task source reference is invalid.")
projected.append(
{
"module_id": _bounded(item.get("module_id"), 100),
"resource_type": _bounded(item.get("resource_type"), 100),
"resource_id": _bounded(item.get("resource_id"), 255),
"revision": _bounded(item.get("revision"), 255),
"url": _bounded(item.get("url"), 1_500),
"label": _bounded(item.get("label"), 500),
}
)
return projected
def _bounded(value: object, limit: int) -> str | None:
return str(value)[:limit] if value is not None else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Tasks DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "tasks" or record.module_id != "tasks":
raise ValueError("Tasks DSAR cannot plan a foreign provider record.")
if (
record.resource_type
not in {
"assigned_task",
"task_actor_attribution",
}
or not record.resource_id
):
raise ValueError("Tasks DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "tasks" or action.module_id != "tasks":
raise ValueError("Tasks DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("tasks:"):
raise ValueError("Tasks DSAR action identity is invalid.")
__all__ = ["TASKS_DSAR_CAPABILITY", "TasksDsarProvider"]
+507
View File
@@ -0,0 +1,507 @@
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,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
QuickAccessTool,
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.dsar_provider import (
TASKS_DSAR_CAPABILITY,
TasksDsarProvider,
)
from govoplan_tasks.backend.service import SqlTaskService
MODULE_ID = "tasks"
MODULE_NAME = "Tasks"
MODULE_VERSION = "0.1.20"
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 _dsar_provider(_context: ModuleContext) -> TasksDsarProvider:
return TasksDsarProvider()
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.data-subject-requests",
title="Task data-subject requests",
summary=(
"Export account-assigned task data and lifecycle attribution while "
"keeping shared institutional work under owner review."
),
body=(
"Tasks correlates exact account and actor identifiers only inside the "
"active tenant. Account-assigned explicit Tasks contribute bounded "
"title, summary, required action, lifecycle state, due dates, the exact "
"matching account assignment, source references, and any create, update, "
"or completion activities performed by the subject. Group, role, function, "
"and anyone visibility is not inferred from external directories and other "
"assignment targets are excluded. When the subject acted on a Task without "
"being its direct account assignee, only minimized lifecycle attribution "
"and source identity are exported. Provenance, arbitrary metadata, request "
"hashes, idempotency keys, and source-module payloads are excluded; source "
"references are never traversed. Task attribution is retained as immutable "
"accountability evidence. Assignment or content erasure requires manual "
"source-owner and retention review because a Task can be shared institutional "
"work; the provider performs no automatic mutation."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
related_modules=("core", "workflow_engine", "approvals", "notifications"),
translations={
"de": {
"title": "Betroffenenanfragen für Aufgaben",
"summary": (
"Kontobezogene Aufgabendaten und Lebenszykluszuordnungen exportieren, "
"während gemeinsam verantwortete institutionelle Arbeit der fachlichen Prüfung unterliegt."
),
"body": (
"Tasks gleicht ausschließlich exakte Konto- und Akteurskennungen innerhalb des aktiven Mandanten ab. "
"Direkt einem Konto zugewiesene Aufgaben tragen begrenzte Angaben zu Titel, Zusammenfassung, erforderlicher "
"Handlung, Lebenszyklusstatus, Fristen, exakter Kontozuweisung, Quellverweisen sowie vom Betroffenen ausgeführten "
"Erstellungs-, Änderungs- oder Abschlussaktivitäten bei. Sichtbarkeit für Gruppen, Rollen, Funktionen oder alle "
"wird nicht aus externen Verzeichnissen abgeleitet; andere Zuweisungsziele bleiben ausgeschlossen. Hat die "
"betroffene Person an einer Aufgabe gehandelt, ohne deren direkte Kontozuweisung zu sein, werden nur minimierte "
"Lebenszykluszuordnung und Quellidentität exportiert. Herkunftsmetadaten, beliebige Metadaten, Anfrage-Hashes, "
"Idempotenzschlüssel und Nutzdaten des Quellmoduls bleiben ausgeschlossen; Quellverweise werden niemals verfolgt. "
"Aufgabenzuordnungen bleiben als unveränderlicher Verantwortungsnachweis erhalten. Die Löschung einer Zuweisung "
"oder von Inhalten erfordert eine manuelle Prüfung durch Quellverantwortliche und Aufbewahrungsstelle, da eine "
"Aufgabe gemeinsam verantwortete institutionelle Arbeit sein kann; der Anbieter nimmt keine automatische Änderung vor."
),
}
},
metadata={
"help_contexts": [
"tasks.page.inbox",
"tasks.page.detail",
"tasks.field.assignment",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_assigned_task": (
"Returns bounded Task-owned data and exact account assignment only."
),
"review_assignment_erasure": (
"Requires the Task source owner and retention authority before "
"changing shared work."
),
"retain_actor_attribution": (
"Preserves minimized Task lifecycle accountability evidence."
),
},
},
),
DocumentationTopic(
id="tasks.quick-access-and-product-area",
title="Work in product navigation and Quick Access",
summary="Keep assigned work available in the Work area and the optional right-side Quick Access rail.",
body=(
"Tasks contributes its authorized workspace to the Work product area. When Quick Access is enabled, "
"a bounded seven-item authorized inbox and detail can appear beside the current page. Explicit Tasks can be "
"started or completed there; work from another provider exposes only that provider's launch path. Every load "
"and command is rechecked by Tasks, and completion returns a typed work-item reference to the host. Views may "
"hide or reorder the contribution, but neither presentation grants task access or copies completion state."
),
layer="configured",
documentation_types=("user", "admin"),
audience=("user", "tenant_admin", "module_admin"),
related_modules=("quick_access", "views"),
translations={
"de": {
"title": "Arbeit in Produktnavigation und Schnellzugriff",
"summary": "Zugewiesene Arbeit im Produktbereich Arbeit und optional in der rechten Schnellzugriffsleiste verwenden.",
"body": (
"Tasks ordnet den berechtigten Arbeitsbereich dem Produktbereich Arbeit zu. Ist der Schnellzugriff aktiviert, "
"kann ein begrenzter, berechtigungsgeprüfter Arbeitsvorrat mit sieben Einträgen und Details neben der "
"aktuellen Seite erscheinen. Explizite Tasks lassen sich dort beginnen oder abschließen; fremde Quellen "
"behalten ihre eigenen Befehle und Startpfade. Jeder Aufruf wird erneut durch Tasks geprüft. Ansichten "
"dürfen den Beitrag ausblenden oder ordnen, erteilen aber keine Aufgabenberechtigung."
),
}
},
metadata={"kind": "reference", "help_contexts": ["tasks.quick_access.work"]},
order=9,
),
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",
),
conditions=(
DocumentationCondition(
required_modules=("tasks",),
required_scopes=(READ_SCOPE,),
),
),
links=(
DocumentationLink(
label="Tasks domain",
href="govoplan-tasks/docs/TASKS_DOMAIN.md",
kind="repository",
),
),
translations={
"de": {
"title": "Gemeinsamer Arbeitsvorrat",
"summary": "Explizite Aufgaben und Arbeitsvorgänge anderer Module sicher fortsetzen.",
"body": (
"Der Arbeitsvorrat verbindet explizite Aufgaben mit Arbeitsobjekten aktivierter Module. "
"Jede Quelle behält die Verantwortung für Befehle und Abschlussstatus. Tasks kopiert "
"keine Workflow-Übergabe, Postfachnachricht, Freigabe oder Benachrichtigung in einen "
"zweiten Fachzustand. Filter, Fristen, Prioritäten und Quellverweise helfen beim sicheren Fortsetzen."
),
}
},
metadata={
"kind": "workflow",
"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"),
ModuleInterfaceProvider(name=TASKS_DSAR_CAPABILITY, version="0.1.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,
),
ViewSurface(
id="tasks.quick_access.work",
module_id=MODULE_ID,
kind="quick_access",
label="Work Quick Access",
order=70,
),
),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("tasks.route.work",),
order=10,
),
),
quick_access_tools=(
QuickAccessTool(
id="tasks.work",
module_id=MODULE_ID,
category_id="work",
label="i18n:govoplan-tasks.work",
description="i18n:govoplan-tasks.quick_access_description",
surface_id="tasks.quick_access.work",
icon="list-checks",
full_page_path="/tasks",
required_any=(READ_SCOPE,),
order=10,
modes=("browse", "resume"),
returned_reference_kinds=("tasks.work-item",),
help_context_id="tasks.quick_access.work",
),
),
),
tenant_summary_providers=(_tenant_summary,),
capability_factories={
CAPABILITY_TASK_COMMANDS: _service,
TASKS_DSAR_CAPABILITY: _dsar_provider,
},
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",
),
TASKS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Tasks data-subject request provider",
summary=(
"Exports account-assigned Tasks and minimized actor attribution "
"with governed non-executable erasure outcomes."
),
contract_version="0.1.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",
]
@@ -0,0 +1 @@
@@ -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")
@@ -0,0 +1 @@
+343
View File
@@ -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"]
+118
View File
@@ -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",
]
+516
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+376
View File
@@ -0,0 +1,376 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_tasks.backend.db.models import TaskAssignment, TaskItem
from govoplan_tasks.backend.dsar_provider import (
TASKS_DSAR_CAPABILITY,
TasksDsarProvider,
)
from govoplan_tasks.backend.manifest import manifest
class _Registry:
def __init__(self, provider: TasksDsarProvider, *, active: bool = True) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (TASKS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "tasks"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("tasks",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "tasks"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != TASKS_DSAR_CAPABILITY:
raise KeyError(name)
class TasksDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = TasksDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _task(
self,
task_id: str,
*,
tenant_id: str = "tenant-1",
created_by: str = "account-other",
updated_by: str = "account-other",
completed_by: str | None = None,
) -> TaskItem:
return TaskItem(
id=task_id,
tenant_id=tenant_id,
title=f"Private title for {task_id}",
summary=f"Private summary for {task_id}",
status="completed" if completed_by else "open",
priority="high",
required_action="Review the source decision",
action_url="/cases/case-1",
source_module="cases",
source_resource_type="case",
source_resource_id="case-1",
source_revision="4",
sources=[
{
"module_id": "cases",
"resource_type": "case",
"resource_id": "case-1",
"revision": "4",
"url": "/cases/case-1",
"label": "Case reference",
}
],
provenance={"secret": "provenance-secret-do-not-export"},
metadata_={"secret": "metadata-secret-do-not-export"},
revision=2,
idempotency_key=f"idempotency-{task_id}-do-not-export",
request_sha256="a" * 64,
created_by=created_by,
updated_by=updated_by,
completed_by=completed_by,
)
def _seed(self) -> None:
assigned = self._task("task-assigned")
assigned.assignments.extend(
(
TaskAssignment(
id="assignment-account",
tenant_id="tenant-1",
assignment_kind="account",
assignment_id="account-1",
assignment_label="Resident account",
),
TaskAssignment(
id="assignment-group",
tenant_id="tenant-1",
assignment_kind="group",
assignment_id="group-private",
assignment_label="Private group label do not export",
),
)
)
actor_only = self._task(
"task-actor-only",
created_by="account-1",
updated_by="account-1",
completed_by="account-1",
)
actor_only.assignments.append(
TaskAssignment(
id="assignment-other",
tenant_id="tenant-1",
assignment_kind="account",
assignment_id="account-other",
assignment_label="Other account",
)
)
other = self._task("task-other")
other.assignments.append(
TaskAssignment(
id="assignment-other-task",
tenant_id="tenant-1",
assignment_kind="account",
assignment_id="account-other",
)
)
other_tenant = self._task(
"task-other-tenant",
tenant_id="tenant-2",
created_by="account-1",
updated_by="account-1",
)
other_tenant.assignments.append(
TaskAssignment(
id="assignment-other-tenant",
tenant_id="tenant-2",
assignment_kind="account",
assignment_id="account-1",
)
)
self.session.add_all((assigned, actor_only, other, other_tenant))
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1")
def test_search_exports_assigned_task_and_minimized_actor_attribution(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
)
by_id = {record.resource_id: record for record in records}
self.assertEqual(
{"task-assigned", "task-actor-only"},
set(by_id),
)
self.assertEqual("assigned_task", by_id["task-assigned"].resource_type)
self.assertEqual(
"task_actor_attribution",
by_id["task-actor-only"].resource_type,
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("Private summary for task-assigned", exported)
self.assertIn('"module_id": "cases"', exported)
self.assertIn('"resource_id": "case-1"', exported)
self.assertIn("completed_task", exported)
self.assertNotIn("Private summary for task-actor-only", exported)
self.assertNotIn("Private group label do not export", exported)
self.assertNotIn("group-private", exported)
self.assertNotIn("provenance-secret-do-not-export", exported)
self.assertNotIn("metadata-secret-do-not-export", exported)
self.assertNotIn("idempotency-task-assigned-do-not-export", exported)
self.assertNotIn("task-other-tenant", exported)
def test_exact_task_reference_narrows_and_conflicts_fail_closed(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"tasks.task": "task-assigned"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"tasks.account": "account-other"},
),
)
reference_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={"tasks.task": "task-assigned"}),
)
self.assertEqual(["task-assigned"], [item.resource_id for item in narrowed])
self.assertEqual((), conflict)
self.assertEqual((), reference_only)
def test_erasure_requires_review_or_retention_and_changes_nothing(self) -> None:
subject = self._subject()
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(
{"manual_review", "retain"},
{action.kind for action in actions},
)
self.assertTrue(all(not action.executable for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertIsNotNone(self.session.get(TaskItem, "task-assigned"))
self.assertIsNotNone(self.session.get(TaskAssignment, "assignment-account"))
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = self._subject()
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="workflow_engine",
module_id="workflow_engine",
resource_type="assigned_task",
resource_id="task-assigned",
category="work",
title="Foreign task",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="workflow_engine:retain:task:task-assigned",
provider_id="workflow_engine",
module_id="workflow_engine",
kind="retain",
resource_type="task_actor_attribution",
resource_id="task-assigned",
title="Retain task",
rationale="Foreign action",
executable=False,
),
),
request_id="dsar-1",
)
def test_core_workflow_and_manifest_register_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-TASKS-1",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual([TASKS_DSAR_CAPABILITY], row.coverage["provider_capabilities"])
self.assertEqual(2, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-TASKS-2",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual(
[TASKS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertIn(TASKS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(TASKS_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
TASKS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "tasks.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -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()
+293
View File
@@ -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()
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_tasks.backend.manifest import get_manifest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
class TasksQuickAccessContractTests(unittest.TestCase):
def test_public_topics_have_complete_german_workflow_and_reference_coverage(
self,
) -> None:
topics = get_manifest().documentation
kinds = {topic.metadata.get("kind", "system") for topic in topics}
self.assertEqual(3, len(topics))
self.assertTrue({"workflow", "reference"}.issubset(kinds))
for topic in topics:
translation = topic.translations["de"]
self.assertEqual({"title", "summary", "body"}, set(translation))
self.assertTrue(
all(str(translation[field]).strip() for field in translation)
)
def test_manifest_declares_typed_work_result_and_help(self) -> None:
tool = get_manifest().frontend.quick_access_tools[0]
self.assertEqual("tasks.work", tool.id)
self.assertEqual(("tasks.work-item",), tool.returned_reference_kinds)
self.assertEqual("tasks.quick_access.work", tool.help_context_id)
self.assertEqual("/tasks", tool.full_page_path)
def test_renderer_is_bounded_and_keeps_commands_source_owned(self) -> None:
source = (
REPOSITORY_ROOT
/ "webui"
/ "src"
/ "features"
/ "tasks"
/ "TasksQuickAccess.tsx"
).read_text()
self.assertIn("limit: 7", source)
self.assertIn('selected.provider_id !== "tasks.explicit"', source)
self.assertIn("transitionTask(settings, selected, action)", source)
self.assertIn("quickAccessLaunchState(launchContext)", source)
self.assertIn('kind: "work-item"', source)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/tasks-webui",
"version": "0.1.20",
"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
}
}
}
+146
View File
@@ -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<string, unknown>;
metadata: Record<string, unknown>;
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<string, unknown>;
metadata?: Record<string, unknown>;
idempotency_key: string;
};
export function listWork(
settings: ApiSettings,
filters: {
statuses?: WorkStatus[];
priorities?: WorkPriority[];
providers?: string[];
modules?: string[];
q?: string;
limit?: number;
} = {}
): Promise<WorkListResponse> {
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<WorkListResponse>(
settings,
`/api/v1/tasks${query ? `?${query}` : ""}`
);
}
export function loadWorkSummary(settings: ApiSettings): Promise<WorkSummary> {
return apiFetch<WorkSummary>(settings, "/api/v1/tasks/summary");
}
export function createTask(
settings: ApiSettings,
payload: TaskCreatePayload
): Promise<WorkItem> {
return apiFetch<WorkItem>(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<WorkItem> {
if (!task.etag) throw new Error("i18n:govoplan-tasks.reason.refresh_required");
return apiFetch<WorkItem>(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
})
});
}
+403
View File
@@ -0,0 +1,403 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
CalendarClock,
Check,
CirclePlay,
ExternalLink,
ListChecks,
Plus,
RotateCcw,
Search,
XCircle
} from "lucide-react";
import { Link } from "react-router";
import { FormGrid,
Button,
DateTimeField,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
FilterBar,
SegmentedControl,
SelectionList,
SelectionListItem,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
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<WorkItem[]>([]);
const [selectedKey, setSelectedKey] = useState("");
const [statusView, setStatusView] = useState<StatusView>("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<string[]>([]);
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 (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="tasks-page" label="Task inbox" data-help-context-id="tasks.page.inbox">
<WorkspaceLayout
variant="split"
primarySize="default"
primaryScrollable={false}
contentScrollable={false}
surface="contained"
primaryClassName="tasks-sidebar"
contentClassName="tasks-workspace"
primaryLabel="i18n:govoplan-tasks.work"
contentLabel="i18n:govoplan-tasks.work_details"
interfaceId="tasks.inbox.workspace"
helpContextId="tasks.page.inbox"
helpModuleId="tasks"
primary={<>
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "i18n:govoplan-tasks.refresh" }}
className="tasks-sidebar-bar"
contextActions={<span className="tasks-title"><ListChecks size={18} /><strong>i18n:govoplan-tasks.work</strong></span>}
createAction={canWrite ? (
<Button
variant="primary"
onClick={() => setCreateOpen(true)}
disabled={busy}
helpContextId="tasks.action.create"
>
<Plus size={16} aria-hidden="true" /> i18n:govoplan-tasks.create_task
</Button>
) : undefined}
/>
<FilterBar as="form" surface="control" wrap="never" className="tasks-search" onSubmit={submitSearch} role="search">
<Search size={15} aria-hidden="true" />
<input
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
placeholder="i18n:govoplan-tasks.search_placeholder"
aria-label="i18n:govoplan-tasks.search"
/>
</FilterBar>
<SegmentedControl
className="tasks-status-filter"
options={[
{ id: "active", label: "i18n:govoplan-tasks.active" },
{ id: "completed", label: "i18n:govoplan-tasks.completed" },
{ id: "all", label: "i18n:govoplan-tasks.all" }
]}
value={statusView}
onChange={setStatusView}
ariaLabel="i18n:govoplan-tasks.status_filter"
width="fill"
/>
<div className="tasks-list">
{loading ? <p className="tasks-note">i18n:govoplan-tasks.loading</p> : null}
{!loading && items.length === 0 ? <p className="tasks-note">i18n:govoplan-tasks.empty</p> : null}
{items.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-tasks.work_items">
{items.map((item) => (
<SelectionListItem
key={`${item.provider_id}:${item.id}`}
selected={selected ? workKey(selected) === workKey(item) : false}
onClick={() => setSelectedKey(workKey(item))}
className="tasks-list-item"
>
<span className="tasks-list-heading"><strong>{item.title}</strong><StatusBadge status={item.status} label={statusLabel(item.status)} /></span>
<span className="tasks-list-meta"><span>{moduleLabel(item.owner_module)}</span><span>{dueLabel(item.due_at)}</span></span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
</>}
>
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="tasks-topbar"
data-help-context-id="tasks.page.detail"
contextActions={<span className="tasks-detail-title"><ListChecks size={18} /><strong>{selected?.title ?? "i18n:govoplan-tasks.work_details"}</strong></span>}
helpAction={<DocumentationHelpLink reference={DOCUMENTATION} />}
primaryActions={selected?.provider_id === "tasks.explicit" && canWrite ? (
<TaskPrimaryActions item={selected} busy={busy} onAction={(action) => action === "defer" ? setDeferOpen(true) : void runAction(action)} />
) : undefined}
destructiveActions={selected?.provider_id === "tasks.explicit" && canWrite && !["completed", "cancelled"].includes(selected.status) ? (
<Button variant="danger" onClick={() => void runAction("cancel")} disabled={busy}><XCircle size={15} /> i18n:govoplan-tasks.cancel_task</Button>
) : undefined}
/>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
{diagnostics.map((message) => <DismissibleAlert key={message} tone="warning" compact resetKey={message}>{message}</DismissibleAlert>)}
{selected ? <TaskDetails item={selected} /> : (
<StatePanel size="fill" icon={<ListChecks size={24} />} title="i18n:govoplan-tasks.work" description="i18n:govoplan-tasks.select_help" />
)}
</WorkspaceLayout>
<CreateTaskDialog
open={createOpen}
busy={busy}
settings={settings}
auth={auth}
onClose={() => setCreateOpen(false)}
onCreated={async (item) => {
setCreateOpen(false);
setSelectedKey(workKey(item));
await load();
}}
onError={setError}
setBusy={setBusy}
/>
<Dialog
open={deferOpen}
title="i18n:govoplan-tasks.defer_task"
onClose={() => setDeferOpen(false)}
closeDisabled={busy}
portal
helpContextId="tasks.action.advance"
footer={<><Button onClick={() => setDeferOpen(false)} disabled={busy}>i18n:govoplan-tasks.cancel</Button><Button variant="primary" onClick={() => void runAction("defer")} disabled={!deferredUntil || busy}>i18n:govoplan-tasks.defer</Button></>}
>
<FormField label="i18n:govoplan-tasks.resume_at" helpContextId="tasks.field.due-at">
<DateTimeField value={deferredUntil} onChange={setDeferredUntil} min={localDateTime(new Date())} />
</FormField>
</Dialog>
</WorkspaceFrame>
);
}
function TaskPrimaryActions({ item, busy, onAction }: { item: WorkItem; busy: boolean; onAction: (action: "start" | "complete" | "defer" | "reopen") => void }) {
if (["completed", "cancelled"].includes(item.status)) {
return <Button onClick={() => onAction("reopen")} disabled={busy}><RotateCcw size={15} /> i18n:govoplan-tasks.reopen</Button>;
}
return (
<>
{["open", "deferred"].includes(item.status) ? <Button onClick={() => onAction("start")} disabled={busy}><CirclePlay size={15} /> i18n:govoplan-tasks.start</Button> : null}
<Button variant="primary" onClick={() => onAction("complete")} disabled={busy}><Check size={15} /> i18n:govoplan-tasks.complete</Button>
<Button onClick={() => onAction("defer")} disabled={busy}><CalendarClock size={15} /> i18n:govoplan-tasks.defer</Button>
</>
);
}
function TaskDetails({ item }: { item: WorkItem }) {
const safeAction = safeActionUrl(item.action_url);
return (
<div className="tasks-detail">
<section className="tasks-detail-main">
<div className="tasks-detail-meta"><StatusBadge status={item.status} label={statusLabel(item.status)} /><StatusBadge status={item.priority} label={priorityLabel(item.priority)} /><span>{moduleLabel(item.owner_module)}</span>{item.due_at ? <span>{dueLabel(item.due_at)}</span> : null}</div>
<h1>{item.title}</h1>
{item.summary ? <p>{item.summary}</p> : null}
{item.required_action ? <div className="tasks-required-action"><strong>i18n:govoplan-tasks.required_action</strong><span>{item.required_action}</span></div> : null}
{safeAction ? <Link className="btn btn-primary tasks-open-action" to={safeAction}><ExternalLink size={16} /> i18n:govoplan-tasks.open_work</Link> : null}
</section>
<section className="tasks-properties">
<h2>i18n:govoplan-tasks.context</h2>
<dl>
<div><dt>i18n:govoplan-tasks.source_module</dt><dd>{moduleLabel(item.owner_module)}</dd></div>
<div><dt>i18n:govoplan-tasks.provider</dt><dd>{item.provider_id}</dd></div>
<div><dt>i18n:govoplan-tasks.assigned_to</dt><dd>{item.assignments.map(assignmentLabel).join(", ") || "i18n:govoplan-tasks.not_set"}</dd></div>
<div><dt>i18n:govoplan-tasks.updated</dt><dd>{formatDate(item.updated_at)}</dd></div>
</dl>
</section>
{item.sources.length ? <section className="tasks-sources"><h2>i18n:govoplan-tasks.sources</h2>{item.sources.map((source) => <div key={`${source.module_id}:${source.resource_type}:${source.resource_id}`} className="tasks-source"><strong>{source.label || `${source.resource_type} ${source.resource_id}`}</strong><span>{moduleLabel(source.module_id)}{source.revision ? ` · ${source.revision}` : ""}</span></div>)}</section> : null}
</div>
);
}
function CreateTaskDialog({ open, busy, settings, auth, onClose, onCreated, onError, setBusy }: { open: boolean; busy: boolean; settings: ApiSettings; auth: AuthInfo; onClose: () => void; onCreated: (item: WorkItem) => Promise<void>; onError: (message: string) => void; setBusy: (value: boolean) => void }) {
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [requiredAction, setRequiredAction] = useState("");
const [priority, setPriority] = useState<WorkPriority>("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 (
<Dialog
open={open}
title="i18n:govoplan-tasks.create_task"
onClose={onClose}
closeDisabled={busy}
portal
helpContextId="tasks.action.create"
footer={<><Button onClick={onClose} disabled={busy}>i18n:govoplan-tasks.cancel</Button><Button type="submit" form="tasks-create-form" variant="primary" disabled={!title.trim() || !accountId || busy}>i18n:govoplan-tasks.create</Button></>}
>
<form id="tasks-create-form" className="tasks-create-form" onSubmit={submit}>
<FormField label="i18n:govoplan-tasks.title"><input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={500} autoFocus required /></FormField>
<FormField label="i18n:govoplan-tasks.summary"><textarea value={summary} onChange={(event) => setSummary(event.target.value)} maxLength={4000} rows={4} /></FormField>
<FormGrid columns={2} gap="small" collapseAt="workspace" className="tasks-create-grid">
<FormField label="i18n:govoplan-tasks.priority" helpContextId="tasks.field.priority"><select value={priority} onChange={(event) => setPriority(event.target.value as WorkPriority)}><option value="low">i18n:govoplan-tasks.priority.low</option><option value="normal">i18n:govoplan-tasks.priority.normal</option><option value="high">i18n:govoplan-tasks.priority.high</option><option value="urgent">i18n:govoplan-tasks.priority.urgent</option></select></FormField>
<FormField label="i18n:govoplan-tasks.due_at" helpContextId="tasks.field.due-at"><DateTimeField value={dueAt} onChange={setDueAt} min={localDateTime(new Date())} /></FormField>
</FormGrid>
<FormField label="i18n:govoplan-tasks.required_action"><input value={requiredAction} onChange={(event) => setRequiredAction(event.target.value)} maxLength={500} /></FormField>
<p className="tasks-assignment-note">i18n:govoplan-tasks.assigned_to_you</p>
</form>
</Dialog>
);
}
function safeActionUrl(value?: string | null): string | null {
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return null;
return value;
}
function workKey(item: WorkItem): string {
return `${item.provider_id}:${item.id}`;
}
function assignmentLabel(value: WorkItem["assignments"][number]): string {
return value.label || `${value.kind}: ${value.id}`;
}
function statusLabel(value: string): string {
return `i18n:govoplan-tasks.status.${value}`;
}
function priorityLabel(value: string): string {
return `i18n:govoplan-tasks.priority.${value}`;
}
function moduleLabel(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function dueLabel(value?: string | null): string {
if (!value) return "i18n:govoplan-tasks.no_due_date";
return formatDate(value);
}
function formatDate(value?: string | null): string {
if (!value) return "i18n:govoplan-tasks.not_set";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function localDateTime(value: Date): string {
const shifted = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return shifted.toISOString().slice(0, 16);
}
function localDateTimeToIso(value: string): string {
return new Date(value).toISOString();
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed";
}
@@ -0,0 +1,220 @@
import { Check, CirclePlay, ExternalLink, ListChecks } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
Button,
DismissibleAlert,
LoadingFrame,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import {
listWork,
transitionTask,
type WorkItem,
type WorkStatus
} from "../../api/tasks";
const ACTIVE_STATUSES: WorkStatus[] = [
"open",
"in_progress",
"deferred",
"blocked"
];
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "auth" | "launchContext" | "complete"
>;
/**
* A bounded projection of the unified inbox. Every load and command goes back
* through Tasks, so optional providers retain ownership of visibility and
* completion semantics.
*/
export default function TasksQuickAccess({
settings,
auth,
launchContext,
complete
}: Props) {
const [refreshKey, setRefreshKey] = useState(0);
const [selectedKey, setSelectedKey] = useState("");
const [commandError, setCommandError] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(
() => listWork(settings, { statuses: ACTIVE_STATUSES, limit: 7 }),
[settings]
);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
const items = data?.items ?? [];
const selected = useMemo(
() => items.find((item) => workKey(item) === selectedKey) ?? items[0] ?? null,
[items, selectedKey]
);
const canWrite = hasScope(auth, "tasks:item:write");
useEffect(() => {
if (!selectedKey && items[0]) setSelectedKey(workKey(items[0]));
if (selectedKey && !items.some((item) => workKey(item) === selectedKey)) {
setSelectedKey(items[0] ? workKey(items[0]) : "");
}
}, [items, selectedKey]);
async function runCommand(action: "start" | "complete") {
if (!selected || selected.provider_id !== "tasks.explicit" || !canWrite) return;
setBusy(true);
setCommandError("");
try {
const updated = await transitionTask(settings, selected, action);
if (action === "complete") {
complete(workResult(updated, "completed"));
return;
}
setRefreshKey((value) => value + 1);
} catch (reason) {
setCommandError(errorMessage(reason));
} finally {
setBusy(false);
}
}
function selectForHost(item: WorkItem) {
complete(workResult(item, "selected"));
}
const actionPath = selected ? safeActionUrl(selected.action_url) : null;
return (
<LoadingFrame loading={loading} label="i18n:govoplan-tasks.loading">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{commandError ? <DismissibleAlert tone="danger" resetKey={commandError}>{commandError}</DismissibleAlert> : null}
{data?.diagnostics.map((diagnostic) => (
<DismissibleAlert
key={`${diagnostic.provider_id}:${diagnostic.code}`}
tone="warning"
resetKey={`${diagnostic.provider_id}:${diagnostic.code}:${diagnostic.message}`}
>
{diagnostic.message}
</DismissibleAlert>
))}
{items.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-tasks.work_items">
{items.map((item) => (
<SelectionListItem
key={workKey(item)}
selected={selected ? workKey(item) === workKey(selected) : false}
onClick={() => setSelectedKey(workKey(item))}
>
<SelectionListItemContent
leading={<ListChecks size={16} aria-hidden="true" />}
title={item.title}
description={item.required_action || item.summary || moduleLabel(item.owner_module)}
/>
<StatusBadge status={item.status} label={statusLabel(item.status)} />
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-tasks.empty</p>
) : null}
{selected ? (
<section className="tasks-quick-detail" aria-label="i18n:govoplan-tasks.work_details">
<div className="tasks-quick-detail-heading">
<strong>{selected.title}</strong>
<span>{moduleLabel(selected.owner_module)} · {dueLabel(selected.due_at)}</span>
</div>
{selected.summary ? <p>{selected.summary}</p> : null}
{selected.required_action ? (
<p><strong>i18n:govoplan-tasks.required_action:</strong> {selected.required_action}</p>
) : null}
<div className="button-row compact-actions">
{selected.provider_id === "tasks.explicit" && canWrite && ["open", "deferred"].includes(selected.status) ? (
<Button onClick={() => void runCommand("start")} disabled={busy}>
<CirclePlay size={15} aria-hidden="true" /> i18n:govoplan-tasks.start
</Button>
) : null}
{selected.provider_id === "tasks.explicit" && canWrite ? (
<Button variant="primary" onClick={() => void runCommand("complete")} disabled={busy}>
<Check size={15} aria-hidden="true" /> i18n:govoplan-tasks.complete
</Button>
) : null}
{actionPath ? (
<Link
className="btn btn-secondary"
to={actionPath}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-tasks.open_work
</Link>
) : (
<Button onClick={() => selectForHost(selected)}>
i18n:govoplan-tasks.select_help
</Button>
)}
</div>
</section>
) : null}
{data && data.total > items.length ? (
<p className="muted small-note">
{items.length} / {data.total} · i18n:govoplan-tasks.open_work_inbox
</p>
) : null}
</LoadingFrame>
);
}
function workResult(item: WorkItem, action: "selected" | "completed") {
return {
contractVersion: "1" as const,
outcome: "completed" as const,
action,
reference: {
ownerModule: "tasks",
kind: "work-item",
objectId: `${item.provider_id}:${item.id}`,
tenantId: item.tenant_id,
label: item.title,
version: item.revision,
path: safeActionUrl(item.action_url) || "/tasks"
}
};
}
function workKey(item: WorkItem): string {
return `${item.provider_id}:${item.id}`;
}
function safeActionUrl(value?: string | null): string | null {
if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return null;
return value;
}
function statusLabel(value: string): string {
return `i18n:govoplan-tasks.status.${value}`;
}
function moduleLabel(value: string): string {
return value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function dueLabel(value?: string | null): string {
if (!value) return "i18n:govoplan-tasks.no_due_date";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(parsed);
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed";
}
@@ -0,0 +1,47 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import { Link } from "react-router";
import {
DismissibleAlert,
LoadingFrame,
MetricCard,
type ApiSettings
} from "@govoplan/core-webui";
import { loadWorkSummary, type WorkSummary } from "../../api/tasks";
export default function TasksSummaryWidget({ settings, refreshKey }: { settings: ApiSettings; refreshKey: number }) {
const [summary, setSummary] = useState<WorkSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setLoading(true);
void loadWorkSummary(settings)
.then((value) => {
if (active) {
setSummary(value);
setError("");
}
})
.catch((reason: unknown) => {
if (active) setError(reason instanceof Error ? reason.message : "i18n:govoplan-tasks.request_failed");
})
.finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, refreshKey]);
return (
<LoadingFrame loading={loading} label="i18n:govoplan-tasks.loading_summary">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
<MetricGrid columns={3} spacing="none">
<MetricCard label="i18n:govoplan-tasks.open" value={(summary?.open ?? 0) + (summary?.in_progress ?? 0)} tone="info" detail="i18n:govoplan-tasks.actionable_work" />
<MetricCard label="i18n:govoplan-tasks.overdue" value={summary?.overdue ?? 0} tone={summary?.overdue ? "danger" : "good"} detail="i18n:govoplan-tasks.due_date_passed" />
<MetricCard label="i18n:govoplan-tasks.blocked" value={summary?.blocked ?? 0} tone={summary?.blocked ? "warning" : "good"} detail="i18n:govoplan-tasks.needs_resolution" />
</MetricGrid>
<div className="tasks-widget-actions"><Link className="btn btn-secondary" to="/tasks">i18n:govoplan-tasks.open_work_inbox</Link></div>
</LoadingFrame>
);
}
+136
View File
@@ -0,0 +1,136 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
de: {
"i18n:govoplan-tasks.work": "Arbeit",
"i18n:govoplan-tasks.quick_access_description": "Offene und überfällige Arbeit, die Ihre Aufmerksamkeit benötigt.",
"i18n:govoplan-tasks.work_inbox": "Arbeitsvorrat",
"i18n:govoplan-tasks.work_details": "Arbeitsdetails",
"i18n:govoplan-tasks.work_items": "Arbeitsobjekte",
"i18n:govoplan-tasks.work_category": "Arbeit und Verfahren",
"i18n:govoplan-tasks.widget": "Widget für offene Arbeit",
"i18n:govoplan-tasks.widget_description": "Zugewiesene, überfällige und blockierte Arbeit aus aktivierten Modulen.",
"i18n:govoplan-tasks.refresh": "Arbeitsvorrat aktualisieren",
"i18n:govoplan-tasks.create_task": "Aufgabe erstellen",
"i18n:govoplan-tasks.create": "Erstellen",
"i18n:govoplan-tasks.search": "Arbeitsvorrat durchsuchen",
"i18n:govoplan-tasks.search_placeholder": "Titel, Beschreibung oder erforderliche Aktion",
"i18n:govoplan-tasks.status_filter": "Statusfilter",
"i18n:govoplan-tasks.active": "Aktiv",
"i18n:govoplan-tasks.completed": "Abgeschlossen",
"i18n:govoplan-tasks.all": "Alle",
"i18n:govoplan-tasks.loading": "Arbeitsvorrat wird geladen.",
"i18n:govoplan-tasks.empty": "In dieser Ansicht ist keine Arbeit vorhanden.",
"i18n:govoplan-tasks.select_help": "Wählen Sie ein Arbeitsobjekt, um Kontext, Zuständigkeit und nächste Aktion zu prüfen.",
"i18n:govoplan-tasks.start": "Beginnen",
"i18n:govoplan-tasks.complete": "Abschließen",
"i18n:govoplan-tasks.defer": "Zurückstellen",
"i18n:govoplan-tasks.defer_task": "Aufgabe zurückstellen",
"i18n:govoplan-tasks.reopen": "Wieder öffnen",
"i18n:govoplan-tasks.cancel": "Abbrechen",
"i18n:govoplan-tasks.cancel_task": "Aufgabe abbrechen",
"i18n:govoplan-tasks.advance_task": "Aufgabenstatus fortschreiben",
"i18n:govoplan-tasks.resume_at": "Wieder vorlegen am",
"i18n:govoplan-tasks.title": "Titel",
"i18n:govoplan-tasks.summary": "Beschreibung",
"i18n:govoplan-tasks.priority": "Priorität",
"i18n:govoplan-tasks.due_at": "Fällig am",
"i18n:govoplan-tasks.required_action": "Erforderliche Aktion",
"i18n:govoplan-tasks.assigned_to_you": "Die Aufgabe wird Ihnen zugewiesen. Weitere Zuständigkeitsarten stehen über angebundene Verfahren und die API zur Verfügung.",
"i18n:govoplan-tasks.open_work": "Arbeit fortsetzen",
"i18n:govoplan-tasks.context": "Kontext und Zuständigkeit",
"i18n:govoplan-tasks.source_module": "Quellmodul",
"i18n:govoplan-tasks.provider": "Quelle des Arbeitsobjekts",
"i18n:govoplan-tasks.assigned_to": "Zugewiesen an",
"i18n:govoplan-tasks.updated": "Aktualisiert",
"i18n:govoplan-tasks.sources": "Verknüpfte Quellen",
"i18n:govoplan-tasks.not_set": "Nicht festgelegt",
"i18n:govoplan-tasks.no_due_date": "Ohne Frist",
"i18n:govoplan-tasks.request_failed": "Der Arbeitsvorrat konnte nicht verarbeitet werden.",
"i18n:govoplan-tasks.reason.refresh_required": "Die Aufgabe muss vor der Änderung aktualisiert werden.",
"i18n:govoplan-tasks.loading_summary": "Arbeitsübersicht wird geladen",
"i18n:govoplan-tasks.open": "Offen",
"i18n:govoplan-tasks.overdue": "Überfällig",
"i18n:govoplan-tasks.blocked": "Blockiert",
"i18n:govoplan-tasks.actionable_work": "Offen oder in Bearbeitung",
"i18n:govoplan-tasks.due_date_passed": "Frist ist überschritten",
"i18n:govoplan-tasks.needs_resolution": "Hindernis muss geklärt werden",
"i18n:govoplan-tasks.open_work_inbox": "Arbeitsvorrat öffnen",
"i18n:govoplan-tasks.status.open": "Offen",
"i18n:govoplan-tasks.status.in_progress": "In Bearbeitung",
"i18n:govoplan-tasks.status.deferred": "Zurückgestellt",
"i18n:govoplan-tasks.status.blocked": "Blockiert",
"i18n:govoplan-tasks.status.completed": "Abgeschlossen",
"i18n:govoplan-tasks.status.cancelled": "Abgebrochen",
"i18n:govoplan-tasks.priority.low": "Niedrig",
"i18n:govoplan-tasks.priority.normal": "Normal",
"i18n:govoplan-tasks.priority.high": "Hoch",
"i18n:govoplan-tasks.priority.urgent": "Dringend"
},
en: {
"i18n:govoplan-tasks.work": "Work",
"i18n:govoplan-tasks.quick_access_description": "Open and overdue work requiring your attention.",
"i18n:govoplan-tasks.work_inbox": "Work inbox",
"i18n:govoplan-tasks.work_details": "Work details",
"i18n:govoplan-tasks.work_items": "Work items",
"i18n:govoplan-tasks.work_category": "Work and procedures",
"i18n:govoplan-tasks.widget": "Open work widget",
"i18n:govoplan-tasks.widget_description": "Assigned, overdue, and blocked work from enabled modules.",
"i18n:govoplan-tasks.refresh": "Refresh work inbox",
"i18n:govoplan-tasks.create_task": "Create task",
"i18n:govoplan-tasks.create": "Create",
"i18n:govoplan-tasks.search": "Search work inbox",
"i18n:govoplan-tasks.search_placeholder": "Title, description, or required action",
"i18n:govoplan-tasks.status_filter": "Status filter",
"i18n:govoplan-tasks.active": "Active",
"i18n:govoplan-tasks.completed": "Completed",
"i18n:govoplan-tasks.all": "All",
"i18n:govoplan-tasks.loading": "Loading work.",
"i18n:govoplan-tasks.empty": "There is no work in this view.",
"i18n:govoplan-tasks.select_help": "Select a work item to inspect its context, responsibility, and next action.",
"i18n:govoplan-tasks.start": "Start",
"i18n:govoplan-tasks.complete": "Complete",
"i18n:govoplan-tasks.defer": "Defer",
"i18n:govoplan-tasks.defer_task": "Defer task",
"i18n:govoplan-tasks.reopen": "Reopen",
"i18n:govoplan-tasks.cancel": "Cancel",
"i18n:govoplan-tasks.cancel_task": "Cancel task",
"i18n:govoplan-tasks.advance_task": "Advance task state",
"i18n:govoplan-tasks.resume_at": "Resume at",
"i18n:govoplan-tasks.title": "Title",
"i18n:govoplan-tasks.summary": "Summary",
"i18n:govoplan-tasks.priority": "Priority",
"i18n:govoplan-tasks.due_at": "Due at",
"i18n:govoplan-tasks.required_action": "Required action",
"i18n:govoplan-tasks.assigned_to_you": "The task is assigned to you. Connected procedures and the API support further responsibility types.",
"i18n:govoplan-tasks.open_work": "Continue work",
"i18n:govoplan-tasks.context": "Context and responsibility",
"i18n:govoplan-tasks.source_module": "Source module",
"i18n:govoplan-tasks.provider": "Work source",
"i18n:govoplan-tasks.assigned_to": "Assigned to",
"i18n:govoplan-tasks.updated": "Updated",
"i18n:govoplan-tasks.sources": "Linked sources",
"i18n:govoplan-tasks.not_set": "Not set",
"i18n:govoplan-tasks.no_due_date": "No due date",
"i18n:govoplan-tasks.request_failed": "The work request failed.",
"i18n:govoplan-tasks.reason.refresh_required": "Refresh the task before changing it.",
"i18n:govoplan-tasks.loading_summary": "Loading work summary",
"i18n:govoplan-tasks.open": "Open",
"i18n:govoplan-tasks.overdue": "Overdue",
"i18n:govoplan-tasks.blocked": "Blocked",
"i18n:govoplan-tasks.actionable_work": "Open or in progress",
"i18n:govoplan-tasks.due_date_passed": "Due date has passed",
"i18n:govoplan-tasks.needs_resolution": "A blocker needs resolution",
"i18n:govoplan-tasks.open_work_inbox": "Open work inbox",
"i18n:govoplan-tasks.status.open": "Open",
"i18n:govoplan-tasks.status.in_progress": "In progress",
"i18n:govoplan-tasks.status.deferred": "Deferred",
"i18n:govoplan-tasks.status.blocked": "Blocked",
"i18n:govoplan-tasks.status.completed": "Completed",
"i18n:govoplan-tasks.status.cancelled": "Cancelled",
"i18n:govoplan-tasks.priority.low": "Low",
"i18n:govoplan-tasks.priority.normal": "Normal",
"i18n:govoplan-tasks.priority.high": "High",
"i18n:govoplan-tasks.priority.urgent": "Urgent"
}
};
+2
View File
@@ -0,0 +1,2 @@
export { tasksModule as default, tasksModule } from "./module";
export { default as TasksPage } from "./features/tasks/TasksPage";
+84
View File
@@ -0,0 +1,84 @@
import { createElement, lazy } from "react";
import type {
DashboardWidgetsUiCapability,
PlatformWebModule,
QuickAccessToolsUiCapability
} from "@govoplan/core-webui";
import TasksSummaryWidget from "./features/tasks/TasksSummaryWidget";
import TasksQuickAccess from "./features/tasks/TasksQuickAccess";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/tasks.css";
const TasksPage = lazy(() => import("./features/tasks/TasksPage"));
const readScope = ["tasks:item:read"];
const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "tasks.open-work",
surfaceId: "tasks.widget.open-work",
title: "i18n:govoplan-tasks.work",
description: "i18n:govoplan-tasks.widget_description",
moduleId: "tasks",
category: "i18n:govoplan-tasks.work_category",
order: 20,
defaultVisible: true,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: readScope,
refreshIntervalMs: 30_000,
render: ({ settings, refreshKey }) => createElement(TasksSummaryWidget, { settings, refreshKey })
}
]
};
const quickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "tasks.work",
render: (context) => createElement(TasksQuickAccess, context)
}
]
};
export const tasksModule: PlatformWebModule = {
id: "tasks",
label: "i18n:govoplan-tasks.work",
version: "0.1.20",
dependencies: ["access"],
optionalDependencies: ["idm", "organizations", "workflow_engine", "workflow", "notifications", "postbox", "approvals", "views", "dashboard", "search"],
translations: generatedTranslations,
navItems: [
{
to: "/tasks",
label: "i18n:govoplan-tasks.work",
iconName: "list-checks",
anyOf: readScope,
order: 21,
surfaceId: "tasks.route.work"
}
],
routes: [
{
path: "/tasks",
anyOf: readScope,
order: 21,
surfaceId: "tasks.route.work",
render: ({ settings, auth }) => createElement(TasksPage, { settings, auth })
}
],
viewSurfaces: [
{ id: "tasks.page.inbox", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.work_inbox", parentId: "tasks.route.work", order: 20 },
{ id: "tasks.page.detail", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.work_details", parentId: "tasks.route.work", order: 30 },
{ id: "tasks.action.create", moduleId: "tasks", kind: "action", label: "i18n:govoplan-tasks.create_task", parentId: "tasks.page.inbox", order: 40 },
{ id: "tasks.action.advance", moduleId: "tasks", kind: "action", label: "i18n:govoplan-tasks.advance_task", parentId: "tasks.page.detail", order: 50 },
{ id: "tasks.widget.open-work", moduleId: "tasks", kind: "section", label: "i18n:govoplan-tasks.widget", order: 60 },
{ id: "tasks.quick_access.work", moduleId: "tasks", kind: "quick_access", label: "i18n:govoplan-tasks.work", order: 70 }
],
uiCapabilities: {
"dashboard.widgets": dashboardWidgets,
"quickAccess.tools": quickAccessTools
}
};
export default tasksModule;
+242
View File
@@ -0,0 +1,242 @@
.tasks-sidebar,
.tasks-workspace {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tasks-title,
.tasks-detail-title,
.tasks-toolbar-actions,
.tasks-list-heading,
.tasks-list-meta,
.tasks-detail-meta,
.tasks-open-action {
display: flex;
align-items: center;
gap: 8px;
}
.tasks-detail-title {
min-width: 0;
}
.tasks-detail-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasks-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.tasks-toolbar-actions .btn {
min-height: 32px;
gap: 6px;
}
.tasks-search {
margin: 8px 8px 0;
}
.tasks-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: 0;
}
.tasks-status-filter {
width: calc(100% - 16px);
margin: 8px;
}
.tasks-list {
min-height: 0;
overflow: auto;
padding: 0 8px 8px;
}
.tasks-list-item {
min-height: 64px;
display: grid;
gap: 6px;
}
.tasks-list-heading,
.tasks-list-meta {
min-width: 0;
justify-content: space-between;
}
.tasks-list-heading strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasks-list-meta,
.tasks-note {
color: var(--muted);
font-size: 12px;
}
.tasks-workspace > .alert {
margin: 8px 12px 0;
}
.tasks-detail {
min-height: 0;
overflow: auto;
padding: 18px;
}
.tasks-detail-main,
.tasks-properties,
.tasks-sources {
border-bottom: var(--border-line);
margin-bottom: 18px;
padding-bottom: 18px;
}
.tasks-detail-main h1 {
margin: 10px 0 8px;
color: var(--text-strong);
font-size: 24px;
letter-spacing: 0;
}
.tasks-detail-main > p {
max-width: 900px;
line-height: 1.55;
white-space: pre-line;
}
.tasks-detail-meta {
color: var(--muted);
font-size: 12px;
}
.tasks-required-action {
max-width: 900px;
display: grid;
gap: 4px;
border-left: 3px solid var(--accent);
background: var(--info-soft);
margin: 14px 0;
padding: 10px 12px;
}
.tasks-open-action {
width: max-content;
max-width: 100%;
text-decoration: none;
}
.tasks-properties h2,
.tasks-sources h2 {
margin: 0 0 12px;
color: var(--text-strong);
font-size: 15px;
}
.tasks-properties dl {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 12px 20px;
margin: 0;
}
.tasks-properties dt {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.tasks-properties dd {
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.tasks-source {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
border-top: var(--border-line);
padding: 10px 0;
}
.tasks-source span {
color: var(--muted);
}
.tasks-create-form {
width: min(620px, 75vw);
display: grid;
gap: 14px;
}
.tasks-create-form textarea {
resize: vertical;
}
.tasks-create-grid {
grid-template-columns: minmax(150px, .75fr) minmax(260px, 1.25fr);
}
.tasks-assignment-note {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.tasks-widget-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.tasks-quick-detail {
display: grid;
gap: 10px;
margin-top: 12px;
border-top: var(--border-line);
padding-top: 12px;
}
.tasks-quick-detail-heading {
display: grid;
gap: 3px;
}
.tasks-quick-detail-heading span,
.tasks-quick-detail > p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 760px) {
.tasks-topbar {
align-items: flex-start;
}
.tasks-properties dl {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
.tasks-create-form {
width: min(100%, 88vw);
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"]
}
},
"include": ["src"]
}