Initial commit
This commit is contained in:
0
routers/__init__.py
Normal file
0
routers/__init__.py
Normal file
129
routers/admin.py
Normal file
129
routers/admin.py
Normal 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
302
routers/appointments.py
Normal 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
122
routers/areas.py
Normal 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
138
routers/calendar.py
Normal 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
361
routers/capture.py
Normal 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
126
routers/contacts.py
Normal 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
218
routers/decisions.py
Normal 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
83
routers/domains.py
Normal 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
120
routers/eisenhower.py
Normal 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
398
routers/files.py
Normal 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
146
routers/focus.py
Normal 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
90
routers/history.py
Normal 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
156
routers/links.py
Normal 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
334
routers/lists.py
Normal 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
406
routers/meetings.py
Normal 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
195
routers/notes.py
Normal 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
569
routers/processes.py
Normal 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
403
routers/projects.py
Normal 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
237
routers/search.py
Normal 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
483
routers/tasks.py
Normal 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
189
routers/time_budgets.py
Normal 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
211
routers/time_tracking.py
Normal 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
369
routers/weblinks.py
Normal 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)
|
||||
Reference in New Issue
Block a user