various enhancements for new tabs and bug fixes
This commit is contained in:
@@ -56,6 +56,7 @@ async def list_decisions(
|
||||
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)
|
||||
@@ -72,6 +73,7 @@ async def create_form(
|
||||
"page_title": "New Decision", "active_nav": "decisions",
|
||||
"item": None,
|
||||
"prefill_meeting_id": meeting_id or "",
|
||||
"prefill_task_id": task_id or "",
|
||||
})
|
||||
|
||||
|
||||
@@ -84,6 +86,7 @@ async def create_decision(
|
||||
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),
|
||||
):
|
||||
@@ -96,10 +99,14 @@ async def create_decision(
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ 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"])
|
||||
@@ -15,11 +17,36 @@ 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)
|
||||
|
||||
result = await db.execute(text("""
|
||||
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,
|
||||
@@ -27,10 +54,9 @@ async def eisenhower_matrix(
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE t.is_deleted = false
|
||||
AND t.status IN ('open', 'in_progress', 'blocked')
|
||||
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
|
||||
@@ -39,10 +65,10 @@ async def eisenhower_matrix(
|
||||
urgent_cutoff = today + timedelta(days=7)
|
||||
|
||||
quadrants = {
|
||||
"do_first": [], # Urgent + Important
|
||||
"schedule": [], # Not Urgent + Important
|
||||
"delegate": [], # Urgent + Not Important
|
||||
"eliminate": [], # Not Urgent + Not Important
|
||||
"do_first": [],
|
||||
"schedule": [],
|
||||
"delegate": [],
|
||||
"eliminate": [],
|
||||
}
|
||||
|
||||
for t in tasks:
|
||||
@@ -64,6 +90,17 @@ async def eisenhower_matrix(
|
||||
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,
|
||||
@@ -71,6 +108,13 @@ async def eisenhower_matrix(
|
||||
"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",
|
||||
})
|
||||
|
||||
@@ -17,7 +17,14 @@ templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
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()
|
||||
|
||||
@@ -36,30 +43,57 @@ async def focus_view(request: Request, focus_date: Optional[str] = None, db: Asy
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Available tasks to add (open, not already in today's focus)
|
||||
result = await db.execute(text("""
|
||||
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 t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
|
||||
AND t.id NOT IN (
|
||||
SELECT task_id FROM daily_focus
|
||||
WHERE focus_date = :target_date AND is_deleted = false
|
||||
)
|
||||
WHERE {avail_sql}
|
||||
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
||||
LIMIT 50
|
||||
"""), {"target_date": target_date})
|
||||
"""), 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",
|
||||
})
|
||||
|
||||
|
||||
91
routers/history.py
Normal file
91
routers/history.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""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"),
|
||||
("weblinks", "label", "Weblink", "/weblinks"),
|
||||
("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",
|
||||
})
|
||||
@@ -70,6 +70,8 @@ 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)
|
||||
@@ -87,6 +89,8 @@ async def create_form(
|
||||
"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 "",
|
||||
})
|
||||
|
||||
|
||||
@@ -97,6 +101,8 @@ async def create_list(
|
||||
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),
|
||||
@@ -112,10 +118,18 @@ async def create_list(
|
||||
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)
|
||||
|
||||
|
||||
@@ -156,10 +170,28 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends
|
||||
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",
|
||||
})
|
||||
|
||||
@@ -273,3 +305,30 @@ async def edit_item(
|
||||
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)
|
||||
|
||||
@@ -107,61 +107,118 @@ async def create_meeting(
|
||||
|
||||
|
||||
@router.get("/{meeting_id}")
|
||||
async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
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)
|
||||
|
||||
# Action items (tasks linked to this meeting)
|
||||
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]
|
||||
# Overview data (always needed for overview tab)
|
||||
action_items = []
|
||||
decisions = []
|
||||
domains = []
|
||||
tab_data = []
|
||||
all_contacts = []
|
||||
|
||||
# Notes linked to this meeting
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM notes
|
||||
WHERE meeting_id = :mid AND is_deleted = false
|
||||
ORDER BY created_at
|
||||
"""), {"mid": meeting_id})
|
||||
meeting_notes = [dict(r._mapping) for r in result]
|
||||
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]
|
||||
# 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]
|
||||
|
||||
# Attendees
|
||||
result = await db.execute(text("""
|
||||
SELECT c.*, cm.role FROM contact_meetings cm
|
||||
JOIN contacts c ON cm.contact_id = c.id
|
||||
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
||||
ORDER BY c.first_name
|
||||
"""), {"mid": meeting_id})
|
||||
attendees = [dict(r._mapping) for r in result]
|
||||
# Domains for action item creation
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
# 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 == "weblinks":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM weblinks
|
||||
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 == "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"),
|
||||
("weblinks", "SELECT count(*) FROM weblinks 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"),
|
||||
("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, "meeting_notes": meeting_notes,
|
||||
"decisions": decisions, "attendees": attendees,
|
||||
"domains": domains,
|
||||
"action_items": action_items, "decisions": decisions,
|
||||
"domains": domains, "tab": tab, "tab_data": tab_data,
|
||||
"all_contacts": all_contacts, "counts": counts,
|
||||
"page_title": item["title"], "active_nav": "meetings",
|
||||
})
|
||||
|
||||
@@ -266,3 +323,30 @@ async def create_action_item(
|
||||
"""), {"mid": meeting_id, "tid": task["id"]})
|
||||
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}", 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)
|
||||
|
||||
@@ -61,6 +61,8 @@ 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)
|
||||
@@ -75,6 +77,8 @@ async def create_form(
|
||||
"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 "",
|
||||
})
|
||||
|
||||
|
||||
@@ -84,6 +88,8 @@ async def create_note(
|
||||
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),
|
||||
@@ -96,9 +102,17 @@ async def create_note(
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
@@ -15,6 +15,27 @@ 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,
|
||||
@@ -151,7 +172,7 @@ async def project_detail(
|
||||
row = result.first()
|
||||
area = dict(row._mapping) if row else None
|
||||
|
||||
# Tasks for this project
|
||||
# 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
|
||||
@@ -161,29 +182,92 @@ async def project_detail(
|
||||
"""), {"pid": project_id})
|
||||
tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# 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]
|
||||
|
||||
# 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]
|
||||
|
||||
# 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 = []
|
||||
|
||||
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 == "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"),
|
||||
("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, "counts": counts,
|
||||
"progress": progress, "task_count": total, "done_count": done,
|
||||
"tab": tab,
|
||||
"page_title": item["name"], "active_nav": "projects",
|
||||
@@ -246,3 +330,30 @@ 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)
|
||||
|
||||
|
||||
# ---- 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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""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
|
||||
@@ -14,174 +15,162 @@ router = APIRouter(prefix="/search", tags=["search"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
|
||||
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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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": "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",
|
||||
"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",
|
||||
"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",
|
||||
"query": """
|
||||
SELECT m.id, m.title as name, m.status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM meetings m
|
||||
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/meetings/{id}",
|
||||
"icon": "meeting",
|
||||
"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",
|
||||
"query": """
|
||||
SELECT d.id, d.title as name, d.status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM decisions d
|
||||
WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/decisions/{id}",
|
||||
"icon": "decision",
|
||||
"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": "weblinks",
|
||||
"label": "Weblinks",
|
||||
"query": """
|
||||
SELECT w.id, w.label as name, NULL as status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM weblinks w
|
||||
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/weblinks",
|
||||
"icon": "weblink",
|
||||
"type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w",
|
||||
"name_col": "w.label", "status_col": "NULL",
|
||||
"joins": "",
|
||||
"domain_col": "NULL", "project_col": "NULL",
|
||||
"url": "/weblinks", "icon": "weblink",
|
||||
},
|
||||
{
|
||||
"type": "processes",
|
||||
"label": "Processes",
|
||||
"query": """
|
||||
SELECT p.id, p.name, p.status,
|
||||
p.category as domain_name, NULL as project_name,
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM processes p
|
||||
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/processes/{id}",
|
||||
"icon": "process",
|
||||
"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",
|
||||
"query": """
|
||||
SELECT a.id, a.title as name, NULL as status,
|
||||
a.location as domain_name, NULL as project_name,
|
||||
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM appointments a
|
||||
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/appointments/{id}",
|
||||
"icon": "appointment",
|
||||
"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),
|
||||
@@ -190,33 +179,30 @@ async def search_api(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""JSON search endpoint for the Cmd/K modal."""
|
||||
if not q or not q.strip():
|
||||
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:
|
||||
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
|
||||
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)
|
||||
@@ -234,24 +220,22 @@ async def search_page(
|
||||
sidebar = await get_sidebar_data(db)
|
||||
results = []
|
||||
|
||||
if q and q.strip():
|
||||
if q and q.strip() and len(q.strip()) >= 2:
|
||||
tsquery_str = build_prefix_tsquery(q)
|
||||
|
||||
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
|
||||
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,
|
||||
|
||||
129
routers/tasks.py
129
routers/tasks.py
@@ -186,7 +186,11 @@ async def create_task(
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
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)
|
||||
@@ -212,19 +216,101 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
|
||||
row = result.first()
|
||||
parent = dict(row._mapping) if row else None
|
||||
|
||||
# Subtasks
|
||||
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]
|
||||
# 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 == "weblinks":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM weblinks 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"),
|
||||
("weblinks", "SELECT count(*) FROM weblinks 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,
|
||||
"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",
|
||||
})
|
||||
@@ -368,3 +454,30 @@ async def quick_add(
|
||||
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)
|
||||
|
||||
@@ -78,6 +78,8 @@ async def list_weblinks(
|
||||
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)
|
||||
@@ -92,6 +94,8 @@ async def create_form(
|
||||
"page_title": "New Weblink", "active_nav": "weblinks",
|
||||
"item": None,
|
||||
"prefill_folder_id": folder_id or "",
|
||||
"prefill_task_id": task_id or "",
|
||||
"prefill_meeting_id": meeting_id or "",
|
||||
})
|
||||
|
||||
|
||||
@@ -102,11 +106,17 @@ async def create_weblink(
|
||||
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("weblinks", 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()]
|
||||
|
||||
@@ -119,6 +129,10 @@ async def create_weblink(
|
||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
||||
"""), {"fid": folder_id, "wid": weblink["id"]})
|
||||
|
||||
if task_id and task_id.strip():
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=weblinks", status_code=303)
|
||||
if meeting_id and meeting_id.strip():
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=weblinks", 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user