From 8b0895f63f76cc97efed84f054012fee2ce328e5 Mon Sep 17 00:00:00 2001 From: Matteo Parenti Date: Fri, 13 Feb 2026 15:39:52 +0100 Subject: [PATCH] Add soft-delete for system maintenance tasks --- backend/app/api/routes/maintenance.py | 32 ++++++++-- backend/app/core/database.py | 7 +++ backend/app/models/maintenance.py | 1 + backend/tests/unit/test_code_quality.py | 2 +- frontend/src/api/client.ts | 2 + frontend/src/components/ConfirmModal.tsx | 16 ++++- frontend/src/i18n/locales/de.ts | 4 ++ frontend/src/i18n/locales/en.ts | 4 ++ frontend/src/i18n/locales/it.ts | 4 ++ frontend/src/i18n/locales/ja.ts | 4 ++ frontend/src/pages/MaintenancePage.tsx | 74 +++++++++++++++++++++--- 11 files changed, 135 insertions(+), 15 deletions(-) diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index ee24ff810..30fc9a27d 100644 --- a/backend/app/api/routes/maintenance.py +++ b/backend/app/api/routes/maintenance.py @@ -177,7 +177,11 @@ async def get_maintenance_types( ): """Get all maintenance types.""" await ensure_default_types(db) - result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)) + result = await db.execute( + select(MaintenanceType) + .where(MaintenanceType.is_deleted.is_(False)) + .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name) + ) return result.scalars().all() @@ -230,20 +234,40 @@ async def delete_maintenance_type( db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE), ): - """Delete a custom maintenance type.""" + """Delete a maintenance type.""" result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id)) maint_type = result.scalar_one_or_none() if not maint_type: raise HTTPException(status_code=404, detail="Maintenance type not found") if maint_type.is_system: - raise HTTPException(status_code=400, detail="Cannot delete system maintenance type") + maint_type.is_deleted = True + await db.commit() + return {"status": "deleted"} await db.delete(maint_type) await db.commit() return {"status": "deleted"} +@router.post("/types/restore-defaults") +async def restore_default_maintenance_types( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE), +): + """Restore deleted default maintenance types.""" + await ensure_default_types(db) + result = await db.execute( + select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True)) + ) + deleted_types = result.scalars().all() + for maint_type in deleted_types: + maint_type.is_deleted = False + + await db.commit() + return {"restored": len(deleted_types)} + + # ============== Printer Maintenance ============== @@ -264,7 +288,7 @@ async def _get_printer_maintenance_internal( total_hours = await get_printer_total_hours(db, printer_id) # Get all maintenance types - result = await db.execute(select(MaintenanceType)) + result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False))) all_types = result.scalars().all() # Get printer's maintenance items diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 89d750ff3..d0e14bc3a 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -188,6 +188,13 @@ async def run_migrations(conn): # Column already exists pass + # Migration: Add is_deleted column to maintenance_types for soft-deletes + try: + await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")) + except OperationalError: + # Column already exists + pass + # Migration: Add custom_interval_type column to printer_maintenance try: await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")) diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py index 2bd772829..beb036415 100644 --- a/backend/app/models/maintenance.py +++ b/backend/app/models/maintenance.py @@ -22,6 +22,7 @@ class MaintenanceType(Base): icon: Mapped[str | None] = mapped_column(String(50)) # Icon name for UI wiki_url: Mapped[str | None] = mapped_column(String(500)) # Documentation link is_system: Mapped[bool] = mapped_column(Boolean, default=False) # Pre-defined vs custom + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) # Hidden/removed type created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) # Relationships diff --git a/backend/tests/unit/test_code_quality.py b/backend/tests/unit/test_code_quality.py index 32a3b7aae..73734e618 100644 --- a/backend/tests/unit/test_code_quality.py +++ b/backend/tests/unit/test_code_quality.py @@ -137,7 +137,7 @@ def find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]: Returns list of (name, line_number, function_name) tuples. """ try: - with open(file_path) as f: + with open(file_path, encoding="utf-8") as f: source = f.read() tree = ast.parse(source) visitor = DangerousImportVisitor() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ddaf728d7..4e7bbbc13 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3462,6 +3462,8 @@ export const api = { }), deleteMaintenanceType: (id: number) => request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }), + restoreDefaultMaintenanceTypes: () => + request<{ restored: number }>(`/maintenance/types/restore-defaults`, { method: 'POST' }), getMaintenanceOverview: () => request('/maintenance/overview'), getPrinterMaintenance: (printerId: number) => request(`/maintenance/printers/${printerId}`), diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index 775a88216..93da3b54e 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -9,6 +9,8 @@ interface ConfirmModalProps { message: string; confirmText?: string; cancelText?: string; + cancelVariant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + cardClassName?: string; variant?: 'danger' | 'warning' | 'default'; isLoading?: boolean; loadingText?: string; @@ -21,6 +23,8 @@ export function ConfirmModal({ message, confirmText, cancelText, + cancelVariant, + cardClassName, variant = 'default', isLoading = false, loadingText, @@ -62,7 +66,10 @@ export function ConfirmModal({ className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={isLoading ? undefined : onCancel} > - e.stopPropagation()}> + e.stopPropagation()} + >
@@ -74,7 +81,12 @@ export function ConfirmModal({
-
- +
+ + +
{/* Add custom type form */} @@ -846,6 +862,17 @@ function SettingsSection({ {formatIntervalLabel(type.default_interval_hours, intervalType, t)} + ); @@ -1124,6 +1151,23 @@ function SettingsSection({
)} + + {pendingSystemDelete && ( + { + onDeleteType(pendingSystemDelete.id); + setPendingSystemDelete(null); + }} + onCancel={() => setPendingSystemDelete(null)} + /> + )} ); } @@ -1199,6 +1243,18 @@ export function MaintenancePage() { }, }); + const restoreDefaultsMutation = useMutation({ + mutationFn: api.restoreDefaultMaintenanceTypes, + onSuccess: (data: { restored: number }) => { + queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] }); + queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] }); + showToast(t('maintenance.defaultsRestored', { count: data.restored })); + }, + onError: (error: Error) => { + showToast(error.message, 'error'); + }, + }); + const setHoursMutation = useMutation({ mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) => api.setPrinterHours(printerId, hours), @@ -1352,6 +1408,8 @@ export function MaintenancePage() { }} onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })} onDeleteType={(id) => deleteTypeMutation.mutate(id)} + onRestoreDefaults={() => restoreDefaultsMutation.mutate()} + isRestoringDefaults={restoreDefaultsMutation.isPending} onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })} onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)} hasPermission={hasPermission}