update: Add Global Search on Header, Add Global Audit log for all actions.
This commit is contained in:
@@ -2,16 +2,86 @@ 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(
|
||||
@@ -46,10 +116,14 @@ 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
|
||||
|
||||
|
||||
@@ -65,12 +139,19 @@ async def _init_nextcloud_folder(customer) -> None:
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerInDB)
|
||||
def update_customer(
|
||||
async def update_customer(
|
||||
customer_id: str,
|
||||
body: CustomerUpdate,
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_customer(customer_id, body)
|
||||
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)
|
||||
@@ -80,6 +161,7 @@ async def delete_customer(
|
||||
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)
|
||||
@@ -104,6 +186,10 @@ async def delete_customer(
|
||||
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(
|
||||
@@ -117,12 +203,17 @@ async def get_last_comm_direction(
|
||||
# ── Relationship Status ───────────────────────────────────────────────────────
|
||||
|
||||
@router.patch("/{customer_id}/relationship-status", response_model=CustomerInDB)
|
||||
def update_relationship_status(
|
||||
async def update_relationship_status(
|
||||
customer_id: str,
|
||||
body: dict = Body(...),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
return service.update_relationship_status(customer_id, body.get("status", ""))
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user