Files
leocrm/app/plugins/builtins/tasks/services.py
T
Agent Zero a4d0f0c35d
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(D): Phase D — Undo/Restore komplett implementiert
- D-GEN: RestoreRegistry mit RestoreConfig (model_class, restore_permission, excluded_fields, special_handler)
- D-HOOK: history_hooks.py mit register_history_hooks() für after_create/update/delete
- D-CORE: Company create+update record_history in companies.py
- D-PLUG: Task/Calendar/DMS record_history in services/routes
- D-SOFT: Alle registrierten Entitäten haben deleted_at + un-delete via Registry
- D-MAIL: Mail special_handler (IMAP Trash-Move, Folder-Verify) + record_history in delete/move
- D-TRASH: GET /entity-history/trash (filterbar, paginiert) + Frontend Trash.tsx
- D-TOAST: UndoToast.tsx (5s Auto-Dismiss, useUndoToast Hook)
- D-HIST-UI: HistoryPanel.tsx (Timeline, Diff-View, Restore-Button)
- D-BULK: POST /entity-history/bulk-restore mit partial_success Semantik
- D-RET: POST /entity-history/retention/archive (GDPR hard-delete >90 Tage)
- D-TEST: 26 Tests in test_restore_registry.py, alle grün
- D-DOC: test-strategy.md + security_kernel.md aktualisiert

Backend: 10 Dateien, Frontend: 7 Dateien, Tests: 1 Datei, Docs: 3 Dateien
26/26 Tests passed, TSC 0 errors, App import 492 routes
2026-08-13 23:08:29 +02:00

274 lines
9.5 KiB
Python

"""Business logic for the Tasks plugin."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter
from app.plugins.builtins.tasks.models import Task
def _task_to_dict(task: Task) -> dict[str, Any]:
"""Serialize a Task model to a dict."""
return {
"id": str(task.id),
"title": task.title,
"description": task.description,
"status": task.status,
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
"assigned_to": str(task.assigned_to) if task.assigned_to else None,
"contact_id": str(task.contact_id) if task.contact_id else None,
"created_by": str(task.created_by) if task.created_by else None,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
async def list_tasks(
db: AsyncSession,
tenant_id: uuid.UUID,
*,
page: int = 1,
page_size: int = 25,
status: str | None = None,
priority: str | None = None,
assigned_to: str | None = None,
contact_id: str | None = None,
search: str | None = None,
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
) -> dict[str, Any]:
"""List tasks with filtering and pagination."""
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
if user_id and not is_system_admin:
query = await apply_visibility_filter(
db, query, "task", Task, user_id, tenant_id, is_system_admin
)
if status:
query = query.where(Task.status == status)
if priority:
query = query.where(Task.priority == priority)
if assigned_to:
query = query.where(Task.assigned_to == uuid.UUID(assigned_to))
if contact_id:
query = query.where(Task.contact_id == uuid.UUID(contact_id))
if search:
query = query.where(Task.title.ilike(f"%{search}%"))
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
query = query.order_by(Task.due_date.asc().nulls_last(), Task.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
tasks = result.scalars().all()
return {
"items": [_task_to_dict(t) for t in tasks],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> dict[str, Any] | None:
"""Get a single task by ID."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
return _task_to_dict(task)
async def create_task(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new task."""
from app.core.hooks import do_action
await do_action("task.before_create", data, db=db, tenant_id=tenant_id, user_id=user_id)
task = Task(
tenant_id=tenant_id,
title=data["title"],
description=data.get("description"),
status=data.get("status", "open"),
priority=data.get("priority", "medium"),
due_date=data.get("due_date"),
assigned_to=uuid.UUID(data["assigned_to"]) if data.get("assigned_to") else None,
contact_id=uuid.UUID(data["contact_id"]) if data.get("contact_id") else None,
created_by=user_id,
owner_id=user_id,
)
db.add(task)
await db.flush()
snapshot = _task_to_dict(task)
# Record history (D-PLUG)
from app.services.entity_history_service import record_history
await record_history(db, tenant_id, user_id, "task", task.id, "create", snapshot_after=snapshot)
from app.core.hooks import do_action
await do_action("task.after_create", snapshot, db=db, tenant_id=tenant_id, user_id=user_id)
# Publish task.created event
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.publish('task.created', {
'task_id': str(task.id),
'tenant_id': str(tenant_id),
'user_id': str(user_id),
'title': task.title,
'assigned_to': str(task.assigned_to) if task.assigned_to else None,
})
return _task_to_dict(task)
async def update_task(
db: AsyncSession,
tenant_id: uuid.UUID,
task_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update a task."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
from app.core.hooks import do_action
await do_action("task.before_update", data, db=db, tenant_id=tenant_id, task_id=str(task_id))
# Capture snapshot before update (D-PLUG)
snapshot_before = _task_to_dict(task)
if "title" in data and data["title"] is not None:
task.title = data["title"]
if "description" in data:
task.description = data["description"]
if "status" in data and data["status"] is not None:
task.status = data["status"]
if "priority" in data and data["priority"] is not None:
task.priority = data["priority"]
if "due_date" in data:
task.due_date = data["due_date"]
if "assigned_to" in data:
task.assigned_to = uuid.UUID(data["assigned_to"]) if data["assigned_to"] else None
if "contact_id" in data:
task.contact_id = uuid.UUID(data["contact_id"]) if data["contact_id"] else None
await db.flush()
await db.refresh(task)
snapshot_after = _task_to_dict(task)
# Compute changes diff (D-PLUG)
changes: dict = {}
for key, new_val in snapshot_after.items():
old_val = snapshot_before.get(key)
if old_val != new_val:
changes[key] = {"old": old_val, "new": new_val}
# Record history (D-PLUG)
from app.services.entity_history_service import record_history as _rh
await _rh(db, tenant_id, None, "task", task.id, "update",
snapshot_before=snapshot_before, snapshot_after=snapshot_after, changes=changes or None)
from app.core.hooks import do_action
await do_action("task.after_update", snapshot_after, db=db, tenant_id=tenant_id, task_id=str(task_id))
return snapshot_after
async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID) -> bool:
"""Soft-delete a task."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return False
from app.core.hooks import do_action
await do_action("task.before_delete", db=db, tenant_id=tenant_id, task_id=str(task_id))
# Capture snapshot before delete (D-PLUG)
snapshot_before = _task_to_dict(task)
task.deleted_at = datetime.now(timezone.utc)
await db.flush()
# Record history (D-PLUG)
from app.services.entity_history_service import record_history as _rh
await _rh(db, tenant_id, None, "task", task.id, "delete", snapshot_before=snapshot_before)
from app.core.hooks import do_action
await do_action("task.after_delete", db=db, tenant_id=tenant_id, task_id=str(task_id))
return True
async def assign_task(
db: AsyncSession,
tenant_id: uuid.UUID,
task_id: uuid.UUID,
assigned_to: uuid.UUID,
) -> dict[str, Any] | None:
"""Assign a task to a user."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
task.assigned_to = assigned_to
await db.flush()
return _task_to_dict(task)
async def update_task_status(
db: AsyncSession,
tenant_id: uuid.UUID,
task_id: uuid.UUID,
new_status: str,
) -> dict[str, Any] | None:
"""Update task status."""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
)
task = result.scalar_one_or_none()
if task is None:
return None
task.status = new_status
await db.flush()
if new_status == "done":
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, "task.completed", {"task_id": str(task.id), "tenant_id": str(tenant_id), "title": task.title, "assigned_to": str(task.assigned_to) if task.assigned_to else None}, aggregate_type="task", aggregate_id=task.id)
return _task_to_dict(task)
async def get_due_tasks(
db: AsyncSession,
tenant_id: uuid.UUID,
*,
before: datetime | None = None,
) -> list[dict[str, Any]]:
"""Get tasks that are due (for ARQ reminder job)."""
now = before or datetime.now(timezone.utc)
result = await db.execute(
select(Task).where(
Task.tenant_id == tenant_id,
Task.deleted_at.is_(None),
Task.status != "done",
Task.due_date.is_not(None),
Task.due_date <= now,
)
)
tasks = result.scalars().all()
return [_task_to_dict(t) for t in tasks]