Files
lifeos-dev/routers/projects.py

404 lines
15 KiB
Python

"""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 = []
all_meetings = []
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 == "meetings":
result = await db.execute(text("""
SELECT m.*, pm.created_at as linked_at
FROM meetings m
JOIN project_meetings pm ON pm.meeting_id = m.id
WHERE pm.project_id = :pid AND m.is_deleted = false
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
all_meetings = [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"),
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.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,
"all_meetings": all_meetings, "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)
# ---- Meeting linking ----
@router.post("/{project_id}/meetings/add")
async def add_meeting(
project_id: str,
meeting_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO project_meetings (project_id, meeting_id)
VALUES (:pid, :mid) ON CONFLICT DO NOTHING
"""), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
@router.post("/{project_id}/meetings/{meeting_id}/remove")
async def remove_meeting(
project_id: str, meeting_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM project_meetings WHERE project_id = :pid AND meeting_id = :mid"
), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", 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)