Session 2: files upload/preview, meetings with action items, decisions

This commit is contained in:
2026-02-28 04:00:55 +00:00
parent 5773808ae4
commit 82d03ce23a
18 changed files with 1317 additions and 0 deletions

View File

@@ -24,6 +24,9 @@ TRASH_ENTITIES = [
{"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"},
{"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"},
{"table": "lists", "label": "Lists", "name_col": "name", "url": "/lists/{id}"},
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
]

211
routers/decisions.py Normal file
View File

@@ -0,0 +1,211 @@
"""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,
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 "",
})
@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),
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 tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
decision = await repo.create(data)
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)

193
routers/files.py Normal file
View File

@@ -0,0 +1,193 @@
"""Files: upload, download, list, preview, and polymorphic entity attachment."""
import os
import uuid
import shutil
from pathlib import Path
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, FileResponse
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="/files", tags=["files"])
templates = Jinja2Templates(directory="templates")
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev")
# Ensure storage dir exists
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
# MIME types that can be previewed inline
PREVIEWABLE = {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
"application/pdf", "text/plain", "text/html", "text/csv",
}
@router.get("/")
async def list_files(
request: Request,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
if context_type and context_id:
# Files attached to a specific entity
result = await db.execute(text("""
SELECT f.*, fm.context_type, fm.context_id
FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
ORDER BY f.created_at DESC
"""), {"ct": context_type, "cid": context_id})
else:
# All files
result = await db.execute(text("""
SELECT f.* FROM files f
WHERE f.is_deleted = false
ORDER BY f.created_at DESC
LIMIT 100
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("files.html", {
"request": request, "sidebar": sidebar, "items": items,
"context_type": context_type or "",
"context_id": context_id or "",
"page_title": "Files", "active_nav": "files",
})
@router.get("/upload")
async def upload_form(
request: Request,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("file_upload.html", {
"request": request, "sidebar": sidebar,
"context_type": context_type or "",
"context_id": context_id or "",
"page_title": "Upload File", "active_nav": "files",
})
@router.post("/upload")
async def upload_file(
request: Request,
file: UploadFile = FastAPIFile(...),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
context_type: Optional[str] = Form(None),
context_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
# Generate storage filename
file_uuid = str(uuid.uuid4())
original = file.filename or "unknown"
safe_name = original.replace("/", "_").replace("\\", "_")
storage_name = f"{file_uuid}_{safe_name}"
storage_path = os.path.join(FILE_STORAGE_PATH, storage_name)
# Save to disk
with open(storage_path, "wb") as f:
content = await file.read()
f.write(content)
size_bytes = len(content)
# Insert file record
repo = BaseRepository("files", db)
data = {
"filename": storage_name,
"original_filename": original,
"storage_path": storage_path,
"mime_type": file.content_type,
"size_bytes": size_bytes,
"description": description,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_file = await repo.create(data)
# Create file mapping if context provided
if context_type and context_type.strip() and context_id and context_id.strip():
await db.execute(text("""
INSERT INTO file_mappings (file_id, context_type, context_id)
VALUES (:fid, :ct, :cid)
ON CONFLICT DO NOTHING
"""), {"fid": new_file["id"], "ct": context_type, "cid": context_id})
# Redirect back to context or file list
if context_type and context_id:
return RedirectResponse(
url=f"/files?context_type={context_type}&context_id={context_id}",
status_code=303,
)
return RedirectResponse(url="/files", status_code=303)
@router.get("/{file_id}/download")
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("files", db)
item = await repo.get(file_id)
if not item or not os.path.exists(item["storage_path"]):
return RedirectResponse(url="/files", status_code=303)
return FileResponse(
path=item["storage_path"],
filename=item["original_filename"],
media_type=item.get("mime_type") or "application/octet-stream",
)
@router.get("/{file_id}/preview")
async def preview_file(file_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""Inline preview for images and PDFs."""
repo = BaseRepository("files", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(file_id)
if not item:
return RedirectResponse(url="/files", status_code=303)
can_preview = item.get("mime_type", "") in PREVIEWABLE
return templates.TemplateResponse("file_preview.html", {
"request": request, "sidebar": sidebar, "item": item,
"can_preview": can_preview,
"page_title": item["original_filename"], "active_nav": "files",
})
@router.get("/{file_id}/serve")
async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)):
"""Serve file inline (for img src, iframe, etc)."""
repo = BaseRepository("files", db)
item = await repo.get(file_id)
if not item or not os.path.exists(item["storage_path"]):
return RedirectResponse(url="/files", status_code=303)
return FileResponse(
path=item["storage_path"],
media_type=item.get("mime_type") or "application/octet-stream",
)
@router.post("/{file_id}/delete")
async def delete_file(file_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("files", db)
await repo.soft_delete(file_id)
referer = request.headers.get("referer", "/files")
return RedirectResponse(url=referer, status_code=303)

268
routers/meetings.py Normal file
View File

@@ -0,0 +1,268 @@
"""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, 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)
# Action items (tasks linked to this meeting)
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]
# Notes linked to this meeting
result = await db.execute(text("""
SELECT * FROM notes
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY created_at
"""), {"mid": meeting_id})
meeting_notes = [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]
# Attendees
result = await db.execute(text("""
SELECT c.*, cm.role FROM contact_meetings cm
JOIN contacts c ON cm.contact_id = c.id
WHERE cm.meeting_id = :mid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"mid": meeting_id})
attendees = [dict(r._mapping) for r in result]
# Domains for action item creation
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("meeting_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"action_items": action_items, "meeting_notes": meeting_notes,
"decisions": decisions, "attendees": attendees,
"domains": domains,
"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)

View File

@@ -109,6 +109,34 @@ SEARCH_ENTITIES = [
"url": "/lists/{id}",
"icon": "list",
},
{
"type": "meetings",
"label": "Meetings",
"query": """
SELECT m.id, m.title as name, m.status,
NULL as domain_name, NULL as project_name,
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM meetings m
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/meetings/{id}",
"icon": "meeting",
},
{
"type": "decisions",
"label": "Decisions",
"query": """
SELECT d.id, d.title as name, d.status,
NULL as domain_name, NULL as project_name,
ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM decisions d
WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/decisions/{id}",
"icon": "decision",
},
]