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
+33
View File
@@ -288,6 +288,7 @@ describe('EnergyPage — delete device', () => {
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
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())
fireEvent.click(screen.getByTestId('device-delete-confirm'))
@@ -295,6 +296,38 @@ describe('EnergyPage — delete device', () => {
await waitFor(() => {
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 } },
})
})
})
})
+54 -14
View File
@@ -57,13 +57,22 @@ import { formatLocalDateTime } from '../utils/datetime'
interface ConfirmDeleteProps {
device: ModbusDevice
onConfirm: () => void
/** Called when the user explicitly opts into cascade (force) deletion. */
onForceDelete: () => void
onCancel: () => void
loading: boolean
/** Set when the delete failed with 409. */
has409Error: boolean
}
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
function ConfirmDeleteModal({
device,
onConfirm,
onForceDelete,
onCancel,
loading,
has409Error,
}: ConfirmDeleteProps) {
return (
<Modal
opened
@@ -78,24 +87,43 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
</Text>
{has409Error && (
<Alert color="orange" data-testid="device-delete-409-hint">
This device has existing readings and cannot be deleted. Consider{' '}
<strong>disabling</strong> it instead (click Edit uncheck Enabled).
</Alert>
<>
<Alert color="orange" data-testid="device-delete-409-hint">
This device has existing readings and cannot be deleted. Consider{' '}
<strong>disabling</strong> it instead (click Edit uncheck Enabled).
</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">
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
Cancel
</Button>
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="device-delete-confirm"
>
Delete
</Button>
{!has409Error && (
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="device-delete-confirm"
>
Delete
</Button>
)}
{has409Error && (
<Button
color="red"
loading={loading}
onClick={onForceDelete}
data-testid="device-delete-force"
>
Force Delete (all data)
</Button>
)}
</Group>
</Stack>
</Modal>
@@ -532,7 +560,7 @@ function DevicesTab() {
if (!deleteDevice) return
setDelete409(false)
try {
await deleteMutation.mutateAsync(deleteDevice.uuid)
await deleteMutation.mutateAsync({ uuid: deleteDevice.uuid })
closeDelete()
} catch (err) {
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) {
return (
<Center pt="xl" data-testid="energy-loading">
@@ -600,6 +639,7 @@ function DevicesTab() {
<ConfirmDeleteModal
device={deleteDevice}
onConfirm={handleDeleteConfirm}
onForceDelete={handleForceDelete}
onCancel={closeDelete}
loading={deleteMutation.isPending}
has409Error={delete409}