360 lines
13 KiB
Python
360 lines
13 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 = []
|
|
|
|
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)
|