Files
leocrm/app/routes/contacts.py
T

261 lines
9.1 KiB
Python
Raw Normal View History

"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete."""
from __future__ import annotations
import csv
import io
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.schemas.contact import (
ContactCreate,
ContactUpdate,
ContactPersonCreate,
ContactPersonUpdate,
)
from app.services import contact_service
from app.services import dedup_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
# ── Deduplication / Merge (Task 5.23) ──────────────────────────────────────────
from pydantic import BaseModel, Field
class DuplicateCheckRequest(BaseModel):
"""Request body for duplicate detection."""
threshold: float = Field(default=0.7, ge=0.0, le=1.0)
limit: int = Field(default=50, ge=1, le=200)
class MergeRequest(BaseModel):
"""Request body for merging two contacts."""
source_contact_id: str
target_contact_id: str
field_overrides: dict[str, Any] | None = Field(default=None)
note: str | None = Field(default=None)
@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),
type: str | None = Query(None, pattern="^(company|person)$"),
folder_id: str | None = Query(None),
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, FTS search, type/folder filter, sorting."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await contact_service.list_contacts(
db, tenant_id,
page=page, page_size=page_size, search=search,
contact_type=type, folder_id=folder_id,
sort_by=sort_by, sort_order=sort_order,
resolved_perms=current_user,
)
@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."""
tenant_id = uuid.UUID(current_user["tenant_id"])
csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search)
return StreamingResponse(
io.StringIO(csv_data),
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 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(exclude_none=True)
return await contact_service.create_contact(db, tenant_id, user_id, data)
@router.get("/merge-history")
async def get_contact_merge_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Get paginated merge history for the tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await dedup_service.get_merge_history(db, tenant_id, page=page, page_size=page_size)
@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 contact_persons."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
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}")
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"])
data = body.model_dump(exclude_none=True)
try:
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}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contact(
contact_id: str,
hard: bool = Query(False, description="GDPR hard-delete"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Soft-delete (or hard-delete with ?hard=true) a contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
2026-07-23 08:42:26 +02:00
user_id = uuid.UUID(current_user["user_id"])
try:
if hard:
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
else:
2026-07-23 08:42:26 +02:00
await contact_service.delete_contact(db, tenant_id, contact_id, user_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:
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))
@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))
@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))
# ── Deduplication / Merge endpoints (Task 5.23) ───────────────────────────────
@router.post("/duplicates")
async def find_duplicate_contacts(
body: DuplicateCheckRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""Find potential duplicate contacts within the tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await dedup_service.find_duplicates(
db, tenant_id, threshold=body.threshold, limit=body.limit
)
@router.post("/merge")
async def merge_duplicate_contacts(
body: MergeRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Merge two contacts (source → target)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
return await dedup_service.merge_contacts(
db, tenant_id, user_id,
source_id=body.source_contact_id,
target_id=body.target_contact_id,
field_overrides=body.field_overrides,
note=body.note,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))