2c9e74776e
- New builtin plugin app/plugins/builtins/tasks/ with full CRUD
- Task model with TenantMixin (title, description, status, priority, due_date, assigned_to, contact_id)
- Routes: GET/POST /tasks, GET/PATCH/DELETE /tasks/{id}, POST /tasks/{id}/assign, POST /tasks/{id}/status
- All routes RBAC-protected (tasks:read, tasks:write, tasks:delete)
- ARQ cron job tasks_due_reminder (daily 8:00) sends notifications for due tasks
- Migration 0001_initial.sql creates tasks table with indexes
- Frontend: Tasks.tsx page with list, filter, create/edit modal, detail modal
- Frontend: api/tasks.ts with React Query hooks
- Route /tasks in routes/index.tsx, sidebar entry via plugin manifest
- i18n keys for nav.tasks and tasks.* in de.json and en.json
- Tests: test_tasks.py (11 tests) + Tasks.test.tsx (3 tests)
- Registered TasksPlugin in conftest.py and worker.py
213 lines
6.5 KiB
Python
213 lines
6.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.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,
|
|
) -> dict[str, Any]:
|
|
"""List tasks with filtering and pagination."""
|
|
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
|
|
|
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."""
|
|
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,
|
|
)
|
|
db.add(task)
|
|
await db.flush()
|
|
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
|
|
|
|
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()
|
|
return _task_to_dict(task)
|
|
|
|
|
|
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
|
|
task.deleted_at = datetime.now(timezone.utc)
|
|
await db.flush()
|
|
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()
|
|
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]
|