feat(devices): add Reset Stats action for clearing pre-ship QA/bench data
New POST /devices/{id}/reset-stats + ResetStatsModal lets staff clear
QA/bench test data accumulated before a device ships to a customer:
Firestore bell/playback counters and Postgres history tables (logs,
heartbeats, commands, boot events, alert events, ping samples,
diagnostics reports, control/reports, and opt-in live alerts). The two
firmware-side resets (telemetry.reset_boot_data, logs.clear) go over
the existing MQTT command/ack flow instead, since they need the device
online and duplicating that round-trip server-side would just be a
second, inferior implementation.
Also includes two incidental cleanups in devices/router.py: audit-log
entries for create/update now use console_name in their label (missed
by the earlier console_name commit), and add/remove-device-user rename
their local Firestore client from `db` to `fs` to stop shadowing the
`db: AsyncSession` dependency param in the same function scope.
AddDeviceUserModal's results list also gets a max-height + scroll so a
long match list doesn't grow the modal off-screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from devices.models import (
|
||||
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
||||
DeviceUsersResponse, DeviceUserInfo,
|
||||
DeviceNoteCreate, DeviceNoteUpdate,
|
||||
ResetStatsRequest, ResetStatsResult,
|
||||
)
|
||||
from devices import service
|
||||
import database as mqtt_db
|
||||
@@ -65,7 +66,7 @@ async def create_device(
|
||||
):
|
||||
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)
|
||||
device.device_id, device.console_name or device.device_name or device.device_id)
|
||||
return device
|
||||
|
||||
|
||||
@@ -87,7 +88,7 @@ async def update_device(
|
||||
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)
|
||||
device_id, device.console_name or device.device_name or device_id, changes=changes or None)
|
||||
return device
|
||||
|
||||
|
||||
@@ -437,14 +438,14 @@ async def add_user_to_device(
|
||||
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)
|
||||
fs = get_firestore()
|
||||
device_ref = fs.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()
|
||||
user_doc = fs.collection("users").document(body.user_id).get()
|
||||
if not user_doc.exists:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
@@ -461,7 +462,7 @@ async def add_user_to_device(
|
||||
existing_ids.add(entry.split("/")[-1])
|
||||
|
||||
if body.user_id not in existing_ids:
|
||||
user_ref = db.collection("users").document(body.user_id)
|
||||
user_ref = fs.collection("users").document(body.user_id)
|
||||
user_list.append(user_ref)
|
||||
device_ref.update({"user_list": user_list})
|
||||
|
||||
@@ -479,8 +480,8 @@ async def remove_user_from_device(
|
||||
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)
|
||||
fs = get_firestore()
|
||||
device_ref = fs.collection("devices").document(device_id)
|
||||
device_doc = device_ref.get()
|
||||
if not device_doc.exists:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
@@ -504,4 +505,75 @@ async def remove_user_from_device(
|
||||
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}
|
||||
|
||||
|
||||
@router.post("/{device_id}/reset-stats", response_model=ResetStatsResult)
|
||||
async def reset_device_stats(
|
||||
device_id: str,
|
||||
body: ResetStatsRequest,
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
):
|
||||
"""Clears QA/bench test data accumulated before a device ships to a
|
||||
customer. Only handles categories that don't require the device itself
|
||||
to be online — Postgres history tables and Firestore device_stats
|
||||
counters. The two firmware-side resets (telemetry.reset_boot_data,
|
||||
logs.clear) are sent directly by the frontend over MQTT via the existing
|
||||
command-ack flow, since they need the device online and this console
|
||||
already has that round-trip built (see DeviceDetail.jsx's
|
||||
sendMqttCommand) — duplicating it backend-side would just be a second,
|
||||
inferior implementation of the same wait-for-device-reply logic.
|
||||
|
||||
Nothing here touches device identity, customer assignment, notes, tags,
|
||||
warranty/maintenance fields, or audit logs — see ResetStatsRequest's
|
||||
field list for exactly what's in scope.
|
||||
"""
|
||||
device = service.get_device(device_id)
|
||||
serial = device.serial_number or device.device_id
|
||||
|
||||
result = ResetStatsResult()
|
||||
|
||||
# Firestore device_stats — field-level only. DeviceStatistics mixes true
|
||||
# counters with warranty/maintenance fields in one object; a blanket
|
||||
# clear would destroy warranty data, so only the 4 named counter fields
|
||||
# are ever sent here (deep-merge in service.update_device preserves
|
||||
# every other key, including warrantyActive/warrantyStart/etc.).
|
||||
stats_update = {}
|
||||
if body.bell_stats:
|
||||
stats_update["totalHammerStrikes"] = 0
|
||||
stats_update["perBellStrikes"] = []
|
||||
if body.playback_stats:
|
||||
stats_update["totalPlaybacks"] = 0
|
||||
stats_update["totalWarningsGiven"] = 0
|
||||
if stats_update:
|
||||
service.update_device(device_id, DeviceUpdate(device_stats=stats_update))
|
||||
if body.bell_stats:
|
||||
result.bell_stats = True
|
||||
if body.playback_stats:
|
||||
result.playback_stats = True
|
||||
|
||||
# Postgres — pure history/telemetry tables, per-device row deletes.
|
||||
if body.device_logs:
|
||||
result.device_logs = await mqtt_db.delete_device_logs(serial)
|
||||
if body.heartbeats:
|
||||
result.heartbeats = await mqtt_db.delete_device_heartbeats(serial)
|
||||
if body.commands:
|
||||
result.commands = await mqtt_db.delete_device_commands(serial)
|
||||
if body.boot_events:
|
||||
result.boot_events = await mqtt_db.delete_device_boot_events(serial)
|
||||
if body.alert_history:
|
||||
result.alert_history = await mqtt_db.delete_device_alert_events(serial)
|
||||
if body.ping_samples:
|
||||
result.ping_samples = await mqtt_db.delete_device_ping_samples(serial)
|
||||
if body.diagnostics_reports:
|
||||
result.diagnostics_reports = await mqtt_db.delete_device_diagnostics_reports(serial)
|
||||
if body.reports:
|
||||
result.reports = await mqtt_db.delete_device_reports(serial)
|
||||
if body.current_alerts:
|
||||
result.current_alerts = await mqtt_db.delete_device_current_alerts(serial)
|
||||
|
||||
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||
device_id, device.console_name or device.device_name or serial,
|
||||
meta={"action_detail": "reset_stats", "categories": body.model_dump()})
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user