- Add generic move_in_order() to BaseRepository for reorder support - Add reusable reorder_arrows.html partial with grip dot handles - Add reorder routes to all 9 list routers (tasks, notes, links, contacts, meetings, decisions, appointments, lists, focus) - Compact row padding (6px 12px, gap 8px) on .list-row and .focus-item - Reduce font size to 0.80rem on row titles, sidebar nav, domain tree Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
18 KiB
Python
496 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)
|
|
|
|
|
|
@router.post("/reorder")
|
|
async def reorder_task(
|
|
request: Request,
|
|
item_id: str = Form(...),
|
|
direction: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("tasks", db)
|
|
await repo.move_in_order(item_id, direction)
|
|
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
|