"""Projects: organizational unit within domain/area 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 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("/") 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 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] # 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] # 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] # 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) return templates.TemplateResponse("project_detail.html", { "request": request, "sidebar": sidebar, "item": item, "domain": domain, "area": area, "tasks": tasks, "notes": notes, "links": links, "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)