Files
lifeos-dev/routers/tasks.py

484 lines
18 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")
async def get_running_task_id(db: AsyncSession) -> Optional[str]:
"""Get the task_id of the currently running timer, if any."""
result = await db.execute(text(
"SELECT task_id FROM time_entries WHERE end_at IS NULL AND is_deleted = false LIMIT 1"
))
row = result.first()
return str(row.task_id) if row else None
@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]
running_task_id = await get_running_task_id(db)
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,
"running_task_id": running_task_id,
"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,
tab: str = "overview",
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 (always needed for overview tab)
subtasks = []
if tab == "overview":
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]
running_task_id = await get_running_task_id(db)
# Tab-specific data
tab_data = []
all_contacts = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links WHERE task_id = :tid AND is_deleted = false
ORDER BY sort_order, label
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.task_id = :tid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
ORDER BY created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, ct.role, ct.created_at as linked_at
FROM contacts c
JOIN contact_tasks ct ON ct.contact_id = c.id
WHERE ct.task_id = :tid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
# All contacts for add dropdown
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts for badges
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE task_id = :tid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"tid": task_id})
counts[count_tab] = result.scalar() or 0
# Subtask count for overview badge
result = await db.execute(text(
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
), {"tid": task_id})
counts["overview"] = result.scalar() or 0
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts,
"running_task_id": running_task_id,
"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, request: Request, 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)
# ---- Contact linking ----
@router.post("/{task_id}/contacts/add")
async def add_contact(
task_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_tasks (contact_id, task_id, role)
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
@router.post("/{task_id}/contacts/{contact_id}/remove")
async def remove_contact(
task_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
), {"cid": contact_id, "tid": task_id})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)