update: overhauled firmware ui. Added public flash page.
This commit is contained in:
@@ -1,17 +1,25 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
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(
|
||||
@@ -79,3 +87,375 @@ async def get_device_alerts(
|
||||
"""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}
|
||||
|
||||
Reference in New Issue
Block a user