mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Merge pull request #359 from Keybored02/0.2.0b
Add soft-delete support for system maintenance types
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PrinterMaintenanceOverview[]>('/maintenance/overview'),
|
||||
getPrinterMaintenance: (printerId: number) =>
|
||||
request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Card
|
||||
className={`w-full max-w-md ${cardClassName ?? ''}`}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
|
||||
@@ -74,7 +81,12 @@ export function ConfirmModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
|
||||
<Button
|
||||
variant={cancelVariant ?? 'secondary'}
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{resolvedCancelText}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -958,6 +958,7 @@ export default {
|
||||
maintenanceTypes: 'Wartungstypen',
|
||||
maintenanceTypesDescription: 'Systemtypen und Ihre benutzerdefinierten Wartungsaufgaben',
|
||||
addCustomType: 'Benutzerdefinierten Typ hinzufügen',
|
||||
restoreDefaults: 'Standardaufgaben wiederherstellen',
|
||||
intervalType: 'Intervalltyp',
|
||||
intervalValue: 'Intervall ({{type}})',
|
||||
icon: 'Symbol',
|
||||
@@ -1001,11 +1002,14 @@ export default {
|
||||
maintenanceComplete: 'Wartung als abgeschlossen markiert',
|
||||
typeUpdated: 'Wartungstyp aktualisiert',
|
||||
typeDeleted: 'Wartungstyp gelöscht',
|
||||
defaultsRestored: '{{count}} Standardaufgabe(n) wiederhergestellt',
|
||||
printHoursUpdated: 'Druckstunden aktualisiert',
|
||||
printerAssigned: 'Drucker zugewiesen',
|
||||
printerRemoved: 'Drucker entfernt',
|
||||
// Confirmation
|
||||
deleteTypeConfirm: '"{{name}}" löschen?',
|
||||
deleteSystemTypeTitle: 'Standard-Wartungsaufgabe löschen?',
|
||||
deleteSystemTypeMessage: 'Möchten Sie die Standard-Wartungsaufgabe "{{name}}" wirklich löschen?',
|
||||
// Permissions
|
||||
noPermissionUpdate: 'Sie haben keine Berechtigung, Wartungselemente zu aktualisieren',
|
||||
noPermissionPerform: 'Sie haben keine Berechtigung, Wartungen durchzuführen',
|
||||
|
||||
@@ -958,6 +958,7 @@ export default {
|
||||
maintenanceTypes: 'Maintenance Types',
|
||||
maintenanceTypesDescription: 'System types and your custom maintenance tasks',
|
||||
addCustomType: 'Add Custom Type',
|
||||
restoreDefaults: 'Restore Default Tasks',
|
||||
intervalType: 'Interval Type',
|
||||
intervalValue: 'Interval ({{type}})',
|
||||
icon: 'Icon',
|
||||
@@ -1001,11 +1002,14 @@ export default {
|
||||
maintenanceComplete: 'Maintenance marked as complete',
|
||||
typeUpdated: 'Maintenance type updated',
|
||||
typeDeleted: 'Maintenance type deleted',
|
||||
defaultsRestored: 'Restored {{count}} default task(s)',
|
||||
printHoursUpdated: 'Print hours updated',
|
||||
printerAssigned: 'Printer assigned',
|
||||
printerRemoved: 'Printer removed',
|
||||
// Confirmation
|
||||
deleteTypeConfirm: 'Delete "{{name}}"?',
|
||||
deleteSystemTypeTitle: 'Delete default maintenance task?',
|
||||
deleteSystemTypeMessage: 'Are you sure you want to delete the default maintenance task "{{name}}"?',
|
||||
// Permissions
|
||||
noPermissionUpdate: 'You do not have permission to update maintenance items',
|
||||
noPermissionPerform: 'You do not have permission to perform maintenance',
|
||||
|
||||
@@ -945,6 +945,7 @@ export default {
|
||||
maintenanceTypes: 'Tipi di manutenzione',
|
||||
maintenanceTypesDescription: 'Tipi di sistema e tue attivita personalizzate',
|
||||
addCustomType: 'Aggiungi tipo personalizzato',
|
||||
restoreDefaults: 'Ripristina attivita predefinite',
|
||||
intervalType: 'Tipo intervallo',
|
||||
intervalValue: 'Intervallo ({{type}})',
|
||||
icon: 'Icona',
|
||||
@@ -988,11 +989,14 @@ export default {
|
||||
maintenanceComplete: 'Manutenzione segnata come completata',
|
||||
typeUpdated: 'Tipo manutenzione aggiornato',
|
||||
typeDeleted: 'Tipo manutenzione eliminato',
|
||||
defaultsRestored: 'Ripristinate {{count}} attivita predefinite',
|
||||
printHoursUpdated: 'Ore di stampa aggiornate',
|
||||
printerAssigned: 'Stampante assegnata',
|
||||
printerRemoved: 'Stampante rimossa',
|
||||
// Confirmation
|
||||
deleteTypeConfirm: 'Eliminare "{{name}}"?',
|
||||
deleteSystemTypeTitle: 'Eliminare attività di manutenzione predefinita?',
|
||||
deleteSystemTypeMessage: 'Sei sicuro di voler eliminare l\'attività di manutenzione predefinita "{{name}}"?',
|
||||
// Permissions
|
||||
noPermissionUpdate: 'Non hai il permesso di aggiornare elementi manutenzione',
|
||||
noPermissionPerform: 'Non hai il permesso di eseguire manutenzione',
|
||||
|
||||
@@ -1001,6 +1001,7 @@ export default {
|
||||
months: '{{count}}ヶ月',
|
||||
maintenanceTypes: 'メンテナンスタイプ',
|
||||
addCustomType: 'カスタムタイプを追加',
|
||||
restoreDefaults: 'デフォルトタスクを復元',
|
||||
intervalType: 'インターバルタイプ',
|
||||
icon: 'アイコン',
|
||||
documentationLink: 'ドキュメントリンク(任意)',
|
||||
@@ -1034,9 +1035,12 @@ export default {
|
||||
},
|
||||
typeUpdated: 'メンテナンスタイプを更新しました',
|
||||
typeDeleted: 'メンテナンスタイプを削除しました',
|
||||
defaultsRestored: 'デフォルトタスクを{{count}}件復元しました',
|
||||
printerAssigned: 'プリンターを割り当てました',
|
||||
printerRemoved: 'プリンターを削除しました',
|
||||
deleteTypeConfirm: '「{{name}}」を削除しますか?',
|
||||
deleteSystemTypeTitle: 'デフォルトのメンテナンスタスクを削除しますか?',
|
||||
deleteSystemTypeMessage: 'デフォルトのメンテナンスタスク「{{name}}」を削除してもよろしいですか?',
|
||||
noPermissionUpdate: 'メンテナンス記録を更新する権限がありません',
|
||||
noPermissionPerform: 'メンテナンスを実行する権限がありません',
|
||||
noPermissionEditTypes: 'メンテナンスタイプを編集する権限がありません',
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Pe
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { Toggle } from '../components/Toggle';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
@@ -552,6 +553,8 @@ function SettingsSection({
|
||||
onAddType,
|
||||
onUpdateType,
|
||||
onDeleteType,
|
||||
onRestoreDefaults,
|
||||
isRestoringDefaults,
|
||||
onAssignType,
|
||||
onRemoveItem,
|
||||
hasPermission,
|
||||
@@ -563,6 +566,8 @@ function SettingsSection({
|
||||
onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string; wiki_url?: string | null }, printerIds: number[]) => void;
|
||||
onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string; wiki_url?: string | null }) => void;
|
||||
onDeleteType: (id: number) => void;
|
||||
onRestoreDefaults: () => void;
|
||||
isRestoringDefaults: boolean;
|
||||
onAssignType: (printerId: number, typeId: number) => void;
|
||||
onRemoveItem: (itemId: number) => void;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
@@ -579,6 +584,7 @@ function SettingsSection({
|
||||
const [newTypeWikiUrl, setNewTypeWikiUrl] = useState('');
|
||||
const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
|
||||
const [expandedType, setExpandedType] = useState<number | null>(null);
|
||||
const [pendingSystemDelete, setPendingSystemDelete] = useState<MaintenanceType | null>(null);
|
||||
|
||||
// Get unique printers from overview
|
||||
const printers = useMemo(() => {
|
||||
@@ -697,14 +703,24 @@ function SettingsSection({
|
||||
<h2 className="text-lg font-semibold text-white">{t('maintenance.maintenanceTypes')}</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">{t('maintenance.maintenanceTypesDescription')}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddType(!showAddType)}
|
||||
disabled={!hasPermission('maintenance:create')}
|
||||
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('maintenance.addCustomType')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onRestoreDefaults}
|
||||
disabled={!hasPermission('maintenance:delete') || isRestoringDefaults}
|
||||
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
|
||||
>
|
||||
{t('maintenance.restoreDefaults')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowAddType(!showAddType)}
|
||||
disabled={!hasPermission('maintenance:create')}
|
||||
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('maintenance.addCustomType')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add custom type form */}
|
||||
@@ -846,6 +862,17 @@ function SettingsSection({
|
||||
{formatIntervalLabel(type.default_interval_hours, intervalType, t)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!hasPermission('maintenance:delete')) return;
|
||||
setPendingSystemDelete(type);
|
||||
}}
|
||||
disabled={!hasPermission('maintenance:delete')}
|
||||
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
|
||||
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1124,6 +1151,23 @@ function SettingsSection({
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pendingSystemDelete && (
|
||||
<ConfirmModal
|
||||
title={t('maintenance.deleteSystemTypeTitle')}
|
||||
message={t('maintenance.deleteSystemTypeMessage', { name: pendingSystemDelete.name })}
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="danger"
|
||||
cancelVariant="primary"
|
||||
cardClassName="bg-red-950/70 border border-red-800/70"
|
||||
onConfirm={() => {
|
||||
onDeleteType(pendingSystemDelete.id);
|
||||
setPendingSystemDelete(null);
|
||||
}}
|
||||
onCancel={() => setPendingSystemDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user