Session 1: bug fix, global search, admin trash, lists CRUD
This commit is contained in:
121
routers/admin.py
Normal file
121
routers/admin.py
Normal 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
275
routers/lists.py
Normal 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
190
routers/search.py
Normal 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",
|
||||
})
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user