Unified contacts model: Rentman-style type field (company/person), inline addresses, ContactPerson 1:N, all Rentman fields except rental-specific

This commit is contained in:
Agent Zero
2026-07-19 21:12:49 +02:00
parent d80feb2a3a
commit cf75680583
14 changed files with 1411 additions and 869 deletions
+96 -145
View File
@@ -1,4 +1,4 @@
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export."""
"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete."""
from __future__ import annotations
@@ -8,13 +8,16 @@ 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.schemas.contact import (
ContactCreate,
ContactUpdate,
ContactPersonCreate,
ContactPersonUpdate,
)
from app.services import contact_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
@@ -25,120 +28,35 @@ 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"),
type: str | None = Query(None, pattern="^(company|person)$"),
sort_by: str = Query("displayname"),
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.
"""
"""List contacts with pagination, FTS search, type filter, sorting."""
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,
return await contact_service.list_contacts(
db, tenant_id,
page=page, page_size=page_size, search=search,
contact_type=type, 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)$"),
type: str | None = Query(None, pattern="^(company|person)$"),
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.
"""
"""Stream contacts as CSV."""
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
csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search)
return StreamingResponse(
generate_csv(),
io.StringIO(csv_data),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
)
@@ -150,12 +68,10 @@ async def create_contact(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Create a contact. Optionally link to companies via company_ids array."""
"""Create a new contact (company or person)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump()
data = body.model_dump(exclude_none=True)
return await contact_service.create_contact(db, tenant_id, user_id, data)
@@ -165,19 +81,12 @@ async def get_contact(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Get a single contact with companies array. Cross-tenant returns 404."""
"""Get a single contact with contact_persons."""
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
return await contact_service.get_contact(db, tenant_id, contact_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{contact_id}")
@@ -190,47 +99,89 @@ async def update_contact(
"""Update a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
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
return await contact_service.update_contact(db, tenant_id, user_id, contact_id, data)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{contact_id}")
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact(
contact_id: str,
gdpr: bool = Query(False),
hard: bool = Query(False, description="GDPR hard-delete"),
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."""
"""Soft-delete (or hard-delete with ?hard=true) a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
if hard:
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
else:
await contact_service.delete_contact(db, tenant_id, contact_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# ── ContactPersons ──
@router.get("/{contact_id}/persons")
async def list_contact_persons(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""List all contact persons for a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await contact_service.list_contact_persons(db, tenant_id, contact_id)
@router.post("/{contact_id}/persons", status_code=status.HTTP_201_CREATED)
async def create_contact_person(
contact_id: str,
body: ContactPersonCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Add a contact person to a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
try:
cid = uuid.UUID(contact_id)
except ValueError:
raise HTTPException(
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
) from None
return await contact_service.create_contact_person(db, tenant_id, user_id, contact_id, data)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
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"})
@router.put("/{contact_id}/persons/{person_id}")
async def update_contact_person(
contact_id: str,
person_id: str,
body: ContactPersonUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Update a contact person."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
data = body.model_dump(exclude_none=True)
try:
return await contact_service.update_contact_person(db, tenant_id, user_id, contact_id, person_id, data)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{contact_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact_person(
contact_id: str,
person_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Delete a contact person."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
await contact_service.delete_contact_person(db, tenant_id, contact_id, person_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))