update: overhauled firmware ui. Added public flash page.

This commit is contained in:
2026-03-18 17:49:40 +02:00
parent 4381a6681d
commit d0ac4f1d91
45 changed files with 6798 additions and 1723 deletions

View File

@@ -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}