"""Lists: checklist/ordered list management with inline items.""" 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 from core.template_filters import autolink router = APIRouter(prefix="/lists", tags=["lists"]) templates = Jinja2Templates(directory="templates") templates.env.filters["autolink"] = autolink @router.get("/") async def list_lists( request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) where_clauses = ["l.is_deleted = false"] params = {} if domain_id: where_clauses.append("l.domain_id = :domain_id") params["domain_id"] = domain_id if project_id: where_clauses.append("l.project_id = :project_id") params["project_id"] = project_id where_sql = " AND ".join(where_clauses) result = await db.execute(text(f""" SELECT l.*, d.name as domain_name, d.color as domain_color, p.name as project_name, (SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count, (SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false AND li.completed = true) as completed_count FROM lists l LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id WHERE {where_sql} ORDER BY l.sort_order, l.created_at DESC """), params) items = [dict(r._mapping) for r in result] # Filter options domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() projects_repo = BaseRepository("projects", db) projects = await projects_repo.list() return templates.TemplateResponse("lists.html", { "request": request, "sidebar": sidebar, "items": items, "domains": domains, "projects": projects, "current_domain_id": domain_id or "", "current_project_id": project_id or "", "page_title": "Lists", "active_nav": "lists", }) @router.get("/create") async def create_form( request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, task_id: Optional[str] = None, meeting_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() areas_repo = BaseRepository("areas", db) areas = await areas_repo.list() return templates.TemplateResponse("list_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "areas": areas, "page_title": "New List", "active_nav": "lists", "item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "", "prefill_task_id": task_id or "", "prefill_meeting_id": meeting_id or "", }) @router.post("/create") async def create_list( request: Request, name: str = Form(...), domain_id: str = Form(...), area_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None), list_type: str = Form("checklist"), description: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("lists", db) data = { "name": name, "domain_id": domain_id, "list_type": list_type, "description": description, } if area_id and area_id.strip(): data["area_id"] = area_id if project_id and project_id.strip(): data["project_id"] = project_id if task_id and task_id.strip(): data["task_id"] = task_id if meeting_id and meeting_id.strip(): data["meeting_id"] = meeting_id if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] new_list = await repo.create(data) if task_id and task_id.strip(): return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303) if meeting_id and meeting_id.strip(): return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303) return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303) @router.get("/{list_id}") async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("lists", db) sidebar = await get_sidebar_data(db) item = await repo.get(list_id) if not item: return RedirectResponse(url="/lists", status_code=303) # Domain/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 # List items (ordered, with hierarchy) result = await db.execute(text(""" SELECT * FROM list_items WHERE list_id = :list_id AND is_deleted = false ORDER BY sort_order, created_at """), {"list_id": list_id}) list_items = [dict(r._mapping) for r in result] # Separate top-level and child items top_items = [i for i in list_items if i.get("parent_item_id") is None] child_map = {} for i in list_items: pid = i.get("parent_item_id") if pid: child_map.setdefault(str(pid), []).append(i) # Contacts linked to this list result = await db.execute(text(""" SELECT c.*, cl.role, cl.created_at as linked_at FROM contacts c JOIN contact_lists cl ON cl.contact_id = c.id WHERE cl.list_id = :lid AND c.is_deleted = false ORDER BY c.first_name """), {"lid": list_id}) contacts = [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] # All links for insert-into-content picker result = await db.execute(text(""" SELECT id, label, url FROM links WHERE is_deleted = false ORDER BY label """)) all_links = [dict(r._mapping) for r in result] return templates.TemplateResponse("list_detail.html", { "request": request, "sidebar": sidebar, "item": item, "domain": domain, "project": project, "list_items": top_items, "child_map": child_map, "contacts": contacts, "all_contacts": all_contacts, "all_links": all_links, "page_title": item["name"], "active_nav": "lists", }) @router.get("/{list_id}/edit") async def edit_form(list_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("lists", db) sidebar = await get_sidebar_data(db) item = await repo.get(list_id) if not item: return RedirectResponse(url="/lists", status_code=303) domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() projects_repo = BaseRepository("projects", db) projects = await projects_repo.list() areas_repo = BaseRepository("areas", db) areas = await areas_repo.list() return templates.TemplateResponse("list_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "areas": areas, "page_title": "Edit List", "active_nav": "lists", "item": item, "prefill_domain_id": "", "prefill_project_id": "", }) @router.post("/{list_id}/edit") async def update_list( list_id: str, name: str = Form(...), domain_id: str = Form(...), area_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), list_type: str = Form("checklist"), description: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("lists", db) data = { "name": name, "domain_id": domain_id, "list_type": list_type, "description": description, "area_id": area_id if area_id and area_id.strip() else None, "project_id": project_id if project_id and project_id.strip() else None, } if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] else: data["tags"] = None await repo.update(list_id, data) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/{list_id}/delete") async def delete_list(list_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("lists", db) await repo.soft_delete(list_id) return RedirectResponse(url="/lists", status_code=303) # ---- List Items ---- @router.post("/{list_id}/items/add") async def add_item( list_id: str, request: Request, content: str = Form(...), parent_item_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("list_items", db) data = {"list_id": list_id, "content": content, "completed": False} if parent_item_id and parent_item_id.strip(): data["parent_item_id"] = parent_item_id await repo.create(data) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/{list_id}/items/{item_id}/toggle") async def toggle_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("list_items", db) item = await repo.get(item_id) if not item: return RedirectResponse(url=f"/lists/{list_id}", status_code=303) if item["completed"]: await repo.update(item_id, {"completed": False, "completed_at": None}) else: await repo.update(item_id, {"completed": True, "completed_at": datetime.now(timezone.utc)}) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/{list_id}/items/{item_id}/delete") async def delete_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("list_items", db) await repo.soft_delete(item_id) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/{list_id}/items/{item_id}/edit") async def edit_item( list_id: str, item_id: str, content: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("list_items", db) await repo.update(item_id, {"content": content}) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) # ---- Contact linking ---- @router.post("/{list_id}/contacts/add") async def add_contact( list_id: str, contact_id: str = Form(...), role: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): await db.execute(text(""" INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING """), {"cid": contact_id, "lid": list_id, "role": role if role and role.strip() else None}) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/{list_id}/contacts/{contact_id}/remove") async def remove_contact( list_id: str, contact_id: str, db: AsyncSession = Depends(get_db), ): await db.execute(text( "DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid" ), {"cid": contact_id, "lid": list_id}) return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/reorder") async def reorder_list( request: Request, item_id: str = Form(...), direction: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("lists", db) await repo.move_in_order(item_id, direction) return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303) @router.post("/{list_id}/items/reorder") async def reorder_list_item( list_id: str, request: Request, item_id: str = Form(...), direction: str = Form(...), parent_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("list_items", db) filters = {"list_id": list_id} if parent_id: filters["parent_item_id"] = parent_id else: # Top-level items only (no parent) filters["parent_item_id"] = None await repo.move_in_order(item_id, direction, filters=filters) return RedirectResponse(url=f"/lists/{list_id}", status_code=303)