fix: Bugs created after the overhaul, performance and layout fixes

This commit is contained in:
2026-03-08 22:30:56 +02:00
parent 8c15c932b6
commit 6f9fd5cba3
112 changed files with 5771 additions and 970 deletions

View File

@@ -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()