Initial commit

This commit is contained in:
2026-03-03 00:44:33 +00:00
commit 5297da485f
126 changed files with 54767 additions and 0 deletions

334
routers/lists.py Normal file
View File

@@ -0,0 +1,334 @@
"""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
router = APIRouter(prefix="/lists", tags=["lists"])
templates = Jinja2Templates(directory="templates")
@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]
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,
"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)