- 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>
231 lines
7.9 KiB
Python
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)
|