Initial commit

This commit is contained in:
2026-03-03 00:44:33 +00:00
commit 5297da485f
126 changed files with 54767 additions and 0 deletions

0
routers/__init__.py Normal file
View File

129
routers/admin.py Normal file
View File

@@ -0,0 +1,129 @@
"""Admin Trash: view, restore, and permanently delete soft-deleted items."""
from fastapi import APIRouter, Request, Depends, Form
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="/admin/trash", tags=["admin"])
templates = Jinja2Templates(directory="templates")
# Entity configs for trash view: (table, display_name, name_column, detail_url_pattern)
TRASH_ENTITIES = [
{"table": "tasks", "label": "Tasks", "name_col": "title", "url": "/tasks/{id}"},
{"table": "projects", "label": "Projects", "name_col": "name", "url": "/projects/{id}"},
{"table": "notes", "label": "Notes", "name_col": "title", "url": "/notes/{id}"},
{"table": "links", "label": "Links", "name_col": "label", "url": "/links"},
{"table": "contacts", "label": "Contacts", "name_col": "first_name", "url": "/contacts/{id}"},
{"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": "link_folders", "label": "Link Folders", "name_col": "name", "url": "/weblinks"},
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
{"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"},
]
@router.get("/")
async def trash_view(
request: Request,
entity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
deleted_items = []
entity_counts = {}
for entity in TRASH_ENTITIES:
try:
# Count deleted items per type
result = await db.execute(text(
f"SELECT count(*) FROM {entity['table']} WHERE is_deleted = true"
))
count = result.scalar() or 0
entity_counts[entity["table"]] = count
# Load items for selected type (or all if none selected)
if count > 0 and (entity_type is None or entity_type == entity["table"]):
result = await db.execute(text(f"""
SELECT id, {entity['name_col']} as display_name, deleted_at, created_at
FROM {entity['table']}
WHERE is_deleted = true
ORDER BY deleted_at DESC
LIMIT 50
"""))
rows = [dict(r._mapping) for r in result]
for row in rows:
deleted_items.append({
"id": str(row["id"]),
"name": str(row.get("display_name") or row["id"])[:100],
"table": entity["table"],
"type_label": entity["label"],
"deleted_at": row.get("deleted_at"),
"created_at": row.get("created_at"),
})
except Exception:
continue
total_deleted = sum(entity_counts.values())
return templates.TemplateResponse("trash.html", {
"request": request, "sidebar": sidebar,
"deleted_items": deleted_items,
"entity_counts": entity_counts,
"trash_entities": TRASH_ENTITIES,
"current_type": entity_type or "",
"total_deleted": total_deleted,
"page_title": "Trash", "active_nav": "trash",
})
@router.post("/{table}/{item_id}/restore")
async def restore_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
# Validate table name against known entities
valid_tables = {e["table"] for e in TRASH_ENTITIES}
if table not in valid_tables:
return RedirectResponse(url="/admin/trash", status_code=303)
repo = BaseRepository(table, db)
await repo.restore(item_id)
referer = request.headers.get("referer", "/admin/trash")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{table}/{item_id}/permanent-delete")
async def permanent_delete_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
valid_tables = {e["table"] for e in TRASH_ENTITIES}
if table not in valid_tables:
return RedirectResponse(url="/admin/trash", status_code=303)
repo = BaseRepository(table, db)
await repo.permanent_delete(item_id)
referer = request.headers.get("referer", "/admin/trash")
return RedirectResponse(url=referer, status_code=303)
@router.post("/empty")
async def empty_trash(request: Request, db: AsyncSession = Depends(get_db)):
"""Permanently delete ALL soft-deleted items across all tables."""
for entity in TRASH_ENTITIES:
try:
await db.execute(text(
f"DELETE FROM {entity['table']} WHERE is_deleted = true"
))
except Exception:
continue
return RedirectResponse(url="/admin/trash", status_code=303)

302
routers/appointments.py Normal file
View File

@@ -0,0 +1,302 @@
"""Appointments CRUD: scheduling with contacts, recurrence, all-day support."""
from fastapi import APIRouter, Request, Depends, Form, Query
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
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/appointments", tags=["appointments"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_appointments(
request: Request,
status: Optional[str] = None,
timeframe: Optional[str] = "upcoming",
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
# Build filter and sort based on timeframe
if timeframe == "past":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at < now()
ORDER BY start_at DESC
LIMIT 100
"""))
elif timeframe == "all":
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false
ORDER BY start_at DESC
LIMIT 200
"""))
else:
# upcoming (default)
result = await db.execute(text("""
SELECT * FROM appointments
WHERE is_deleted = false AND start_at >= CURRENT_DATE
ORDER BY start_at ASC
LIMIT 100
"""))
appointments = [dict(r._mapping) for r in result]
# Get contact counts per appointment
if appointments:
ids = [str(a["id"]) for a in appointments]
placeholders = ", ".join(f"'{i}'" for i in ids)
contact_result = await db.execute(text(f"""
SELECT appointment_id, count(*) as cnt
FROM contact_appointments
WHERE appointment_id IN ({placeholders})
GROUP BY appointment_id
"""))
contact_counts = {str(r._mapping["appointment_id"]): r._mapping["cnt"] for r in contact_result}
for a in appointments:
a["contact_count"] = contact_counts.get(str(a["id"]), 0)
count = len(appointments)
return templates.TemplateResponse("appointments.html", {
"request": request,
"sidebar": sidebar,
"appointments": appointments,
"count": count,
"timeframe": timeframe or "upcoming",
"page_title": "Appointments",
"active_nav": "appointments",
})
@router.get("/new")
async def new_appointment(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
# Load contacts for attendee selection
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": None,
"contacts": contacts,
"selected_contacts": [],
"page_title": "New Appointment",
"active_nav": "appointments",
})
@router.post("/create")
async def create_appointment(
request: Request,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
# Build start_at
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
# Build end_at
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
appointment = await repo.create(data)
# Add contact associations
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": str(appointment["id"])})
return RedirectResponse(url=f"/appointments/{appointment['id']}", status_code=303)
@router.get("/{appointment_id}")
async def appointment_detail(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# Get associated contacts
result = await db.execute(text("""
SELECT c.id, c.first_name, c.last_name, c.company, c.email, ca.role
FROM contact_appointments ca
JOIN contacts c ON ca.contact_id = c.id
WHERE ca.appointment_id = :aid AND c.is_deleted = false
ORDER BY c.first_name, c.last_name
"""), {"aid": appointment_id})
contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("appointment_detail.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"page_title": appointment["title"],
"active_nav": "appointments",
})
@router.get("/{appointment_id}/edit")
async def edit_appointment_form(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("appointments", db)
appointment = await repo.get(appointment_id)
if not appointment:
return RedirectResponse(url="/appointments", status_code=303)
# All contacts
result = await db.execute(text("""
SELECT id, first_name, last_name, company
FROM contacts WHERE is_deleted = false
ORDER BY first_name, last_name
"""))
contacts = [dict(r._mapping) for r in result]
# Currently linked contacts
result = await db.execute(text("""
SELECT contact_id FROM contact_appointments WHERE appointment_id = :aid
"""), {"aid": appointment_id})
selected_contacts = [str(r._mapping["contact_id"]) for r in result]
return templates.TemplateResponse("appointment_form.html", {
"request": request,
"sidebar": sidebar,
"appointment": appointment,
"contacts": contacts,
"selected_contacts": selected_contacts,
"page_title": f"Edit: {appointment['title']}",
"active_nav": "appointments",
})
@router.post("/{appointment_id}/edit")
async def update_appointment(
request: Request,
appointment_id: str,
title: str = Form(...),
description: Optional[str] = Form(None),
location: Optional[str] = Form(None),
start_date: str = Form(...),
start_time: Optional[str] = Form(None),
end_date: Optional[str] = Form(None),
end_time: Optional[str] = Form(None),
all_day: Optional[str] = Form(None),
recurrence: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
contact_ids: Optional[list[str]] = Form(None),
db: AsyncSession = Depends(get_db),
):
is_all_day = all_day == "on"
if is_all_day:
start_at = f"{start_date}T00:00:00"
else:
start_at = f"{start_date}T{start_time or '09:00'}:00"
end_at = None
if end_date:
if is_all_day:
end_at = f"{end_date}T23:59:59"
else:
end_at = f"{end_date}T{end_time or '10:00'}:00"
data = {
"title": title,
"description": description or None,
"location": location or None,
"start_at": start_at,
"end_at": end_at,
"all_day": is_all_day,
"recurrence": recurrence or None,
"tags": [t.strip() for t in tags.split(",") if t.strip()] if tags else None,
}
repo = BaseRepository("appointments", db)
await repo.update(appointment_id, data)
# Rebuild contact associations
await db.execute(text("DELETE FROM contact_appointments WHERE appointment_id = :aid"), {"aid": appointment_id})
if contact_ids:
for cid in contact_ids:
if cid:
await db.execute(text("""
INSERT INTO contact_appointments (contact_id, appointment_id)
VALUES (:cid, :aid)
ON CONFLICT DO NOTHING
"""), {"cid": cid, "aid": appointment_id})
return RedirectResponse(url=f"/appointments/{appointment_id}", status_code=303)
@router.post("/{appointment_id}/delete")
async def delete_appointment(
request: Request,
appointment_id: str,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("appointments", db)
await repo.soft_delete(appointment_id)
return RedirectResponse(url="/appointments", status_code=303)

122
routers/areas.py Normal file
View File

@@ -0,0 +1,122 @@
"""Areas: grouping within domains."""
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="/areas", tags=["areas"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_areas(
request: Request,
domain_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("areas", db)
sidebar = await get_sidebar_data(db)
filters = {}
if domain_id:
filters["domain_id"] = domain_id
result = await db.execute(text("""
SELECT a.*, d.name as domain_name, d.color as domain_color
FROM areas a
JOIN domains d ON a.domain_id = d.id
WHERE a.is_deleted = false
ORDER BY d.sort_order, d.name, a.sort_order, a.name
"""))
items = [dict(r._mapping) for r in result]
if domain_id:
items = [i for i in items if str(i["domain_id"]) == domain_id]
# Get domains for filter/form
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("areas.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "current_domain_id": domain_id or "",
"page_title": "Areas", "active_nav": "areas",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("area_form.html", {
"request": request, "sidebar": sidebar, "domains": domains,
"page_title": "New Area", "active_nav": "areas",
"item": None, "prefill_domain_id": domain_id or "",
})
@router.post("/create")
async def create_area(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
description: Optional[str] = Form(None),
status: str = Form("active"),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("areas", db)
await repo.create({
"name": name, "domain_id": domain_id,
"description": description, "status": status,
})
return RedirectResponse(url="/areas", status_code=303)
@router.get("/{area_id}/edit")
async def edit_form(area_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("areas", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(area_id)
if not item:
return RedirectResponse(url="/areas", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("area_form.html", {
"request": request, "sidebar": sidebar, "domains": domains,
"page_title": f"Edit {item['name']}", "active_nav": "areas",
"item": item, "prefill_domain_id": "",
})
@router.post("/{area_id}/edit")
async def update_area(
area_id: str,
name: str = Form(...),
domain_id: str = Form(...),
description: Optional[str] = Form(None),
status: str = Form("active"),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("areas", db)
await repo.update(area_id, {
"name": name, "domain_id": domain_id,
"description": description, "status": status,
})
return RedirectResponse(url="/areas", status_code=303)
@router.post("/{area_id}/delete")
async def delete_area(area_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("areas", db)
await repo.soft_delete(area_id)
return RedirectResponse(url="/areas", status_code=303)

138
routers/calendar.py Normal file
View File

@@ -0,0 +1,138 @@
"""Calendar: unified read-only month view of appointments, meetings, and tasks."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from datetime import date, timedelta
import calendar
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/calendar", tags=["calendar"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def calendar_view(
request: Request,
year: int = None,
month: int = None,
db: AsyncSession = Depends(get_db),
):
today = date.today()
year = year or today.year
month = month or today.month
# Clamp to valid range
if month < 1 or month > 12:
month = today.month
if year < 2000 or year > 2100:
year = today.year
first_day = date(year, month, 1)
last_day = date(year, month, calendar.monthrange(year, month)[1])
# Prev/next month
prev_month = first_day - timedelta(days=1)
next_month = last_day + timedelta(days=1)
sidebar = await get_sidebar_data(db)
# Appointments in this month (by start_at)
appt_result = await db.execute(text("""
SELECT id, title, start_at, end_at, all_day, location
FROM appointments
WHERE is_deleted = false
AND start_at::date >= :first AND start_at::date <= :last
ORDER BY start_at
"""), {"first": first_day, "last": last_day})
appointments = [dict(r._mapping) for r in appt_result]
# Meetings in this month (by meeting_date)
meet_result = await db.execute(text("""
SELECT id, title, meeting_date, start_at, location, status
FROM meetings
WHERE is_deleted = false
AND meeting_date >= :first AND meeting_date <= :last
ORDER BY meeting_date, start_at
"""), {"first": first_day, "last": last_day})
meetings = [dict(r._mapping) for r in meet_result]
# Tasks with due dates in this month (open/in_progress only)
task_result = await db.execute(text("""
SELECT t.id, t.title, t.due_date, t.priority, t.status,
p.name as project_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.is_deleted = false
AND t.status IN ('open', 'in_progress')
AND t.due_date >= :first AND t.due_date <= :last
ORDER BY t.due_date, t.priority
"""), {"first": first_day, "last": last_day})
tasks = [dict(r._mapping) for r in task_result]
# Build day-indexed event map
days_map = {}
for a in appointments:
day = a["start_at"].date().day if a["start_at"] else None
if day:
days_map.setdefault(day, []).append({
"type": "appointment",
"id": a["id"],
"title": a["title"],
"time": None if a["all_day"] else a["start_at"].strftime("%-I:%M %p"),
"url": f"/appointments/{a['id']}",
"all_day": a["all_day"],
})
for m in meetings:
day = m["meeting_date"].day if m["meeting_date"] else None
if day:
time_str = m["start_at"].strftime("%-I:%M %p") if m["start_at"] else None
days_map.setdefault(day, []).append({
"type": "meeting",
"id": m["id"],
"title": m["title"],
"time": time_str,
"url": f"/meetings/{m['id']}",
"all_day": False,
})
for t in tasks:
day = t["due_date"].day if t["due_date"] else None
if day:
days_map.setdefault(day, []).append({
"type": "task",
"id": t["id"],
"title": t["title"],
"time": None,
"url": f"/tasks/{t['id']}",
"priority": t["priority"],
"project_name": t.get("project_name"),
"all_day": False,
})
# Build calendar grid (weeks of days)
# Monday=0, Sunday=6
cal = calendar.Calendar(firstweekday=6) # Sunday start
weeks = cal.monthdayscalendar(year, month)
return templates.TemplateResponse("calendar.html", {
"request": request,
"sidebar": sidebar,
"year": year,
"month": month,
"month_name": calendar.month_name[month],
"weeks": weeks,
"days_map": days_map,
"today": today,
"first_day": first_day,
"prev_year": prev_month.year,
"prev_month": prev_month.month,
"next_year": next_month.year,
"next_month": next_month.month,
"page_title": f"Calendar - {calendar.month_name[month]} {year}",
"active_nav": "calendar",
})

361
routers/capture.py Normal file
View File

@@ -0,0 +1,361 @@
"""Capture: quick text capture queue with conversion to any entity type."""
import re
from uuid import uuid4
from typing import Optional
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 core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
from routers.weblinks import get_default_folder_id
router = APIRouter(prefix="/capture", tags=["capture"])
templates = Jinja2Templates(directory="templates")
CONVERT_TYPES = {
"task": "Task",
"note": "Note",
"project": "Project",
"list_item": "List Item",
"contact": "Contact",
"decision": "Decision",
"link": "Link",
}
@router.get("/")
async def list_capture(request: Request, show: str = "inbox", db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
if show == "processed":
where = "is_deleted = false AND processed = true"
elif show == "all":
where = "is_deleted = false"
else: # inbox
where = "is_deleted = false AND processed = false"
result = await db.execute(text(f"""
SELECT * FROM capture WHERE {where} ORDER BY created_at DESC
"""))
items = [dict(r._mapping) for r in result]
# Mark first item per batch for batch-undo display
batches = {}
for item in items:
bid = item.get("import_batch_id")
if bid:
bid_str = str(bid)
if bid_str not in batches:
batches[bid_str] = 0
item["_batch_first"] = True
else:
item["_batch_first"] = False
batches[bid_str] += 1
else:
item["_batch_first"] = False
# Get lists for list_item conversion
result = await db.execute(text(
"SELECT id, name FROM lists WHERE is_deleted = false ORDER BY name"
))
all_lists = [dict(r._mapping) for r in result]
return templates.TemplateResponse("capture.html", {
"request": request, "sidebar": sidebar, "items": items,
"show": show, "batches": batches, "all_lists": all_lists,
"convert_types": CONVERT_TYPES,
"page_title": "Capture", "active_nav": "capture",
})
@router.post("/add")
async def add_capture(
request: Request,
raw_text: str = Form(...),
redirect_to: Optional[str] = Form(None),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("capture", db)
lines = [l.strip() for l in raw_text.strip().split("\n") if l.strip()]
batch_id = str(uuid4()) if len(lines) > 1 else None
for line in lines:
data = {"raw_text": line, "processed": False}
if batch_id:
data["import_batch_id"] = batch_id
if area_id and area_id.strip():
data["area_id"] = area_id
if project_id and project_id.strip():
data["project_id"] = project_id
await repo.create(data)
url = redirect_to if redirect_to and redirect_to.startswith("/") else "/capture"
return RedirectResponse(url=url, status_code=303)
# ---- Batch undo (must be before /{capture_id} routes) ----
@router.post("/batch/{batch_id}/undo")
async def batch_undo(batch_id: str, db: AsyncSession = Depends(get_db)):
"""Delete all items from a batch."""
await db.execute(text("""
UPDATE capture SET is_deleted = true, deleted_at = now(), updated_at = now()
WHERE import_batch_id = :bid AND is_deleted = false
"""), {"bid": batch_id})
await db.commit()
return RedirectResponse(url="/capture", status_code=303)
# ---- Conversion form page ----
@router.get("/{capture_id}/convert/{convert_type}")
async def convert_form(
capture_id: str, convert_type: str,
request: Request, db: AsyncSession = Depends(get_db),
):
"""Show conversion form for a specific capture item."""
repo = BaseRepository("capture", db)
item = await repo.get(capture_id)
if not item or convert_type not in CONVERT_TYPES:
return RedirectResponse(url="/capture", status_code=303)
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM lists WHERE is_deleted = false ORDER BY name"
))
all_lists = [dict(r._mapping) for r in result]
# Parse name for contact pre-fill
parts = item["raw_text"].strip().split(None, 1)
first_name = parts[0] if parts else item["raw_text"]
last_name = parts[1] if len(parts) > 1 else ""
# Extract URL for weblink pre-fill
url_match = re.search(r'https?://\S+', item["raw_text"])
prefill_url = url_match.group(0) if url_match else ""
prefill_label = item["raw_text"].replace(prefill_url, "").strip() if url_match else item["raw_text"]
return templates.TemplateResponse("capture_convert.html", {
"request": request, "sidebar": sidebar,
"item": item, "convert_type": convert_type,
"type_label": CONVERT_TYPES[convert_type],
"all_lists": all_lists,
"first_name": first_name, "last_name": last_name,
"prefill_url": prefill_url, "prefill_label": prefill_label or item["raw_text"],
"page_title": f"Convert to {CONVERT_TYPES[convert_type]}",
"active_nav": "capture",
})
# ---- Conversion handlers ----
@router.post("/{capture_id}/to-task")
async def convert_to_task(
capture_id: str, request: Request,
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
priority: int = Form(3),
title: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
task_repo = BaseRepository("tasks", db)
data = {
"title": (title or item["raw_text"]).strip(),
"domain_id": domain_id, "status": "open", "priority": priority,
}
if project_id and project_id.strip():
data["project_id"] = project_id
task = await task_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "task",
"converted_to_id": str(task["id"]),
})
return RedirectResponse(url=f"/tasks/{task['id']}", status_code=303)
@router.post("/{capture_id}/to-note")
async def convert_to_note(
capture_id: str, request: Request,
title: Optional[str] = Form(None),
domain_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
note_repo = BaseRepository("notes", db)
raw = item["raw_text"]
data = {"title": (title or raw[:100]).strip(), "body": raw}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip():
data["project_id"] = project_id
note = await note_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "note",
"converted_to_id": str(note["id"]),
})
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
@router.post("/{capture_id}/to-project")
async def convert_to_project(
capture_id: str, request: Request,
domain_id: str = Form(...),
name: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
project_repo = BaseRepository("projects", db)
data = {"name": (name or item["raw_text"]).strip(), "domain_id": domain_id, "status": "active"}
project = await project_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "project",
"converted_to_id": str(project["id"]),
})
return RedirectResponse(url=f"/projects/{project['id']}", status_code=303)
@router.post("/{capture_id}/to-list_item")
async def convert_to_list_item(
capture_id: str, request: Request,
list_id: str = Form(...),
content: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
li_repo = BaseRepository("list_items", db)
data = {"list_id": list_id, "content": (content or item["raw_text"]).strip()}
li = await li_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "list_item",
"converted_to_id": str(li["id"]), "list_id": list_id,
})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{capture_id}/to-contact")
async def convert_to_contact(
capture_id: str, request: Request,
first_name: str = Form(...),
last_name: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
contact_repo = BaseRepository("contacts", db)
data = {"first_name": first_name.strip()}
if last_name and last_name.strip():
data["last_name"] = last_name.strip()
contact = await contact_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "contact",
"converted_to_id": str(contact["id"]),
})
return RedirectResponse(url=f"/contacts/{contact['id']}", status_code=303)
@router.post("/{capture_id}/to-decision")
async def convert_to_decision(
capture_id: str, request: Request,
title: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
decision_repo = BaseRepository("decisions", db)
data = {"title": (title or item["raw_text"]).strip(), "status": "proposed", "impact": "medium"}
decision = await decision_repo.create(data)
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "decision",
"converted_to_id": str(decision["id"]),
})
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
@router.post("/{capture_id}/to-link")
async def convert_to_link(
capture_id: str, request: Request,
label: Optional[str] = Form(None),
url: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
capture_repo = BaseRepository("capture", db)
item = await capture_repo.get(capture_id)
if not item:
return RedirectResponse(url="/capture", status_code=303)
link_repo = BaseRepository("links", db)
raw = item["raw_text"]
url_match = re.search(r'https?://\S+', raw)
link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw)
link_label = (label.strip() if label and label.strip() else None) or (raw.replace(link_url, "").strip() if url_match else raw[:100])
if not link_label:
link_label = link_url
data = {"label": link_label, "url": link_url}
link = await link_repo.create(data)
# Assign to Default folder
default_fid = await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]})
await capture_repo.update(capture_id, {
"processed": True, "converted_to_type": "link",
"converted_to_id": str(link["id"]),
})
return RedirectResponse(url="/links", status_code=303)
@router.post("/{capture_id}/dismiss")
async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("capture", db)
await repo.update(capture_id, {"processed": True, "converted_to_type": "dismissed"})
return RedirectResponse(url="/capture", status_code=303)
@router.post("/{capture_id}/delete")
async def delete_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("capture", db)
await repo.soft_delete(capture_id)
return RedirectResponse(url="/capture", status_code=303)

126
routers/contacts.py Normal file
View File

@@ -0,0 +1,126 @@
"""Contacts: people directory for CRM."""
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="/contacts", tags=["contacts"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_contacts(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
result = await db.execute(text("""
SELECT * FROM contacts WHERE is_deleted = false
ORDER BY sort_order, first_name, last_name
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("contacts.html", {
"request": request, "sidebar": sidebar, "items": items,
"page_title": "Contacts", "active_nav": "contacts",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("contact_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "New Contact", "active_nav": "contacts",
"item": None,
})
@router.post("/create")
async def create_contact(
request: Request,
first_name: str = Form(...),
last_name: Optional[str] = Form(None),
company: Optional[str] = Form(None),
role: Optional[str] = Form(None),
email: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
notes: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("contacts", db)
data = {
"first_name": first_name, "last_name": last_name,
"company": company, "role": role, "email": email,
"phone": phone, "notes": notes,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
await repo.create(data)
return RedirectResponse(url="/contacts", status_code=303)
@router.get("/{contact_id}")
async def contact_detail(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(contact_id)
if not item:
return RedirectResponse(url="/contacts", status_code=303)
return templates.TemplateResponse("contact_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"page_title": f"{item['first_name']} {item.get('last_name', '')}".strip(),
"active_nav": "contacts",
})
@router.get("/{contact_id}/edit")
async def edit_form(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(contact_id)
if not item:
return RedirectResponse(url="/contacts", status_code=303)
return templates.TemplateResponse("contact_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "Edit Contact", "active_nav": "contacts",
"item": item,
})
@router.post("/{contact_id}/edit")
async def update_contact(
contact_id: str,
first_name: str = Form(...),
last_name: Optional[str] = Form(None),
company: Optional[str] = Form(None),
role: Optional[str] = Form(None),
email: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
notes: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("contacts", db)
data = {
"first_name": first_name, "last_name": last_name,
"company": company, "role": role, "email": email,
"phone": phone, "notes": notes,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(contact_id, data)
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
@router.post("/{contact_id}/delete")
async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("contacts", db)
await repo.soft_delete(contact_id)
return RedirectResponse(url="/contacts", status_code=303)

218
routers/decisions.py Normal file
View File

@@ -0,0 +1,218 @@
"""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)

83
routers/domains.py Normal file
View File

@@ -0,0 +1,83 @@
"""Domains: top-level organizational buckets."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
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="/domains", tags=["domains"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_domains(request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("domains", db)
sidebar = await get_sidebar_data(db)
items = await repo.list(sort="sort_order")
return templates.TemplateResponse("domains.html", {
"request": request, "sidebar": sidebar, "items": items,
"page_title": "Domains", "active_nav": "domains",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("domain_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "New Domain", "active_nav": "domains",
"item": None,
})
@router.post("/create")
async def create_domain(
request: Request,
name: str = Form(...),
color: Optional[str] = Form(None),
description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("domains", db)
domain = await repo.create({"name": name, "color": color, "description": description})
return RedirectResponse(url="/domains", status_code=303)
@router.get("/{domain_id}/edit")
async def edit_form(domain_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("domains", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(domain_id)
if not item:
return RedirectResponse(url="/domains", status_code=303)
return templates.TemplateResponse("domain_form.html", {
"request": request, "sidebar": sidebar,
"page_title": f"Edit {item['name']}", "active_nav": "domains",
"item": item,
})
@router.post("/{domain_id}/edit")
async def update_domain(
domain_id: str,
request: Request,
name: str = Form(...),
color: Optional[str] = Form(None),
description: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("domains", db)
await repo.update(domain_id, {"name": name, "color": color, "description": description})
return RedirectResponse(url="/domains", status_code=303)
@router.post("/{domain_id}/delete")
async def delete_domain(domain_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("domains", db)
await repo.soft_delete(domain_id)
return RedirectResponse(url="/domains", status_code=303)

120
routers/eisenhower.py Normal file
View File

@@ -0,0 +1,120 @@
"""Eisenhower Matrix: read-only 2x2 priority/urgency grid of open tasks."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
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="/eisenhower", tags=["eisenhower"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def eisenhower_matrix(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[str] = None,
context: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = [
"t.is_deleted = false",
"t.status IN ('open', 'in_progress', 'blocked')",
]
params = {}
if domain_id:
where_clauses.append("t.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("t.project_id = :project_id")
params["project_id"] = project_id
if status:
where_clauses.append("t.status = :status")
params["status"] = status
if context:
where_clauses.append("t.context = :context")
params["context"] = context
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT t.id, t.title, t.priority, t.status, t.due_date,
t.context, t.estimated_minutes,
p.name as project_name,
d.name as domain_name, d.color as domain_color
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE {where_sql}
ORDER BY t.priority, t.due_date NULLS LAST, t.title
"""), params)
tasks = [dict(r._mapping) for r in result]
# Classify into quadrants
from datetime import date, timedelta
today = date.today()
urgent_cutoff = today + timedelta(days=7)
quadrants = {
"do_first": [],
"schedule": [],
"delegate": [],
"eliminate": [],
}
for t in tasks:
important = t["priority"] in (1, 2)
urgent = (
t["due_date"] is not None
and t["due_date"] <= urgent_cutoff
)
if important and urgent:
quadrants["do_first"].append(t)
elif important and not urgent:
quadrants["schedule"].append(t)
elif not important and urgent:
quadrants["delegate"].append(t)
else:
quadrants["eliminate"].append(t)
counts = {k: len(v) for k, v in quadrants.items()}
total = sum(counts.values())
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("eisenhower.html", {
"request": request,
"sidebar": sidebar,
"quadrants": quadrants,
"counts": counts,
"total": total,
"today": today,
"domains": domains,
"projects": projects,
"context_types": context_types,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"current_status": status or "",
"current_context": context or "",
"page_title": "Eisenhower Matrix",
"active_nav": "eisenhower",
})

398
routers/files.py Normal file
View File

@@ -0,0 +1,398 @@
"""Files: upload, download, list, preview, folder-aware storage, and WebDAV sync."""
import os
import mimetypes
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, HTMLResponse
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/webdav")
# 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",
}
# Files to skip during sync
SKIP_FILES = {".DS_Store", "Thumbs.db", ".gitkeep", "desktop.ini"}
def _resolve_path(item):
"""Resolve a DB record's relative storage_path to an absolute path."""
return os.path.join(FILE_STORAGE_PATH, item["storage_path"])
def get_folders():
"""Walk FILE_STORAGE_PATH and return sorted list of relative folder paths."""
folders = []
for dirpath, dirnames, _filenames in os.walk(FILE_STORAGE_PATH):
# Skip hidden directories
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
rel = os.path.relpath(dirpath, FILE_STORAGE_PATH)
if rel != ".":
folders.append(rel)
return sorted(folders)
def resolve_collision(folder_abs, filename):
"""If filename exists in folder_abs, return name (2).ext, name (3).ext, etc."""
target = os.path.join(folder_abs, filename)
if not os.path.exists(target):
return filename
name, ext = os.path.splitext(filename)
counter = 2
while True:
candidate = f"{name} ({counter}){ext}"
if not os.path.exists(os.path.join(folder_abs, candidate)):
return candidate
counter += 1
async def sync_files(db: AsyncSession):
"""Sync filesystem state with the database.
- Files on disk not in DB → create record
- Active DB records with missing files → soft-delete
Returns dict with added/removed counts.
"""
added = 0
removed = 0
# Build set of all relative file paths on disk
disk_files = set()
for dirpath, dirnames, filenames in os.walk(FILE_STORAGE_PATH):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for fname in filenames:
if fname in SKIP_FILES or fname.startswith("."):
continue
abs_path = os.path.join(dirpath, fname)
rel_path = os.path.relpath(abs_path, FILE_STORAGE_PATH)
disk_files.add(rel_path)
# Get ALL DB records (including soft-deleted) to avoid re-creating deleted files
result = await db.execute(text(
"SELECT id, storage_path, is_deleted FROM files"
))
db_records = [dict(r._mapping) for r in result]
# Build lookup maps
all_db_paths = {r["storage_path"] for r in db_records}
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
deleted_on_disk = {r["storage_path"]: r for r in db_records
if r["is_deleted"] and r["storage_path"] in disk_files}
# New on disk, not in DB at all → create record
new_files = disk_files - all_db_paths
for rel_path in new_files:
abs_path = os.path.join(FILE_STORAGE_PATH, rel_path)
filename = os.path.basename(rel_path)
mime_type = mimetypes.guess_type(filename)[0]
try:
size_bytes = os.path.getsize(abs_path)
except OSError:
continue
repo = BaseRepository("files", db)
await repo.create({
"filename": filename,
"original_filename": filename,
"storage_path": rel_path,
"mime_type": mime_type,
"size_bytes": size_bytes,
})
added += 1
# Soft-deleted in DB but still on disk → restore
for rel_path, record in deleted_on_disk.items():
repo = BaseRepository("files", db)
await repo.restore(record["id"])
added += 1
# Active in DB but missing from disk → soft-delete
missing_files = active_db_paths - disk_files
for record in db_records:
if record["storage_path"] in missing_files and not record["is_deleted"]:
repo = BaseRepository("files", db)
await repo.soft_delete(record["id"])
removed += 1
return {"added": added, "removed": removed}
SORT_OPTIONS = {
"path": "storage_path ASC",
"path_desc": "storage_path DESC",
"name": "original_filename ASC",
"name_desc": "original_filename DESC",
"date": "created_at DESC",
"date_asc": "created_at ASC",
}
@router.get("/")
async def list_files(
request: Request,
folder: Optional[str] = None,
sort: Optional[str] = None,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Auto-sync on page load
sync_result = await sync_files(db)
folders = get_folders()
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
if context_type and context_id:
# Files attached to a specific entity
result = await db.execute(text(f"""
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 {order_by}
"""), {"ct": context_type, "cid": context_id})
elif folder is not None:
if folder == "":
# Root folder: files with no directory separator in storage_path
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false AND storage_path NOT LIKE '%/%'
ORDER BY {order_by}
"""))
else:
# Specific folder: storage_path starts with folder/
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false AND storage_path LIKE :prefix
ORDER BY {order_by}
"""), {"prefix": folder + "/%"})
else:
# All files
result = await db.execute(text(f"""
SELECT * FROM files
WHERE is_deleted = false
ORDER BY {order_by}
"""))
items = [dict(r._mapping) for r in result]
# Add derived folder field for display
for item in items:
dirname = os.path.dirname(item["storage_path"])
item["folder"] = dirname if dirname else "/"
return templates.TemplateResponse("files.html", {
"request": request, "sidebar": sidebar, "items": items,
"folders": folders, "current_folder": folder,
"current_sort": sort or "path",
"sync_result": sync_result,
"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,
folder: Optional[str] = None,
context_type: Optional[str] = None,
context_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
folders = get_folders()
return templates.TemplateResponse("file_upload.html", {
"request": request, "sidebar": sidebar,
"folders": folders, "prefill_folder": folder or "",
"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),
folder: Optional[str] = Form(None),
new_folder: Optional[str] = Form(None),
context_type: Optional[str] = Form(None),
context_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
# Determine target folder
target_folder = ""
if new_folder and new_folder.strip():
target_folder = new_folder.strip().strip("/")
elif folder and folder.strip():
target_folder = folder.strip()
# Build absolute folder path and ensure it exists
if target_folder:
folder_abs = os.path.join(FILE_STORAGE_PATH, target_folder)
else:
folder_abs = FILE_STORAGE_PATH
os.makedirs(folder_abs, exist_ok=True)
# Use original filename, handle collisions
original = file.filename or "unknown"
safe_name = original.replace("/", "_").replace("\\", "_")
final_name = resolve_collision(folder_abs, safe_name)
# Build relative storage path
if target_folder:
storage_path = os.path.join(target_folder, final_name)
else:
storage_path = final_name
abs_path = os.path.join(FILE_STORAGE_PATH, storage_path)
# Save to disk
with open(abs_path, "wb") as f:
content = await file.read()
f.write(content)
size_bytes = len(content)
# Insert file record
repo = BaseRepository("files", db)
data = {
"filename": final_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.post("/sync")
async def manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
"""Manual sync trigger."""
await sync_files(db)
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:
return RedirectResponse(url="/files", status_code=303)
abs_path = _resolve_path(item)
if not os.path.exists(abs_path):
return RedirectResponse(url="/files", status_code=303)
return FileResponse(
path=abs_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
folder = os.path.dirname(item["storage_path"])
return templates.TemplateResponse("file_preview.html", {
"request": request, "sidebar": sidebar, "item": item,
"can_preview": can_preview,
"folder": folder if folder else "/",
"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:
return RedirectResponse(url="/files", status_code=303)
abs_path = _resolve_path(item)
if not os.path.exists(abs_path):
return RedirectResponse(url="/files", status_code=303)
mime = item.get("mime_type") or "application/octet-stream"
# Wrap text files in HTML with forced white background / dark text
if mime.startswith("text/"):
try:
with open(abs_path, "r", errors="replace") as f:
text_content = f.read()
except Exception:
return FileResponse(path=abs_path, media_type=mime)
from html import escape
html = (
'<!DOCTYPE html><html><head><meta charset="utf-8">'
'<style>body{background:#fff;color:#1a1a1a;font-family:monospace;'
'font-size:14px;padding:16px;margin:0;white-space:pre-wrap;'
'word-wrap:break-word;}</style></head><body>'
f'{escape(text_content)}</body></html>'
)
return HTMLResponse(content=html)
return FileResponse(path=abs_path, media_type=mime)
@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)

146
routers/focus.py Normal file
View File

@@ -0,0 +1,146 @@
"""Daily Focus: date-scoped task commitment list."""
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 date, datetime, timezone
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/focus", tags=["focus"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def focus_view(
request: Request,
focus_date: Optional[str] = None,
domain_id: Optional[str] = None,
area_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
result = await db.execute(text("""
SELECT df.*, t.title, t.priority, t.status as task_status,
t.project_id, t.due_date, t.estimated_minutes,
p.name as project_name,
d.name as domain_name, d.color as domain_color
FROM daily_focus df
JOIN tasks t ON df.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE df.focus_date = :target_date AND df.is_deleted = false
ORDER BY df.sort_order, df.created_at
"""), {"target_date": target_date})
items = [dict(r._mapping) for r in result]
# Available tasks to add (open, not already in today's focus)
avail_where = [
"t.is_deleted = false",
"t.status NOT IN ('done', 'cancelled')",
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false)",
]
avail_params = {"target_date": target_date}
if domain_id:
avail_where.append("t.domain_id = :domain_id")
avail_params["domain_id"] = domain_id
if area_id:
avail_where.append("t.area_id = :area_id")
avail_params["area_id"] = area_id
if project_id:
avail_where.append("t.project_id = :project_id")
avail_params["project_id"] = project_id
avail_sql = " AND ".join(avail_where)
result = await db.execute(text(f"""
SELECT t.id, t.title, t.priority, t.due_date,
p.name as project_name, d.name as domain_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE {avail_sql}
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
LIMIT 50
"""), avail_params)
available_tasks = [dict(r._mapping) for r in result]
# Estimated total minutes
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("focus.html", {
"request": request, "sidebar": sidebar,
"items": items, "available_tasks": available_tasks,
"focus_date": target_date,
"total_estimated": total_est,
"domains": domains, "areas": areas, "projects": projects,
"current_domain_id": domain_id or "",
"current_area_id": area_id or "",
"current_project_id": project_id or "",
"page_title": "Daily Focus", "active_nav": "focus",
})
@router.post("/add")
async def add_to_focus(
request: Request,
task_id: str = Form(...),
focus_date: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
parsed_date = date.fromisoformat(focus_date)
# Get next sort order
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus
WHERE focus_date = :fd AND is_deleted = false
"""), {"fd": parsed_date})
next_order = result.scalar()
await repo.create({
"task_id": task_id, "focus_date": parsed_date,
"sort_order": next_order, "completed": False,
})
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/{focus_id}/toggle")
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
if item:
await repo.update(focus_id, {"completed": not item["completed"]})
# Also toggle the task status
task_repo = BaseRepository("tasks", db)
if not item["completed"]:
await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)})
else:
await task_repo.update(item["task_id"], {"status": "open", "completed_at": None})
focus_date = item["focus_date"] if item else date.today()
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/{focus_id}/remove")
async def remove_from_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
item = await repo.get(focus_id)
await repo.soft_delete(focus_id)
focus_date = item["focus_date"] if item else date.today()
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)

90
routers/history.py Normal file
View File

@@ -0,0 +1,90 @@
"""Change History: reverse-chronological feed of recently modified items."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
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.sidebar import get_sidebar_data
router = APIRouter(prefix="/history", tags=["history"])
templates = Jinja2Templates(directory="templates")
# Entity configs: (table, label_column, type_label, url_prefix)
HISTORY_ENTITIES = [
("domains", "name", "Domain", "/domains"),
("areas", "name", "Area", "/areas"),
("projects", "name", "Project", "/projects"),
("tasks", "title", "Task", "/tasks"),
("notes", "title", "Note", "/notes"),
("contacts", "first_name", "Contact", "/contacts"),
("meetings", "title", "Meeting", "/meetings"),
("decisions", "title", "Decision", "/decisions"),
("lists", "name", "List", "/lists"),
("appointments", "title", "Appointment", "/appointments"),
("links", "label", "Link", "/links"),
("files", "original_filename", "File", "/files"),
("capture", "content", "Capture", "/capture"),
]
@router.get("/")
async def history_view(
request: Request,
entity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
all_items = []
for table, label_col, type_label, url_prefix in HISTORY_ENTITIES:
if entity_type and entity_type != table:
continue
try:
result = await db.execute(text(f"""
SELECT id, {label_col} as label, updated_at, created_at
FROM {table}
WHERE is_deleted = false
ORDER BY updated_at DESC
LIMIT 20
"""))
for r in result:
row = dict(r._mapping)
# Determine action
action = "created"
if row["updated_at"] and row["created_at"]:
diff = abs((row["updated_at"] - row["created_at"]).total_seconds())
if diff > 1:
action = "modified"
all_items.append({
"type": table,
"type_label": type_label,
"id": str(row["id"]),
"label": str(row["label"] or "Untitled")[:80],
"url": f"{url_prefix}/{row['id']}",
"updated_at": row["updated_at"],
"action": action,
})
except Exception:
continue
# Sort by updated_at descending, take top 50
all_items.sort(key=lambda x: x["updated_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
all_items = all_items[:50]
# Build entity type options for filter
type_options = [{"value": t[0], "label": t[2]} for t in HISTORY_ENTITIES]
return templates.TemplateResponse("history.html", {
"request": request, "sidebar": sidebar,
"items": all_items,
"type_options": type_options,
"current_type": entity_type or "",
"page_title": "Change History", "active_nav": "history",
})

156
routers/links.py Normal file
View File

@@ -0,0 +1,156 @@
"""Links: URL references attached to domains/projects/tasks/meetings."""
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
from routers.weblinks import get_default_folder_id
router = APIRouter(prefix="/links", tags=["links"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_links(request: Request, domain_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
where_clauses = ["l.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("l.domain_id = :domain_id")
params["domain_id"] = domain_id
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT l.*, d.name as domain_name, d.color as domain_color, p.name as project_name
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE {where_sql} ORDER BY l.sort_order, l.created_at
"""), params)
items = [dict(r._mapping) for r in result]
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("links.html", {
"request": request, "sidebar": sidebar, "items": items, "domains": domains,
"current_domain_id": domain_id or "",
"page_title": "Links", "active_nav": "links",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("link_form.html", {
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
"page_title": "New Link", "active_nav": "links",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_link(
request: Request, label: str = Form(...), url: str = Form(...),
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {"label": label, "url": url, "description": description}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
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()]
link = await repo.create(data)
# Assign to Default folder
default_fid = await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": link["id"]})
# Redirect back to context if created from task/meeting
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
return RedirectResponse(url="/links", status_code=303)
@router.get("/{link_id}/edit")
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("links", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(link_id)
if not item:
return RedirectResponse(url="/links", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("link_form.html", {
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
"page_title": "Edit Link", "active_nav": "links",
"item": item,
"prefill_domain_id": "", "prefill_project_id": "",
"prefill_task_id": "", "prefill_meeting_id": "",
})
@router.post("/{link_id}/edit")
async def update_link(
link_id: str, label: str = Form(...), url: str = Form(...),
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {
"label": label, "url": url,
"domain_id": domain_id if domain_id and domain_id.strip() else None,
"project_id": project_id if project_id and project_id.strip() else None,
"description": description,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(link_id, data)
return RedirectResponse(url="/links", status_code=303)
@router.post("/{link_id}/delete")
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("links", db)
await repo.soft_delete(link_id)
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)

334
routers/lists.py Normal file
View File

@@ -0,0 +1,334 @@
"""Lists: checklist/ordered list management with inline items."""
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="/lists", tags=["lists"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_lists(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["l.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("l.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("l.project_id = :project_id")
params["project_id"] = project_id
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT l.*,
d.name as domain_name, d.color as domain_color,
p.name as project_name,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false AND li.completed = true) as completed_count
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE {where_sql}
ORDER BY l.sort_order, l.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("lists.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "projects": projects,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"page_title": "Lists", "active_nav": "lists",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("list_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "areas": areas,
"page_title": "New List", "active_nav": "lists",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_list(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"list_type": list_type,
"description": description,
}
if area_id and area_id.strip():
data["area_id"] = area_id
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
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()]
new_list = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303)
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
@router.get("/{list_id}")
async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(list_id)
if not item:
return RedirectResponse(url="/lists", status_code=303)
# Domain/project info
domain = None
if item.get("domain_id"):
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
row = result.first()
domain = dict(row._mapping) if row else None
project = None
if item.get("project_id"):
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
row = result.first()
project = dict(row._mapping) if row else None
# List items (ordered, with hierarchy)
result = await db.execute(text("""
SELECT * FROM list_items
WHERE list_id = :list_id AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"list_id": list_id})
list_items = [dict(r._mapping) for r in result]
# Separate top-level and child items
top_items = [i for i in list_items if i.get("parent_item_id") is None]
child_map = {}
for i in list_items:
pid = i.get("parent_item_id")
if pid:
child_map.setdefault(str(pid), []).append(i)
# Contacts linked to this list
result = await db.execute(text("""
SELECT c.*, cl.role, cl.created_at as linked_at
FROM contacts c
JOIN contact_lists cl ON cl.contact_id = c.id
WHERE cl.list_id = :lid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"lid": list_id})
contacts = [dict(r._mapping) for r in result]
# All contacts for add dropdown
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]
return templates.TemplateResponse("list_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"list_items": top_items, "child_map": child_map,
"contacts": contacts, "all_contacts": all_contacts,
"page_title": item["name"], "active_nav": "lists",
})
@router.get("/{list_id}/edit")
async def edit_form(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(list_id)
if not item:
return RedirectResponse(url="/lists", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("list_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "areas": areas,
"page_title": "Edit List", "active_nav": "lists",
"item": item,
"prefill_domain_id": "", "prefill_project_id": "",
})
@router.post("/{list_id}/edit")
async def update_list(
list_id: str,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"list_type": list_type, "description": description,
"area_id": area_id if area_id and area_id.strip() else None,
"project_id": project_id if project_id and project_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(list_id, data)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/delete")
async def delete_list(list_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("lists", db)
await repo.soft_delete(list_id)
return RedirectResponse(url="/lists", status_code=303)
# ---- List Items ----
@router.post("/{list_id}/items/add")
async def add_item(
list_id: str,
request: Request,
content: str = Form(...),
parent_item_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
data = {"list_id": list_id, "content": content, "completed": False}
if parent_item_id and parent_item_id.strip():
data["parent_item_id"] = parent_item_id
await repo.create(data)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/toggle")
async def toggle_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("list_items", db)
item = await repo.get(item_id)
if not item:
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
if item["completed"]:
await repo.update(item_id, {"completed": False, "completed_at": None})
else:
await repo.update(item_id, {"completed": True, "completed_at": datetime.now(timezone.utc)})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/delete")
async def delete_item(list_id: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("list_items", db)
await repo.soft_delete(item_id)
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/items/{item_id}/edit")
async def edit_item(
list_id: str,
item_id: str,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
await repo.update(item_id, {"content": content})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
# ---- Contact linking ----
@router.post("/{list_id}/contacts/add")
async def add_contact(
list_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_lists (contact_id, list_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": list_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/contacts/{contact_id}/remove")
async def remove_contact(
list_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
), {"cid": contact_id, "lid": list_id})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)

406
routers/meetings.py Normal file
View File

@@ -0,0 +1,406 @@
"""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)

195
routers/notes.py Normal file
View File

@@ -0,0 +1,195 @@
"""Notes: knowledge documents with project associations."""
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="/notes", tags=["notes"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_notes(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["n.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("n.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("n.project_id = :project_id")
params["project_id"] = project_id
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT n.*, d.name as domain_name, d.color as domain_color,
p.name as project_name
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE {where_sql}
ORDER BY n.updated_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("notes.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"page_title": "Notes", "active_nav": "notes",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("note_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects,
"page_title": "New Note", "active_nav": "notes",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_note(
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
body: Optional[str] = Form(None),
content_format: str = Form("rich"),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("notes", db)
data = {
"title": title, "domain_id": domain_id,
"body": body, "content_format": content_format,
}
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
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()]
note = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=notes", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=notes", status_code=303)
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
@router.get("/{note_id}")
async def note_detail(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("notes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(note_id)
if not item:
return RedirectResponse(url="/notes", status_code=303)
domain = None
if item.get("domain_id"):
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
row = result.first()
domain = dict(row._mapping) if row else None
project = None
if item.get("project_id"):
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
row = result.first()
project = dict(row._mapping) if row else None
return templates.TemplateResponse("note_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"page_title": item["title"], "active_nav": "notes",
})
@router.get("/{note_id}/edit")
async def edit_form(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("notes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(note_id)
if not item:
return RedirectResponse(url="/notes", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("note_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects,
"page_title": f"Edit Note", "active_nav": "notes",
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
})
@router.post("/{note_id}/edit")
async def update_note(
note_id: str,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
body: Optional[str] = Form(None),
content_format: str = Form("rich"),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("notes", db)
data = {
"title": title, "domain_id": domain_id, "body": body,
"content_format": content_format,
"project_id": project_id if project_id and project_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(note_id, data)
return RedirectResponse(url=f"/notes/{note_id}", status_code=303)
@router.post("/{note_id}/delete")
async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("notes", db)
await repo.soft_delete(note_id)
referer = request.headers.get("referer", "/notes")
return RedirectResponse(url=referer, status_code=303)

569
routers/processes.py Normal file
View File

@@ -0,0 +1,569 @@
"""Processes: reusable workflows/checklists with runs and step tracking."""
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="/processes", tags=["processes"])
templates = Jinja2Templates(directory="templates")
# ── Process Template CRUD ─────────────────────────────────────
@router.get("/")
async def list_processes(
request: Request,
status: Optional[str] = None,
process_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
filters = {}
if status:
filters["status"] = status
if process_type:
filters["process_type"] = process_type
repo = BaseRepository("processes", db)
items = await repo.list(filters=filters, sort="sort_order")
# Get step counts per process
result = await db.execute(text("""
SELECT process_id, count(*) as step_count
FROM process_steps WHERE is_deleted = false
GROUP BY process_id
"""))
step_counts = {str(r.process_id): r.step_count for r in result}
for item in items:
item["step_count"] = step_counts.get(str(item["id"]), 0)
return templates.TemplateResponse("processes.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"current_type": process_type or "",
"page_title": "Processes", "active_nav": "processes",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("processes_form.html", {
"request": request, "sidebar": sidebar, "item": None,
"page_title": "New Process", "active_nav": "processes",
})
@router.post("/create")
async def create_process(
request: Request,
name: str = Form(...),
description: Optional[str] = Form(None),
process_type: str = Form("checklist"),
status: str = Form("draft"),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("processes", db)
data = {
"name": name,
"description": description,
"process_type": process_type,
"status": status,
}
if category and category.strip():
data["category"] = category
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
item = await repo.create(data)
return RedirectResponse(url=f"/processes/{item['id']}", status_code=303)
@router.get("/runs")
async def list_all_runs(
request: Request,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""List all runs across all processes."""
sidebar = await get_sidebar_data(db)
where = "pr.is_deleted = false"
params = {}
if status:
where += " AND pr.status = :status"
params["status"] = status
result = await db.execute(text(f"""
SELECT pr.*, p.name as process_name,
proj.name as project_name,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
FROM process_runs pr
JOIN processes p ON pr.process_id = p.id
LEFT JOIN projects proj ON pr.project_id = proj.id
WHERE {where}
ORDER BY pr.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("process_runs.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"page_title": "All Process Runs", "active_nav": "processes",
})
@router.get("/runs/{run_id}")
async def run_detail(run_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""View a specific process run with step checklist."""
sidebar = await get_sidebar_data(db)
# Get the run with process info
result = await db.execute(text("""
SELECT pr.*, p.name as process_name, p.id as process_id_ref,
proj.name as project_name,
c.first_name as contact_first, c.last_name as contact_last
FROM process_runs pr
JOIN processes p ON pr.process_id = p.id
LEFT JOIN projects proj ON pr.project_id = proj.id
LEFT JOIN contacts c ON pr.contact_id = c.id
WHERE pr.id = :id
"""), {"id": run_id})
run = result.first()
if not run:
return RedirectResponse(url="/processes/runs", status_code=303)
run = dict(run._mapping)
# Get run steps
result = await db.execute(text("""
SELECT * FROM process_run_steps
WHERE run_id = :run_id AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"run_id": run_id})
steps = [dict(r._mapping) for r in result]
total = len(steps)
completed = sum(1 for s in steps if s["status"] == "completed")
# Get linked tasks via junction table
result = await db.execute(text("""
SELECT t.id, t.title, t.status, t.priority,
prt.run_step_id,
p.name as project_name
FROM process_run_tasks prt
JOIN tasks t ON prt.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE prt.run_step_id IN (
SELECT id FROM process_run_steps WHERE run_id = :run_id
)
ORDER BY t.created_at
"""), {"run_id": run_id})
tasks = [dict(r._mapping) for r in result]
# Map tasks to their steps
step_tasks = {}
for task in tasks:
sid = str(task["run_step_id"])
step_tasks.setdefault(sid, []).append(task)
return templates.TemplateResponse("process_run_detail.html", {
"request": request, "sidebar": sidebar,
"run": run, "steps": steps, "tasks": tasks,
"step_tasks": step_tasks,
"total_steps": total, "completed_steps": completed,
"page_title": run["title"], "active_nav": "processes",
})
@router.get("/{process_id}")
async def process_detail(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(process_id)
if not item:
return RedirectResponse(url="/processes", status_code=303)
# Get steps
result = await db.execute(text("""
SELECT * FROM process_steps
WHERE process_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": process_id})
steps = [dict(r._mapping) for r in result]
# Get runs
result = await db.execute(text("""
SELECT pr.*,
proj.name as project_name,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
FROM process_runs pr
LEFT JOIN projects proj ON pr.project_id = proj.id
WHERE pr.process_id = :pid AND pr.is_deleted = false
ORDER BY pr.created_at DESC
"""), {"pid": process_id})
runs = [dict(r._mapping) for r in result]
# Load projects and contacts for "Start Run" form
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
contacts_repo = BaseRepository("contacts", db)
contacts = await contacts_repo.list()
return templates.TemplateResponse("processes_detail.html", {
"request": request, "sidebar": sidebar,
"item": item, "steps": steps, "runs": runs,
"projects": projects, "contacts": contacts,
"page_title": item["name"], "active_nav": "processes",
})
@router.get("/{process_id}/edit")
async def edit_form(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(process_id)
if not item:
return RedirectResponse(url="/processes", status_code=303)
return templates.TemplateResponse("processes_form.html", {
"request": request, "sidebar": sidebar, "item": item,
"page_title": "Edit Process", "active_nav": "processes",
})
@router.post("/{process_id}/edit")
async def update_process(
process_id: str,
name: str = Form(...),
description: Optional[str] = Form(None),
process_type: str = Form("checklist"),
status: str = Form("draft"),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("processes", db)
data = {
"name": name,
"description": description,
"process_type": process_type,
"status": status,
"category": category if category and category.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(process_id, data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/delete")
async def delete_process(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
await repo.soft_delete(process_id)
return RedirectResponse(url="/processes", status_code=303)
# ── Process Steps ─────────────────────────────────────────────
@router.post("/{process_id}/steps/add")
async def add_step(
process_id: str,
title: str = Form(...),
instructions: Optional[str] = Form(None),
expected_output: Optional[str] = Form(None),
estimated_days: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
# Get current max sort_order
result = await db.execute(text("""
SELECT coalesce(max(sort_order), -1) + 1 as next_order
FROM process_steps WHERE process_id = :pid AND is_deleted = false
"""), {"pid": process_id})
next_order = result.scalar()
repo = BaseRepository("process_steps", db)
data = {
"process_id": process_id,
"title": title,
"sort_order": next_order,
}
if instructions and instructions.strip():
data["instructions"] = instructions
if expected_output and expected_output.strip():
data["expected_output"] = expected_output
if estimated_days and estimated_days.strip():
data["estimated_days"] = int(estimated_days)
await repo.create(data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/{step_id}/edit")
async def edit_step(
process_id: str,
step_id: str,
title: str = Form(...),
instructions: Optional[str] = Form(None),
expected_output: Optional[str] = Form(None),
estimated_days: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("process_steps", db)
data = {
"title": title,
"instructions": instructions if instructions and instructions.strip() else None,
"expected_output": expected_output if expected_output and expected_output.strip() else None,
"estimated_days": int(estimated_days) if estimated_days and estimated_days.strip() else None,
}
await repo.update(step_id, data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/{step_id}/delete")
async def delete_step(process_id: str, step_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("process_steps", db)
await repo.soft_delete(step_id)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/reorder")
async def reorder_steps(
process_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
form = await request.form()
ids = form.getlist("step_ids")
if ids:
repo = BaseRepository("process_steps", db)
await repo.reorder(ids)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
# ── Process Runs ──────────────────────────────────────────────
@router.post("/{process_id}/runs/start")
async def start_run(
process_id: str,
title: str = Form(...),
task_generation: str = Form("all_at_once"),
project_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Start a new process run: snapshot steps, optionally generate tasks."""
# Get process
proc_repo = BaseRepository("processes", db)
process = await proc_repo.get(process_id)
if not process:
return RedirectResponse(url="/processes", status_code=303)
# Create the run
run_repo = BaseRepository("process_runs", db)
run_data = {
"process_id": process_id,
"title": title,
"status": "in_progress",
"process_type": process["process_type"],
"task_generation": task_generation,
"started_at": datetime.now(timezone.utc),
}
if project_id and project_id.strip():
run_data["project_id"] = project_id
if contact_id and contact_id.strip():
run_data["contact_id"] = contact_id
run = await run_repo.create(run_data)
# Snapshot steps from the process template
result = await db.execute(text("""
SELECT * FROM process_steps
WHERE process_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": process_id})
template_steps = [dict(r._mapping) for r in result]
step_repo = BaseRepository("process_run_steps", db)
run_steps = []
for step in template_steps:
rs = await step_repo.create({
"run_id": str(run["id"]),
"title": step["title"],
"instructions": step.get("instructions"),
"status": "pending",
"sort_order": step["sort_order"],
})
run_steps.append(rs)
# Task generation
if run_steps:
await _generate_tasks(db, run, run_steps, task_generation)
return RedirectResponse(url=f"/processes/runs/{run['id']}", status_code=303)
async def _generate_tasks(db, run, run_steps, mode):
"""Generate tasks for run steps based on mode."""
task_repo = BaseRepository("tasks", db)
# Get a default domain for tasks
result = await db.execute(text(
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
))
row = result.first()
default_domain_id = str(row[0]) if row else None
if not default_domain_id:
return
if mode == "all_at_once":
steps_to_generate = run_steps
else: # step_by_step
steps_to_generate = [run_steps[0]]
for step in steps_to_generate:
task_data = {
"title": step["title"],
"description": step.get("instructions") or "",
"status": "open",
"priority": 3,
"domain_id": default_domain_id,
}
if run.get("project_id"):
task_data["project_id"] = str(run["project_id"])
task = await task_repo.create(task_data)
# Link via junction table
await db.execute(text("""
INSERT INTO process_run_tasks (run_step_id, task_id)
VALUES (:rsid, :tid)
ON CONFLICT DO NOTHING
"""), {"rsid": str(step["id"]), "tid": str(task["id"])})
@router.post("/runs/{run_id}/steps/{step_id}/complete")
async def complete_step(
run_id: str,
step_id: str,
notes: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Mark a run step as completed."""
now = datetime.now(timezone.utc)
step_repo = BaseRepository("process_run_steps", db)
await step_repo.update(step_id, {
"status": "completed",
"completed_at": now,
"notes": notes if notes and notes.strip() else None,
})
# If step_by_step mode, generate task for next pending step
result = await db.execute(text("""
SELECT pr.task_generation FROM process_runs pr WHERE pr.id = :rid
"""), {"rid": run_id})
run_row = result.first()
if run_row and run_row.task_generation == "step_by_step":
# Find next pending step
result = await db.execute(text("""
SELECT * FROM process_run_steps
WHERE run_id = :rid AND is_deleted = false AND status = 'pending'
ORDER BY sort_order LIMIT 1
"""), {"rid": run_id})
next_step = result.first()
if next_step:
next_step = dict(next_step._mapping)
# Get the full run for project_id
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
await _generate_tasks(db, run, [next_step], "all_at_once")
# Auto-complete run if all steps done
result = await db.execute(text("""
SELECT count(*) FILTER (WHERE status != 'completed') as pending
FROM process_run_steps
WHERE run_id = :rid AND is_deleted = false
"""), {"rid": run_id})
pending = result.scalar()
if pending == 0:
run_repo = BaseRepository("process_runs", db)
await run_repo.update(run_id, {
"status": "completed",
"completed_at": now,
})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/steps/{step_id}/uncomplete")
async def uncomplete_step(
run_id: str,
step_id: str,
db: AsyncSession = Depends(get_db),
):
"""Undo step completion."""
step_repo = BaseRepository("process_run_steps", db)
await step_repo.update(step_id, {
"status": "pending",
"completed_at": None,
})
# If run was completed, reopen it
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
if run and run["status"] == "completed":
await run_repo.update(run_id, {
"status": "in_progress",
"completed_at": None,
})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/complete")
async def complete_run(run_id: str, db: AsyncSession = Depends(get_db)):
"""Mark entire run as complete."""
now = datetime.now(timezone.utc)
run_repo = BaseRepository("process_runs", db)
await run_repo.update(run_id, {
"status": "completed",
"completed_at": now,
})
# Mark all pending steps as completed too
await db.execute(text("""
UPDATE process_run_steps
SET status = 'completed', completed_at = :now, updated_at = :now
WHERE run_id = :rid AND status != 'completed' AND is_deleted = false
"""), {"rid": run_id, "now": now})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/delete")
async def delete_run(run_id: str, db: AsyncSession = Depends(get_db)):
# Get process_id before deleting for redirect
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
await run_repo.soft_delete(run_id)
if run:
return RedirectResponse(url=f"/processes/{run['process_id']}", status_code=303)
return RedirectResponse(url="/processes/runs", status_code=303)

403
routers/projects.py Normal file
View File

@@ -0,0 +1,403 @@
"""Projects: organizational unit within domain/area hierarchy."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, JSONResponse
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="/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates")
@router.get("/api/by-domain")
async def api_projects_by_domain(
domain_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""JSON API: return projects filtered by domain_id for dynamic dropdowns."""
if domain_id:
result = await db.execute(text("""
SELECT id, name FROM projects
WHERE is_deleted = false AND (domain_id = :did OR domain_id IS NULL)
ORDER BY name
"""), {"did": domain_id})
else:
result = await db.execute(text("""
SELECT id, name FROM projects
WHERE is_deleted = false ORDER BY name
"""))
projects = [{"id": str(r.id), "name": r.name} for r in result]
return JSONResponse(content=projects)
@router.get("/")
async def list_projects(
request: Request,
domain_id: Optional[str] = None,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Build query with joins for hierarchy display
where_clauses = ["p.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("p.domain_id = :domain_id")
params["domain_id"] = domain_id
if status:
where_clauses.append("p.status = :status")
params["status"] = status
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT p.*,
d.name as domain_name, d.color as domain_color,
a.name as area_name,
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false) as task_count,
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false AND t.status = 'done') as done_count
FROM projects p
JOIN domains d ON p.domain_id = d.id
LEFT JOIN areas a ON p.area_id = a.id
WHERE {where_sql}
ORDER BY d.sort_order, d.name, a.sort_order, a.name, p.sort_order, p.name
"""), params)
items = [dict(r._mapping) for r in result]
# Calculate progress percentage
for item in items:
total = item["task_count"] or 0
done = item["done_count"] or 0
item["progress"] = round((done / total * 100) if total > 0 else 0)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
return templates.TemplateResponse("projects.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains,
"current_domain_id": domain_id or "",
"current_status": status or "",
"page_title": "Projects", "active_nav": "projects",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
area_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("project_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "areas": areas,
"page_title": "New Project", "active_nav": "projects",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_area_id": area_id or "",
})
@router.post("/create")
async def create_project(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
status: str = Form("active"),
priority: int = Form(3),
start_date: Optional[str] = Form(None),
target_date: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("projects", db)
data = {
"name": name, "domain_id": domain_id,
"description": description, "status": status,
"priority": priority,
}
if area_id and area_id.strip():
data["area_id"] = area_id
if start_date and start_date.strip():
data["start_date"] = start_date
if target_date and target_date.strip():
data["target_date"] = target_date
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
project = await repo.create(data)
return RedirectResponse(url=f"/projects/{project['id']}", status_code=303)
@router.get("/{project_id}")
async def project_detail(
project_id: str,
request: Request,
tab: str = "tasks",
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("projects", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(project_id)
if not item:
return RedirectResponse(url="/projects", status_code=303)
# Get domain and area names
result = await db.execute(text(
"SELECT name, color FROM domains WHERE id = :id"
), {"id": str(item["domain_id"])})
domain = dict(result.first()._mapping) if result else {}
area = None
if item.get("area_id"):
result = await db.execute(text(
"SELECT name FROM areas WHERE id = :id"
), {"id": str(item["area_id"])})
row = result.first()
area = dict(row._mapping) if row else None
# Tasks for this project (always needed for progress bar)
result = await db.execute(text("""
SELECT t.*, d.name as domain_name, d.color as domain_color
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.project_id = :pid AND t.is_deleted = false
ORDER BY t.sort_order, t.created_at
"""), {"pid": project_id})
tasks = [dict(r._mapping) for r in result]
# Progress
total = len(tasks)
done = len([t for t in tasks if t["status"] == "done"])
progress = round((done / total * 100) if total > 0 else 0)
# Tab-specific data
notes = []
links = []
tab_data = []
all_contacts = []
all_meetings = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at DESC
"""), {"pid": project_id})
notes = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": project_id})
links = [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 = 'project' AND fm.context_id = :pid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"pid": project_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.project_id = :pid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT d.* FROM decisions d
JOIN decision_projects dp ON dp.decision_id = d.id
WHERE dp.project_id = :pid AND d.is_deleted = false
ORDER BY d.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "meetings":
result = await db.execute(text("""
SELECT m.*, pm.created_at as linked_at
FROM meetings m
JOIN project_meetings pm ON pm.meeting_id = m.id
WHERE pm.project_id = :pid AND m.is_deleted = false
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, title, meeting_date FROM meetings
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
"""))
all_meetings = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cp.role, cp.created_at as linked_at
FROM contacts c
JOIN contact_projects cp ON cp.contact_id = c.id
WHERE cp.project_id = :pid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"pid": project_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 project_id = :pid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"pid": project_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("project_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "area": area,
"tasks": tasks, "notes": notes, "links": links,
"tab_data": tab_data, "all_contacts": all_contacts,
"all_meetings": all_meetings, "counts": counts,
"progress": progress, "task_count": total, "done_count": done,
"tab": tab,
"page_title": item["name"], "active_nav": "projects",
})
@router.get("/{project_id}/edit")
async def edit_form(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("projects", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(project_id)
if not item:
return RedirectResponse(url="/projects", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
return templates.TemplateResponse("project_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "areas": areas,
"page_title": f"Edit {item['name']}", "active_nav": "projects",
"item": item, "prefill_domain_id": "", "prefill_area_id": "",
})
@router.post("/{project_id}/edit")
async def update_project(
project_id: str,
name: str = Form(...),
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
status: str = Form("active"),
priority: int = Form(3),
start_date: Optional[str] = Form(None),
target_date: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("projects", db)
data = {
"name": name, "domain_id": domain_id,
"area_id": area_id if area_id and area_id.strip() else None,
"description": description, "status": status,
"priority": priority,
"start_date": start_date if start_date and start_date.strip() else None,
"target_date": target_date if target_date and target_date.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(project_id, data)
return RedirectResponse(url=f"/projects/{project_id}", status_code=303)
@router.post("/{project_id}/delete")
async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("projects", db)
await repo.soft_delete(project_id)
return RedirectResponse(url="/projects", status_code=303)
# ---- Meeting linking ----
@router.post("/{project_id}/meetings/add")
async def add_meeting(
project_id: str,
meeting_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO project_meetings (project_id, meeting_id)
VALUES (:pid, :mid) ON CONFLICT DO NOTHING
"""), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
@router.post("/{project_id}/meetings/{meeting_id}/remove")
async def remove_meeting(
project_id: str, meeting_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM project_meetings WHERE project_id = :pid AND meeting_id = :mid"
), {"pid": project_id, "mid": meeting_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
# ---- Contact linking ----
@router.post("/{project_id}/contacts/add")
async def add_contact(
project_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_projects (contact_id, project_id, role)
VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
@router.post("/{project_id}/contacts/{contact_id}/remove")
async def remove_contact(
project_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid"
), {"cid": contact_id, "pid": project_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)

237
routers/search.py Normal file
View File

@@ -0,0 +1,237 @@
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
import re
from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/search", tags=["search"])
templates = Jinja2Templates(directory="templates")
def build_prefix_tsquery(query: str) -> Optional[str]:
"""Build a prefix-matching tsquery string from user input.
'Sys Admin' -> 'Sys:* & Admin:*'
Returns None if no valid terms.
"""
terms = query.strip().split()
# Keep only alphanumeric terms >= 2 chars
clean = [re.sub(r'[^\w]', '', t) for t in terms]
clean = [t for t in clean if len(t) >= 2]
if not clean:
return None
return " & ".join(f"{t}:*" for t in clean)
# Entity search configs
# Each has: type, label, table, name_col, joins, extra_cols, url, icon
SEARCH_ENTITIES = [
{
"type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t",
"name_col": "t.title", "status_col": "t.status",
"joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/tasks/{id}", "icon": "task",
},
{
"type": "projects", "label": "Projects", "table": "projects", "alias": "p",
"name_col": "p.name", "status_col": "p.status",
"joins": "LEFT JOIN domains d ON p.domain_id = d.id",
"domain_col": "d.name", "project_col": "NULL",
"url": "/projects/{id}", "icon": "project",
},
{
"type": "notes", "label": "Notes", "table": "notes", "alias": "n",
"name_col": "n.title", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/notes/{id}", "icon": "note",
},
{
"type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c",
"name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL",
"joins": "",
"domain_col": "c.company", "project_col": "NULL",
"url": "/contacts/{id}", "icon": "contact",
},
{
"type": "links", "label": "Links", "table": "links", "alias": "l",
"name_col": "l.label", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/links", "icon": "link",
},
{
"type": "lists", "label": "Lists", "table": "lists", "alias": "l",
"name_col": "l.name", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/lists/{id}", "icon": "list",
},
{
"type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m",
"name_col": "m.title", "status_col": "m.status",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/meetings/{id}", "icon": "meeting",
},
{
"type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d",
"name_col": "d.title", "status_col": "d.status",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/decisions/{id}", "icon": "decision",
},
{
"type": "processes", "label": "Processes", "table": "processes", "alias": "p",
"name_col": "p.name", "status_col": "p.status",
"joins": "",
"domain_col": "p.category", "project_col": "NULL",
"url": "/processes/{id}", "icon": "process",
},
{
"type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a",
"name_col": "a.title", "status_col": "NULL",
"joins": "",
"domain_col": "a.location", "project_col": "NULL",
"url": "/appointments/{id}", "icon": "appointment",
},
]
async def _search_entity(entity: dict, q: str, tsquery_str: str, limit: int, db: AsyncSession) -> list[dict]:
"""Search a single entity using prefix tsquery, with ILIKE fallback."""
a = entity["alias"]
results = []
seen_ids = set()
# 1. tsvector prefix search
if tsquery_str:
sql = f"""
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
ts_rank({a}.search_vector, to_tsquery('english', :tsq)) as rank
FROM {entity['table']} {a}
{entity['joins']}
WHERE {a}.is_deleted = false AND {a}.search_vector @@ to_tsquery('english', :tsq)
ORDER BY rank DESC LIMIT :lim
"""
try:
result = await db.execute(text(sql), {"tsq": tsquery_str, "lim": limit})
for r in result:
row = dict(r._mapping)
results.append(row)
seen_ids.add(str(row["id"]))
except Exception:
pass
# 2. ILIKE fallback if < 3 tsvector results
if len(results) < 3:
ilike_param = f"%{q.strip()}%"
remaining = limit - len(results)
if remaining > 0:
# Build exclusion for already-found IDs
exclude_sql = ""
params = {"ilike_q": ilike_param, "lim2": remaining}
if seen_ids:
id_placeholders = ", ".join(f":ex_{i}" for i in range(len(seen_ids)))
exclude_sql = f"AND {a}.id NOT IN ({id_placeholders})"
for i, sid in enumerate(seen_ids):
params[f"ex_{i}"] = sid
sql2 = f"""
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
0.0 as rank
FROM {entity['table']} {a}
{entity['joins']}
WHERE {a}.is_deleted = false AND {entity['name_col']} ILIKE :ilike_q {exclude_sql}
ORDER BY {entity['name_col']} LIMIT :lim2
"""
try:
result = await db.execute(text(sql2), params)
for r in result:
results.append(dict(r._mapping))
except Exception:
pass
return results
@router.get("/api")
async def search_api(
q: str = Query("", min_length=1),
entity_type: Optional[str] = None,
limit: int = Query(5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
):
"""JSON search endpoint for the Cmd/K modal."""
if not q or not q.strip() or len(q.strip()) < 2:
return JSONResponse({"results": [], "query": q})
tsquery_str = build_prefix_tsquery(q)
results = []
entities = SEARCH_ENTITIES
if entity_type:
entities = [e for e in entities if e["type"] == entity_type]
for entity in entities:
rows = await _search_entity(entity, q, tsquery_str, limit, db)
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"rank": float(row.get("rank", 0)),
"icon": entity["icon"],
})
# Sort all results by rank descending
results.sort(key=lambda r: r["rank"], reverse=True)
return JSONResponse({"results": results[:20], "query": q})
@router.get("/")
async def search_page(
request: Request,
q: str = "",
db: AsyncSession = Depends(get_db),
):
"""Full search page (fallback for non-JS)."""
sidebar = await get_sidebar_data(db)
results = []
if q and q.strip() and len(q.strip()) >= 2:
tsquery_str = build_prefix_tsquery(q)
for entity in SEARCH_ENTITIES:
rows = await _search_entity(entity, q, tsquery_str, 10, db)
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"icon": entity["icon"],
})
return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar,
"results": results, "query": q,
"page_title": "Search", "active_nav": "search",
})

483
routers/tasks.py Normal file
View File

@@ -0,0 +1,483 @@
"""Tasks: core work items with full filtering and hierarchy."""
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="/tasks", tags=["tasks"])
templates = Jinja2Templates(directory="templates")
async def get_running_task_id(db: AsyncSession) -> Optional[str]:
"""Get the task_id of the currently running timer, if any."""
result = await db.execute(text(
"SELECT task_id FROM time_entries WHERE end_at IS NULL AND is_deleted = false LIMIT 1"
))
row = result.first()
return str(row.task_id) if row else None
@router.get("/")
async def list_tasks(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[str] = None,
context: Optional[str] = None,
sort: str = "sort_order",
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
where_clauses = ["t.is_deleted = false"]
params = {}
if domain_id:
where_clauses.append("t.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("t.project_id = :project_id")
params["project_id"] = project_id
if status:
where_clauses.append("t.status = :status")
params["status"] = status
if priority:
where_clauses.append("t.priority = :priority")
params["priority"] = int(priority)
if context:
where_clauses.append("t.context = :context")
params["context"] = context
where_sql = " AND ".join(where_clauses)
sort_map = {
"sort_order": "t.sort_order, t.created_at",
"priority": "t.priority ASC, t.due_date ASC NULLS LAST",
"due_date": "t.due_date ASC NULLS LAST, t.priority ASC",
"created_at": "t.created_at DESC",
"title": "t.title ASC",
}
order_sql = sort_map.get(sort, sort_map["sort_order"])
result = await db.execute(text(f"""
SELECT t.*,
d.name as domain_name, d.color as domain_color,
p.name as project_name
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE {where_sql}
ORDER BY
CASE WHEN t.status = 'done' THEN 1 WHEN t.status = 'cancelled' THEN 2 ELSE 0 END,
{order_sql}
"""), params)
items = [dict(r._mapping) for r in result]
# Get filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
running_task_id = await get_running_task_id(db)
return templates.TemplateResponse("tasks.html", {
"request": request, "sidebar": sidebar, "items": items,
"domains": domains, "projects": projects, "context_types": context_types,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"current_status": status or "",
"current_priority": priority or "",
"current_context": context or "",
"current_sort": sort,
"running_task_id": running_task_id,
"page_title": "All Tasks", "active_nav": "tasks",
})
@router.get("/create")
async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
parent_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("task_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "context_types": context_types,
"page_title": "New Task", "active_nav": "tasks",
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_parent_id": parent_id or "",
})
@router.post("/create")
async def create_task(
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
parent_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
priority: int = Form(3),
status: str = Form("open"),
due_date: Optional[str] = Form(None),
deadline: Optional[str] = Form(None),
context: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
estimated_minutes: Optional[str] = Form(None),
energy_required: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
data = {
"title": title, "domain_id": domain_id,
"description": description, "priority": priority, "status": status,
}
if project_id and project_id.strip():
data["project_id"] = project_id
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
if due_date and due_date.strip():
data["due_date"] = due_date
if deadline and deadline.strip():
data["deadline"] = deadline
if context and context.strip():
data["context"] = context
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
if estimated_minutes and estimated_minutes.strip():
data["estimated_minutes"] = int(estimated_minutes)
if energy_required and energy_required.strip():
data["energy_required"] = energy_required
task = await repo.create(data)
# Redirect back to project if created from project context
if data.get("project_id"):
return RedirectResponse(url=f"/projects/{data['project_id']}?tab=tasks", status_code=303)
return RedirectResponse(url="/tasks", status_code=303)
@router.get("/{task_id}")
async def task_detail(
task_id: str, request: Request,
tab: str = "overview",
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(task_id)
if not item:
return RedirectResponse(url="/tasks", status_code=303)
# Domain and project info
domain = None
if item.get("domain_id"):
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
row = result.first()
domain = dict(row._mapping) if row else None
project = None
if item.get("project_id"):
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
row = result.first()
project = dict(row._mapping) if row else None
parent = None
if item.get("parent_id"):
result = await db.execute(text("SELECT id, title FROM tasks WHERE id = :id"), {"id": str(item["parent_id"])})
row = result.first()
parent = dict(row._mapping) if row else None
# Subtasks (always needed for overview tab)
subtasks = []
if tab == "overview":
result = await db.execute(text("""
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"tid": task_id})
subtasks = [dict(r._mapping) for r in result]
running_task_id = await get_running_task_id(db)
# Tab-specific data
tab_data = []
all_contacts = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links WHERE task_id = :tid AND is_deleted = false
ORDER BY sort_order, label
"""), {"tid": task_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 = 'task' AND fm.context_id = :tid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"tid": task_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.task_id = :tid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
ORDER BY created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, ct.role, ct.created_at as linked_at
FROM contacts c
JOIN contact_tasks ct ON ct.contact_id = c.id
WHERE ct.task_id = :tid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
# All contacts for add dropdown
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 for badges
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE task_id = :tid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"tid": task_id})
counts[count_tab] = result.scalar() or 0
# Subtask count for overview badge
result = await db.execute(text(
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
), {"tid": task_id})
counts["overview"] = result.scalar() or 0
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts,
"running_task_id": running_task_id,
"page_title": item["title"], "active_nav": "tasks",
})
@router.get("/{task_id}/edit")
async def edit_form(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("tasks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(task_id)
if not item:
return RedirectResponse(url="/tasks", status_code=303)
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("task_form.html", {
"request": request, "sidebar": sidebar,
"domains": domains, "projects": projects, "context_types": context_types,
"page_title": f"Edit Task", "active_nav": "tasks",
"item": item,
"prefill_domain_id": "", "prefill_project_id": "", "prefill_parent_id": "",
})
@router.post("/{task_id}/edit")
async def update_task(
task_id: str,
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
parent_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
priority: int = Form(3),
status: str = Form("open"),
due_date: Optional[str] = Form(None),
deadline: Optional[str] = Form(None),
context: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
estimated_minutes: Optional[str] = Form(None),
energy_required: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
data = {
"title": title, "domain_id": domain_id,
"description": description, "priority": priority, "status": status,
"project_id": project_id if project_id and project_id.strip() else None,
"parent_id": parent_id if parent_id and parent_id.strip() else None,
"due_date": due_date if due_date and due_date.strip() else None,
"deadline": deadline if deadline and deadline.strip() else None,
"context": context if context and context.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
if estimated_minutes and estimated_minutes.strip():
data["estimated_minutes"] = int(estimated_minutes)
if energy_required and energy_required.strip():
data["energy_required"] = energy_required
# Handle completion
old = await repo.get(task_id)
if old and old["status"] != "done" and status == "done":
data["completed_at"] = datetime.now(timezone.utc)
elif status != "done":
data["completed_at"] = None
await repo.update(task_id, data)
return RedirectResponse(url=f"/tasks/{task_id}", status_code=303)
@router.post("/{task_id}/complete")
async def complete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""Quick complete from list view."""
repo = BaseRepository("tasks", db)
await repo.update(task_id, {
"status": "done",
"completed_at": datetime.now(timezone.utc),
})
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
@router.post("/{task_id}/toggle")
async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""Toggle task done/open from list view."""
repo = BaseRepository("tasks", db)
task = await repo.get(task_id)
if not task:
return RedirectResponse(url="/tasks", status_code=303)
if task["status"] == "done":
await repo.update(task_id, {"status": "open", "completed_at": None})
else:
await repo.update(task_id, {"status": "done", "completed_at": datetime.now(timezone.utc)})
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{task_id}/delete")
async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("tasks", db)
await repo.soft_delete(task_id)
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
# Quick add from any task list
@router.post("/quick-add")
async def quick_add(
request: Request,
title: str = Form(...),
domain_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
data = {"title": title, "status": "open", "priority": 3}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip():
data["project_id"] = project_id
# If no domain, use first domain
if "domain_id" not in data:
result = await db.execute(text(
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
))
row = result.first()
if row:
data["domain_id"] = str(row[0])
await repo.create(data)
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
# ---- Contact linking ----
@router.post("/{task_id}/contacts/add")
async def add_contact(
task_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_tasks (contact_id, task_id, role)
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
@router.post("/{task_id}/contacts/{contact_id}/remove")
async def remove_contact(
task_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
), {"cid": contact_id, "tid": task_id})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)

189
routers/time_budgets.py Normal file
View File

@@ -0,0 +1,189 @@
"""Time Budgets: weekly hour allocations per domain with actual vs budgeted comparison."""
from fastapi import APIRouter, Request, Depends, Form
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="/time-budgets", tags=["time_budgets"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def list_time_budgets(
request: Request,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Get current budget per domain (most recent effective_from <= today)
result = await db.execute(text("""
SELECT DISTINCT ON (tb.domain_id)
tb.*, d.name as domain_name, d.color as domain_color
FROM time_budgets tb
JOIN domains d ON tb.domain_id = d.id
WHERE tb.is_deleted = false AND d.is_deleted = false
AND tb.effective_from <= CURRENT_DATE
ORDER BY tb.domain_id, tb.effective_from DESC
"""))
current_budgets = [dict(r._mapping) for r in result]
# Get actual hours per domain this week (Mon-Sun)
result = await db.execute(text("""
SELECT t.domain_id,
COALESCE(SUM(
CASE
WHEN te.duration_minutes IS NOT NULL THEN te.duration_minutes
WHEN te.end_at IS NOT NULL THEN EXTRACT(EPOCH FROM (te.end_at - te.start_at)) / 60
ELSE 0
END
), 0) / 60.0 as actual_hours
FROM time_entries te
JOIN tasks t ON te.task_id = t.id
WHERE te.is_deleted = false
AND te.start_at >= date_trunc('week', CURRENT_DATE)
AND te.start_at < date_trunc('week', CURRENT_DATE) + INTERVAL '7 days'
AND t.domain_id IS NOT NULL
GROUP BY t.domain_id
"""))
actual_map = {str(r._mapping["domain_id"]): float(r._mapping["actual_hours"]) for r in result}
# Attach actual hours to budgets
for b in current_budgets:
b["actual_hours"] = round(actual_map.get(str(b["domain_id"]), 0), 1)
b["weekly_hours_float"] = float(b["weekly_hours"])
if b["weekly_hours_float"] > 0:
b["pct"] = round(b["actual_hours"] / b["weekly_hours_float"] * 100)
else:
b["pct"] = 0
total_budgeted = sum(float(b["weekly_hours"]) for b in current_budgets)
overcommitted = total_budgeted > 168
# Also get all budgets (including future / historical) for full list
result = await db.execute(text("""
SELECT tb.*, d.name as domain_name, d.color as domain_color
FROM time_budgets tb
JOIN domains d ON tb.domain_id = d.id
WHERE tb.is_deleted = false AND d.is_deleted = false
ORDER BY tb.effective_from DESC, d.name
"""))
all_budgets = [dict(r._mapping) for r in result]
return templates.TemplateResponse("time_budgets.html", {
"request": request,
"sidebar": sidebar,
"current_budgets": current_budgets,
"all_budgets": all_budgets,
"total_budgeted": total_budgeted,
"overcommitted": overcommitted,
"count": len(all_budgets),
"page_title": "Time Budgets",
"active_nav": "time_budgets",
})
@router.get("/create")
async def create_form(
request: Request,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
result = await db.execute(text("""
SELECT id, name, color FROM domains
WHERE is_deleted = false ORDER BY sort_order, name
"""))
domains = [dict(r._mapping) for r in result]
return templates.TemplateResponse("time_budgets_form.html", {
"request": request,
"sidebar": sidebar,
"budget": None,
"domains": domains,
"page_title": "New Time Budget",
"active_nav": "time_budgets",
})
@router.post("/create")
async def create_budget(
request: Request,
domain_id: str = Form(...),
weekly_hours: str = Form(...),
effective_from: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("time_budgets", db)
data = {
"domain_id": domain_id,
"weekly_hours": float(weekly_hours),
"effective_from": effective_from,
}
budget = await repo.create(data)
return RedirectResponse(url="/time-budgets", status_code=303)
@router.get("/{budget_id}/edit")
async def edit_form(
request: Request,
budget_id: str,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
repo = BaseRepository("time_budgets", db)
budget = await repo.get(budget_id)
if not budget:
return RedirectResponse(url="/time-budgets", status_code=303)
result = await db.execute(text("""
SELECT id, name, color FROM domains
WHERE is_deleted = false ORDER BY sort_order, name
"""))
domains = [dict(r._mapping) for r in result]
return templates.TemplateResponse("time_budgets_form.html", {
"request": request,
"sidebar": sidebar,
"budget": budget,
"domains": domains,
"page_title": "Edit Time Budget",
"active_nav": "time_budgets",
})
@router.post("/{budget_id}/edit")
async def update_budget(
request: Request,
budget_id: str,
domain_id: str = Form(...),
weekly_hours: str = Form(...),
effective_from: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("time_budgets", db)
data = {
"domain_id": domain_id,
"weekly_hours": float(weekly_hours),
"effective_from": effective_from,
}
await repo.update(budget_id, data)
return RedirectResponse(url="/time-budgets", status_code=303)
@router.post("/{budget_id}/delete")
async def delete_budget(
request: Request,
budget_id: str,
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("time_budgets", db)
await repo.soft_delete(budget_id)
return RedirectResponse(url="/time-budgets", status_code=303)

211
routers/time_tracking.py Normal file
View File

@@ -0,0 +1,211 @@
"""Time tracking: start/stop timer per task, manual time entries, time log view."""
from fastapi import APIRouter, Request, Depends, Form, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, JSONResponse
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.sidebar import get_sidebar_data
router = APIRouter(prefix="/time", tags=["time"])
templates = Jinja2Templates(directory="templates")
async def get_running_timer(db: AsyncSession) -> dict | None:
"""Get the currently running timer (end_at IS NULL), if any."""
result = await db.execute(text("""
SELECT te.*, t.title as task_title, t.id as task_id,
p.name as project_name, d.name as domain_name
FROM time_entries te
JOIN tasks t ON te.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE te.end_at IS NULL AND te.is_deleted = false
ORDER BY te.start_at DESC
LIMIT 1
"""))
row = result.first()
return dict(row._mapping) if row else None
@router.get("/")
async def time_log(
request: Request,
task_id: Optional[str] = None,
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
):
"""Time entries log view."""
sidebar = await get_sidebar_data(db)
running = await get_running_timer(db)
params = {"days": days}
task_filter = ""
if task_id:
task_filter = "AND te.task_id = :task_id"
params["task_id"] = task_id
result = await db.execute(text(f"""
SELECT te.*, t.title as task_title, t.id as task_id,
p.name as project_name, d.name as domain_name, d.color as domain_color
FROM time_entries te
JOIN tasks t ON te.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE te.is_deleted = false
AND te.start_at >= CURRENT_DATE - INTERVAL ':days days'
{task_filter}
ORDER BY te.start_at DESC
LIMIT 200
""".replace(":days days", f"{days} days")), params)
entries = [dict(r._mapping) for r in result]
# Calculate totals
total_minutes = sum(e.get("duration_minutes") or 0 for e in entries)
# Daily breakdown
daily_totals = {}
for e in entries:
if e.get("start_at"):
day = e["start_at"].strftime("%Y-%m-%d")
daily_totals[day] = daily_totals.get(day, 0) + (e.get("duration_minutes") or 0)
return templates.TemplateResponse("time_entries.html", {
"request": request,
"sidebar": sidebar,
"entries": entries,
"running": running,
"total_minutes": total_minutes,
"daily_totals": daily_totals,
"days": days,
"task_id": task_id or "",
"page_title": "Time Log",
"active_nav": "time",
})
@router.post("/start")
async def start_timer(
request: Request,
task_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""Start a timer for a task. Auto-stops any running timer first."""
now = datetime.now(timezone.utc)
# Stop any currently running timer
running = await get_running_timer(db)
if running:
duration = int((now - running["start_at"]).total_seconds() / 60)
await db.execute(text("""
UPDATE time_entries SET end_at = :now, duration_minutes = :dur
WHERE id = :id
"""), {"now": now, "dur": max(duration, 1), "id": str(running["id"])})
# Start new timer
await db.execute(text("""
INSERT INTO time_entries (task_id, start_at, is_deleted, created_at)
VALUES (:task_id, :now, false, :now)
"""), {"task_id": task_id, "now": now})
# Redirect back to where they came from
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
@router.post("/stop")
async def stop_timer(
request: Request,
entry_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Stop the running timer (or a specific entry)."""
now = datetime.now(timezone.utc)
if entry_id:
# Stop specific entry
result = await db.execute(text(
"SELECT * FROM time_entries WHERE id = :id AND end_at IS NULL"
), {"id": entry_id})
entry = result.first()
else:
# Stop whatever is running
result = await db.execute(text(
"SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false ORDER BY start_at DESC LIMIT 1"
))
entry = result.first()
if entry:
entry = dict(entry._mapping)
duration = int((now - entry["start_at"]).total_seconds() / 60)
await db.execute(text("""
UPDATE time_entries SET end_at = :now, duration_minutes = :dur
WHERE id = :id
"""), {"now": now, "dur": max(duration, 1), "id": str(entry["id"])})
referer = request.headers.get("referer", "/time")
return RedirectResponse(url=referer, status_code=303)
@router.get("/running")
async def running_timer_api(db: AsyncSession = Depends(get_db)):
"""JSON endpoint for the topbar timer pill to poll."""
running = await get_running_timer(db)
if not running:
return JSONResponse({"running": False})
elapsed_seconds = int((datetime.now(timezone.utc) - running["start_at"]).total_seconds())
return JSONResponse({
"running": True,
"entry_id": str(running["id"]),
"task_id": str(running["task_id"]),
"task_title": running["task_title"],
"project_name": running.get("project_name"),
"start_at": running["start_at"].isoformat(),
"elapsed_seconds": elapsed_seconds,
})
@router.post("/manual")
async def manual_entry(
request: Request,
task_id: str = Form(...),
date: str = Form(...),
duration_minutes: int = Form(...),
notes: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Add a manual time entry (no start/stop, just duration)."""
start_at = datetime.fromisoformat(f"{date}T12:00:00+00:00")
await db.execute(text("""
INSERT INTO time_entries (task_id, start_at, end_at, duration_minutes, notes, is_deleted, created_at)
VALUES (:task_id, :start_at, :start_at, :dur, :notes, false, now())
"""), {
"task_id": task_id,
"start_at": start_at,
"dur": duration_minutes,
"notes": notes or None,
})
referer = request.headers.get("referer", "/time")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{entry_id}/delete")
async def delete_entry(
request: Request,
entry_id: str,
db: AsyncSession = Depends(get_db),
):
# Direct SQL because time_entries has no updated_at column
await db.execute(text("""
UPDATE time_entries SET is_deleted = true, deleted_at = now()
WHERE id = :id AND is_deleted = false
"""), {"id": entry_id})
referer = request.headers.get("referer", "/time")
return RedirectResponse(url=referer, status_code=303)

369
routers/weblinks.py Normal file
View File

@@ -0,0 +1,369 @@
"""Bookmarks: organized folder directory for links."""
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="/weblinks", tags=["bookmarks"])
templates = Jinja2Templates(directory="templates")
async def get_default_folder_id(db: AsyncSession) -> str:
"""Return the Default folder id, creating it if it doesn't exist."""
result = await db.execute(text(
"SELECT id FROM link_folders WHERE name = 'Default' AND is_deleted = false ORDER BY created_at LIMIT 1"
))
row = result.first()
if row:
return str(row[0])
repo = BaseRepository("link_folders", db)
folder = await repo.create({"name": "Default", "sort_order": 0})
return str(folder["id"])
@router.get("/")
async def list_bookmarks(
request: Request,
folder_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
# Get all folders for tree nav
result = await db.execute(text("""
SELECT lf.*, (SELECT count(*) FROM folder_links fl WHERE fl.folder_id = lf.id) as link_count
FROM link_folders lf
WHERE lf.is_deleted = false
ORDER BY lf.sort_order, lf.name
"""))
all_folders = [dict(r._mapping) for r in result]
# Top-level folders and child folders
top_folders = [f for f in all_folders if f.get("parent_id") is None]
child_folder_map = {}
for f in all_folders:
pid = f.get("parent_id")
if pid:
child_folder_map.setdefault(str(pid), []).append(f)
# Current folder info
current_folder = None
if folder_id:
for f in all_folders:
if str(f["id"]) == folder_id:
current_folder = f
break
# Get links (filtered by folder or all)
available_links = []
if folder_id:
result = await db.execute(text("""
SELECT l.* FROM links l
JOIN folder_links fl ON fl.link_id = l.id
WHERE fl.folder_id = :fid AND l.is_deleted = false
ORDER BY fl.sort_order, l.label
"""), {"fid": folder_id})
items = [dict(r._mapping) for r in result]
# Links NOT in this folder (for "add existing" dropdown)
result = await db.execute(text("""
SELECT l.id, l.label FROM links l
WHERE l.is_deleted = false
AND l.id NOT IN (SELECT link_id FROM folder_links WHERE folder_id = :fid)
ORDER BY l.label
"""), {"fid": folder_id})
available_links = [dict(r._mapping) for r in result]
else:
# Show all links
result = await db.execute(text("""
SELECT l.* FROM links l
WHERE l.is_deleted = false
ORDER BY l.sort_order, l.label
"""))
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblinks.html", {
"request": request, "sidebar": sidebar, "items": items,
"top_folders": top_folders, "child_folder_map": child_folder_map,
"current_folder": current_folder,
"current_folder_id": folder_id or "",
"available_links": available_links,
"page_title": "Bookmarks", "active_nav": "bookmarks",
})
@router.get("/create")
async def create_form(
request: Request,
folder_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "New Link", "active_nav": "bookmarks",
"item": None,
"prefill_folder_id": folder_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@router.post("/create")
async def create_link(
request: Request,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {"label": label, "url": url, "description": description}
if task_id and task_id.strip():
data["task_id"] = task_id
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()]
link = await repo.create(data)
# Add to folder (default if none specified)
effective_folder = folder_id if folder_id and folder_id.strip() else await get_default_folder_id(db)
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": effective_folder, "lid": link["id"]})
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
return RedirectResponse(url=redirect_url, status_code=303)
@router.get("/{link_id}/edit")
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("links", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(link_id)
if not item:
return RedirectResponse(url="/weblinks", status_code=303)
result = await db.execute(text(
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
))
folders = [dict(r._mapping) for r in result]
# Current folder assignment
result = await db.execute(text(
"SELECT folder_id FROM folder_links WHERE link_id = :lid LIMIT 1"
), {"lid": link_id})
row = result.first()
current_folder_id = str(row[0]) if row else ""
return templates.TemplateResponse("weblink_form.html", {
"request": request, "sidebar": sidebar,
"folders": folders,
"page_title": "Edit Link", "active_nav": "bookmarks",
"item": item,
"prefill_folder_id": current_folder_id,
})
@router.post("/{link_id}/edit")
async def update_link(
link_id: str,
label: str = Form(...),
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("links", db)
data = {
"label": label, "url": url,
"description": description if description and description.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(link_id, data)
# Update folder assignment
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
if folder_id and folder_id.strip():
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "lid": link_id})
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/{link_id}/delete")
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("links", db)
await repo.soft_delete(link_id)
referer = request.headers.get("referer", "/weblinks")
return RedirectResponse(url=referer, status_code=303)
# ---- Reorder links within a folder ----
@router.post("/folders/{folder_id}/reorder")
async def reorder_link(
folder_id: str,
link_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Get all folder_links for this folder, ordered by sort_order then created_at
result = await db.execute(text("""
SELECT link_id, sort_order FROM folder_links
WHERE folder_id = :fid
ORDER BY sort_order, created_at
"""), {"fid": folder_id})
rows = [dict(r._mapping) for r in result]
if not rows:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Lazy-initialize sort_order if all zeros
all_zero = all(r["sort_order"] == 0 for r in rows)
if all_zero:
for i, r in enumerate(rows):
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": (i + 1) * 10, "fid": folder_id, "lid": r["link_id"]})
r["sort_order"] = (i + 1) * 10
# Find target index
idx = None
for i, r in enumerate(rows):
if str(r["link_id"]) == link_id:
idx = i
break
if idx is None:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Determine swap partner
if direction == "up" and idx > 0:
swap_idx = idx - 1
elif direction == "down" and idx < len(rows) - 1:
swap_idx = idx + 1
else:
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# Swap sort_order values
so_a, so_b = rows[idx]["sort_order"], rows[swap_idx]["sort_order"]
lid_a, lid_b = rows[idx]["link_id"], rows[swap_idx]["link_id"]
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_b, "fid": folder_id, "lid": lid_a})
await db.execute(text("""
UPDATE folder_links SET sort_order = :so
WHERE folder_id = :fid AND link_id = :lid
"""), {"so": so_a, "fid": folder_id, "lid": lid_b})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Add existing link to folder ----
@router.post("/folders/{folder_id}/add-link")
async def add_link_to_folder(
folder_id: str,
link_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
# Remove link from any existing folder (single-folder membership)
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
# Get max sort_order in target folder
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) FROM folder_links WHERE folder_id = :fid
"""), {"fid": folder_id})
max_so = result.scalar()
# Insert into target folder at end
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id, sort_order)
VALUES (:fid, :lid, :so) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "lid": link_id, "so": max_so + 10})
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
# ---- Folders ----
@router.get("/folders/create")
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
result = await db.execute(text(
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
))
parent_folders = [dict(r._mapping) for r in result]
return templates.TemplateResponse("weblink_folder_form.html", {
"request": request, "sidebar": sidebar,
"parent_folders": parent_folders,
"page_title": "New Folder", "active_nav": "bookmarks",
"item": None,
})
@router.post("/folders/create")
async def create_folder(
request: Request,
name: str = Form(...),
parent_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("link_folders", db)
data = {"name": name}
if parent_id and parent_id.strip():
data["parent_id"] = parent_id
await repo.create(data)
return RedirectResponse(url="/weblinks", status_code=303)
@router.post("/folders/{folder_id}/delete")
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
# Prevent deleting the Default folder
result = await db.execute(text(
"SELECT name FROM link_folders WHERE id = :id"
), {"id": folder_id})
row = result.first()
if row and row[0] == "Default":
return RedirectResponse(url="/weblinks", status_code=303)
repo = BaseRepository("link_folders", db)
await repo.soft_delete(folder_id)
return RedirectResponse(url="/weblinks", status_code=303)