R1 foundation - Phase 1 live build
This commit is contained in:
0
routers/__init__.py
Normal file
0
routers/__init__.py
Normal file
122
routers/areas.py
Normal file
122
routers/areas.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Areas: grouping within domains."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/areas", tags=["areas"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_areas(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("areas", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
filters = {}
|
||||
if domain_id:
|
||||
filters["domain_id"] = domain_id
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT a.*, d.name as domain_name, d.color as domain_color
|
||||
FROM areas a
|
||||
JOIN domains d ON a.domain_id = d.id
|
||||
WHERE a.is_deleted = false
|
||||
ORDER BY d.sort_order, d.name, a.sort_order, a.name
|
||||
"""))
|
||||
items = [dict(r._mapping) for r in result]
|
||||
if domain_id:
|
||||
items = [i for i in items if str(i["domain_id"]) == domain_id]
|
||||
|
||||
# Get domains for filter/form
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("areas.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains, "current_domain_id": domain_id or "",
|
||||
"page_title": "Areas", "active_nav": "areas",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
return templates.TemplateResponse("area_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains,
|
||||
"page_title": "New Area", "active_nav": "areas",
|
||||
"item": None, "prefill_domain_id": domain_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_area(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("areas", db)
|
||||
await repo.create({
|
||||
"name": name, "domain_id": domain_id,
|
||||
"description": description, "status": status,
|
||||
})
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{area_id}/edit")
|
||||
async def edit_form(area_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("areas", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(area_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
return templates.TemplateResponse("area_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains,
|
||||
"page_title": f"Edit {item['name']}", "active_nav": "areas",
|
||||
"item": item, "prefill_domain_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{area_id}/edit")
|
||||
async def update_area(
|
||||
area_id: str,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("areas", db)
|
||||
await repo.update(area_id, {
|
||||
"name": name, "domain_id": domain_id,
|
||||
"description": description, "status": status,
|
||||
})
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{area_id}/delete")
|
||||
async def delete_area(area_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("areas", db)
|
||||
await repo.soft_delete(area_id)
|
||||
return RedirectResponse(url="/areas", status_code=303)
|
||||
92
routers/capture.py
Normal file
92
routers/capture.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Capture: quick text capture queue with conversion."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/capture", tags=["capture"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_capture(request: Request, show: str = "unprocessed", db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
if show == "all":
|
||||
filters = {}
|
||||
else:
|
||||
filters = {"processed": False}
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM capture WHERE is_deleted = false
|
||||
AND (:show_all OR processed = false)
|
||||
ORDER BY created_at DESC
|
||||
"""), {"show_all": show == "all"})
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("capture.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"show": show,
|
||||
"page_title": "Capture", "active_nav": "capture",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_capture(
|
||||
request: Request,
|
||||
raw_text: str = Form(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("capture", db)
|
||||
# Multi-line: split into individual items
|
||||
lines = [l.strip() for l in raw_text.strip().split("\n") if l.strip()]
|
||||
for line in lines:
|
||||
await repo.create({"raw_text": line, "processed": False})
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{capture_id}/to-task")
|
||||
async def convert_to_task(
|
||||
capture_id: str,
|
||||
request: Request,
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
capture_repo = BaseRepository("capture", db)
|
||||
item = await capture_repo.get(capture_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
task_repo = BaseRepository("tasks", db)
|
||||
data = {"title": item["raw_text"], "domain_id": domain_id, "status": "open", "priority": 3}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
task = await task_repo.create(data)
|
||||
|
||||
await capture_repo.update(capture_id, {
|
||||
"processed": True,
|
||||
"converted_to_type": "task",
|
||||
"converted_to_id": str(task["id"]),
|
||||
})
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{capture_id}/dismiss")
|
||||
async def dismiss_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("capture", db)
|
||||
await repo.update(capture_id, {"processed": True})
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{capture_id}/delete")
|
||||
async def delete_capture(capture_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("capture", db)
|
||||
await repo.soft_delete(capture_id)
|
||||
return RedirectResponse(url="/capture", status_code=303)
|
||||
126
routers/contacts.py
Normal file
126
routers/contacts.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Contacts: people directory for CRM."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_contacts(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM contacts WHERE is_deleted = false
|
||||
ORDER BY sort_order, first_name, last_name
|
||||
"""))
|
||||
items = [dict(r._mapping) for r in result]
|
||||
return templates.TemplateResponse("contacts.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"page_title": "Contacts", "active_nav": "contacts",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
return templates.TemplateResponse("contact_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": "New Contact", "active_nav": "contacts",
|
||||
"item": None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_contact(
|
||||
request: Request,
|
||||
first_name: str = Form(...),
|
||||
last_name: Optional[str] = Form(None),
|
||||
company: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
email: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("contacts", db)
|
||||
data = {
|
||||
"first_name": first_name, "last_name": last_name,
|
||||
"company": company, "role": role, "email": email,
|
||||
"phone": phone, "notes": notes,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
await repo.create(data)
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{contact_id}")
|
||||
async def contact_detail(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("contacts", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(contact_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
return templates.TemplateResponse("contact_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"page_title": f"{item['first_name']} {item.get('last_name', '')}".strip(),
|
||||
"active_nav": "contacts",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{contact_id}/edit")
|
||||
async def edit_form(contact_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("contacts", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(contact_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
return templates.TemplateResponse("contact_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": "Edit Contact", "active_nav": "contacts",
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{contact_id}/edit")
|
||||
async def update_contact(
|
||||
contact_id: str,
|
||||
first_name: str = Form(...),
|
||||
last_name: Optional[str] = Form(None),
|
||||
company: Optional[str] = Form(None),
|
||||
role: Optional[str] = Form(None),
|
||||
email: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("contacts", db)
|
||||
data = {
|
||||
"first_name": first_name, "last_name": last_name,
|
||||
"company": company, "role": role, "email": email,
|
||||
"phone": phone, "notes": notes,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
await repo.update(contact_id, data)
|
||||
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{contact_id}/delete")
|
||||
async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("contacts", db)
|
||||
await repo.soft_delete(contact_id)
|
||||
return RedirectResponse(url="/contacts", status_code=303)
|
||||
83
routers/domains.py
Normal file
83
routers/domains.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Domains: top-level organizational buckets."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/domains", tags=["domains"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_domains(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("domains", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
items = await repo.list(sort="sort_order")
|
||||
return templates.TemplateResponse("domains.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"page_title": "Domains", "active_nav": "domains",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
return templates.TemplateResponse("domain_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": "New Domain", "active_nav": "domains",
|
||||
"item": None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_domain(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
color: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("domains", db)
|
||||
domain = await repo.create({"name": name, "color": color, "description": description})
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/edit")
|
||||
async def edit_form(domain_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("domains", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(domain_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
return templates.TemplateResponse("domain_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"page_title": f"Edit {item['name']}", "active_nav": "domains",
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{domain_id}/edit")
|
||||
async def update_domain(
|
||||
domain_id: str,
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
color: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("domains", db)
|
||||
await repo.update(domain_id, {"name": name, "color": color, "description": description})
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{domain_id}/delete")
|
||||
async def delete_domain(domain_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("domains", db)
|
||||
await repo.soft_delete(domain_id)
|
||||
return RedirectResponse(url="/domains", status_code=303)
|
||||
111
routers/focus.py
Normal file
111
routers/focus.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Daily Focus: date-scoped task commitment list."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/focus", tags=["focus"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
target_date = focus_date or str(date.today())
|
||||
|
||||
result = await db.execute(text("""
|
||||
SELECT df.*, t.title, t.priority, t.status as task_status,
|
||||
t.project_id, t.due_date, t.estimated_minutes,
|
||||
p.name as project_name,
|
||||
d.name as domain_name, d.color as domain_color
|
||||
FROM daily_focus df
|
||||
JOIN tasks t ON df.task_id = t.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE df.focus_date = :target_date AND df.is_deleted = false
|
||||
ORDER BY df.sort_order, df.created_at
|
||||
"""), {"target_date": target_date})
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Available tasks to add (open, not already in today's focus)
|
||||
result = await db.execute(text("""
|
||||
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
|
||||
)
|
||||
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
||||
LIMIT 50
|
||||
"""), {"target_date": target_date})
|
||||
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)
|
||||
|
||||
return templates.TemplateResponse("focus.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"items": items, "available_tasks": available_tasks,
|
||||
"focus_date": target_date,
|
||||
"total_estimated": total_est,
|
||||
"page_title": "Daily Focus", "active_nav": "focus",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def add_to_focus(
|
||||
request: Request,
|
||||
task_id: str = Form(...),
|
||||
focus_date: str = Form(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("daily_focus", db)
|
||||
# Get next sort order
|
||||
result = await db.execute(text("""
|
||||
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus
|
||||
WHERE focus_date = :fd AND is_deleted = false
|
||||
"""), {"fd": focus_date})
|
||||
next_order = result.scalar()
|
||||
|
||||
await repo.create({
|
||||
"task_id": task_id, "focus_date": focus_date,
|
||||
"sort_order": next_order, "completed": False,
|
||||
})
|
||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{focus_id}/toggle")
|
||||
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("daily_focus", db)
|
||||
item = await repo.get(focus_id)
|
||||
if item:
|
||||
await repo.update(focus_id, {"completed": not item["completed"]})
|
||||
# Also toggle the task status
|
||||
task_repo = BaseRepository("tasks", db)
|
||||
if not item["completed"]:
|
||||
await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)})
|
||||
else:
|
||||
await task_repo.update(item["task_id"], {"status": "open", "completed_at": None})
|
||||
focus_date = item["focus_date"] if item else date.today()
|
||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{focus_id}/remove")
|
||||
async def remove_from_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("daily_focus", db)
|
||||
item = await repo.get(focus_id)
|
||||
await repo.soft_delete(focus_id)
|
||||
focus_date = item["focus_date"] if item else date.today()
|
||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||
113
routers/links.py
Normal file
113
routers/links.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Links: URL references attached to domains/projects."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/links", tags=["links"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_links(request: Request, domain_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
where_clauses = ["l.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("l.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT l.*, d.name as domain_name, d.color as domain_color, p.name as project_name
|
||||
FROM links l
|
||||
LEFT JOIN domains d ON l.domain_id = d.id
|
||||
LEFT JOIN projects p ON l.project_id = p.id
|
||||
WHERE {where_sql} ORDER BY l.sort_order, l.created_at
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("links.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items, "domains": domains,
|
||||
"current_domain_id": domain_id or "",
|
||||
"page_title": "Links", "active_nav": "links",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("link_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
||||
"page_title": "New Link", "active_nav": "links",
|
||||
"item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_link(
|
||||
request: Request, label: str = Form(...), url: str = Form(...),
|
||||
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("links", db)
|
||||
data = {"label": label, "url": url, "domain_id": domain_id, "description": description}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
await repo.create(data)
|
||||
referer = request.headers.get("referer", "/links")
|
||||
return RedirectResponse(url="/links", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{link_id}/edit")
|
||||
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("links", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(link_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/links", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("link_form.html", {
|
||||
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
||||
"page_title": "Edit Link", "active_nav": "links",
|
||||
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{link_id}/edit")
|
||||
async def update_link(
|
||||
link_id: str, label: str = Form(...), url: str = Form(...),
|
||||
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("links", db)
|
||||
await repo.update(link_id, {
|
||||
"label": label, "url": url, "domain_id": domain_id,
|
||||
"project_id": project_id if project_id and project_id.strip() else None,
|
||||
"description": description,
|
||||
})
|
||||
return RedirectResponse(url="/links", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{link_id}/delete")
|
||||
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("links", db)
|
||||
await repo.soft_delete(link_id)
|
||||
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
||||
181
routers/notes.py
Normal file
181
routers/notes.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Notes: knowledge documents with project associations."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_notes(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
where_clauses = ["n.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("n.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if project_id:
|
||||
where_clauses.append("n.project_id = :project_id")
|
||||
params["project_id"] = project_id
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT n.*, d.name as domain_name, d.color as domain_color,
|
||||
p.name as project_name
|
||||
FROM notes n
|
||||
LEFT JOIN domains d ON n.domain_id = d.id
|
||||
LEFT JOIN projects p ON n.project_id = p.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY n.updated_at DESC
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("notes.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_project_id": project_id or "",
|
||||
"page_title": "Notes", "active_nav": "notes",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("note_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects,
|
||||
"page_title": "New Note", "active_nav": "notes",
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_project_id": project_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_note(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
body: Optional[str] = Form(None),
|
||||
content_format: str = Form("rich"),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("notes", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id,
|
||||
"body": body, "content_format": content_format,
|
||||
}
|
||||
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()]
|
||||
note = await repo.create(data)
|
||||
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{note_id}")
|
||||
async def note_detail(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("notes", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(note_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/notes", status_code=303)
|
||||
|
||||
domain = None
|
||||
if item.get("domain_id"):
|
||||
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
|
||||
row = result.first()
|
||||
domain = dict(row._mapping) if row else None
|
||||
|
||||
project = None
|
||||
if item.get("project_id"):
|
||||
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
|
||||
row = result.first()
|
||||
project = dict(row._mapping) if row else None
|
||||
|
||||
return templates.TemplateResponse("note_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "project": project,
|
||||
"page_title": item["title"], "active_nav": "notes",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{note_id}/edit")
|
||||
async def edit_form(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("notes", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(note_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/notes", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
return templates.TemplateResponse("note_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects,
|
||||
"page_title": f"Edit Note", "active_nav": "notes",
|
||||
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{note_id}/edit")
|
||||
async def update_note(
|
||||
note_id: str,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
body: Optional[str] = Form(None),
|
||||
content_format: str = Form("rich"),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("notes", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id, "body": body,
|
||||
"content_format": content_format,
|
||||
"project_id": project_id if project_id and project_id.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
await repo.update(note_id, data)
|
||||
return RedirectResponse(url=f"/notes/{note_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{note_id}/delete")
|
||||
async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("notes", db)
|
||||
await repo.soft_delete(note_id)
|
||||
referer = request.headers.get("referer", "/notes")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
248
routers/projects.py
Normal file
248
routers/projects.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Projects: organizational unit within domain/area hierarchy."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_projects(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
# Build query with joins for hierarchy display
|
||||
where_clauses = ["p.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("p.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if status:
|
||||
where_clauses.append("p.status = :status")
|
||||
params["status"] = status
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT p.*,
|
||||
d.name as domain_name, d.color as domain_color,
|
||||
a.name as area_name,
|
||||
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false) as task_count,
|
||||
(SELECT count(*) FROM tasks t WHERE t.project_id = p.id AND t.is_deleted = false AND t.status = 'done') as done_count
|
||||
FROM projects p
|
||||
JOIN domains d ON p.domain_id = d.id
|
||||
LEFT JOIN areas a ON p.area_id = a.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY d.sort_order, d.name, a.sort_order, a.name, p.sort_order, p.name
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Calculate progress percentage
|
||||
for item in items:
|
||||
total = item["task_count"] or 0
|
||||
done = item["done_count"] or 0
|
||||
item["progress"] = round((done / total * 100) if total > 0 else 0)
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
return templates.TemplateResponse("projects.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_status": status or "",
|
||||
"page_title": "Projects", "active_nav": "projects",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
area_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
areas_repo = BaseRepository("areas", db)
|
||||
areas = await areas_repo.list()
|
||||
|
||||
return templates.TemplateResponse("project_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "areas": areas,
|
||||
"page_title": "New Project", "active_nav": "projects",
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_area_id": area_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_project(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
area_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
priority: int = Form(3),
|
||||
start_date: Optional[str] = Form(None),
|
||||
target_date: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("projects", db)
|
||||
data = {
|
||||
"name": name, "domain_id": domain_id,
|
||||
"description": description, "status": status,
|
||||
"priority": priority,
|
||||
}
|
||||
if area_id and area_id.strip():
|
||||
data["area_id"] = area_id
|
||||
if start_date and start_date.strip():
|
||||
data["start_date"] = start_date
|
||||
if target_date and target_date.strip():
|
||||
data["target_date"] = target_date
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
project = await repo.create(data)
|
||||
return RedirectResponse(url=f"/projects/{project['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
async def project_detail(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
tab: str = "tasks",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("projects", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(project_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
|
||||
# Get domain and area names
|
||||
result = await db.execute(text(
|
||||
"SELECT name, color FROM domains WHERE id = :id"
|
||||
), {"id": str(item["domain_id"])})
|
||||
domain = dict(result.first()._mapping) if result else {}
|
||||
|
||||
area = None
|
||||
if item.get("area_id"):
|
||||
result = await db.execute(text(
|
||||
"SELECT name FROM areas WHERE id = :id"
|
||||
), {"id": str(item["area_id"])})
|
||||
row = result.first()
|
||||
area = dict(row._mapping) if row else None
|
||||
|
||||
# Tasks for this project
|
||||
result = await db.execute(text("""
|
||||
SELECT t.*, d.name as domain_name, d.color as domain_color
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE t.project_id = :pid AND t.is_deleted = false
|
||||
ORDER BY t.sort_order, t.created_at
|
||||
"""), {"pid": project_id})
|
||||
tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# 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)
|
||||
|
||||
return templates.TemplateResponse("project_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "area": area,
|
||||
"tasks": tasks, "notes": notes, "links": links,
|
||||
"progress": progress, "task_count": total, "done_count": done,
|
||||
"tab": tab,
|
||||
"page_title": item["name"], "active_nav": "projects",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/edit")
|
||||
async def edit_form(project_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("projects", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(project_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
areas_repo = BaseRepository("areas", db)
|
||||
areas = await areas_repo.list()
|
||||
return templates.TemplateResponse("project_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "areas": areas,
|
||||
"page_title": f"Edit {item['name']}", "active_nav": "projects",
|
||||
"item": item, "prefill_domain_id": "", "prefill_area_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{project_id}/edit")
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
name: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
area_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
status: str = Form("active"),
|
||||
priority: int = Form(3),
|
||||
start_date: Optional[str] = Form(None),
|
||||
target_date: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("projects", db)
|
||||
data = {
|
||||
"name": name, "domain_id": domain_id,
|
||||
"area_id": area_id if area_id and area_id.strip() else None,
|
||||
"description": description, "status": status,
|
||||
"priority": priority,
|
||||
"start_date": start_date if start_date and start_date.strip() else None,
|
||||
"target_date": target_date if target_date and target_date.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
|
||||
await repo.update(project_id, data)
|
||||
return RedirectResponse(url=f"/projects/{project_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{project_id}/delete")
|
||||
async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("projects", db)
|
||||
await repo.soft_delete(project_id)
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
355
routers/tasks.py
Normal file
355
routers/tasks.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Tasks: core work items with full filtering and hierarchy."""
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_tasks(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
context: Optional[str] = None,
|
||||
sort: str = "sort_order",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
where_clauses = ["t.is_deleted = false"]
|
||||
params = {}
|
||||
if domain_id:
|
||||
where_clauses.append("t.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if project_id:
|
||||
where_clauses.append("t.project_id = :project_id")
|
||||
params["project_id"] = project_id
|
||||
if status:
|
||||
where_clauses.append("t.status = :status")
|
||||
params["status"] = status
|
||||
if priority:
|
||||
where_clauses.append("t.priority = :priority")
|
||||
params["priority"] = int(priority)
|
||||
if context:
|
||||
where_clauses.append("t.context = :context")
|
||||
params["context"] = context
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
sort_map = {
|
||||
"sort_order": "t.sort_order, t.created_at",
|
||||
"priority": "t.priority ASC, t.due_date ASC NULLS LAST",
|
||||
"due_date": "t.due_date ASC NULLS LAST, t.priority ASC",
|
||||
"created_at": "t.created_at DESC",
|
||||
"title": "t.title ASC",
|
||||
}
|
||||
order_sql = sort_map.get(sort, sort_map["sort_order"])
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT t.*,
|
||||
d.name as domain_name, d.color as domain_color,
|
||||
p.name as project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE {where_sql}
|
||||
ORDER BY
|
||||
CASE WHEN t.status = 'done' THEN 1 WHEN t.status = 'cancelled' THEN 2 ELSE 0 END,
|
||||
{order_sql}
|
||||
"""), params)
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Get filter options
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("tasks.html", {
|
||||
"request": request, "sidebar": sidebar, "items": items,
|
||||
"domains": domains, "projects": projects, "context_types": context_types,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_project_id": project_id or "",
|
||||
"current_status": status or "",
|
||||
"current_priority": priority or "",
|
||||
"current_context": context or "",
|
||||
"current_sort": sort,
|
||||
"page_title": "All Tasks", "active_nav": "tasks",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/create")
|
||||
async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("task_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects, "context_types": context_types,
|
||||
"page_title": "New Task", "active_nav": "tasks",
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_project_id": project_id or "",
|
||||
"prefill_parent_id": parent_id or "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_task(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
parent_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
priority: int = Form(3),
|
||||
status: str = Form("open"),
|
||||
due_date: Optional[str] = Form(None),
|
||||
deadline: Optional[str] = Form(None),
|
||||
context: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
estimated_minutes: Optional[str] = Form(None),
|
||||
energy_required: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id,
|
||||
"description": description, "priority": priority, "status": status,
|
||||
}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
if parent_id and parent_id.strip():
|
||||
data["parent_id"] = parent_id
|
||||
if due_date and due_date.strip():
|
||||
data["due_date"] = due_date
|
||||
if deadline and deadline.strip():
|
||||
data["deadline"] = deadline
|
||||
if context and context.strip():
|
||||
data["context"] = context
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
if estimated_minutes and estimated_minutes.strip():
|
||||
data["estimated_minutes"] = int(estimated_minutes)
|
||||
if energy_required and energy_required.strip():
|
||||
data["energy_required"] = energy_required
|
||||
|
||||
task = await repo.create(data)
|
||||
|
||||
# Redirect back to project if created from project context
|
||||
if data.get("project_id"):
|
||||
return RedirectResponse(url=f"/projects/{data['project_id']}?tab=tasks", status_code=303)
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("tasks", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(task_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
# Domain and project info
|
||||
domain = None
|
||||
if item.get("domain_id"):
|
||||
result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])})
|
||||
row = result.first()
|
||||
domain = dict(row._mapping) if row else None
|
||||
|
||||
project = None
|
||||
if item.get("project_id"):
|
||||
result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])})
|
||||
row = result.first()
|
||||
project = dict(row._mapping) if row else None
|
||||
|
||||
parent = None
|
||||
if item.get("parent_id"):
|
||||
result = await db.execute(text("SELECT id, title FROM tasks WHERE id = :id"), {"id": str(item["parent_id"])})
|
||||
row = result.first()
|
||||
parent = dict(row._mapping) if row else None
|
||||
|
||||
# Subtasks
|
||||
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]
|
||||
|
||||
return templates.TemplateResponse("task_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "project": project, "parent": parent,
|
||||
"subtasks": subtasks,
|
||||
"page_title": item["title"], "active_nav": "tasks",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{task_id}/edit")
|
||||
async def edit_form(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("tasks", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(task_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("task_form.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"domains": domains, "projects": projects, "context_types": context_types,
|
||||
"page_title": f"Edit Task", "active_nav": "tasks",
|
||||
"item": item,
|
||||
"prefill_domain_id": "", "prefill_project_id": "", "prefill_parent_id": "",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{task_id}/edit")
|
||||
async def update_task(
|
||||
task_id: str,
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
parent_id: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
priority: int = Form(3),
|
||||
status: str = Form("open"),
|
||||
due_date: Optional[str] = Form(None),
|
||||
deadline: Optional[str] = Form(None),
|
||||
context: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
estimated_minutes: Optional[str] = Form(None),
|
||||
energy_required: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
data = {
|
||||
"title": title, "domain_id": domain_id,
|
||||
"description": description, "priority": priority, "status": status,
|
||||
"project_id": project_id if project_id and project_id.strip() else None,
|
||||
"parent_id": parent_id if parent_id and parent_id.strip() else None,
|
||||
"due_date": due_date if due_date and due_date.strip() else None,
|
||||
"deadline": deadline if deadline and deadline.strip() else None,
|
||||
"context": context if context and context.strip() else None,
|
||||
}
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
else:
|
||||
data["tags"] = None
|
||||
if estimated_minutes and estimated_minutes.strip():
|
||||
data["estimated_minutes"] = int(estimated_minutes)
|
||||
if energy_required and energy_required.strip():
|
||||
data["energy_required"] = energy_required
|
||||
|
||||
# Handle completion
|
||||
old = await repo.get(task_id)
|
||||
if old and old["status"] != "done" and status == "done":
|
||||
data["completed_at"] = datetime.now(timezone.utc)
|
||||
elif status != "done":
|
||||
data["completed_at"] = None
|
||||
|
||||
await repo.update(task_id, data)
|
||||
return RedirectResponse(url=f"/tasks/{task_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/complete")
|
||||
async def complete_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Quick complete from list view."""
|
||||
repo = BaseRepository("tasks", db)
|
||||
await repo.update(task_id, {
|
||||
"status": "done",
|
||||
"completed_at": datetime.now(timezone.utc),
|
||||
})
|
||||
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/toggle")
|
||||
async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Toggle task done/open from list view."""
|
||||
repo = BaseRepository("tasks", db)
|
||||
task = await repo.get(task_id)
|
||||
if not task:
|
||||
return RedirectResponse(url="/tasks", status_code=303)
|
||||
|
||||
if task["status"] == "done":
|
||||
await repo.update(task_id, {"status": "open", "completed_at": None})
|
||||
else:
|
||||
await repo.update(task_id, {"status": "done", "completed_at": datetime.now(timezone.utc)})
|
||||
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/delete")
|
||||
async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("tasks", db)
|
||||
await repo.soft_delete(task_id)
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
# Quick add from any task list
|
||||
@router.post("/quick-add")
|
||||
async def quick_add(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
domain_id: Optional[str] = Form(None),
|
||||
project_id: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
data = {"title": title, "status": "open", "priority": 3}
|
||||
if domain_id and domain_id.strip():
|
||||
data["domain_id"] = domain_id
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
|
||||
# If no domain, use first domain
|
||||
if "domain_id" not in data:
|
||||
result = await db.execute(text(
|
||||
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
|
||||
))
|
||||
row = result.first()
|
||||
if row:
|
||||
data["domain_id"] = str(row[0])
|
||||
|
||||
await repo.create(data)
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
Reference in New Issue
Block a user