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 write endpoints (POST/PATCH/DELETE + test-read) additionally require a
non-empty X-CSRF-Token header. non-empty X-CSRF-Token header.
Deletion safety (red-line): Deletion safety:
DELETE /api/modbus/devices/{uuid} explicitly queries the application DELETE /api/modbus/devices/{uuid} queries the application layer for
layer for existing modbus_reading rows before deleting. If any exist existing modbus_reading rows before deleting. Without ``cascade`` it
the endpoint returns 409 and suggests disabling the device instead. returns 409 and suggests disabling the device instead — a friendly guard
We do NOT rely on the SQLite FK RESTRICT constraint because SQLite does rather than letting the DB raise. With ``cascade=true`` it removes the
not enforce foreign-key constraints at runtime unless readings (and expose toggles) first, then the device. SQLite FK RESTRICT
PRAGMA foreign_keys=ON is set per connection (and it is not in this *is* enforced at runtime here (the app sets PRAGMA foreign_keys=ON; see
project — see M5-T02 review note OBS-1). app/db.py), so the cascade delete order matters.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,7 +21,7 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status 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 sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session from app.api.routes.api.deps import require_csrf, require_session
@@ -34,9 +34,11 @@ from app.integrations.modbus.profiles import (
list_profiles, list_profiles,
load_profile, load_profile,
) )
from app.models.expose import ExposedEntityToggle
from app.models.modbus import ModbusDevice, ModbusReading from app.models.modbus import ModbusDevice, ModbusReading
from app.schemas.modbus import ( from app.schemas.modbus import (
MetricInfo, MetricInfo,
ModbusDeleteResponse,
ModbusDeviceCreate, ModbusDeviceCreate,
ModbusDeviceListResponse, ModbusDeviceListResponse,
ModbusDeviceResponse, ModbusDeviceResponse,
@@ -220,29 +222,41 @@ def patch_device(
) )
def delete_device( def delete_device(
uuid: str, uuid: str,
cascade: bool = Query(default=False),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session), _auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf), _csrf: None = Depends(require_csrf),
) -> None: ) -> None | ModbusDeleteResponse:
"""Delete a Modbus device. """Delete a Modbus device.
**Default behaviour (cascade=false)**:
Returns 409 Conflict if the device has any associated readings; use Returns 409 Conflict if the device has any associated readings; use
``enabled=false`` to disable it instead. ``enabled=false`` to disable it instead.
**Application-layer safety**: the check is performed via an explicit **Cascade delete (cascade=true)**:
SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint, Permanently deletes the device together with all its readings and any
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON). ``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) device = _get_device_or_404(db, uuid)
# Application-layer guard: refuse deletion if any readings exist. # 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( reading_count: int = db.execute(
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id) select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
).scalar_one() ).scalar_one()
if reading_count > 0: if reading_count > 0 and not cascade:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail=( 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) logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
db.delete(device) db.delete(device)
db.commit() db.commit()
+17
View File
@@ -152,3 +152,20 @@ class ModbusTestReadResponse(BaseModel):
ok: bool ok: bool
payload: dict[str, Any] | None = None payload: dict[str, Any] | None = None
error: str | None = None error: str | None = None
# ---------------------------------------------------------------------------
# Cascade-delete schema
# ---------------------------------------------------------------------------
class ModbusDeleteResponse(BaseModel):
"""Response for DELETE /api/modbus/devices/{uuid}?cascade=true.
Returned only when cascade deletion succeeds (HTTP 200). Non-cascade
successful deletes continue to return HTTP 204 (no body).
"""
deleted: bool
readings_deleted: int
toggles_deleted: int
+68
View File
@@ -407,6 +407,74 @@ def publish_device_state(session: Session, device: Any) -> None:
) )
def clear_device_discovery(session: Session, device_uuid: str) -> None:
"""Best-effort: send empty retained payloads to all HA Discovery config topics
for the given device, effectively removing the device's entities from HA.
This must be called **before** the device rows are deleted from the DB, so
that ``build_catalog`` can still enumerate the device's entities.
Behaviour
---------
- No-op if MQTT / HA Discovery is not enabled or the MQTT client is not
connected.
- All exceptions are caught internally; this function never raises.
The caller proceeds with the DB deletion regardless of MQTT outcome.
Parameters
----------
session:
Active SQLAlchemy session (device must still exist in DB at call time).
device_uuid:
UUID string of the device being deleted.
"""
from app.config import get_settings
settings = build_runtime_settings(session, get_settings())
if not _should_publish(settings):
logger.debug(
"clear_device_discovery: skipped (MQTT/Discovery not enabled or not connected)"
)
return
discovery_prefix = settings.ha_discovery_prefix
state_prefix = settings.ha_state_topic_prefix
try:
catalog = build_catalog(session)
except Exception:
logger.exception(
"clear_device_discovery: failed to build catalog for device uuid=%s; skipping HA cleanup",
device_uuid,
)
return
cleared = 0
for entry in catalog:
entity = entry.entity
# Only clear entities belonging to this device.
if entity.device.identifiers[1] != device_uuid:
continue
try:
topic, _config = build_discovery_payload(entity, discovery_prefix, state_prefix)
mqtt_manager.publish(topic, b"", retain=True)
cleared += 1
logger.debug(
"clear_device_discovery: cleared config topic for entity %r%s",
entity.key,
topic,
)
except Exception:
logger.exception(
"clear_device_discovery: error clearing entity %r; continuing", entity.key
)
logger.info(
"clear_device_discovery: cleared %d HA discovery topic(s) for device uuid=%s",
cleared,
device_uuid,
)
def publish_device_offline(session: Session, device_uuid: str) -> None: def publish_device_offline(session: Session, device_uuid: str) -> None:
"""Publish an "offline" availability payload for *device_uuid*. """Publish an "offline" availability payload for *device_uuid*.
+17 -4
View File
@@ -686,12 +686,23 @@ export interface paths {
* Delete Device * Delete Device
* @description Delete a Modbus device. * @description Delete a Modbus device.
* *
* **Default behaviour (cascade=false)**:
* Returns 409 Conflict if the device has any associated readings; use * Returns 409 Conflict if the device has any associated readings; use
* ``enabled=false`` to disable it instead. * ``enabled=false`` to disable it instead.
* *
* **Application-layer safety**: the check is performed via an explicit * **Cascade delete (cascade=true)**:
* SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint, * Permanently deletes the device together with all its readings and any
* which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON). * ``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.
*/ */
delete: operations["delete_device_api_modbus_devices__uuid__delete"]; delete: operations["delete_device_api_modbus_devices__uuid__delete"];
options?: never; options?: never;
@@ -3190,7 +3201,9 @@ export interface operations {
}; };
delete_device_api_modbus_devices__uuid__delete: { delete_device_api_modbus_devices__uuid__delete: {
parameters: { parameters: {
query?: never; query?: {
cascade?: boolean;
};
header?: { header?: {
"X-CSRF-Token"?: string | null; "X-CSRF-Token"?: string | null;
}; };
+21 -2
View File
@@ -173,7 +173,7 @@ describe('useUpdateDevice', () => {
describe('useDeleteDevice', () => { describe('useDeleteDevice', () => {
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
it('calls DELETE /api/modbus/devices/{uuid}', async () => { it('calls DELETE /api/modbus/devices/{uuid} without query param when cascade omitted', async () => {
mockDelete.mockResolvedValue({ data: null }) mockDelete.mockResolvedValue({ data: null })
mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
@@ -182,13 +182,32 @@ describe('useDeleteDevice', () => {
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper }) const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
await act(async () => { await act(async () => {
await result.current.mutateAsync('test-uuid-1') await result.current.mutateAsync({ uuid: 'test-uuid-1' })
}) })
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', { expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: 'test-uuid-1' } }, params: { path: { uuid: 'test-uuid-1' } },
}) })
}) })
it('calls DELETE /api/modbus/devices/{uuid} with cascade=true in query when cascade=true', async () => {
mockDelete.mockResolvedValue({
data: { deleted: true, readings_deleted: 5, toggles_deleted: 2 },
})
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
const { Wrapper } = makeWrapper()
const { useDeleteDevice } = await import('./hooks')
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
await act(async () => {
await result.current.mutateAsync({ uuid: 'test-uuid-1', cascade: true })
})
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: 'test-uuid-1' }, query: { cascade: true } },
})
})
}) })
describe('useTestReadDevice', () => { describe('useTestReadDevice', () => {
+11 -2
View File
@@ -115,12 +115,21 @@ export function useUpdateDevice() {
// Mutation: delete device // Mutation: delete device
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface DeleteDeviceParams {
uuid: string
/** When true, perform a cascade delete (removes readings + expose toggles). */
cascade?: boolean
}
export function useDeleteDevice() { export function useDeleteDevice() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (uuid: string) => mutationFn: ({ uuid, cascade }: DeleteDeviceParams) =>
apiClient.DELETE('/api/modbus/devices/{uuid}', { apiClient.DELETE('/api/modbus/devices/{uuid}', {
params: { path: { uuid } }, params: {
path: { uuid },
...(cascade ? { query: { cascade: true } } : {}),
},
}), }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
}) })
+33
View File
@@ -288,6 +288,7 @@ describe('EnergyPage — delete device', () => {
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`)) fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
// On first open (no 409 yet), the regular confirm button is visible.
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('device-delete-confirm')) fireEvent.click(screen.getByTestId('device-delete-confirm'))
@@ -295,6 +296,38 @@ describe('EnergyPage — delete device', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument() expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
}) })
// After 409, the force-delete button appears instead of the normal confirm.
expect(screen.getByTestId('device-delete-force')).toBeInTheDocument()
expect(screen.queryByTestId('device-delete-confirm')).not.toBeInTheDocument()
})
it('force-delete button triggers cascade delete after 409', async () => {
const { ApiError } = await import('../api/client')
// First call → 409, second call (cascade) → success
mockDelete
.mockRejectedValueOnce(new ApiError(409, { detail: 'device has readings' }))
.mockResolvedValueOnce({ data: { deleted: true, readings_deleted: 3, toggles_deleted: 1 } })
renderEnergy()
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
// First attempt → 409
fireEvent.click(screen.getByTestId('device-delete-confirm'))
// Force-delete button appears
await waitFor(() => expect(screen.getByTestId('device-delete-force')).toBeInTheDocument())
// Click force-delete → should call DELETE with cascade=true
fireEvent.click(screen.getByTestId('device-delete-force'))
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: DEVICE.uuid }, query: { cascade: true } },
})
})
}) })
}) })
+42 -2
View File
@@ -57,13 +57,22 @@ import { formatLocalDateTime } from '../utils/datetime'
interface ConfirmDeleteProps { interface ConfirmDeleteProps {
device: ModbusDevice device: ModbusDevice
onConfirm: () => void onConfirm: () => void
/** Called when the user explicitly opts into cascade (force) deletion. */
onForceDelete: () => void
onCancel: () => void onCancel: () => void
loading: boolean loading: boolean
/** Set when the delete failed with 409. */ /** Set when the delete failed with 409. */
has409Error: boolean has409Error: boolean
} }
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) { function ConfirmDeleteModal({
device,
onConfirm,
onForceDelete,
onCancel,
loading,
has409Error,
}: ConfirmDeleteProps) {
return ( return (
<Modal <Modal
opened opened
@@ -78,16 +87,24 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
</Text> </Text>
{has409Error && ( {has409Error && (
<>
<Alert color="orange" data-testid="device-delete-409-hint"> <Alert color="orange" data-testid="device-delete-409-hint">
This device has existing readings and cannot be deleted. Consider{' '} This device has existing readings and cannot be deleted. Consider{' '}
<strong>disabling</strong> it instead (click Edit uncheck Enabled). <strong>disabling</strong> it instead (click Edit uncheck Enabled).
</Alert> </Alert>
<Alert color="red" variant="light" data-testid="device-delete-force-warning">
Alternatively, you can <strong>permanently delete</strong> the device together with{' '}
all its historical readings and expose settings. This action{' '}
<strong>cannot be undone</strong>.
</Alert>
</>
)} )}
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel"> <Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
Cancel Cancel
</Button> </Button>
{!has409Error && (
<Button <Button
color="red" color="red"
loading={loading} loading={loading}
@@ -96,6 +113,17 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
> >
Delete Delete
</Button> </Button>
)}
{has409Error && (
<Button
color="red"
loading={loading}
onClick={onForceDelete}
data-testid="device-delete-force"
>
Force Delete (all data)
</Button>
)}
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
@@ -532,7 +560,7 @@ function DevicesTab() {
if (!deleteDevice) return if (!deleteDevice) return
setDelete409(false) setDelete409(false)
try { try {
await deleteMutation.mutateAsync(deleteDevice.uuid) await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid })
closeDelete() closeDelete()
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 409) { if (err instanceof ApiError && err.status === 409) {
@@ -542,6 +570,17 @@ function DevicesTab() {
} }
} }
async function handleForceDelete() {
if (!deleteDevice) return
try {
await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid, cascade: true })
closeDelete()
} catch {
// If cascade delete also fails for some reason, keep the modal open.
// (Should be very rare — only if the device was concurrently deleted.)
}
}
if (devicesQuery.isLoading) { if (devicesQuery.isLoading) {
return ( return (
<Center pt="xl" data-testid="energy-loading"> <Center pt="xl" data-testid="energy-loading">
@@ -600,6 +639,7 @@ function DevicesTab() {
<ConfirmDeleteModal <ConfirmDeleteModal
device={deleteDevice} device={deleteDevice}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onForceDelete={handleForceDelete}
onCancel={closeDelete} onCancel={closeDelete}
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
has409Error={delete409} has409Error={delete409}
+11 -1
View File
@@ -1725,7 +1725,7 @@
"api-modbus" "api-modbus"
], ],
"summary": "Delete Device", "summary": "Delete Device",
"description": "Delete a Modbus device.\n\nReturns 409 Conflict if the device has any associated readings; use\n``enabled=false`` to disable it instead.\n\n**Application-layer safety**: the check is performed via an explicit\nSELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,\nwhich is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).", "description": "Delete a Modbus device.\n\n**Default behaviour (cascade=false)**:\nReturns 409 Conflict if the device has any associated readings; use\n``enabled=false`` to disable it instead.\n\n**Cascade delete (cascade=true)**:\nPermanently deletes the device together with all its readings and any\n``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.\nAlso makes a best-effort attempt to clear the device's HA Discovery\nconfig topics from MQTT (empty retained payload) before the DB rows\nare removed. MQTT failures are swallowed — the DB deletion proceeds\nregardless.\nReturns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.\n\n**Application-layer safety**: the 409 guard uses an explicit SELECT COUNT\nquery to return a friendly message. FK RESTRICT is enforced at runtime\n(the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes\nreadings before the device.",
"operationId": "delete_device_api_modbus_devices__uuid__delete", "operationId": "delete_device_api_modbus_devices__uuid__delete",
"parameters": [ "parameters": [
{ {
@@ -1737,6 +1737,16 @@
"title": "Uuid" "title": "Uuid"
} }
}, },
{
"name": "cascade",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": false,
"title": "Cascade"
}
},
{ {
"name": "X-CSRF-Token", "name": "X-CSRF-Token",
"in": "header", "in": "header",
+31 -3
View File
@@ -1332,16 +1332,37 @@ paths:
description: 'Delete a Modbus device. description: 'Delete a Modbus device.
**Default behaviour (cascade=false)**:
Returns 409 Conflict if the device has any associated readings; use Returns 409 Conflict if the device has any associated readings; use
``enabled=false`` to disable it instead. ``enabled=false`` to disable it instead.
**Application-layer safety**: the check is performed via an explicit **Cascade delete (cascade=true)**:
SELECT COUNT query — not by relying on SQLite''s FK RESTRICT constraint, Permanently deletes the device together with all its readings and any
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).' ``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.'
operationId: delete_device_api_modbus_devices__uuid__delete operationId: delete_device_api_modbus_devices__uuid__delete
parameters: parameters:
- name: uuid - name: uuid
@@ -1350,6 +1371,13 @@ paths:
schema: schema:
type: string type: string
title: Uuid title: Uuid
- name: cascade
in: query
required: false
schema:
type: boolean
default: false
title: Cascade
- name: X-CSRF-Token - name: X-CSRF-Token
in: header in: header
required: false required: false
+152
View File
@@ -432,6 +432,158 @@ def test_delete_device_not_found_returns_404(modbus_client):
assert resp.status_code == 404 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 # GET /api/modbus/devices/{uuid}/latest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------