Session 2: files upload/preview, meetings with action items, decisions
This commit is contained in:
@@ -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
211
routers/decisions.py
Normal 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
193
routers/files.py
Normal 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
268
routers/meetings.py
Normal 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)
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user