Session 1: bug fix, global search, admin trash, lists CRUD

This commit is contained in:
2026-02-28 03:52:12 +00:00
parent f36ea194f3
commit 5773808ae4
14 changed files with 1211 additions and 2 deletions

121
routers/admin.py Normal file
View File

@@ -0,0 +1,121 @@
"""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": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
]
@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)

275
routers/lists.py Normal file
View File

@@ -0,0 +1,275 @@
"""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,
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 "",
})
@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),
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 tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_list = await repo.create(data)
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)
return templates.TemplateResponse("list_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"list_items": top_items, "child_map": child_map,
"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)

190
routers/search.py Normal file
View File

@@ -0,0 +1,190 @@
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
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")
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
SEARCH_ENTITIES = [
{
"type": "tasks",
"label": "Tasks",
"query": """
SELECT t.id, t.title as name, t.status,
d.name as domain_name, p.name as project_name,
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/tasks/{id}",
"icon": "task",
},
{
"type": "projects",
"label": "Projects",
"query": """
SELECT p.id, p.name, p.status,
d.name as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/projects/{id}",
"icon": "project",
},
{
"type": "notes",
"label": "Notes",
"query": """
SELECT n.id, n.title as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/notes/{id}",
"icon": "note",
},
{
"type": "contacts",
"label": "Contacts",
"query": """
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
NULL as status, c.company as domain_name, NULL as project_name,
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM contacts c
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/contacts/{id}",
"icon": "contact",
},
{
"type": "links",
"label": "Links",
"query": """
SELECT l.id, l.label as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/links",
"icon": "link",
},
{
"type": "lists",
"label": "Lists",
"query": """
SELECT l.id, l.name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/lists/{id}",
"icon": "list",
},
]
@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():
return JSONResponse({"results": [], "query": q})
results = []
entities = SEARCH_ENTITIES
if entity_type:
entities = [e for e in entities if e["type"] == entity_type]
for entity in entities:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
rows = [dict(r._mapping) for r in result]
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"],
})
except Exception:
# Table might not have search_vector yet, skip silently
continue
# 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():
for entity in SEARCH_ENTITIES:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
rows = [dict(r._mapping) for r in result]
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"],
})
except Exception:
continue
return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar,
"results": results, "query": q,
"page_title": "Search", "active_nav": "search",
})

View File

@@ -290,7 +290,7 @@ async def update_task(
@router.post("/{task_id}/complete")
async def complete_task(task_id: str, db: AsyncSession = Depends(get_db)):
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, {