Files
lifeos-dev/routers/contacts.py
Michael e334e0e9db feat: add links section to contact create/edit form
Links can now be attached to contacts directly from the create and edit
forms with dynamic add/remove rows and role suggestions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:32:13 +00:00

231 lines
8.4 KiB
Python

"""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)
result = await db.execute(text(
"SELECT id, label, url FROM links WHERE is_deleted = false ORDER BY label"
))
all_links = [dict(r._mapping) for r in result]
return templates.TemplateResponse("contact_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "New Contact", "active_nav": "contacts",
"item": None, "all_links": all_links,
})
@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()]
contact = await repo.create(data)
# Process link attachments from form
form_data = await request.form()
link_ids = form_data.getlist("link_ids")
link_roles = form_data.getlist("link_roles")
for i, lid in enumerate(link_ids):
if lid and lid.strip():
lr = link_roles[i] if i < len(link_roles) else None
await db.execute(text("""
INSERT INTO contact_links (contact_id, link_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact["id"], "lid": lid, "role": lr if lr and lr.strip() else None})
await db.commit()
return RedirectResponse(url=f"/contacts/{contact['id']}", 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)
# Linked links
result = await db.execute(text("""
SELECT l.*, cl.role, cl.created_at as linked_at
FROM links l
JOIN contact_links cl ON cl.link_id = l.id
WHERE cl.contact_id = :cid AND l.is_deleted = false
ORDER BY l.label
"""), {"cid": contact_id})
links = [dict(r._mapping) for r in result]
# All links for add dropdown
result = await db.execute(text("""
SELECT id, label, url FROM links
WHERE is_deleted = false ORDER BY label
"""))
all_links = [dict(r._mapping) for r in result]
return templates.TemplateResponse("contact_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"links": links, "all_links": all_links,
"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)
result = await db.execute(text(
"SELECT id, label, url FROM links WHERE is_deleted = false ORDER BY label"
))
all_links = [dict(r._mapping) for r in result]
# Existing linked links
result = await db.execute(text("""
SELECT l.id, l.label, l.url, cl.role
FROM links l JOIN contact_links cl ON cl.link_id = l.id
WHERE cl.contact_id = :cid AND l.is_deleted = false
"""), {"cid": contact_id})
linked_links = [dict(r._mapping) for r in result]
return templates.TemplateResponse("contact_form.html", {
"request": request, "sidebar": sidebar,
"page_title": "Edit Contact", "active_nav": "contacts",
"item": item, "all_links": all_links, "linked_links": linked_links,
})
@router.post("/{contact_id}/edit")
async def update_contact(
contact_id: str,
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()]
else:
data["tags"] = None
await repo.update(contact_id, data)
# Sync link attachments
form_data = await request.form()
link_ids = form_data.getlist("link_ids")
link_roles = form_data.getlist("link_roles")
# Clear existing and re-insert
await db.execute(text("DELETE FROM contact_links WHERE contact_id = :cid"), {"cid": contact_id})
for i, lid in enumerate(link_ids):
if lid and lid.strip():
lr = link_roles[i] if i < len(link_roles) else None
await db.execute(text("""
INSERT INTO contact_links (contact_id, link_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": lid, "role": lr if lr and lr.strip() else None})
await db.commit()
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)
# ---- Link linking ----
@router.post("/{contact_id}/links/add")
async def add_link(
contact_id: str,
link_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_links (contact_id, link_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": link_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
@router.post("/{contact_id}/links/{link_id}/remove")
async def remove_link(
contact_id: str, link_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_links WHERE contact_id = :cid AND link_id = :lid"
), {"cid": contact_id, "lid": link_id})
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
@router.post("/reorder")
async def reorder_contact(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("contacts", db)
await repo.move_in_order(item_id, direction)
return RedirectResponse(url=request.headers.get("referer", "/contacts"), status_code=303)