Files
leocrm/app/plugins/builtins/contacts/routes.py
T
Agent Zero 26506a5027
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(N3): Backend respektiert X-Workspace-ID bei Listen — contacts/dms/mail/calendar (#367)
- Core-Resolver resolve_workspace_scope(): Zuweisungs-Check, leere Werte fallen weg; Exemptions System-Admin + workspaces:configure_modules (Editor-Deadlock)
- require_workspace_scope(module_key) FastAPI-Dependency (deps.py)
- expand_folder_scope(): Ordner-Subtree (zyklensicher) für ContactFolder + DMS Folder; scope_uuid_set() fail-closed
- contacts: folder_ids-Subtree + contact_types auf GET /contacts, List-Cache bei aktivem Scope deaktiviert (Cache-Leak-Gefahr)
- dms: folder_ids-Subtree + file_types (semantische Matcher) auf /files, Baum-Reduktion auf /folders
- mail: account_ids auf /mails, /threads, /accounts
- calendar: calendar_ids auf /calendar/entries, /calendars
- Frontend-Defaults: getModuleConfig() im workspaceStore, ContactsList default_saved_view_id, Calendar default_view
- Tests: 21/21 neu (TDD rot→grün), Regression 81 passed, Checker 0, tsc clean, Vitest grün, Build OK
2026-09-01 10:27:23 +02:00

518 lines
19 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
import redis.asyncio as aioredis
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.commands.contact_commands import (
CreateContactCommand,
DeleteContactCommand,
MergeContactsCommand,
UpdateContactCommand,
)
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, require_workspace_scope
from app.models.contact import Contact
from app.models.custom_field_definition import CustomFieldDefinition
from app.plugins.registry import get_registry
from app.schemas.contact import (
ContactCreate,
ContactPersonCreate,
ContactPersonUpdate,
ContactUpdate,
)
from app.services import contact_service, dedup_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
# ── Deduplication / Merge (Task 5.23) ──────────────────────────────────────────
from pydantic import BaseModel, Field # noqa: E402
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")),
workspace_scope: dict | None = Depends(require_workspace_scope("contacts")),
):
"""List contacts with pagination, FTS search, type/folder filter, sorting.
Supports keyset pagination via ``cursor`` parameter for large datasets.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (folder subtree + contact types) — never a grant.
"""
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,
workspace_scope=workspace_scope,
)
@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 (W4c: via ContactsContract, export_service.py removed)."""
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)
from app.plugins.builtins.contacts.contracts import ContactsContract
headers, rows = await ContactsContract.ie_fetch_rows(
db, tenant_id, "contacts",
user_id=user_id, is_system_admin=is_admin,
contact_type=type, search=search,
)
from app.services.import_export_helpers import write_csv
csv_data = write_csv(rows, headers).decode("utf-8")
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)) from e
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from 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)) from 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)) from 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)) from 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") from None
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
# ─── Custom Fields (W4c: migrated from app/routes/custom_fields.py) ─────────
class CustomFieldUpdateRequest(BaseModel):
"""Request body for updating custom field values."""
values: dict[str, Any] = {}
async def _collect_custom_field_definitions(
db: AsyncSession,
tenant_id: uuid.UUID,
entity: str = "contact",
) -> list[dict[str, Any]]:
"""Collect all custom field definitions from plugin manifests and DB.
DB-stored definitions override plugin definitions with the same name.
"""
definitions: list[dict[str, Any]] = []
seen_names: set[str] = set()
# 1. Collect from active plugin manifests
registry = get_registry()
for name in registry.list_discovered():
plugin = registry.get_plugin(name)
if plugin is None:
continue
manifest = plugin.manifest
for cf in manifest.custom_fields:
if cf.entity != entity:
continue
if cf.name in seen_names:
continue
seen_names.add(cf.name)
definitions.append(
{
"name": cf.name,
"label": cf.label,
"label_key": cf.label_key,
"field_type": cf.field_type,
"options": cf.options,
"default_value": cf.default_value,
"required": cf.required,
"entity": cf.entity,
"plugin": manifest.name,
}
)
# 2. Collect from DB (user-defined custom field definitions)
stmt = select(CustomFieldDefinition).where(
CustomFieldDefinition.tenant_id == tenant_id,
CustomFieldDefinition.entity == entity,
CustomFieldDefinition.is_active == True, # noqa: E712
).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
result = await db.execute(stmt)
db_definitions = result.scalars().all()
for d in db_definitions:
if d.name in seen_names:
# DB definition overrides plugin definition — replace it
definitions = [x for x in definitions if x["name"] != d.name]
else:
seen_names.add(d.name)
definitions.append(
{
"name": d.name,
"label": d.label,
"label_key": "",
"field_type": d.field_type,
"options": d.options or [],
"default_value": d.default_value,
"required": d.required,
"entity": d.entity,
"plugin": "user_defined",
}
)
return definitions
async def _merge_definitions_with_values(
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""Merge field definitions with stored values, applying defaults."""
stored = stored or {}
result: list[dict[str, Any]] = []
for d in definitions:
name = d["name"]
value = stored.get(name, d.get("default_value"))
entry = {**d, "value": value}
result.append(entry)
return result
@router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))])
async def get_custom_fields(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all custom fields for a contact (merged definitions + stored values)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
@router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))])
async def update_custom_fields(
contact_id: str,
body: CustomFieldUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update custom field values for a contact (stored in contacts.custom JSONB)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
# Validate against definitions
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
def_map = {d["name"]: d for d in definitions}
current_custom = dict(contact.custom or {})
for name, value in body.values.items():
if name not in def_map:
raise HTTPException(
400,
detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"},
)
field_def = def_map[name]
# Validate required
if field_def["required"] and (value is None or value == ""):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' is required", "code": "required_field"},
)
# Validate select/multiselect options
if field_def["field_type"] == "select" and value is not None:
if value not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"},
)
if field_def["field_type"] == "multiselect" and value is not None:
if not isinstance(value, list):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"},
)
for v in value:
if v not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"},
)
current_custom[name] = value
contact.custom = current_custom
await db.flush()
merged = await _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}