"""Projects: organizational unit within domain/area hierarchy.""" from fastapi import APIRouter, Request, Form, Depends from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse, JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data router = APIRouter(prefix="/projects", tags=["projects"]) templates = Jinja2Templates(directory="templates") @router.get("/api/by-domain") async def api_projects_by_domain( domain_id: Optional[str] = None, db: AsyncSession = Depends(get_db), ): """JSON API: return projects filtered by domain_id for dynamic dropdowns.""" if domain_id: result = await db.execute(text(""" SELECT id, name FROM projects WHERE is_deleted = false AND (domain_id = :did OR domain_id IS NULL) ORDER BY name """), {"did": domain_id}) else: result = await db.execute(text(""" SELECT id, name FROM projects WHERE is_deleted = false ORDER BY name """)) projects = [{"id": str(r.id), "name": r.name} for r in result] return JSONResponse(content=projects) @router.get("/") async def list_projects( request: Request, domain_id: Optional[str] = None, status: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) # Build query with joins for hierarchy display where_clauses = ["p.is_deleted = false"] params = {} if domain_id: where_clauses.append("p.domain_id = :domain_id") params["domain_id"] = domain_id if status: where_clauses.append("p.status = :status") params["status"] = status where_sql = " AND ".join(where_clauses) result = await db.execute(text(f""" SELECT p.*, d.name as domain_name, d.color as domain_color, a.name as area_name, (SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false) as task_count, (SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false AND t.status = 'done') as done_count FROM projects p JOIN domains d ON p.domain_id = d.id LEFT JOIN areas a ON p.area_id = a.id WHERE {where_sql} ORDER BY d.sort_order, d.name, a.sort_order, a.name, p.sort_order, p.name """), params) items = [dict(r._mapping) for r in result] # Calculate progress percentage for item in items: total = item["task_count"] or 0 done = item["done_count"] or 0 item["progress"] = round((done / total * 100) if total > 0 else 0) domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() return templates.TemplateResponse("projects.html", { "request": request, "sidebar": sidebar, "items": items, "domains": domains, "current_domain_id": domain_id or "", "current_status": status or "", "page_title": "Projects", "active_nav": "projects", }) @router.get("/create") async def create_form( request: Request, domain_id: Optional[str] = None, area_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() areas_repo = BaseRepository("areas", db) areas = await areas_repo.list() return templates.TemplateResponse("project_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "areas": areas, "page_title": "New Project", "active_nav": "projects", "item": None, "prefill_domain_id": domain_id or "", "prefill_area_id": area_id or "", }) @router.post("/create") async def create_project( request: Request, name: str = Form(...), domain_id: str = Form(...), area_id: Optional[str] = Form(None), description: Optional[str] = Form(None), status: str = Form("active"), priority: int = Form(3), start_date: Optional[str] = Form(None), target_date: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("projects", db) data = { "name": name, "domain_id": domain_id, "description": description, "status": status, "priority": priority, } if area_id and area_id.strip(): data["area_id"] = area_id if start_date and start_date.strip(): data["start_date"] = start_date if target_date and target_date.strip(): data["target_date"] = target_date if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] project = await repo.create(data) return RedirectResponse(url=f"/projects/{project['id']}", status_code=303) @router.get("/{project_id}") async def project_detail( project_id: str, request: Request, tab: str = "tasks", db: AsyncSession = Depends(get_db), ): repo = BaseRepository("projects", db) sidebar = await get_sidebar_data(db) item = await repo.get(project_id) if not item: return RedirectResponse(url="/projects", status_code=303) # Get domain and area names result = await db.execute(text( "SELECT name, color FROM domains WHERE id = :id" ), {"id": str(item["domain_id"])}) domain = dict(result.first()._mapping) if result else {} area = None if item.get("area_id"): result = await db.execute(text( "SELECT name FROM areas WHERE id = :id" ), {"id": str(item["area_id"])}) row = result.first() area = dict(row._mapping) if row else None # Tasks for this project (always needed for progress bar) result = await db.execute(text(""" SELECT t.*, d.name as domain_name, d.color as domain_color FROM tasks t LEFT JOIN domains d ON t.domain_id = d.id WHERE t.project_id = :pid AND t.is_deleted = false ORDER BY t.sort_order, t.created_at """), {"pid": project_id}) tasks = [dict(r._mapping) for r in result] # Progress total = len(tasks) done = len([t for t in tasks if t["status"] == "done"]) progress = round((done / total * 100) if total > 0 else 0) # Tab-specific data notes = [] links = [] tab_data = [] all_contacts = [] if tab == "notes": result = await db.execute(text(""" SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false ORDER BY sort_order, created_at DESC """), {"pid": project_id}) notes = [dict(r._mapping) for r in result] elif tab == "links": result = await db.execute(text(""" SELECT * FROM links WHERE project_id = :pid AND is_deleted = false ORDER BY sort_order, created_at """), {"pid": project_id}) links = [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 = 'project' AND fm.context_id = :pid AND f.is_deleted = false ORDER BY f.created_at DESC """), {"pid": project_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.project_id = :pid AND l.is_deleted = false ORDER BY l.sort_order, l.created_at DESC """), {"pid": project_id}) tab_data = [dict(r._mapping) for r in result] elif tab == "decisions": result = await db.execute(text(""" SELECT d.* FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false ORDER BY d.created_at DESC """), {"pid": project_id}) tab_data = [dict(r._mapping) for r in result] elif tab == "contacts": result = await db.execute(text(""" SELECT c.*, cp.role, cp.created_at as linked_at FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false ORDER BY c.first_name """), {"pid": project_id}) tab_data = [dict(r._mapping) for r in result] 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 counts = {} for count_tab, count_sql in [ ("notes", "SELECT count(*) FROM notes WHERE project_id = :pid AND is_deleted = false"), ("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"), ("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"), ("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"), ("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"), ("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"), ]: result = await db.execute(text(count_sql), {"pid": project_id}) counts[count_tab] = result.scalar() or 0 return templates.TemplateResponse("project_detail.html", { "request": request, "sidebar": sidebar, "item": item, "domain": domain, "area": area, "tasks": tasks, "notes": notes, "links": links, "tab_data": tab_data, "all_contacts": all_contacts, "counts": counts, "progress": progress, "task_count": total, "done_count": done, "tab": tab, "page_title": item["name"], "active_nav": "projects", }) @router.get("/{project_id}/edit") async def edit_form(project_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("projects", db) sidebar = await get_sidebar_data(db) item = await repo.get(project_id) if not item: return RedirectResponse(url="/projects", status_code=303) domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() areas_repo = BaseRepository("areas", db) areas = await areas_repo.list() return templates.TemplateResponse("project_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "areas": areas, "page_title": f"Edit {item['name']}", "active_nav": "projects", "item": item, "prefill_domain_id": "", "prefill_area_id": "", }) @router.post("/{project_id}/edit") async def update_project( project_id: str, name: str = Form(...), domain_id: str = Form(...), area_id: Optional[str] = Form(None), description: Optional[str] = Form(None), status: str = Form("active"), priority: int = Form(3), start_date: Optional[str] = Form(None), target_date: Optional[str] = Form(None), tags: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("projects", db) data = { "name": name, "domain_id": domain_id, "area_id": area_id if area_id and area_id.strip() else None, "description": description, "status": status, "priority": priority, "start_date": start_date if start_date and start_date.strip() else None, "target_date": target_date if target_date and target_date.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(project_id, data) return RedirectResponse(url=f"/projects/{project_id}", status_code=303) @router.post("/{project_id}/delete") async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)): repo = BaseRepository("projects", db) await repo.soft_delete(project_id) return RedirectResponse(url="/projects", status_code=303) # ---- Contact linking ---- @router.post("/{project_id}/contacts/add") async def add_contact( project_id: str, contact_id: str = Form(...), role: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): await db.execute(text(""" INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING """), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None}) return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303) @router.post("/{project_id}/contacts/{contact_id}/remove") async def remove_contact( project_id: str, contact_id: str, db: AsyncSession = Depends(get_db), ): await db.execute(text( "DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid" ), {"cid": contact_id, "pid": project_id}) return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)