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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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*.
|
||||||
|
|
||||||
|
|||||||
Vendored
+17
-4
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'] }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user