Files
leocrm/app/routes/contacts.py
T

237 lines
7.8 KiB
Python

"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export."""
from __future__ import annotations
import csv
import io
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.models.contact import Contact
from app.schemas.contact import ContactCreate, ContactUpdate
from app.services import contact_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
@router.get("")
async def list_contacts(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str | None = Query(None),
sort_by: str = Query("last_name"),
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""List contacts with pagination and optional search.
page_size is capped at 100 — values >100 return 422.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await contact_service.list_contacts(
db,
tenant_id,
page=page,
page_size=page_size,
search=search,
sort_by=sort_by,
sort_order=sort_order,
resolved_perms=current_user,
)
return result
@router.get("/export")
async def export_contacts(
format: str = Query("csv", pattern="^(csv)$"),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Stream contacts as CSV (not buffered — uses StreamingResponse).
For large exports (>1000 records), an ARQ background job should be started
with a notification on completion. This endpoint streams directly for
immediate download.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
from app.core.db import get_session_factory
async def generate_csv():
"""Async generator yielding CSV rows one at a time (streaming).
Creates its own DB session to avoid using the request-scoped session
which gets closed after the endpoint function returns.
"""
output = io.StringIO()
writer = csv.writer(output)
# Write header row
header = [
"id",
"first_name",
"last_name",
"email",
"phone",
"mobile",
"position",
"department",
"linkedin_url",
"notes",
"created_at",
"updated_at",
]
writer.writerow(header)
yield output.getvalue()
output.seek(0)
output.truncate(0)
# Stream rows in batches to avoid loading all into memory
batch_size = 500
offset = 0
factory = get_session_factory()
async with factory() as session:
while True:
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
base = base.where(
(Contact.first_name.ilike(pattern))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
)
base = base.order_by(Contact.last_name.asc()).offset(offset).limit(batch_size)
result = await session.execute(base)
contacts = result.scalars().all()
if not contacts:
break
for c in contacts:
writer.writerow(
[
str(c.id),
c.first_name,
c.last_name,
c.email or "",
c.phone or "",
c.mobile or "",
c.position or "",
c.department or "",
c.linkedin_url or "",
c.notes or "",
c.created_at.isoformat() if c.created_at else "",
c.updated_at.isoformat() if c.updated_at else "",
]
)
yield output.getvalue()
output.seek(0)
output.truncate(0)
offset += batch_size
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_contact(
body: ContactCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Create a contact. Optionally link to companies via company_ids array."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump()
return await contact_service.create_contact(db, tenant_id, user_id, data)
@router.get("/{contact_id}")
async def get_contact(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Get a single contact with companies array. Cross-tenant returns 404."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = await contact_service.get_contact_detail(db, tenant_id, cid, resolved_perms=current_user)
if data is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
return data
@router.put("/{contact_id}")
async def update_contact(
contact_id: str,
body: ContactUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Update a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
data = body.model_dump(exclude_unset=True)
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
if result is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
return result
@router.delete("/{contact_id}")
async def delete_contact(
contact_id: str,
gdpr: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Delete a contact. Default: soft-delete. Use gdpr=true for hard-delete with deletion_log."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
if gdpr:
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
else:
deleted = await contact_service.soft_delete_contact(db, tenant_id, user_id, cid)
if not deleted:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
return Response(status_code=status.HTTP_204_NO_CONTENT)