import asyncio import logging from fastapi import APIRouter, Depends, Query, BackgroundTasks, Body from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from auth.models import TokenPayload from auth.dependencies import require_permission from crm.models import CustomerCreate, CustomerUpdate, CustomerInDB, CustomerListResponse, TransactionEntry from crm import service, nextcloud from config import settings from database.postgres import get_pg_session from shared.audit import log_action router = APIRouter(prefix="/api/crm/customers", tags=["crm-customers"]) logger = logging.getLogger(__name__) # ── Diff helpers ────────────────────────────────────────────────────────────── _SCALAR_FIELDS = { "name", "surname", "title", "organization", "religion", "language", "relationship_status", "nextcloud_folder", } _SKIP_FIELDS = {"updated_at", "firestore_id", "id"} def _scalar_diff(old, new) -> dict: result = {} for f in _SCALAR_FIELDS: ov = getattr(old, f, None) nv = getattr(new, f, None) if ov != nv: result[f] = {"old": ov, "new": nv} return result def _list_diff(field: str, old_list: list, new_list: list, label_fn) -> dict: old_labels = {label_fn(i) for i in (old_list or [])} new_labels = {label_fn(i) for i in (new_list or [])} added = new_labels - old_labels removed = old_labels - new_labels result = {} if added: result[f"{field}.added"] = {"old": None, "new": sorted(added)} if removed: result[f"{field}.removed"] = {"old": sorted(removed), "new": None} return result def _customer_diff(old, new, changed_fields: set) -> dict: changes = _scalar_diff(old, new) # contacts — keyed by type+value string if "contacts" in changed_fields: changes.update(_list_diff( "contacts", old.contacts or [], new.contacts or [], lambda c: f"{c.get('type','?')}:{c.get('value','?')}" if isinstance(c, dict) else f"{getattr(c,'type','?')}:{getattr(c,'value','?')}", )) # location — flatten to individual sub-fields if "location" in changed_fields: old_loc = old.location or {} new_loc = new.location or {} if isinstance(old_loc, object) and not isinstance(old_loc, dict): old_loc = old_loc.model_dump() if hasattr(old_loc, "model_dump") else {} if isinstance(new_loc, object) and not isinstance(new_loc, dict): new_loc = new_loc.model_dump() if hasattr(new_loc, "model_dump") else {} for k in set(old_loc) | set(new_loc): ov, nv = old_loc.get(k), new_loc.get(k) if ov != nv: changes[f"location.{k}"] = {"old": ov, "new": nv} # tags if "tags" in changed_fields: old_tags = set(old.tags or []) new_tags = set(new.tags or []) if old_tags != new_tags: changes["tags"] = {"old": sorted(old_tags), "new": sorted(new_tags)} return changes @router.get("", response_model=CustomerListResponse) async def list_customers( search: Optional[str] = Query(None), tag: Optional[str] = Query(None), sort: Optional[str] = Query(None), _user: TokenPayload = Depends(require_permission("crm", "view")), ): customers = service.list_customers(search=search, tag=tag, sort=sort) if sort == "latest_comm": customers = await service.list_customers_sorted_by_latest_comm(customers) return CustomerListResponse(customers=customers, total=len(customers)) @router.get("/tags", response_model=list[str]) def list_tags( _user: TokenPayload = Depends(require_permission("crm", "view")), ): return service.list_all_tags() @router.get("/{customer_id}", response_model=CustomerInDB) def get_customer( customer_id: str, _user: TokenPayload = Depends(require_permission("crm", "view")), ): return service.get_customer(customer_id) @router.post("", response_model=CustomerInDB, status_code=201) async def create_customer( body: CustomerCreate, background_tasks: BackgroundTasks, _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): customer = service.create_customer(body) if settings.nextcloud_url: background_tasks.add_task(_init_nextcloud_folder, customer) label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer.id await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "customer", customer.id, label) return customer async def _init_nextcloud_folder(customer) -> None: try: nc_path = service.get_customer_nc_path(customer) base = f"customers/{nc_path}" for sub in ("media", "documents", "sent", "received"): await nextcloud.ensure_folder(f"{base}/{sub}") await nextcloud.write_info_file(base, customer.name, customer.id) except Exception as e: logger.warning("Nextcloud folder init failed for customer %s: %s", customer.id, e) @router.put("/{customer_id}", response_model=CustomerInDB) async def update_customer( customer_id: str, body: CustomerUpdate, _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): old = service.get_customer(customer_id) customer = service.update_customer(customer_id, body) label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id changes = _customer_diff(old, customer, body.model_fields_set) await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "customer", customer_id, label, changes=changes or None) return customer @router.delete("/{customer_id}", status_code=204) async def delete_customer( customer_id: str, wipe_comms: bool = Query(False), wipe_files: bool = Query(False), wipe_nextcloud: bool = Query(False), _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): customer = service.delete_customer(customer_id) nc_path = service.get_customer_nc_path(customer) if wipe_comms or wipe_nextcloud: await service.delete_customer_comms(customer_id) if wipe_files or wipe_nextcloud: await service.delete_customer_media_entries(customer_id) if settings.nextcloud_url: folder = f"customers/{nc_path}" if wipe_nextcloud: try: await nextcloud.delete_file(folder) except Exception as e: logger.warning("Could not delete NC folder for customer %s: %s", customer_id, e) elif wipe_files: stale_folder = f"customers/STALE_{nc_path}" try: await nextcloud.rename_folder(folder, stale_folder) except Exception as e: logger.warning("Could not rename NC folder for customer %s: %s", customer_id, e) label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "customer", customer_id, label) @router.get("/{customer_id}/last-comm-direction") async def get_last_comm_direction( customer_id: str, _user: TokenPayload = Depends(require_permission("crm", "view")), ): result = await service.get_last_comm_direction(customer_id) return result # ── Relationship Status ─────────────────────────────────────────────────────── @router.patch("/{customer_id}/relationship-status", response_model=CustomerInDB) async def update_relationship_status( customer_id: str, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), db: AsyncSession = Depends(get_pg_session), ): customer = service.update_relationship_status(customer_id, body.get("status", "")) label = " ".join(filter(None, [customer.name, customer.surname])) or customer.organization or customer_id await log_action(db, _user.sub, _user.name or _user.email, "STATUS_CHANGE", "customer", customer_id, label, meta={"status": body.get("status", "")}) return customer # ── Technical Issues ────────────────────────────────────────────────────────── @router.post("/{customer_id}/technical-issues", response_model=CustomerInDB) def add_technical_issue( customer_id: str, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.add_technical_issue( customer_id, note=body.get("note", ""), opened_by=body.get("opened_by", ""), date=body.get("date"), ) @router.patch("/{customer_id}/technical-issues/{index}/resolve", response_model=CustomerInDB) def resolve_technical_issue( customer_id: str, index: int, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.resolve_technical_issue(customer_id, index, body.get("resolved_by", "")) @router.patch("/{customer_id}/technical-issues/{index}", response_model=CustomerInDB) def edit_technical_issue( customer_id: str, index: int, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.edit_technical_issue(customer_id, index, body.get("note", ""), body.get("opened_date")) @router.delete("/{customer_id}/technical-issues/{index}", response_model=CustomerInDB) def delete_technical_issue( customer_id: str, index: int, _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.delete_technical_issue(customer_id, index) # ── Install Support ─────────────────────────────────────────────────────────── @router.post("/{customer_id}/install-support", response_model=CustomerInDB) def add_install_support( customer_id: str, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.add_install_support( customer_id, note=body.get("note", ""), opened_by=body.get("opened_by", ""), date=body.get("date"), ) @router.patch("/{customer_id}/install-support/{index}/resolve", response_model=CustomerInDB) def resolve_install_support( customer_id: str, index: int, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.resolve_install_support(customer_id, index, body.get("resolved_by", "")) @router.patch("/{customer_id}/install-support/{index}", response_model=CustomerInDB) def edit_install_support( customer_id: str, index: int, body: dict = Body(...), _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.edit_install_support(customer_id, index, body.get("note", ""), body.get("opened_date")) @router.delete("/{customer_id}/install-support/{index}", response_model=CustomerInDB) def delete_install_support( customer_id: str, index: int, _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.delete_install_support(customer_id, index) # ── Transactions ────────────────────────────────────────────────────────────── @router.post("/{customer_id}/transactions", response_model=CustomerInDB) def add_transaction( customer_id: str, body: TransactionEntry, _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.add_transaction(customer_id, body) @router.patch("/{customer_id}/transactions/{index}", response_model=CustomerInDB) def update_transaction( customer_id: str, index: int, body: TransactionEntry, _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.update_transaction(customer_id, index, body) @router.delete("/{customer_id}/transactions/{index}", response_model=CustomerInDB) def delete_transaction( customer_id: str, index: int, _user: TokenPayload = Depends(require_permission("crm", "edit")), ): return service.delete_transaction(customer_id, index)