modbus: add explicit cascade delete (device + readings + toggles + HA cleanup)
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user