Files
lifeos-dev/routers/contacts.py
Michael c7a07ed280 feat: return-to-project redirects from create/edit forms
When creating or editing items from a project detail tab, users now
return to that project's tab instead of the entity's own page.
Edit links pass from_project param; forms include hidden field.
Reassigning to a different project redirects to the new project.
Decisions/meetings create from project context inserts junction rows.
File uploads from project context redirect back to project files tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:28:15 +00:00

278 lines
11 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
from routers.weblinks import get_default_folder_id
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})
# Process inline new link creation
new_labels = form_data.getlist("new_link_labels")
new_urls = form_data.getlist("new_link_urls")
new_roles = form_data.getlist("new_link_roles")
link_repo = BaseRepository("links", db)
default_fid = await get_default_folder_id(db) if new_labels else None
for i, label in enumerate(new_labels):
url = new_urls[i] if i < len(new_urls) else ""
if label and label.strip() and url and url.strip():
new_link = await link_repo.create({"label": label.strip(), "url": url.strip()})
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": new_link["id"]})
nr = new_roles[i] if i < len(new_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": new_link["id"], "role": nr if nr and nr.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, from_project: Optional[str] = None, 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,
"from_project": from_project or "",
})
@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),
from_project: 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})
# Process inline new link creation
new_labels = form_data.getlist("new_link_labels")
new_urls = form_data.getlist("new_link_urls")
new_roles = form_data.getlist("new_link_roles")
link_repo = BaseRepository("links", db)
default_fid = await get_default_folder_id(db) if new_labels else None
for i, label in enumerate(new_labels):
url = new_urls[i] if i < len(new_urls) else ""
if label and label.strip() and url and url.strip():
new_link = await link_repo.create({"label": label.strip(), "url": url.strip()})
await db.execute(text("""
INSERT INTO folder_links (folder_id, link_id)
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
"""), {"fid": default_fid, "lid": new_link["id"]})
nr = new_roles[i] if i < len(new_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": new_link["id"], "role": nr if nr and nr.strip() else None})
await db.commit()
if from_project and from_project.strip():
return RedirectResponse(url=f"/projects/{from_project}?tab=contacts", status_code=303)
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)