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>
278 lines
11 KiB
Python
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)
|