import uuid from datetime import datetime from fastapi import APIRouter, Depends, Query, HTTPException from typing import Optional, List from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession 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 from database.postgres import get_pg_session from shared.audit import log_action 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")), db: AsyncSession = Depends(get_pg_session), ): device = service.create_device(body) await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "device", device.device_id, device.device_name or device.device_id) return device @router.put("/{device_id}", response_model=DeviceInDB) async def update_device( device_id: str, body: DeviceUpdate, _user: TokenPayload = Depends(require_permission("devices", "edit")), db: AsyncSession = Depends(get_pg_session), ): old = service.get_device(device_id) device = service.update_device(device_id, body) _SKIP = {"updated_at", "device_id", "tags", "user_list"} changes = { k: {"old": getattr(old, k, None), "new": getattr(device, k, None)} for k in body.model_fields_set if k not in _SKIP and getattr(old, k, None) != getattr(device, k, None) } if "tags" in body.model_fields_set and (old.tags or []) != (device.tags or []): changes["tags"] = {"old": sorted(old.tags or []), "new": sorted(device.tags or [])} await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", device_id, device.device_name or device_id, changes=changes or None) return device @router.delete("/{device_id}", status_code=204) async def delete_device( device_id: str, _user: TokenPayload = Depends(require_permission("devices", "delete")), db: AsyncSession = Depends(get_pg_session), ): service.delete_device(device_id) await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "device", device_id, 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).stream() notes = [] for doc in docs: note = doc.to_dict() note["id"] = doc.id for f in ("created_at", "updated_at"): if hasattr(note.get(f), "isoformat"): note[f] = note[f].isoformat() notes.append(note) notes.sort(key=lambda n: n.get("created_at") or "", reverse=False) 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")), db: AsyncSession = Depends(get_pg_session), ): """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}) await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", device_id, device_id, meta={"action_detail": "assigned_to_customer", "customer_id": body.customer_id}) 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")), db: AsyncSession = Depends(get_pg_session), ): """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}) await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", device_id, device_id, meta={"action_detail": "unassigned_from_customer", "customer_id": customer_id}) # ───────────────────────────────────────────────────────────────────────────── # 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")), db: AsyncSession = Depends(get_pg_session), ): """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}) await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", device_id, device_id, meta={"action_detail": "user_added", "user_id": body.user_id}) 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")), db: AsyncSession = Depends(get_pg_session), ): """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 [] from google.cloud.firestore_v1 import DocumentReference as DocRef def resolves_to(entry, uid: str) -> bool: if isinstance(entry, DocRef): return entry.id == uid if isinstance(entry, str): return entry.split("/")[-1] == uid return False # Remove any entry that resolves to this user_id (handles both DocRef and string paths) new_list = [entry for entry in user_list if not resolves_to(entry, user_id)] device_ref.update({"user_list": new_list}) await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", device_id, device_id, meta={"action_detail": "user_removed", "user_id": user_id}) return {"status": "removed", "user_id": user_id}