import uuid from datetime import datetime from fastapi import APIRouter, Depends, Query, HTTPException from typing import Optional, List from pydantic import BaseModel from auth.models import TokenPayload from auth.dependencies import require_permission from devices.models import ( DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse, DeviceUsersResponse, DeviceUserInfo, DeviceNoteCreate, DeviceNoteUpdate, ) from devices import service import database as mqtt_db from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse from shared.firebase import get_db as get_firestore router = APIRouter(prefix="/api/devices", tags=["devices"]) NOTES_COLLECTION = "notes" CRM_COLLECTION = "crm_customers" @router.get("", response_model=DeviceListResponse) async def list_devices( search: Optional[str] = Query(None), online: Optional[bool] = Query(None), tier: Optional[str] = Query(None), _user: TokenPayload = Depends(require_permission("devices", "view")), ): devices = service.list_devices( search=search, online_only=online, subscription_tier=tier, ) return DeviceListResponse(devices=devices, total=len(devices)) @router.get("/{device_id}", response_model=DeviceInDB) async def get_device( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "view")), ): return service.get_device(device_id) @router.get("/{device_id}/users", response_model=DeviceUsersResponse) async def get_device_users( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "view")), ): users_data = service.get_device_users(device_id) users = [DeviceUserInfo(**u) for u in users_data] return DeviceUsersResponse(users=users, total=len(users)) @router.post("", response_model=DeviceInDB, status_code=201) async def create_device( body: DeviceCreate, _user: TokenPayload = Depends(require_permission("devices", "add")), ): return service.create_device(body) @router.put("/{device_id}", response_model=DeviceInDB) async def update_device( device_id: str, body: DeviceUpdate, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): return service.update_device(device_id, body) @router.delete("/{device_id}", status_code=204) async def delete_device( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "delete")), ): service.delete_device(device_id) @router.get("/{device_id}/alerts", response_model=DeviceAlertsResponse) async def get_device_alerts( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "view")), ): """Return the current active alert set for a device. Empty list means fully healthy.""" rows = await mqtt_db.get_alerts(device_id) return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows]) # ───────────────────────────────────────────────────────────────────────────── # Device Notes # ───────────────────────────────────────────────────────────────────────────── @router.get("/{device_id}/notes") async def list_device_notes( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "view")), ): """List all notes for a device.""" db = get_firestore() docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).order_by("created_at").stream() notes = [] for doc in docs: note = doc.to_dict() note["id"] = doc.id # Convert Firestore Timestamps to ISO strings for f in ("created_at", "updated_at"): if hasattr(note.get(f), "isoformat"): note[f] = note[f].isoformat() notes.append(note) return {"notes": notes, "total": len(notes)} @router.post("/{device_id}/notes", status_code=201) async def create_device_note( device_id: str, body: DeviceNoteCreate, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Create a new note for a device.""" db = get_firestore() now = datetime.utcnow() note_id = str(uuid.uuid4()) note_data = { "device_id": device_id, "content": body.content, "created_by": body.created_by or _user.name or "", "created_at": now, "updated_at": now, } db.collection(NOTES_COLLECTION).document(note_id).set(note_data) note_data["id"] = note_id note_data["created_at"] = now.isoformat() note_data["updated_at"] = now.isoformat() return note_data @router.put("/{device_id}/notes/{note_id}") async def update_device_note( device_id: str, note_id: str, body: DeviceNoteUpdate, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Update an existing device note.""" db = get_firestore() doc_ref = db.collection(NOTES_COLLECTION).document(note_id) doc = doc_ref.get() if not doc.exists or doc.to_dict().get("device_id") != device_id: raise HTTPException(status_code=404, detail="Note not found") now = datetime.utcnow() doc_ref.update({"content": body.content, "updated_at": now}) updated = doc.to_dict() updated["id"] = note_id updated["content"] = body.content updated["updated_at"] = now.isoformat() if hasattr(updated.get("created_at"), "isoformat"): updated["created_at"] = updated["created_at"].isoformat() return updated @router.delete("/{device_id}/notes/{note_id}", status_code=204) async def delete_device_note( device_id: str, note_id: str, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Delete a device note.""" db = get_firestore() doc_ref = db.collection(NOTES_COLLECTION).document(note_id) doc = doc_ref.get() if not doc.exists or doc.to_dict().get("device_id") != device_id: raise HTTPException(status_code=404, detail="Note not found") doc_ref.delete() # ───────────────────────────────────────────────────────────────────────────── # Device Tags # ───────────────────────────────────────────────────────────────────────────── class TagsUpdate(BaseModel): tags: List[str] @router.put("/{device_id}/tags", response_model=DeviceInDB) async def update_device_tags( device_id: str, body: TagsUpdate, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Replace the tags list for a device.""" return service.update_device(device_id, DeviceUpdate(tags=body.tags)) # ───────────────────────────────────────────────────────────────────────────── # Assign Device to Customer # ───────────────────────────────────────────────────────────────────────────── class CustomerSearchResult(BaseModel): id: str name: str email: str organization: str = "" class AssignCustomerBody(BaseModel): customer_id: str label: str = "" @router.get("/{device_id}/customer-search") async def search_customers_for_device( device_id: str, q: str = Query(""), _user: TokenPayload = Depends(require_permission("devices", "view")), ): """Search customers by name, email, phone, org, or tags, returning top 20 matches.""" db = get_firestore() docs = db.collection(CRM_COLLECTION).stream() results = [] q_lower = q.lower().strip() for doc in docs: data = doc.to_dict() 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 "" tags = " ".join(data.get("tags", []) or []) location = data.get("location") or {} city = location.get("city", "") or "" searchable = f"{name} {surname} {email} {organization} {phone} {tags} {city}".lower() if not q_lower or q_lower in searchable: results.append({ "id": doc.id, "name": name, "surname": surname, "email": email, "organization": organization, "city": city, }) if len(results) >= 20: break return {"results": results} @router.post("/{device_id}/assign-customer") async def assign_device_to_customer( device_id: str, body: AssignCustomerBody, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Assign a device to a customer. - Sets owner field on the device document. - Adds a console_device entry to the customer's owned_items list. """ db = get_firestore() # Verify device exists device = service.get_device(device_id) # Get customer customer_ref = db.collection(CRM_COLLECTION).document(body.customer_id) customer_doc = customer_ref.get() if not customer_doc.exists: raise HTTPException(status_code=404, detail="Customer not found") customer_data = customer_doc.to_dict() customer_email = customer_data.get("email", "") # Update device: owner email + customer_id device_ref = db.collection("devices").document(device_id) device_ref.update({"owner": customer_email, "customer_id": body.customer_id}) # Add to customer owned_items (avoid duplicates) owned_items = customer_data.get("owned_items", []) or [] already_assigned = any( item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id for item in owned_items ) if not already_assigned: owned_items.append({ "type": "console_device", "console_device": { "device_id": device_id, "label": body.label or device.device_name or device_id, } }) customer_ref.update({"owned_items": owned_items}) return {"status": "assigned", "device_id": device_id, "customer_id": body.customer_id} @router.delete("/{device_id}/assign-customer", status_code=204) async def unassign_device_from_customer( device_id: str, customer_id: str = Query(...), _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Remove device assignment from a customer.""" db = get_firestore() # Clear customer_id on device device_ref = db.collection("devices").document(device_id) device_ref.update({"customer_id": ""}) # Remove from customer owned_items customer_ref = db.collection(CRM_COLLECTION).document(customer_id) customer_doc = customer_ref.get() if customer_doc.exists: customer_data = customer_doc.to_dict() owned_items = [ item for item in (customer_data.get("owned_items") or []) if not (item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id) ] customer_ref.update({"owned_items": owned_items}) # ───────────────────────────────────────────────────────────────────────────── # Customer detail (for Owner display in fleet) # ───────────────────────────────────────────────────────────────────────────── @router.get("/{device_id}/customer") async def get_device_customer( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "view")), ): """Return basic customer details for a device's assigned customer_id.""" db = get_firestore() device_ref = db.collection("devices").document(device_id) device_doc = device_ref.get() if not device_doc.exists: raise HTTPException(status_code=404, detail="Device not found") device_data = device_doc.to_dict() or {} customer_id = device_data.get("customer_id") if not customer_id: return {"customer": None} customer_doc = db.collection(CRM_COLLECTION).document(customer_id).get() if not customer_doc.exists: return {"customer": None} cd = customer_doc.to_dict() or {} return { "customer": { "id": customer_doc.id, "name": cd.get("name") or "", "email": cd.get("email") or "", "organization": cd.get("organization") or "", "phone": cd.get("phone") or "", } } # ───────────────────────────────────────────────────────────────────────────── # User list management (for Manage tab — assign/remove users from user_list) # ───────────────────────────────────────────────────────────────────────────── class UserSearchResult(BaseModel): id: str display_name: str = "" email: str = "" phone: str = "" photo_url: str = "" @router.get("/{device_id}/user-search") async def search_users_for_device( device_id: str, q: str = Query(""), _user: TokenPayload = Depends(require_permission("devices", "view")), ): """Search the users collection by name, email, or phone.""" db = get_firestore() docs = db.collection("users").stream() results = [] q_lower = q.lower().strip() for doc in docs: data = doc.to_dict() or {} name = (data.get("display_name") or "").lower() email = (data.get("email") or "").lower() phone = (data.get("phone") or "").lower() if not q_lower or q_lower in name or q_lower in email or q_lower in phone: results.append({ "id": doc.id, "display_name": data.get("display_name") or "", "email": data.get("email") or "", "phone": data.get("phone") or "", "photo_url": data.get("photo_url") or "", }) if len(results) >= 20: break return {"results": results} class AddUserBody(BaseModel): user_id: str @router.post("/{device_id}/user-list", status_code=200) async def add_user_to_device( device_id: str, body: AddUserBody, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Add a user reference to the device's user_list field.""" db = get_firestore() device_ref = db.collection("devices").document(device_id) device_doc = device_ref.get() if not device_doc.exists: raise HTTPException(status_code=404, detail="Device not found") # Verify user exists user_doc = db.collection("users").document(body.user_id).get() if not user_doc.exists: raise HTTPException(status_code=404, detail="User not found") data = device_doc.to_dict() or {} user_list = data.get("user_list", []) or [] # Avoid duplicates — check both string paths and DocumentReferences from google.cloud.firestore_v1 import DocumentReference as DocRef existing_ids = set() for entry in user_list: if isinstance(entry, DocRef): existing_ids.add(entry.id) elif isinstance(entry, str): existing_ids.add(entry.split("/")[-1]) if body.user_id not in existing_ids: user_ref = db.collection("users").document(body.user_id) user_list.append(user_ref) device_ref.update({"user_list": user_list}) return {"status": "added", "user_id": body.user_id} @router.delete("/{device_id}/user-list/{user_id}", status_code=200) async def remove_user_from_device( device_id: str, user_id: str, _user: TokenPayload = Depends(require_permission("devices", "edit")), ): """Remove a user reference from the device's user_list field.""" db = get_firestore() device_ref = db.collection("devices").document(device_id) device_doc = device_ref.get() if not device_doc.exists: raise HTTPException(status_code=404, detail="Device not found") data = device_doc.to_dict() or {} user_list = data.get("user_list", []) or [] # Remove any entry that resolves to this user_id new_list = [ entry for entry in user_list if not (isinstance(entry, str) and entry.split("/")[-1] == user_id) ] device_ref.update({"user_list": new_list}) return {"status": "removed", "user_id": user_id}