Implement unified work inbox module
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tasks backend."""
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user