Files
lifeos-dev/routers/decisions.py
Michael 497436a0a3 feat: universal reorder grip handles and compact UI density
- Add generic move_in_order() to BaseRepository for reorder support
- Add reusable reorder_arrows.html partial with grip dot handles
- Add reorder routes to all 9 list routers (tasks, notes, links, contacts, meetings, decisions, appointments, lists, focus)
- Compact row padding (6px 12px, gap 8px) on .list-row and .focus-item
- Reduce font size to 0.80rem on row titles, sidebar nav, domain tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:44:30 +00:00

231 lines
7.9 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,
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 "",
})
@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),
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)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_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, 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": "",
})
@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),
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)
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)