fix: Bugs created after the overhaul, performance and layout fixes
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -6,7 +7,7 @@ from fastapi import HTTPException
|
||||
from shared.firebase import get_db
|
||||
from shared.exceptions import NotFoundError
|
||||
import re as _re
|
||||
from mqtt import database as mqtt_db
|
||||
import database as mqtt_db
|
||||
from crm.models import (
|
||||
ProductCreate, ProductUpdate, ProductInDB,
|
||||
CustomerCreate, CustomerUpdate, CustomerInDB,
|
||||
@@ -20,6 +21,11 @@ COLLECTION = "crm_products"
|
||||
|
||||
def _doc_to_product(doc) -> ProductInDB:
|
||||
data = doc.to_dict()
|
||||
# Backfill bilingual fields for existing products that predate the feature
|
||||
if not data.get("name_en") and data.get("name"):
|
||||
data["name_en"] = data["name"]
|
||||
if not data.get("name_gr") and data.get("name"):
|
||||
data["name_gr"] = data["name"]
|
||||
return ProductInDB(id=doc.id, **data)
|
||||
|
||||
|
||||
@@ -128,6 +134,7 @@ def _doc_to_customer(doc) -> CustomerInDB:
|
||||
def list_customers(
|
||||
search: str | None = None,
|
||||
tag: str | None = None,
|
||||
sort: str | None = None,
|
||||
) -> list[CustomerInDB]:
|
||||
db = get_db()
|
||||
query = db.collection(CUSTOMERS_COLLECTION)
|
||||
@@ -141,28 +148,64 @@ def list_customers(
|
||||
|
||||
if search:
|
||||
s = search.lower()
|
||||
s_nospace = s.replace(" ", "")
|
||||
name_match = s in (customer.name or "").lower()
|
||||
surname_match = s in (customer.surname or "").lower()
|
||||
org_match = s in (customer.organization or "").lower()
|
||||
religion_match = s in (customer.religion or "").lower()
|
||||
language_match = s in (customer.language or "").lower()
|
||||
contact_match = any(
|
||||
s in (c.value or "").lower()
|
||||
s_nospace in (c.value or "").lower().replace(" ", "")
|
||||
or s in (c.value or "").lower()
|
||||
for c in (customer.contacts or [])
|
||||
)
|
||||
loc = customer.location or {}
|
||||
loc_match = (
|
||||
s in (loc.get("city", "") or "").lower() or
|
||||
s in (loc.get("country", "") or "").lower() or
|
||||
s in (loc.get("region", "") or "").lower()
|
||||
loc = customer.location
|
||||
loc_match = bool(loc) and (
|
||||
s in (loc.address or "").lower() or
|
||||
s in (loc.city or "").lower() or
|
||||
s in (loc.postal_code or "").lower() or
|
||||
s in (loc.region or "").lower() or
|
||||
s in (loc.country or "").lower()
|
||||
)
|
||||
tag_match = any(s in (t or "").lower() for t in (customer.tags or []))
|
||||
if not (name_match or surname_match or org_match or contact_match or loc_match or tag_match):
|
||||
if not (name_match or surname_match or org_match or religion_match or language_match or contact_match or loc_match or tag_match):
|
||||
continue
|
||||
|
||||
results.append(customer)
|
||||
|
||||
# Sorting (non-latest_comm; latest_comm is handled by the async router wrapper)
|
||||
_TITLES = {"fr.", "rev.", "archim.", "bp.", "abp.", "met.", "mr.", "mrs.", "ms.", "dr.", "prof."}
|
||||
|
||||
def _sort_name(c):
|
||||
return (c.name or "").lower()
|
||||
|
||||
def _sort_surname(c):
|
||||
return (c.surname or "").lower()
|
||||
|
||||
def _sort_default(c):
|
||||
return c.created_at or ""
|
||||
|
||||
if sort == "name":
|
||||
results.sort(key=_sort_name)
|
||||
elif sort == "surname":
|
||||
results.sort(key=_sort_surname)
|
||||
elif sort == "default":
|
||||
results.sort(key=_sort_default)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_all_tags() -> list[str]:
|
||||
db = get_db()
|
||||
tags: set[str] = set()
|
||||
for doc in db.collection(CUSTOMERS_COLLECTION).select(["tags"]).stream():
|
||||
data = doc.to_dict()
|
||||
for tag in (data.get("tags") or []):
|
||||
if tag:
|
||||
tags.add(tag)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def get_customer(customer_id: str) -> CustomerInDB:
|
||||
db = get_db()
|
||||
doc = db.collection(CUSTOMERS_COLLECTION).document(customer_id).get()
|
||||
@@ -206,6 +249,7 @@ def create_customer(data: CustomerCreate) -> CustomerInDB:
|
||||
|
||||
|
||||
def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
||||
from google.cloud.firestore_v1 import DELETE_FIELD
|
||||
db = get_db()
|
||||
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||
doc = doc_ref.get()
|
||||
@@ -215,18 +259,110 @@ def update_customer(customer_id: str, data: CustomerUpdate) -> CustomerInDB:
|
||||
update_data = data.model_dump(exclude_none=True)
|
||||
update_data["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Fields that should be explicitly deleted from Firestore when set to None
|
||||
# (exclude_none=True would just skip them, leaving the old value intact)
|
||||
NULLABLE_FIELDS = {"title", "surname", "organization", "religion"}
|
||||
set_fields = data.model_fields_set
|
||||
for field in NULLABLE_FIELDS:
|
||||
if field in set_fields and getattr(data, field) is None:
|
||||
update_data[field] = DELETE_FIELD
|
||||
|
||||
doc_ref.update(update_data)
|
||||
updated_doc = doc_ref.get()
|
||||
return _doc_to_customer(updated_doc)
|
||||
|
||||
|
||||
def delete_customer(customer_id: str) -> None:
|
||||
async def toggle_negotiating(customer_id: str) -> CustomerInDB:
|
||||
db_fs = get_db()
|
||||
doc_ref = db_fs.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Customer")
|
||||
current = doc.to_dict().get("negotiating", False)
|
||||
update_data = {"negotiating": not current, "updated_at": datetime.utcnow().isoformat()}
|
||||
doc_ref.update(update_data)
|
||||
return _doc_to_customer(doc_ref.get())
|
||||
|
||||
|
||||
async def toggle_problem(customer_id: str) -> CustomerInDB:
|
||||
db_fs = get_db()
|
||||
doc_ref = db_fs.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Customer")
|
||||
current = doc.to_dict().get("has_problem", False)
|
||||
update_data = {"has_problem": not current, "updated_at": datetime.utcnow().isoformat()}
|
||||
doc_ref.update(update_data)
|
||||
return _doc_to_customer(doc_ref.get())
|
||||
|
||||
|
||||
async def get_last_comm_direction(customer_id: str) -> str | None:
|
||||
"""Return 'inbound' or 'outbound' of the most recent comm for this customer, or None."""
|
||||
db = await mqtt_db.get_db()
|
||||
rows = await db.execute_fetchall(
|
||||
"SELECT direction FROM crm_comms_log WHERE customer_id = ? "
|
||||
"AND direction IN ('inbound', 'outbound') "
|
||||
"ORDER BY COALESCE(occurred_at, created_at) DESC, created_at DESC LIMIT 1",
|
||||
(customer_id,),
|
||||
)
|
||||
if rows:
|
||||
return rows[0][0]
|
||||
return None
|
||||
|
||||
|
||||
async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
||||
"""Return the ISO timestamp of the most recent comm for this customer, or None."""
|
||||
db = await mqtt_db.get_db()
|
||||
rows = await db.execute_fetchall(
|
||||
"SELECT COALESCE(occurred_at, created_at) as ts FROM crm_comms_log "
|
||||
"WHERE customer_id = ? ORDER BY ts DESC LIMIT 1",
|
||||
(customer_id,),
|
||||
)
|
||||
if rows:
|
||||
return rows[0][0]
|
||||
return None
|
||||
|
||||
|
||||
async def list_customers_sorted_by_latest_comm(customers: list[CustomerInDB]) -> list[CustomerInDB]:
|
||||
"""Re-sort a list of customers so those with the most recent comm come first."""
|
||||
timestamps = await asyncio.gather(
|
||||
*[get_last_comm_timestamp(c.id) for c in customers]
|
||||
)
|
||||
paired = list(zip(customers, timestamps))
|
||||
paired.sort(key=lambda x: x[1] or "", reverse=True)
|
||||
return [c for c, _ in paired]
|
||||
|
||||
|
||||
def delete_customer(customer_id: str) -> CustomerInDB:
|
||||
"""Delete customer from Firestore. Returns the customer data (for NC path lookup)."""
|
||||
db = get_db()
|
||||
doc_ref = db.collection(CUSTOMERS_COLLECTION).document(customer_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Customer")
|
||||
customer = _doc_to_customer(doc)
|
||||
doc_ref.delete()
|
||||
return customer
|
||||
|
||||
|
||||
async def delete_customer_comms(customer_id: str) -> int:
|
||||
"""Delete all comm log entries for a customer. Returns count deleted."""
|
||||
db = await mqtt_db.get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM crm_comms_log WHERE customer_id = ?", (customer_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
async def delete_customer_media_entries(customer_id: str) -> int:
|
||||
"""Delete all media DB entries for a customer. Returns count deleted."""
|
||||
db = await mqtt_db.get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM crm_media WHERE customer_id = ?", (customer_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
# ── Orders ───────────────────────────────────────────────────────────────────
|
||||
@@ -594,11 +730,11 @@ async def create_media(data: MediaCreate) -> MediaInDB:
|
||||
await db.execute(
|
||||
"""INSERT INTO crm_media
|
||||
(id, customer_id, order_id, filename, nextcloud_path, mime_type,
|
||||
direction, tags, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
direction, tags, uploaded_by, thumbnail_path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(media_id, data.customer_id, data.order_id, data.filename,
|
||||
data.nextcloud_path, data.mime_type, direction,
|
||||
tags_json, data.uploaded_by, now),
|
||||
tags_json, data.uploaded_by, data.thumbnail_path, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user