Implement unified work inbox module
This commit is contained in:
@@ -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