Files
lifeos-dev/routers/decisions.py
Michael c7a07ed280 feat: return-to-project redirects from create/edit forms
When creating or editing items from a project detail tab, users now
return to that project's tab instead of the entity's own page.
Edit links pass from_project param; forms include hidden field.
Reassigning to a different project redirects to the new project.
Decisions/meetings create from project context inserts junction rows.
File uploads from project context redirect back to project files tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:28:15 +00:00

249 lines
8.8 KiB
Python

"""Decisions: knowledge base of decisions with rationale, status, and supersession."""
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="/decisions", tags=["decisions"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_decisions(
request: Request,
status: Optional[str] = None,
impact: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["d.is_deleted = false"]
params = {}
if status:
where_clauses.append("d.status = :status")
params["status"] = status
if impact:
where_clauses.append("d.impact = :impact")
params["impact"] = impact
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT d.*, m.title as meeting_title
FROM decisions d
LEFT JOIN meetings m ON d.meeting_id = m.id
WHERE {where_sql}
ORDER BY d.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decisions.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"current_impact": impact or "",
"page_title": "Decisions", "active_nav": "decisions",
})
@router.get("/create")
async def create_form(
request: Request,
meeting_id: Optional[str] = None,
task_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Meetings for linking
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
meetings = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decision_form.html", {
"request": request, "sidebar": sidebar,
"meetings": meetings,
"page_title": "New Decision", "active_nav": "decisions",
"item": None,
"prefill_meeting_id": meeting_id or "",
"prefill_task_id": task_id or "",
"prefill_project_id": project_id or "",
})
@router.post("/create")
async def create_decision(
request: Request,
title: str = Form(...),
rationale: Optional[str] = Form(None),
status: str = Form("proposed"),
impact: str = Form("medium"),
decided_at: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("decisions", db)
data = {
"title": title, "status": status, "impact": impact,
"rationale": rationale,
}
if decided_at and decided_at.strip():
data["decided_at"] = decided_at
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if task_id and task_id.strip():
data["task_id"] = task_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
decision = await repo.create(data)
# Link to project if created from project context
if project_id and project_id.strip():
await db.execute(text("""
INSERT INTO decision_projects (decision_id, project_id)
VALUES (:did, :pid) ON CONFLICT DO NOTHING
"""), {"did": decision["id"], "pid": project_id})
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=decisions", status_code=303)
if project_id and project_id.strip():
return RedirectResponse(url=f"/projects/{project_id}?tab=decisions", status_code=303)
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
@router.get("/{decision_id}")
async def decision_detail(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("decisions", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(decision_id)
if not item:
return RedirectResponse(url="/decisions", status_code=303)
# Meeting info
meeting = None
if item.get("meeting_id"):
result = await db.execute(text(
"SELECT id, title, meeting_date FROM meetings WHERE id = :id"
), {"id": str(item["meeting_id"])})
row = result.first()
meeting = dict(row._mapping) if row else None
# Superseded by
superseded_by = None
if item.get("superseded_by_id"):
result = await db.execute(text(
"SELECT id, title FROM decisions WHERE id = :id"
), {"id": str(item["superseded_by_id"])})
row = result.first()
superseded_by = dict(row._mapping) if row else None
# Decisions that this one supersedes
result = await db.execute(text("""
SELECT id, title FROM decisions
WHERE superseded_by_id = :did AND is_deleted = false
"""), {"did": decision_id})
supersedes = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decision_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"meeting": meeting, "superseded_by": superseded_by,
"supersedes": supersedes,
"page_title": item["title"], "active_nav": "decisions",
})
@router.get("/{decision_id}/edit")
async def edit_form(decision_id: str, request: Request, from_project: Optional[str] = None, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("decisions", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(decision_id)
if not item:
return RedirectResponse(url="/decisions", status_code=303)
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
meetings = [dict(r._mapping) for r in result]
# Other decisions for supersession
result = await db.execute(text("""
SELECT id, title FROM decisions
WHERE is_deleted = false AND id != :did ORDER BY created_at DESC LIMIT 50
"""), {"did": decision_id})
other_decisions = [dict(r._mapping) for r in result]
return templates.TemplateResponse("decision_form.html", {
"request": request, "sidebar": sidebar,
"meetings": meetings, "other_decisions": other_decisions,
"page_title": "Edit Decision", "active_nav": "decisions",
"item": item,
"prefill_meeting_id": "",
"from_project": from_project or "",
})
@router.post("/{decision_id}/edit")
async def update_decision(
decision_id: str,
title: str = Form(...),
rationale: Optional[str] = Form(None),
status: str = Form("proposed"),
impact: str = Form("medium"),
decided_at: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
superseded_by_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
from_project: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("decisions", db)
data = {
"title": title, "status": status, "impact": impact,
"rationale": rationale if rationale and rationale.strip() else None,
"decided_at": decided_at if decided_at and decided_at.strip() else None,
"meeting_id": meeting_id if meeting_id and meeting_id.strip() else None,
"superseded_by_id": superseded_by_id if superseded_by_id and superseded_by_id.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(decision_id, data)
if from_project and from_project.strip():
return RedirectResponse(url=f"/projects/{from_project}?tab=decisions", status_code=303)
return RedirectResponse(url=f"/decisions/{decision_id}", status_code=303)
@router.post("/{decision_id}/delete")
async def delete_decision(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("decisions", db)
await repo.soft_delete(decision_id)
return RedirectResponse(url="/decisions", status_code=303)
@router.post("/reorder")
async def reorder_decision(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("decisions", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/decisions"), status_code=303)