- 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>
419 lines
15 KiB
Python
419 lines
15 KiB
Python
"""Meetings: CRUD with agenda, transcript, notes, and action item -> task conversion."""
|
|
|
|
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 datetime import datetime, timezone
|
|
|
|
from core.database import get_db
|
|
from core.base_repository import BaseRepository
|
|
from core.sidebar import get_sidebar_data
|
|
|
|
router = APIRouter(prefix="/meetings", tags=["meetings"])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
@router.get("/")
|
|
async def list_meetings(
|
|
request: Request,
|
|
status: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
sidebar = await get_sidebar_data(db)
|
|
|
|
where_clauses = ["m.is_deleted = false"]
|
|
params = {}
|
|
if status:
|
|
where_clauses.append("m.status = :status")
|
|
params["status"] = status
|
|
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
result = await db.execute(text(f"""
|
|
SELECT m.*,
|
|
(SELECT count(*) FROM meeting_tasks mt
|
|
JOIN tasks t ON mt.task_id = t.id
|
|
WHERE mt.meeting_id = m.id AND t.is_deleted = false) as action_count
|
|
FROM meetings m
|
|
WHERE {where_sql}
|
|
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
|
|
"""), params)
|
|
items = [dict(r._mapping) for r in result]
|
|
|
|
return templates.TemplateResponse("meetings.html", {
|
|
"request": request, "sidebar": sidebar, "items": items,
|
|
"current_status": status or "",
|
|
"page_title": "Meetings", "active_nav": "meetings",
|
|
})
|
|
|
|
|
|
@router.get("/create")
|
|
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
|
sidebar = await get_sidebar_data(db)
|
|
# Get contacts for attendee selection
|
|
contacts_repo = BaseRepository("contacts", db)
|
|
contacts = await contacts_repo.list()
|
|
# Parent meetings for series
|
|
result = await db.execute(text("""
|
|
SELECT id, title, meeting_date FROM meetings
|
|
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
|
|
"""))
|
|
parent_meetings = [dict(r._mapping) for r in result]
|
|
|
|
return templates.TemplateResponse("meeting_form.html", {
|
|
"request": request, "sidebar": sidebar,
|
|
"contacts": contacts, "parent_meetings": parent_meetings,
|
|
"page_title": "New Meeting", "active_nav": "meetings",
|
|
"item": None,
|
|
})
|
|
|
|
|
|
@router.post("/create")
|
|
async def create_meeting(
|
|
request: Request,
|
|
title: str = Form(...),
|
|
meeting_date: str = Form(...),
|
|
start_at: Optional[str] = Form(None),
|
|
end_at: Optional[str] = Form(None),
|
|
location: Optional[str] = Form(None),
|
|
status: str = Form("scheduled"),
|
|
priority: Optional[str] = Form(None),
|
|
parent_id: Optional[str] = Form(None),
|
|
agenda: Optional[str] = Form(None),
|
|
tags: Optional[str] = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("meetings", db)
|
|
data = {
|
|
"title": title, "meeting_date": meeting_date,
|
|
"status": status, "location": location, "agenda": agenda,
|
|
}
|
|
if start_at and start_at.strip():
|
|
data["start_at"] = start_at
|
|
if end_at and end_at.strip():
|
|
data["end_at"] = end_at
|
|
if priority and priority.strip():
|
|
data["priority"] = int(priority)
|
|
if parent_id and parent_id.strip():
|
|
data["parent_id"] = parent_id
|
|
if tags and tags.strip():
|
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
|
|
|
meeting = await repo.create(data)
|
|
return RedirectResponse(url=f"/meetings/{meeting['id']}", status_code=303)
|
|
|
|
|
|
@router.get("/{meeting_id}")
|
|
async def meeting_detail(
|
|
meeting_id: str, request: Request,
|
|
tab: str = "overview",
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("meetings", db)
|
|
sidebar = await get_sidebar_data(db)
|
|
item = await repo.get(meeting_id)
|
|
if not item:
|
|
return RedirectResponse(url="/meetings", status_code=303)
|
|
|
|
# Linked projects (always shown in header)
|
|
result = await db.execute(text("""
|
|
SELECT p.id, p.name, d.color as domain_color
|
|
FROM projects p
|
|
JOIN project_meetings pm ON pm.project_id = p.id
|
|
LEFT JOIN domains d ON p.domain_id = d.id
|
|
WHERE pm.meeting_id = :mid AND p.is_deleted = false
|
|
ORDER BY p.name
|
|
"""), {"mid": meeting_id})
|
|
projects = [dict(r._mapping) for r in result]
|
|
|
|
# Overview data (always needed for overview tab)
|
|
action_items = []
|
|
decisions = []
|
|
domains = []
|
|
tab_data = []
|
|
all_contacts = []
|
|
all_decisions = []
|
|
|
|
if tab == "overview":
|
|
# Action items
|
|
result = await db.execute(text("""
|
|
SELECT t.*, mt.source,
|
|
d.name as domain_name, d.color as domain_color,
|
|
p.name as project_name
|
|
FROM meeting_tasks mt
|
|
JOIN tasks t ON mt.task_id = t.id
|
|
LEFT JOIN domains d ON t.domain_id = d.id
|
|
LEFT JOIN projects p ON t.project_id = p.id
|
|
WHERE mt.meeting_id = :mid AND t.is_deleted = false
|
|
ORDER BY t.sort_order, t.created_at
|
|
"""), {"mid": meeting_id})
|
|
action_items = [dict(r._mapping) for r in result]
|
|
|
|
# Decisions from this meeting
|
|
result = await db.execute(text("""
|
|
SELECT * FROM decisions
|
|
WHERE meeting_id = :mid AND is_deleted = false
|
|
ORDER BY created_at
|
|
"""), {"mid": meeting_id})
|
|
decisions = [dict(r._mapping) for r in result]
|
|
|
|
# Domains for action item creation
|
|
domains_repo = BaseRepository("domains", db)
|
|
domains = await domains_repo.list()
|
|
|
|
elif tab == "notes":
|
|
result = await db.execute(text("""
|
|
SELECT * FROM notes
|
|
WHERE meeting_id = :mid AND is_deleted = false
|
|
ORDER BY updated_at DESC
|
|
"""), {"mid": meeting_id})
|
|
tab_data = [dict(r._mapping) for r in result]
|
|
|
|
elif tab == "links":
|
|
result = await db.execute(text("""
|
|
SELECT * FROM links
|
|
WHERE meeting_id = :mid AND is_deleted = false
|
|
ORDER BY sort_order, label
|
|
"""), {"mid": meeting_id})
|
|
tab_data = [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 = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false
|
|
ORDER BY f.created_at DESC
|
|
"""), {"mid": meeting_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.meeting_id = :mid AND l.is_deleted = false
|
|
ORDER BY l.sort_order, l.created_at DESC
|
|
"""), {"mid": meeting_id})
|
|
tab_data = [dict(r._mapping) for r in result]
|
|
|
|
elif tab == "decisions":
|
|
result = await db.execute(text("""
|
|
SELECT * FROM decisions
|
|
WHERE meeting_id = :mid AND is_deleted = false
|
|
ORDER BY created_at DESC
|
|
"""), {"mid": meeting_id})
|
|
tab_data = [dict(r._mapping) for r in result]
|
|
result = await db.execute(text("""
|
|
SELECT id, title FROM decisions
|
|
WHERE (meeting_id IS NULL) AND is_deleted = false
|
|
ORDER BY created_at DESC LIMIT 50
|
|
"""))
|
|
all_decisions = [dict(r._mapping) for r in result]
|
|
|
|
elif tab == "contacts":
|
|
result = await db.execute(text("""
|
|
SELECT c.*, cm.role, cm.created_at as linked_at
|
|
FROM contacts c
|
|
JOIN contact_meetings cm ON cm.contact_id = c.id
|
|
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
|
ORDER BY c.first_name
|
|
"""), {"mid": meeting_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 meeting_id = :mid AND is_deleted = false"),
|
|
("links", "SELECT count(*) FROM links WHERE meeting_id = :mid AND is_deleted = false"),
|
|
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"),
|
|
("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"),
|
|
("decisions", "SELECT count(*) FROM decisions WHERE meeting_id = :mid AND is_deleted = false"),
|
|
("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"),
|
|
]:
|
|
result = await db.execute(text(count_sql), {"mid": meeting_id})
|
|
counts[count_tab] = result.scalar() or 0
|
|
|
|
return templates.TemplateResponse("meeting_detail.html", {
|
|
"request": request, "sidebar": sidebar, "item": item,
|
|
"action_items": action_items, "decisions": decisions,
|
|
"domains": domains, "projects": projects,
|
|
"tab": tab, "tab_data": tab_data,
|
|
"all_contacts": all_contacts, "all_decisions": all_decisions,
|
|
"counts": counts,
|
|
"page_title": item["title"], "active_nav": "meetings",
|
|
})
|
|
|
|
|
|
@router.get("/{meeting_id}/edit")
|
|
async def edit_form(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
repo = BaseRepository("meetings", db)
|
|
sidebar = await get_sidebar_data(db)
|
|
item = await repo.get(meeting_id)
|
|
if not item:
|
|
return RedirectResponse(url="/meetings", status_code=303)
|
|
|
|
contacts_repo = BaseRepository("contacts", db)
|
|
contacts = await contacts_repo.list()
|
|
result = await db.execute(text("""
|
|
SELECT id, title, meeting_date FROM meetings
|
|
WHERE is_deleted = false AND id != :mid ORDER BY meeting_date DESC LIMIT 50
|
|
"""), {"mid": meeting_id})
|
|
parent_meetings = [dict(r._mapping) for r in result]
|
|
|
|
return templates.TemplateResponse("meeting_form.html", {
|
|
"request": request, "sidebar": sidebar,
|
|
"contacts": contacts, "parent_meetings": parent_meetings,
|
|
"page_title": "Edit Meeting", "active_nav": "meetings",
|
|
"item": item,
|
|
})
|
|
|
|
|
|
@router.post("/{meeting_id}/edit")
|
|
async def update_meeting(
|
|
meeting_id: str,
|
|
title: str = Form(...),
|
|
meeting_date: str = Form(...),
|
|
start_at: Optional[str] = Form(None),
|
|
end_at: Optional[str] = Form(None),
|
|
location: Optional[str] = Form(None),
|
|
status: str = Form("scheduled"),
|
|
priority: Optional[str] = Form(None),
|
|
parent_id: Optional[str] = Form(None),
|
|
agenda: Optional[str] = Form(None),
|
|
transcript: Optional[str] = Form(None),
|
|
notes_body: Optional[str] = Form(None),
|
|
tags: Optional[str] = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("meetings", db)
|
|
data = {
|
|
"title": title, "meeting_date": meeting_date,
|
|
"status": status,
|
|
"location": location if location and location.strip() else None,
|
|
"agenda": agenda if agenda and agenda.strip() else None,
|
|
"transcript": transcript if transcript and transcript.strip() else None,
|
|
"notes_body": notes_body if notes_body and notes_body.strip() else None,
|
|
"start_at": start_at if start_at and start_at.strip() else None,
|
|
"end_at": end_at if end_at and end_at.strip() else None,
|
|
"parent_id": parent_id if parent_id and parent_id.strip() else None,
|
|
}
|
|
if priority and priority.strip():
|
|
data["priority"] = int(priority)
|
|
else:
|
|
data["priority"] = 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(meeting_id, data)
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
|
|
|
|
|
|
@router.post("/{meeting_id}/delete")
|
|
async def delete_meeting(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
repo = BaseRepository("meetings", db)
|
|
await repo.soft_delete(meeting_id)
|
|
return RedirectResponse(url="/meetings", status_code=303)
|
|
|
|
|
|
# ---- Action Items ----
|
|
|
|
@router.post("/{meeting_id}/action-item")
|
|
async def create_action_item(
|
|
meeting_id: str,
|
|
request: Request,
|
|
title: str = Form(...),
|
|
domain_id: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a task and link it to this meeting as an action item."""
|
|
task_repo = BaseRepository("tasks", db)
|
|
task = await task_repo.create({
|
|
"title": title,
|
|
"domain_id": domain_id,
|
|
"status": "open",
|
|
"priority": 2,
|
|
})
|
|
|
|
# Link via meeting_tasks junction
|
|
await db.execute(text("""
|
|
INSERT INTO meeting_tasks (meeting_id, task_id, source)
|
|
VALUES (:mid, :tid, 'action_item')
|
|
ON CONFLICT DO NOTHING
|
|
"""), {"mid": meeting_id, "tid": task["id"]})
|
|
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
|
|
|
|
|
|
# ---- Decision linking ----
|
|
|
|
@router.post("/{meeting_id}/decisions/add")
|
|
async def add_decision(
|
|
meeting_id: str,
|
|
decision_id: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await db.execute(text("""
|
|
UPDATE decisions SET meeting_id = :mid WHERE id = :did
|
|
"""), {"mid": meeting_id, "did": decision_id})
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
|
|
|
|
|
|
@router.post("/{meeting_id}/decisions/{decision_id}/remove")
|
|
async def remove_decision(
|
|
meeting_id: str, decision_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await db.execute(text("""
|
|
UPDATE decisions SET meeting_id = NULL WHERE id = :did AND meeting_id = :mid
|
|
"""), {"did": decision_id, "mid": meeting_id})
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
|
|
|
|
|
|
# ---- Contact linking ----
|
|
|
|
@router.post("/{meeting_id}/contacts/add")
|
|
async def add_contact(
|
|
meeting_id: str,
|
|
contact_id: str = Form(...),
|
|
role: str = Form("attendee"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await db.execute(text("""
|
|
INSERT INTO contact_meetings (contact_id, meeting_id, role)
|
|
VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING
|
|
"""), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"})
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
|
|
|
|
|
@router.post("/{meeting_id}/contacts/{contact_id}/remove")
|
|
async def remove_contact(
|
|
meeting_id: str, contact_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
await db.execute(text(
|
|
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
|
|
), {"cid": contact_id, "mid": meeting_id})
|
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
|
|
|
|
|
@router.post("/reorder")
|
|
async def reorder_meeting(
|
|
request: Request,
|
|
item_id: str = Form(...),
|
|
direction: str = Form(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
repo = BaseRepository("meetings", db)
|
|
await repo.move_in_order(item_id, direction)
|
|
return RedirectResponse(url=request.headers.get("referer", "/meetings"), status_code=303)
|