Files
lifeos-dev/routers/meetings.py

353 lines
13 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)
# Overview data (always needed for overview tab)
action_items = []
decisions = []
domains = []
tab_data = []
all_contacts = []
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 == "weblinks":
result = await db.execute(text("""
SELECT * FROM weblinks
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 == "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"),
("weblinks", "SELECT count(*) FROM weblinks 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"),
("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, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "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)
# ---- 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)