462 lines
17 KiB
Python
462 lines
17 KiB
Python
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}
|