modbus: add explicit cascade delete (device + readings + toggles + HA cleanup)
frontend / frontend (push) Successful in 2m4s
pytest / test (push) Successful in 7m32s

A disabled device with historical readings still cannot be deleted by default
(409 guard stays, to prevent accidental data loss). A new explicit opt-in lets
the user remove it for real:

- DELETE /api/modbus/devices/{uuid}?cascade=true removes the device's readings
  and exposed_entity_toggle rows, then the device, in one transaction (FK order:
  readings before device). Best-effort clears the HA discovery configs first
  (MQTT not connected -> no-op, never blocks the delete). Default (no cascade)
  unchanged. Returns 200 with deletion counts.
- Frontend: a 'Force Delete (all data)' button appears only after the 409, with
  a red irreversible warning; cascade is sent only on that explicit action.
- Also corrected a stale comment: FK RESTRICT IS enforced at runtime now.
This commit is contained in:
2026-06-24 17:38:52 +02:00
parent d7f0731d1c
commit 682e06d256
11 changed files with 499 additions and 42 deletions
+84 -16
View File
@@ -4,14 +4,14 @@ All endpoints are under /api/modbus, require an authenticated session, and
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
non-empty X-CSRF-Token header.
Deletion safety (red-line):
DELETE /api/modbus/devices/{uuid} explicitly queries the application
layer for existing modbus_reading rows before deleting. If any exist
the endpoint returns 409 and suggests disabling the device instead.
We do NOT rely on the SQLite FK RESTRICT constraint because SQLite does
not enforce foreign-key constraints at runtime unless
PRAGMA foreign_keys=ON is set per connection (and it is not in this
project — see M5-T02 review note OBS-1).
Deletion safety:
DELETE /api/modbus/devices/{uuid} queries the application layer for
existing modbus_reading rows before deleting. Without ``cascade`` it
returns 409 and suggests disabling the device instead — a friendly guard
rather than letting the DB raise. With ``cascade=true`` it removes the
readings (and expose toggles) first, then the device. SQLite FK RESTRICT
*is* enforced at runtime here (the app sets PRAGMA foreign_keys=ON; see
app/db.py), so the cascade delete order matters.
"""
from __future__ import annotations
@@ -21,7 +21,7 @@ from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy import delete as sa_delete, func, select
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
@@ -34,9 +34,11 @@ from app.integrations.modbus.profiles import (
list_profiles,
load_profile,
)
from app.models.expose import ExposedEntityToggle
from app.models.modbus import ModbusDevice, ModbusReading
from app.schemas.modbus import (
MetricInfo,
ModbusDeleteResponse,
ModbusDeviceCreate,
ModbusDeviceListResponse,
ModbusDeviceResponse,
@@ -220,29 +222,41 @@ def patch_device(
)
def delete_device(
uuid: str,
cascade: bool = Query(default=False),
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> None:
) -> None | ModbusDeleteResponse:
"""Delete a Modbus device.
**Default behaviour (cascade=false)**:
Returns 409 Conflict if the device has any associated readings; use
``enabled=false`` to disable it instead.
**Application-layer safety**: the check is performed via an explicit
SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
**Cascade delete (cascade=true)**:
Permanently deletes the device together with all its readings and any
``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.
Also makes a best-effort attempt to clear the device's HA Discovery
config topics from MQTT (empty retained payload) before the DB rows
are removed. MQTT failures are swallowed — the DB deletion proceeds
regardless.
Returns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.
**Application-layer safety**: the 409 guard uses an explicit SELECT COUNT
query to return a friendly message. FK RESTRICT is enforced at runtime
(the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes
readings before the device.
"""
from app.services.ha_discovery import clear_device_discovery
device = _get_device_or_404(db, uuid)
# Application-layer guard: refuse deletion if any readings exist.
# We do NOT rely on SQLite FK RESTRICT — it is not enforced at runtime
# without PRAGMA foreign_keys=ON, which is not set in this project.
reading_count: int = db.execute(
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
).scalar_one()
if reading_count > 0:
if reading_count > 0 and not cascade:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
@@ -252,6 +266,60 @@ def delete_device(
),
)
if cascade:
# --- Cascade deletion path ---
logger.info(
"Cascade-deleting Modbus device %r (uuid=%s): %d reading(s)",
device.friendly_name,
uuid,
reading_count,
)
# 1. Best-effort HA Discovery cleanup (before DB rows are gone).
try:
clear_device_discovery(db, uuid)
except Exception: # noqa: BLE001
logger.exception(
"delete_device(cascade): HA discovery cleanup raised for uuid=%s; continuing",
uuid,
)
# 2. Delete all readings for this device (must happen before device row).
deleted_readings_result = db.execute(
sa_delete(ModbusReading).where(ModbusReading.device_id == device.id)
)
readings_deleted: int = deleted_readings_result.rowcount
# 3. Delete all ExposedEntityToggle rows for this device.
# Keys follow the pattern "modbus.<uuid>.<metric>".
toggle_prefix = f"modbus.{uuid}.%"
deleted_toggles_result = db.execute(
sa_delete(ExposedEntityToggle).where(ExposedEntityToggle.key.like(toggle_prefix))
)
toggles_deleted: int = deleted_toggles_result.rowcount
# 4. Delete the device itself.
db.delete(device)
db.commit()
logger.info(
"Cascade-delete complete for device uuid=%s: %d reading(s), %d toggle(s) removed",
uuid,
readings_deleted,
toggles_deleted,
)
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"deleted": True,
"readings_deleted": readings_deleted,
"toggles_deleted": toggles_deleted,
},
)
# --- Non-cascade path (no readings exist at this point) ---
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
db.delete(device)
db.commit()