Session 1: bug fix, global search, admin trash, lists CRUD
This commit is contained in:
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",
|
||||
})
|
||||
Reference in New Issue
Block a user