Files
leocrm/app/routes/contacts.py
T
Agent Zero 0eb6d7621e
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(security): 16 mittlere Probleme behoben (P18-P33)
P18: require_permission zu forgejo_error_reporter und ai_ui_control routes hinzugefügt
P19: Cross-Tenant Permission-Cache-Invalidierung bei Rollenänderungen
P20: Session/Permission-Cache-Invalidierung bei Gruppen-Änderungen
P21: ENTITY_MODELS Registry um fehlende Plugin-Modelle erweitert
P22: Entity-Links prüfen verknüpfte Entity-Permissions
P23: authStore persist Middleware entfernt (kein localStorage mehr)
P24: 5xx Retry nur noch für GET-Requests
P25: KI-Kommentar in address.py (bekannte Inkonsistenz)
P26: DeletionLog in EntityHistory gemerged (action=delete)
P27: KI-Kommentar in entity_policy.py (ABAC nicht aktiv genutzt)
P28: db.commit() aus bulk_permission_service entfernt
P29: CSV-Export in export_service.py ausgelagert
P30: plugins.py Business-Logik in plugin_install_service.py ausgelagert
P31: KI-Kommentar in session.py (Dual-System dokumentiert)
P32: Migration 0115: crm_platform_admin Role droppen
P33: Cross-Plugin Imports über contracts.py behoben (10 Violations → 0)
2026-08-06 13:23:58 +02:00

320 lines
12 KiB
Python

"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete.
Write operations (create, update, delete, merge) are delegated to Commands.
Read operations (list, get, export, contact persons) use services directly.
"""
from __future__ import annotations
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
import redis.asyncio as aioredis
from app.commands.contact_commands import (
CreateContactCommand,
UpdateContactCommand,
DeleteContactCommand,
MergeContactsCommand,
)
from app.core.db import get_db
from app.core.visibility import check_single_entity_access
from app.deps import get_current_user, get_redis_dep, 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)$"),
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
):
"""List contacts with pagination, FTS search, type/folder filter, sorting.
Supports keyset pagination via ``cursor`` parameter for large datasets.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
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,
user_id=user_id,
is_system_admin=is_admin,
cursor=cursor,
)
@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"])
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
csv_data = await export_service.export_contacts_csv(
db, tenant_id, contact_type=type, search=search,
user_id=user_id, is_system_admin=is_admin,
)
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),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Create a new contact (company or person) via CreateContactCommand."""
data = body.model_dump(exclude_none=True)
cmd = CreateContactCommand(data=data)
result = await cmd.execute(db, redis, current_user)
if not result.success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result.error)
return result.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"])
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
try:
return await contact_service.get_contact(db, tenant_id, contact_id, user_id=user_id, is_system_admin=is_admin)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
@router.put("/{contact_id}")
async def update_contact(
contact_id: str,
body: ContactUpdate,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Update a contact via UpdateContactCommand."""
data = body.model_dump(exclude_none=True)
cmd = UpdateContactCommand(contact_id=contact_id, data=data)
result = await cmd.execute(db, redis, current_user)
if not result.success:
if "not found" in (result.error or "").lower():
raise HTTPException(status_code=404, detail=result.error)
if "Invalid state transition" in (result.error or ""):
raise HTTPException(status_code=422, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
return result.data
@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),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:delete")),
):
"""Soft-delete (or hard-delete with ?hard=true) a contact via DeleteContactCommand."""
cmd = DeleteContactCommand(contact_id=contact_id, hard=hard)
result = await cmd.execute(db, redis, current_user)
if not result.success:
raise HTTPException(status_code=404, detail=result.error)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ── 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"])
items = await contact_service.list_contact_persons(db, tenant_id, contact_id)
return {"items": items, "total": len(items)}
@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:delete")),
):
"""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),
redis: aioredis.Redis = Depends(get_redis_dep),
current_user: dict = Depends(require_permission("contacts:write")),
):
"""Merge two contacts (source → target) via MergeContactsCommand."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False)
# Check write access on both contacts
try:
source_uuid = uuid.UUID(body.source_contact_id)
target_uuid = uuid.UUID(body.target_contact_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid contact ID")
source_access = await check_single_entity_access(
db, "contact", source_uuid, user_id, tenant_id,
required_level="write", is_system_admin=is_admin,
)
if not source_access:
raise HTTPException(status_code=403, detail="No write access to source contact")
target_access = await check_single_entity_access(
db, "contact", target_uuid, user_id, tenant_id,
required_level="write", is_system_admin=is_admin,
)
if not target_access:
raise HTTPException(status_code=403, detail="No write access to target contact")
cmd = MergeContactsCommand(
source_contact_id=body.source_contact_id,
target_contact_id=body.target_contact_id,
field_overrides=body.field_overrides,
note=body.note,
)
result = await cmd.execute(db, redis, current_user)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return result.data