Task 5.23: Deduplication / Merge — ContactMergeHistory model, dedup service, API routes (duplicates/merge/merge-history), DedupDialog frontend, i18n, 5 tests
This commit is contained in:
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,10 +20,30 @@ from app.schemas.contact import (
|
||||
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),
|
||||
@@ -77,6 +98,18 @@ async def create_contact(
|
||||
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,
|
||||
@@ -188,3 +221,40 @@ async def delete_contact_person(
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user