Files
lifeos-dev/routers/tasks.py

356 lines
13 KiB
Python

"""Tasks: core work items with full filtering and hierarchy."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime, timezone
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/tasks", tags=["tasks"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_tasks(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[str] = None,
context: Optional[str] = None,
sort: str = "sort_order",
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["t.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("t.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("t.project_id = :project_id")
params["project_id"] = project_id
if status:
where_clauses.append("t.status = :status")
params["status"] = status
if priority:
where_clauses.append("t.priority = :priority")
params["priority"] = int(priority)
if context:
where_clauses.append("t.context = :context")
params["context"] = context
where_sql = " AND ".join(where_clauses)
sort_map = {
"sort_order": "t.sort_order, t.created_at",
"priority": "t.priority ASC, t.due_date ASC NULLS LAST",
"due_date": "t.due_date ASC NULLS LAST, t.priority ASC",
"created_at": "t.created_at DESC",
"title": "t.title ASC",
}
order_sql = sort_map.get(sort, sort_map["sort_order"])
result = await db.execute(text(f"""
SELECT t.*,
d.name as domain_name, d.color as domain_color,
p.name as project_name
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE {where_sql}
ORDER BY
CASE WHEN t.status = 'done' THEN 1 WHEN t.status = 'cancelled' THEN 2 ELSE 0 END,
{order_sql}
"""), params)
items = [dict(r._mapping) for r in result]
# Get filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("tasks.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "projects": projects, "context_types": context_types,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"current_status": status or "",
"current_priority": priority or "",
"current_context": context or "",
"current_sort": sort,
"page_title": "All Tasks", "active_nav": "tasks",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
parent_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("task_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "context_types": context_types,
"page_title": "New Task", "active_nav": "tasks",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_parent_id": parent_id or "",
})
@router.post("/create")
async def create_task(
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
parent_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
priority: int = Form(3),
status: str = Form("open"),
due_date: Optional[str] = Form(None),
deadline: Optional[str] = Form(None),
context: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
estimated_minutes: Optional[str] = Form(None),
energy_required: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
data = {
"title": title, "domain_id": domain_id,
"description": description, "priority": priority, "status": status,
}
if project_id and project_id.strip():
data["project_id"] = project_id
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
if due_date and due_date.strip():
data["due_date"] = due_date
if deadline and deadline.strip():
data["deadline"] = deadline
if context and context.strip():
data["context"] = context
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
if estimated_minutes and estimated_minutes.strip():
data["estimated_minutes"] = int(estimated_minutes)
if energy_required and energy_required.strip():
data["energy_required"] = energy_required
task = await repo.create(data)
# Redirect back to project if created from project context
if data.get("project_id"):
return RedirectResponse(url=f"/projects/{data['project_id']}?tab=tasks", status_code=303)
return RedirectResponse(url="/tasks", status_code=303)
@router.get("/{task_id}")
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("tasks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(task_id)
if not item:
return RedirectResponse(url="/tasks", status_code=303)
# Domain and project info
domain = None
if item.get("domain_id"):
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
row = result.first()
domain = dict(row._mapping) if row else None
project = None
if item.get("project_id"):
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
row = result.first()
project = dict(row._mapping) if row else None
parent = None
if item.get("parent_id"):
result = await db.execute(text("SELECT id, title FROM tasks WHERE id = :id"), {"id": str(item["parent_id"])})
row = result.first()
parent = dict(row._mapping) if row else None
# Subtasks
result = await db.execute(text("""
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"tid": task_id})
subtasks = [dict(r._mapping) for r in result]
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks,
"page_title": item["title"], "active_nav": "tasks",
})
@router.get("/{task_id}/edit")
async def edit_form(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("tasks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(task_id)
if not item:
return RedirectResponse(url="/tasks", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("task_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "context_types": context_types,
"page_title": f"Edit Task", "active_nav": "tasks",
"item": item,
"prefill_domain_id": "", "prefill_project_id": "", "prefill_parent_id": "",
})
@router.post("/{task_id}/edit")
async def update_task(
task_id: str,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
parent_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
priority: int = Form(3),
status: str = Form("open"),
due_date: Optional[str] = Form(None),
deadline: Optional[str] = Form(None),
context: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
estimated_minutes: Optional[str] = Form(None),
energy_required: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
data = {
"title": title, "domain_id": domain_id,
"description": description, "priority": priority, "status": status,
"project_id": project_id if project_id and project_id.strip() else None,
"parent_id": parent_id if parent_id and parent_id.strip() else None,
"due_date": due_date if due_date and due_date.strip() else None,
"deadline": deadline if deadline and deadline.strip() else None,
"context": context if context and context.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
if estimated_minutes and estimated_minutes.strip():
data["estimated_minutes"] = int(estimated_minutes)
if energy_required and energy_required.strip():
data["energy_required"] = energy_required
# Handle completion
old = await repo.get(task_id)
if old and old["status"] != "done" and status == "done":
data["completed_at"] = datetime.now(timezone.utc)
elif status != "done":
data["completed_at"] = None
await repo.update(task_id, data)
return RedirectResponse(url=f"/tasks/{task_id}", status_code=303)
@router.post("/{task_id}/complete")
async def complete_task(task_id: str, db: AsyncSession = Depends(get_db)):
"""Quick complete from list view."""
repo = BaseRepository("tasks", db)
await repo.update(task_id, {
"status": "done",
"completed_at": datetime.now(timezone.utc),
})
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
@router.post("/{task_id}/toggle")
async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""Toggle task done/open from list view."""
repo = BaseRepository("tasks", db)
task = await repo.get(task_id)
if not task:
return RedirectResponse(url="/tasks", status_code=303)
if task["status"] == "done":
await repo.update(task_id, {"status": "open", "completed_at": None})
else:
await repo.update(task_id, {"status": "done", "completed_at": datetime.now(timezone.utc)})
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{task_id}/delete")
async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("tasks", db)
await repo.soft_delete(task_id)
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
# Quick add from any task list
@router.post("/quick-add")
async def quick_add(
request: Request,
title: str = Form(...),
domain_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
data = {"title": title, "status": "open", "priority": 3}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip():
data["project_id"] = project_id
# If no domain, use first domain
if "domain_id" not in data:
result = await db.execute(text(
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
))
row = result.first()
if row:
data["domain_id"] = str(row[0])
await repo.create(data)
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)