Implement unified work inbox module

This commit is contained in:
2026-08-06 16:06:17 +02:00
parent 2e89204a0c
commit 3cdab599ff
31 changed files with 3540 additions and 0 deletions
+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"]
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tasks import (
CAPABILITY_TASK_COMMANDS,
WorkItemProviderRegistration,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_tasks.backend.db import models as task_models
from govoplan_tasks.backend.service import SqlTaskService
MODULE_ID = "tasks"
MODULE_NAME = "Tasks"
MODULE_VERSION = "0.1.18"
READ_SCOPE = "tasks:item:read"
WRITE_SCOPE = "tasks:item:write"
ADMIN_SCOPE = "tasks:item:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Tasks",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(context: ModuleContext):
from govoplan_tasks.backend.router import create_router
return create_router(context.registry)
def _service(context: ModuleContext) -> SqlTaskService:
return SqlTaskService(context.registry)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
total = (
session.query(task_models.TaskItem)
.filter(task_models.TaskItem.tenant_id == tenant_id)
.count()
)
open_items = (
session.query(task_models.TaskItem)
.filter(
task_models.TaskItem.tenant_id == tenant_id,
task_models.TaskItem.status.in_(
("open", "in_progress", "deferred", "blocked")
),
)
.count()
)
return {"tasks": total, "open_tasks": open_items}
PERMISSIONS = (
_permission(
READ_SCOPE,
"View assigned work",
"Read explicit and contributed work visible to the current account, group, role, or function.",
),
_permission(
WRITE_SCOPE,
"Manage assigned work",
"Create explicit tasks and advance visible task state.",
),
_permission(
ADMIN_SCOPE,
"Administer tenant work",
"Read and recover all explicit tasks in the tenant.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="work_participant",
name="Work participant",
description="Read and advance assigned work and create explicit tasks.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="work_supervisor",
name="Work supervisor",
description="Inspect and recover tenant-wide work in addition to participating.",
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
),
)
DOCUMENTATION = (
DocumentationTopic(
id="tasks.work-inbox",
title="Unified work inbox",
summary="Resume explicit tasks and module-owned work requiring attention.",
body=(
"The Work inbox combines explicit Tasks with work contributed by enabled modules. "
"Each source keeps ownership of its commands and completion state. Tasks does not turn a "
"Workflow handoff, Postbox message, approval, or notification into a copied task. Filters, "
"due dates, priorities, and source links help the current actor resume work safely."
),
layer="configured",
documentation_types=("user", "admin"),
audience=("user", "operator", "tenant_admin", "module_admin"),
related_modules=(
"workflow_engine",
"notifications",
"postbox",
"approvals",
"views",
"dashboard",
),
links=(
DocumentationLink(
label="Tasks domain",
href="govoplan-tasks/docs/TASKS_DOMAIN.md",
kind="repository",
),
),
translations={
"de": {
"title": "Gemeinsamer Arbeitsvorrat",
"summary": "Explizite Aufgaben und Arbeitsvorgaenge anderer Module sicher fortsetzen.",
"body": (
"Der Arbeitsvorrat verbindet explizite Aufgaben mit Arbeitsobjekten aktivierter Module. "
"Jede Quelle behaelt die Verantwortung fuer Befehle und Abschlussstatus. Tasks kopiert "
"keine Workflow-Uebergabe, Postfachnachricht, Freigabe oder Benachrichtigung in einen "
"zweiten Fachzustand. Filter, Fristen, Prioritaeten und Quellverweise helfen beim sicheren Fortsetzen."
),
}
},
metadata={
"help_contexts": [
"tasks.route.work",
"tasks.page.inbox",
"tasks.page.detail",
"tasks.action.create",
"tasks.action.advance",
"tasks.field.assignment",
"tasks.field.due-at",
"tasks.field.priority",
]
},
),
)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=(
"idm",
"organizations",
"workflow_engine",
"workflow",
"notifications",
"postbox",
"approvals",
"views",
"dashboard",
"search",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_TASK_COMMANDS, version="1.0.0"),
ModuleInterfaceProvider(name="tasks.work_items", version="1.0.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
nav_items=(
NavItem(
path="/tasks",
label="Work",
icon="list-checks",
required_any=(READ_SCOPE,),
order=21,
surface_id="tasks.route.work",
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/tasks-webui",
routes=(
FrontendRoute(
path="/tasks",
component="TasksPage",
required_any=(READ_SCOPE,),
order=21,
surface_id="tasks.route.work",
),
),
view_surfaces=(
ViewSurface(
id="tasks.page.inbox",
module_id=MODULE_ID,
kind="section",
label="Work inbox",
parent_id="tasks.route.work",
order=20,
),
ViewSurface(
id="tasks.page.detail",
module_id=MODULE_ID,
kind="section",
label="Work details",
parent_id="tasks.route.work",
order=30,
),
ViewSurface(
id="tasks.action.create",
module_id=MODULE_ID,
kind="action",
label="Create task",
parent_id="tasks.page.inbox",
order=40,
),
ViewSurface(
id="tasks.action.advance",
module_id=MODULE_ID,
kind="action",
label="Advance task",
parent_id="tasks.page.detail",
order=50,
),
ViewSurface(
id="tasks.widget.open-work",
module_id=MODULE_ID,
kind="section",
label="Open work widget",
order=60,
),
),
),
tenant_summary_providers=(_tenant_summary,),
capability_factories={CAPABILITY_TASK_COMMANDS: _service},
capability_documentation={
CAPABILITY_TASK_COMMANDS: CapabilityDocumentation(
label="Task commands",
summary="Creates replay-safe explicit tasks without importing the Tasks implementation.",
contract_version="1.0.0",
)
},
work_item_providers=(
WorkItemProviderRegistration(id="tasks.explicit", factory=_service, order=10),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
task_models.TaskAssignment, task_models.TaskItem, label="Tasks"
),
retirement_notes="Destructive retirement removes explicit task state after a database snapshot; contributed work remains with its owner.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
task_models.TaskItem, task_models.TaskAssignment, label="Tasks"
),
),
documentation=DOCUMENTATION,
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/TASKS_DOMAIN.md",
test_ref="tests/test_tasks.py",
known_limits=(
"Function assignment resolution depends on the optional IDM directory; source-owned inline commands remain deep links in this first slice.",
),
supported_authority_modes=("native_authoritative", "linked_reference"),
owned_concepts=("explicit task", "task assignment", "unified work inbox"),
non_owned_concepts=(
"workflow instance",
"notification",
"postbox message",
"approval request",
"domain object",
),
reference_packages=(
"product.service-to-decision",
"product.governed-communication",
"product.governed-data-assurance",
),
migration_docs=("docs/TASKS_DOMAIN.md",),
recovery_docs=("docs/TASKS_DOMAIN.md",),
security_docs=("docs/TASKS_DOMAIN.md",),
operations_docs=("docs/TASKS_DOMAIN.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
__all__ = [
"ADMIN_SCOPE",
"MODULE_ID",
"MODULE_VERSION",
"READ_SCOPE",
"WRITE_SCOPE",
"get_manifest",
"manifest",
]
@@ -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",
]