update: overhauled firmware ui. Added public flash page.
This commit is contained in:
@@ -55,6 +55,13 @@ class MfgStatus(str, Enum):
|
||||
decommissioned = "decommissioned"
|
||||
|
||||
|
||||
class LifecycleEntry(BaseModel):
|
||||
status_id: str
|
||||
date: str # ISO 8601 UTC string
|
||||
note: Optional[str] = None
|
||||
set_by: Optional[str] = None
|
||||
|
||||
|
||||
class BatchCreate(BaseModel):
|
||||
board_type: BoardType
|
||||
board_version: str = Field(
|
||||
@@ -84,6 +91,9 @@ class DeviceInventoryItem(BaseModel):
|
||||
owner: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
device_name: Optional[str] = None
|
||||
lifecycle_history: Optional[List["LifecycleEntry"]] = None
|
||||
customer_id: Optional[str] = None
|
||||
user_list: Optional[List[str]] = None
|
||||
|
||||
|
||||
class DeviceInventoryListResponse(BaseModel):
|
||||
@@ -94,11 +104,19 @@ class DeviceInventoryListResponse(BaseModel):
|
||||
class DeviceStatusUpdate(BaseModel):
|
||||
status: MfgStatus
|
||||
note: Optional[str] = None
|
||||
force_claimed: bool = False
|
||||
|
||||
|
||||
class DeviceAssign(BaseModel):
|
||||
customer_email: str
|
||||
customer_name: Optional[str] = None
|
||||
customer_id: str
|
||||
|
||||
|
||||
class CustomerSearchResult(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
email: str = ""
|
||||
organization: str = ""
|
||||
phone: str = ""
|
||||
|
||||
|
||||
class RecentActivityItem(BaseModel):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
@@ -14,9 +15,23 @@ from manufacturing.models import (
|
||||
from manufacturing import service
|
||||
from manufacturing import audit
|
||||
from shared.exceptions import NotFoundError
|
||||
from shared.firebase import get_db as get_firestore
|
||||
|
||||
|
||||
class LifecycleEntryPatch(BaseModel):
|
||||
index: int
|
||||
date: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
class LifecycleEntryCreate(BaseModel):
|
||||
status_id: str
|
||||
date: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"}
|
||||
VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"}
|
||||
# Bespoke UIDs are dynamic — we allow any non-empty slug that doesn't clash with
|
||||
# a standard hw_type name. The flash-asset upload endpoint checks this below.
|
||||
|
||||
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
||||
|
||||
@@ -83,13 +98,75 @@ def get_device(
|
||||
return service.get_device_by_sn(sn)
|
||||
|
||||
|
||||
@router.get("/customers/search")
|
||||
def search_customers(
|
||||
q: str = Query(""),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
"""Search CRM customers by name, email, phone, organization, or tags."""
|
||||
results = service.search_customers(q)
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}")
|
||||
def get_customer(
|
||||
customer_id: str,
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
"""Get a single CRM customer by ID."""
|
||||
db = get_firestore()
|
||||
doc = db.collection("crm_customers").document(customer_id).get()
|
||||
if not doc.exists:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
data = doc.to_dict() or {}
|
||||
loc = data.get("location") or {}
|
||||
city = loc.get("city") if isinstance(loc, dict) else None
|
||||
return {
|
||||
"id": doc.id,
|
||||
"name": data.get("name") or "",
|
||||
"surname": data.get("surname") or "",
|
||||
"email": data.get("email") or "",
|
||||
"organization": data.get("organization") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"city": city or "",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
|
||||
async def update_status(
|
||||
sn: str,
|
||||
body: DeviceStatusUpdate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
result = service.update_device_status(sn, body)
|
||||
# Guard: claimed requires at least one user in user_list
|
||||
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
|
||||
if body.status.value == "claimed":
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if docs:
|
||||
data = docs[0].to_dict() or {}
|
||||
user_list = data.get("user_list", []) or []
|
||||
if not user_list and not getattr(body, "force_claimed", False):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot set status to 'claimed': device has no users in user_list. "
|
||||
"Assign a user first, then set to Claimed.",
|
||||
)
|
||||
|
||||
# Guard: sold requires a customer assigned
|
||||
if body.status.value == "sold":
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if docs:
|
||||
data = docs[0].to_dict() or {}
|
||||
if not data.get("customer_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot set status to 'sold' without an assigned customer. "
|
||||
"Use the 'Assign to Customer' action first.",
|
||||
)
|
||||
|
||||
result = service.update_device_status(sn, body, set_by=user.email)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="status_updated",
|
||||
@@ -99,12 +176,91 @@ async def update_status(
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem)
|
||||
async def patch_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryPatch,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Edit the date and/or note of a lifecycle history entry by index."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
data = docs[0].to_dict() or {}
|
||||
history = data.get("lifecycle_history") or []
|
||||
if body.index < 0 or body.index >= len(history):
|
||||
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
|
||||
if body.date is not None:
|
||||
history[body.index]["date"] = body.date
|
||||
if body.note is not None:
|
||||
history[body.index]["note"] = body.note
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201)
|
||||
async def create_lifecycle_entry(
|
||||
sn: str,
|
||||
body: LifecycleEntryCreate,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Create a lifecycle history entry for a step that has no entry yet (on-the-fly)."""
|
||||
from datetime import datetime, timezone
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
data = docs[0].to_dict() or {}
|
||||
history = data.get("lifecycle_history") or []
|
||||
new_entry = {
|
||||
"status_id": body.status_id,
|
||||
"date": body.date or datetime.now(timezone.utc).isoformat(),
|
||||
"note": body.note,
|
||||
"set_by": user.email,
|
||||
}
|
||||
history.append(new_entry)
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
|
||||
async def delete_lifecycle_entry(
|
||||
sn: str,
|
||||
index: int,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
|
||||
db = get_firestore()
|
||||
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
doc_ref = docs[0].reference
|
||||
data = docs[0].to_dict() or {}
|
||||
history = data.get("lifecycle_history") or []
|
||||
if index < 0 or index >= len(history):
|
||||
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
|
||||
current_status = data.get("mfg_status", "")
|
||||
if history[index].get("status_id") == current_status:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
|
||||
history.pop(index)
|
||||
doc_ref.update({"lifecycle_history": history})
|
||||
from manufacturing.service import _doc_to_inventory_item
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/nvs.bin")
|
||||
async def download_nvs(
|
||||
sn: str,
|
||||
hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"),
|
||||
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
binary = service.get_nvs_binary(sn)
|
||||
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override)
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_flashed",
|
||||
@@ -123,12 +279,15 @@ async def assign_device(
|
||||
body: DeviceAssign,
|
||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||
):
|
||||
result = service.assign_device(sn, body)
|
||||
try:
|
||||
result = service.assign_device(sn, body)
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await audit.log_action(
|
||||
admin_user=user.email,
|
||||
action="device_assigned",
|
||||
serial_number=sn,
|
||||
detail={"customer_email": body.customer_email, "customer_name": body.customer_name},
|
||||
detail={"customer_id": body.customer_id},
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -201,8 +360,9 @@ async def upload_flash_asset(
|
||||
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
|
||||
each PlatformIO build that changes the partition layout.
|
||||
"""
|
||||
if hw_type not in VALID_HW_TYPES_MFG:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES_MFG))}")
|
||||
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
|
||||
if not hw_type or len(hw_type) > 128:
|
||||
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
|
||||
if asset not in VALID_FLASH_ASSETS:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
|
||||
data = await file.read()
|
||||
@@ -212,34 +372,38 @@ async def upload_flash_asset(
|
||||
@router.get("/devices/{sn}/bootloader.bin")
|
||||
def download_bootloader(
|
||||
sn: str,
|
||||
hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
"""Return the bootloader.bin for this device's hw_type (flashed at 0x1000)."""
|
||||
item = service.get_device_by_sn(sn)
|
||||
hw_type = hw_type_override or item.hw_type
|
||||
try:
|
||||
data = service.get_flash_asset(item.hw_type, "bootloader.bin")
|
||||
data = service.get_flash_asset(hw_type, "bootloader.bin")
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="bootloader_{item.hw_type}.bin"'},
|
||||
headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devices/{sn}/partitions.bin")
|
||||
def download_partitions(
|
||||
sn: str,
|
||||
hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"),
|
||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||
):
|
||||
"""Return the partitions.bin for this device's hw_type (flashed at 0x8000)."""
|
||||
item = service.get_device_by_sn(sn)
|
||||
hw_type = hw_type_override or item.hw_type
|
||||
try:
|
||||
data = service.get_flash_asset(item.hw_type, "partitions.bin")
|
||||
data = service.get_flash_asset(hw_type, "partitions.bin")
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="partitions_{item.hw_type}.bin"'},
|
||||
headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'},
|
||||
)
|
||||
|
||||
@@ -33,6 +33,18 @@ def _get_existing_sns(db) -> set:
|
||||
return existing
|
||||
|
||||
|
||||
def _resolve_user_list(raw_list: list) -> list[str]:
|
||||
"""Convert user_list entries (DocumentReferences or path strings) to plain user ID strings."""
|
||||
from google.cloud.firestore_v1 import DocumentReference
|
||||
result = []
|
||||
for entry in raw_list:
|
||||
if isinstance(entry, DocumentReference):
|
||||
result.append(entry.id)
|
||||
elif isinstance(entry, str):
|
||||
result.append(entry.split("/")[-1])
|
||||
return result
|
||||
|
||||
|
||||
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
||||
data = doc.to_dict() or {}
|
||||
created_raw = data.get("created_at")
|
||||
@@ -52,6 +64,9 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
||||
owner=data.get("owner"),
|
||||
assigned_to=data.get("assigned_to"),
|
||||
device_name=data.get("device_name") or None,
|
||||
lifecycle_history=data.get("lifecycle_history") or [],
|
||||
customer_id=data.get("customer_id"),
|
||||
user_list=_resolve_user_list(data.get("user_list") or []),
|
||||
)
|
||||
|
||||
|
||||
@@ -80,11 +95,19 @@ def create_batch(data: BatchCreate) -> BatchResponse:
|
||||
"created_at": now,
|
||||
"owner": None,
|
||||
"assigned_to": None,
|
||||
"users_list": [],
|
||||
"user_list": [],
|
||||
# Legacy fields left empty so existing device views don't break
|
||||
"device_name": "",
|
||||
"device_location": "",
|
||||
"is_Online": False,
|
||||
"lifecycle_history": [
|
||||
{
|
||||
"status_id": "manufactured",
|
||||
"date": now.isoformat(),
|
||||
"note": None,
|
||||
"set_by": None,
|
||||
}
|
||||
],
|
||||
})
|
||||
serial_numbers.append(sn)
|
||||
|
||||
@@ -135,14 +158,31 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem:
|
||||
return _doc_to_inventory_item(docs[0])
|
||||
|
||||
|
||||
def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem:
|
||||
def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem:
|
||||
db = get_db()
|
||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise NotFoundError("Device")
|
||||
|
||||
doc_ref = docs[0].reference
|
||||
update = {"mfg_status": data.status.value}
|
||||
doc_data = docs[0].to_dict() or {}
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
history = doc_data.get("lifecycle_history") or []
|
||||
|
||||
# Append new lifecycle entry
|
||||
new_entry = {
|
||||
"status_id": data.status.value,
|
||||
"date": now,
|
||||
"note": data.note if data.note else None,
|
||||
"set_by": set_by,
|
||||
}
|
||||
history.append(new_entry)
|
||||
|
||||
update = {
|
||||
"mfg_status": data.status.value,
|
||||
"lifecycle_history": history,
|
||||
}
|
||||
if data.note:
|
||||
update["mfg_status_note"] = data.note
|
||||
doc_ref.update(update)
|
||||
@@ -150,47 +190,114 @@ def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryIt
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
def get_nvs_binary(sn: str) -> bytes:
|
||||
def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None) -> bytes:
|
||||
item = get_device_by_sn(sn)
|
||||
return generate_nvs_binary(
|
||||
serial_number=item.serial_number,
|
||||
hw_type=item.hw_type,
|
||||
hw_version=item.hw_version,
|
||||
hw_family=hw_type_override if hw_type_override else item.hw_type,
|
||||
hw_revision=hw_revision_override if hw_revision_override else item.hw_version,
|
||||
)
|
||||
|
||||
|
||||
def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
|
||||
from utils.email import send_device_assignment_invite
|
||||
"""Assign a device to a customer by customer_id.
|
||||
|
||||
- Stores customer_id on the device doc.
|
||||
- Adds the device to the customer's owned_items list.
|
||||
- Sets mfg_status to 'sold' unless device is already 'claimed'.
|
||||
"""
|
||||
db = get_db()
|
||||
CRM_COLLECTION = "crm_customers"
|
||||
|
||||
# Get device doc
|
||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||
if not docs:
|
||||
raise NotFoundError("Device")
|
||||
|
||||
doc_data = docs[0].to_dict() or {}
|
||||
doc_ref = docs[0].reference
|
||||
doc_ref.update({
|
||||
"owner": data.customer_email,
|
||||
"assigned_to": data.customer_email,
|
||||
"mfg_status": "sold",
|
||||
current_status = doc_data.get("mfg_status", "manufactured")
|
||||
|
||||
# Get customer doc
|
||||
customer_ref = db.collection(CRM_COLLECTION).document(data.customer_id)
|
||||
customer_doc = customer_ref.get()
|
||||
if not customer_doc.exists:
|
||||
raise NotFoundError("Customer")
|
||||
customer_data = customer_doc.to_dict() or {}
|
||||
|
||||
# Determine new status: don't downgrade claimed → sold
|
||||
new_status = current_status if current_status == "claimed" else "sold"
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
history = doc_data.get("lifecycle_history") or []
|
||||
history.append({
|
||||
"status_id": new_status,
|
||||
"date": now,
|
||||
"note": "Assigned to customer",
|
||||
"set_by": None,
|
||||
})
|
||||
|
||||
hw_type = doc_data.get("hw_type", "")
|
||||
device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device")
|
||||
doc_ref.update({
|
||||
"customer_id": data.customer_id,
|
||||
"mfg_status": new_status,
|
||||
"lifecycle_history": history,
|
||||
})
|
||||
|
||||
try:
|
||||
send_device_assignment_invite(
|
||||
customer_email=data.customer_email,
|
||||
serial_number=sn,
|
||||
device_name=device_name,
|
||||
customer_name=data.customer_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Assignment succeeded but email failed for %s → %s: %s", sn, data.customer_email, exc)
|
||||
# Add to customer's owned_items (avoid duplicates)
|
||||
owned_items = customer_data.get("owned_items", []) or []
|
||||
device_doc_id = docs[0].id
|
||||
already_assigned = any(
|
||||
item.get("type") == "console_device"
|
||||
and item.get("console_device", {}).get("device_id") == device_doc_id
|
||||
for item in owned_items
|
||||
)
|
||||
if not already_assigned:
|
||||
device_name = doc_data.get("device_name") or BOARD_TYPE_LABELS.get(doc_data.get("hw_type", ""), sn)
|
||||
owned_items.append({
|
||||
"type": "console_device",
|
||||
"console_device": {
|
||||
"device_id": device_doc_id,
|
||||
"serial_number": sn,
|
||||
"label": device_name,
|
||||
},
|
||||
})
|
||||
customer_ref.update({"owned_items": owned_items})
|
||||
|
||||
return _doc_to_inventory_item(doc_ref.get())
|
||||
|
||||
|
||||
def search_customers(q: str) -> list:
|
||||
"""Search crm_customers by name, email, phone, organization, or tags."""
|
||||
db = get_db()
|
||||
CRM_COLLECTION = "crm_customers"
|
||||
docs = db.collection(CRM_COLLECTION).stream()
|
||||
results = []
|
||||
q_lower = q.lower().strip()
|
||||
for doc in docs:
|
||||
data = doc.to_dict() or {}
|
||||
loc = data.get("location") or {}
|
||||
loc = loc if isinstance(loc, dict) else {}
|
||||
city = loc.get("city") or ""
|
||||
searchable = " ".join(filter(None, [
|
||||
data.get("name"), data.get("surname"),
|
||||
data.get("email"), data.get("phone"), data.get("organization"),
|
||||
loc.get("address"), loc.get("city"), loc.get("postal_code"),
|
||||
loc.get("region"), loc.get("country"),
|
||||
" ".join(data.get("tags") or []),
|
||||
])).lower()
|
||||
if not q_lower or q_lower in searchable:
|
||||
results.append({
|
||||
"id": doc.id,
|
||||
"name": data.get("name") or "",
|
||||
"surname": data.get("surname") or "",
|
||||
"email": data.get("email") or "",
|
||||
"organization": data.get("organization") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"city": city or "",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_stats() -> ManufacturingStats:
|
||||
db = get_db()
|
||||
docs = list(db.collection(COLLECTION).stream())
|
||||
|
||||
Reference in New Issue
Block a user