R1 foundation - Phase 1 live build
This commit is contained in:
248
routers/projects.py
Normal file
248
routers/projects.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user