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
+152
View File
@@ -432,6 +432,158 @@ def test_delete_device_not_found_returns_404(modbus_client):
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# DELETE /api/modbus/devices/{uuid}?cascade=true
# ---------------------------------------------------------------------------
def test_cascade_false_with_readings_still_returns_409(modbus_client):
"""cascade=false (default) + readings → 409, device and readings remain."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
_make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "false"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 409
# Device and reading still in DB
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 1
assert session.query(ModbusReading).count() == 1
def test_cascade_true_with_readings_deletes_device_readings_and_toggles(modbus_client):
"""cascade=true + readings → device, readings, and toggle rows all deleted; 200 response."""
from datetime import UTC
from app.models.expose import ExposedEntityToggle
client, engine = modbus_client
_login(client)
device = _make_device(engine)
# Add two readings
now = datetime.now(UTC)
_make_reading(engine, device.id, now, {"voltage": 230.0})
_make_reading(engine, device.id, now - timedelta(minutes=1), {"voltage": 229.0})
# Add an ExposedEntityToggle row for this device
with Session(engine) as session:
toggle = ExposedEntityToggle(
key=f"modbus.{device.uuid}.voltage",
enabled=True,
updated_at=now,
)
session.add(toggle)
# Add one toggle for a different device to verify isolation
other_toggle = ExposedEntityToggle(
key="modbus.other-uuid.voltage",
enabled=True,
updated_at=now,
)
session.add(other_toggle)
session.commit()
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is True
assert body["readings_deleted"] == 2
assert body["toggles_deleted"] == 1
# All rows for this device must be gone
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 0
assert session.query(ModbusReading).count() == 0
# Only the other device's toggle remains
remaining_toggles = session.query(ExposedEntityToggle).all()
assert len(remaining_toggles) == 1
assert remaining_toggles[0].key == "modbus.other-uuid.voltage"
def test_cascade_true_no_readings_deletes_device(modbus_client):
"""cascade=true + no readings → device deleted, response shows 0 counts."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is True
assert body["readings_deleted"] == 0
assert body["toggles_deleted"] == 0
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 0
def test_cascade_unauthenticated_returns_401(modbus_client):
"""Unauthenticated cascade delete request → 401."""
client, engine = modbus_client
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_cascade_missing_csrf_returns_403(modbus_client):
"""cascade=true without CSRF → 403."""
client, engine = modbus_client
_login(client)
device = _make_device(engine)
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
)
assert resp.status_code == 403
def test_cascade_mqtt_unavailable_still_deletes(modbus_client):
"""When MQTT is not connected, cascade delete must still succeed (HA cleanup is best-effort)."""
from unittest.mock import patch
client, engine = modbus_client
_login(client)
device = _make_device(engine)
_make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})
# Simulate MQTT not connected so clear_device_discovery is a no-op
with patch("app.services.ha_discovery.mqtt_manager") as mock_mqtt:
mock_mqtt.is_connected = False
resp = client.delete(
f"/api/modbus/devices/{device.uuid}",
params={"cascade": "true"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] is True
assert body["readings_deleted"] == 1
# DB rows gone
with Session(engine) as session:
assert session.query(ModbusDevice).count() == 0
assert session.query(ModbusReading).count() == 0
# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/latest
# ---------------------------------------------------------------------------