Update backup feature based on PR feedback

- Simplify to database-only backups (remove data directory backup)
- Update UI to match app styling patterns:
  - Use ActionIcon with transparent variant for table actions
  - Match icon/color conventions (SquareMinus/red.9, RotateCcw/yellow.5, Download/blue.5)
  - Use standard button bar layout with Paper/Box/Flex
  - Green "Create Backup" button matching "Add" pattern
  - Remove Card wrapper, Alert, and Divider for cleaner layout
  - Update to Mantine v8 Table syntax
- Use standard ConfirmationDialog (remove unused color prop)
- Update tests to remove get_data_dirs references
This commit is contained in:
Jim McBride 2025-12-02 19:24:59 -06:00
parent f1320c9a5d
commit 3f9fd424e2
No known key found for this signature in database
GPG key ID: 3BA456686730E580
3 changed files with 171 additions and 209 deletions

View file

@ -20,12 +20,6 @@ def get_backup_dir() -> Path:
return backup_dir
def get_data_dirs() -> list[Path]:
"""Get list of data directories to include in backups."""
dirs = getattr(settings, "BACKUP_DATA_DIRS", [])
return [Path(d) for d in dirs if d and Path(d).exists()]
def _is_postgresql() -> bool:
"""Check if we're using PostgreSQL."""
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
@ -223,14 +217,6 @@ def create_backup() -> Path:
}
zip_file.writestr("metadata.json", json.dumps(metadata, indent=2))
# Add data directories
for data_dir in get_data_dirs():
logger.debug(f"Adding directory: {data_dir}")
for file_path in data_dir.rglob("*"):
if file_path.is_file():
arcname = f"data/{data_dir.name}/{file_path.relative_to(data_dir)}"
zip_file.write(file_path, arcname)
logger.info(f"Backup created successfully: {backup_file}")
return backup_file
@ -264,33 +250,6 @@ def restore_backup(backup_file: Path) -> None:
# Restore database
_restore_database(temp_path, metadata)
# Restore data directories
data_root = temp_path / "data"
if data_root.exists():
logger.info("Restoring data directories...")
for extracted_dir in data_root.iterdir():
if not extracted_dir.is_dir():
continue
target_name = extracted_dir.name
data_dirs = get_data_dirs()
matching = [d for d in data_dirs if d.name == target_name]
if not matching:
logger.warning(f"No configured directory for {target_name}, skipping")
continue
target = matching[0]
logger.debug(f"Restoring {target_name} to {target}")
# Create parent directory if needed
target.parent.mkdir(parents=True, exist_ok=True)
# Remove existing and copy from backup
if target.exists():
shutil.rmtree(target)
shutil.copytree(extracted_dir, target)
logger.info("Restore completed successfully")

View file

@ -20,14 +20,11 @@ class BackupServicesTestCase(TestCase):
def setUp(self):
self.temp_backup_dir = tempfile.mkdtemp()
self.temp_data_dir = tempfile.mkdtemp()
def tearDown(self):
import shutil
if Path(self.temp_backup_dir).exists():
shutil.rmtree(self.temp_backup_dir)
if Path(self.temp_data_dir).exists():
shutil.rmtree(self.temp_data_dir)
@patch('apps.backups.services.settings')
def test_get_backup_dir_creates_directory(self, mock_settings):
@ -42,31 +39,12 @@ class BackupServicesTestCase(TestCase):
services.get_backup_dir()
mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True)
@patch('apps.backups.services.settings')
def test_get_data_dirs_with_empty_config(self, mock_settings):
"""Test that get_data_dirs returns empty list when no dirs configured"""
mock_settings.BACKUP_DATA_DIRS = []
result = services.get_data_dirs()
self.assertEqual(result, [])
@patch('apps.backups.services.settings')
def test_get_data_dirs_filters_nonexistent(self, mock_settings):
"""Test that get_data_dirs filters out non-existent directories"""
nonexistent_dir = '/tmp/does-not-exist-12345'
mock_settings.BACKUP_DATA_DIRS = [self.temp_data_dir, nonexistent_dir]
result = services.get_data_dirs()
self.assertEqual(len(result), 1)
self.assertEqual(str(result[0]), self.temp_data_dir)
@patch('apps.backups.services.get_backup_dir')
@patch('apps.backups.services.get_data_dirs')
@patch('apps.backups.services._is_postgresql')
@patch('apps.backups.services._dump_sqlite')
def test_create_backup_success_sqlite(self, mock_dump_sqlite, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
def test_create_backup_success_sqlite(self, mock_dump_sqlite, mock_is_pg, mock_get_backup_dir):
"""Test successful backup creation with SQLite"""
mock_get_backup_dir.return_value = Path(self.temp_backup_dir)
mock_get_data_dirs.return_value = []
mock_is_pg.return_value = False
# Mock SQLite dump to create a temp file
@ -94,13 +72,11 @@ class BackupServicesTestCase(TestCase):
self.assertEqual(metadata['database_type'], 'sqlite')
@patch('apps.backups.services.get_backup_dir')
@patch('apps.backups.services.get_data_dirs')
@patch('apps.backups.services._is_postgresql')
@patch('apps.backups.services._dump_postgresql')
def test_create_backup_success_postgresql(self, mock_dump_pg, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
def test_create_backup_success_postgresql(self, mock_dump_pg, mock_is_pg, mock_get_backup_dir):
"""Test successful backup creation with PostgreSQL"""
mock_get_backup_dir.return_value = Path(self.temp_backup_dir)
mock_get_data_dirs.return_value = []
mock_is_pg.return_value = True
# Mock PostgreSQL dump to create a temp file
@ -176,14 +152,12 @@ class BackupServicesTestCase(TestCase):
services.delete_backup("nonexistent-backup.zip")
@patch('apps.backups.services.get_backup_dir')
@patch('apps.backups.services.get_data_dirs')
@patch('apps.backups.services._is_postgresql')
@patch('apps.backups.services._restore_postgresql')
def test_restore_backup_postgresql(self, mock_restore_pg, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
def test_restore_backup_postgresql(self, mock_restore_pg, mock_is_pg, mock_get_backup_dir):
"""Test successful restoration of PostgreSQL backup"""
backup_dir = Path(self.temp_backup_dir)
mock_get_backup_dir.return_value = backup_dir
mock_get_data_dirs.return_value = []
mock_is_pg.return_value = True
# Create PostgreSQL backup file
@ -201,14 +175,12 @@ class BackupServicesTestCase(TestCase):
mock_restore_pg.assert_called_once()
@patch('apps.backups.services.get_backup_dir')
@patch('apps.backups.services.get_data_dirs')
@patch('apps.backups.services._is_postgresql')
@patch('apps.backups.services._restore_sqlite')
def test_restore_backup_sqlite(self, mock_restore_sqlite, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
def test_restore_backup_sqlite(self, mock_restore_sqlite, mock_is_pg, mock_get_backup_dir):
"""Test successful restoration of SQLite backup"""
backup_dir = Path(self.temp_backup_dir)
mock_get_backup_dir.return_value = backup_dir
mock_get_data_dirs.return_value = []
mock_is_pg.return_value = False
# Create SQLite backup file
@ -226,13 +198,11 @@ class BackupServicesTestCase(TestCase):
mock_restore_sqlite.assert_called_once()
@patch('apps.backups.services.get_backup_dir')
@patch('apps.backups.services.get_data_dirs')
@patch('apps.backups.services._is_postgresql')
def test_restore_backup_database_type_mismatch(self, mock_is_pg, mock_get_data_dirs, mock_get_backup_dir):
def test_restore_backup_database_type_mismatch(self, mock_is_pg, mock_get_backup_dir):
"""Test restore fails when database type doesn't match"""
backup_dir = Path(self.temp_backup_dir)
mock_get_backup_dir.return_value = backup_dir
mock_get_data_dirs.return_value = []
mock_is_pg.return_value = True # Current system is PostgreSQL
# Create SQLite backup file

View file

@ -1,14 +1,15 @@
import { useEffect, useState } from 'react';
import {
Alert,
ActionIcon,
Box,
Button,
Card,
Divider,
FileInput,
Flex,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Switch,
@ -19,12 +20,11 @@ import {
} from '@mantine/core';
import {
Download,
PlayCircle,
RefreshCcw,
RotateCcw,
SquareMinus,
SquarePlus,
UploadCloud,
Trash2,
Clock,
Save,
} from 'lucide-react';
import { notifications } from '@mantine/notifications';
@ -312,20 +312,11 @@ export default function BackupManager() {
};
return (
<Stack spacing="md">
<Alert color="blue" title="Backup Information">
Backups include your database and configured data directories. Use the
create button to generate a new backup, or upload an existing backup to
restore.
</Alert>
<Stack gap="md">
{/* Schedule Settings */}
<Card withBorder>
<Group position="apart" mb="md">
<Group>
<Clock size={20} />
<Text weight={600}>Scheduled Backups</Text>
</Group>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={500}>Scheduled Backups</Text>
<Switch
checked={schedule.enabled}
onChange={(e) => handleScheduleChange('enabled', e.currentTarget.checked)}
@ -337,7 +328,7 @@ export default function BackupManager() {
<Loader size="sm" />
) : (
<>
<Group grow mb="md" align="flex-start">
<Group grow align="flex-start">
<Select
label="Frequency"
value={schedule.frequency}
@ -388,130 +379,174 @@ export default function BackupManager() {
/>
)}
<NumberInput
label="Keep Last N Backups"
description="0 = keep all"
inputWrapperOrder={['label', 'input', 'description', 'error']}
label="Retention (0 = all)"
value={schedule.retention_count}
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
min={0}
disabled={!schedule.enabled}
/>
</Group>
<Group position="right">
<Flex justify="flex-end">
<Button
leftIcon={<Save size={16} />}
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged}
variant="default"
>
Save Schedule
Save
</Button>
</Group>
</Flex>
</>
)}
</Card>
</Stack>
<Divider />
{/* Backups List */}
<Stack gap={0}>
<Paper>
<Box
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: 10,
}}
>
<Flex gap={6}>
<Tooltip label="Upload existing backup">
<Button
leftSection={<UploadCloud size={18} />}
variant="light"
size="xs"
onClick={() => setUploadModalOpen(true)}
p={5}
>
Upload
</Button>
</Tooltip>
<Tooltip label="Refresh list">
<Button
leftSection={<RefreshCcw size={18} />}
variant="light"
size="xs"
onClick={loadBackups}
loading={loading}
p={5}
>
Refresh
</Button>
</Tooltip>
<Tooltip label="Create new backup">
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={handleCreateBackup}
loading={creating}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Create Backup
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<Group position="apart">
<Text size="xl" weight={700}>
Backups
</Text>
<Group>
<Button
leftIcon={<UploadCloud size={16} />}
onClick={() => setUploadModalOpen(true)}
<Box
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: 300,
width: '100%',
overflow: 'hidden',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
Upload Backup
</Button>
<Button
leftIcon={<RefreshCcw size={16} />}
onClick={loadBackups}
loading={loading}
variant="light"
>
Refresh
</Button>
<Button
leftIcon={<PlayCircle size={16} />}
onClick={handleCreateBackup}
loading={creating}
>
Create Backup
</Button>
</Group>
</Group>
{loading ? (
<Group position="center" p="xl">
<Loader />
</Group>
) : backups.length === 0 ? (
<Alert color="gray">No backups found. Create one to get started!</Alert>
) : (
<Table striped highlightOnHover>
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.name}>
<td>
<Text size="sm" weight={500}>
{backup.name}
</Text>
</td>
<td>
<Text size="sm">{formatBytes(backup.size)}</Text>
</td>
<td>
<Text size="sm">{formatDate(backup.created)}</Text>
</td>
<td>
<Group spacing="xs">
<Tooltip label="Download">
<Button
size="xs"
variant="light"
onClick={() => handleDownload(backup.name)}
loading={downloading === backup.name}
disabled={downloading !== null}
>
<Download size={16} />
</Button>
</Tooltip>
<Tooltip label="Restore">
<Button
size="xs"
variant="light"
color="orange"
onClick={() => handleRestoreClick(backup)}
>
<PlayCircle size={16} />
</Button>
</Tooltip>
<Tooltip label="Delete">
<Button
size="xs"
variant="light"
color="red"
onClick={() => handleDeleteClick(backup)}
>
<Trash2 size={16} />
</Button>
</Tooltip>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
)}
{loading ? (
<Box p="xl" style={{ display: 'flex', justifyContent: 'center' }}>
<Loader />
</Box>
) : backups.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
No backups found. Create one to get started.
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Filename</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{backups.map((backup) => (
<Table.Tr key={backup.name}>
<Table.Td>
<Text size="sm" fw={500}>
{backup.name}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{formatBytes(backup.size)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{formatDate(backup.created)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Tooltip label="Download">
<ActionIcon
variant="transparent"
color="blue.5"
onClick={() => handleDownload(backup.name)}
loading={downloading === backup.name}
disabled={downloading !== null}
>
<Download size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label="Restore">
<ActionIcon
variant="transparent"
color="yellow.5"
onClick={() => handleRestoreClick(backup)}
>
<RotateCcw size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon
variant="transparent"
color="red.9"
onClick={() => handleDeleteClick(backup)}
>
<SquareMinus size={18} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Box>
</Box>
</Stack>
<Modal
opened={uploadModalOpen}
@ -529,9 +564,9 @@ export default function BackupManager() {
value={uploadFile}
onChange={setUploadFile}
/>
<Group position="right">
<Group justify="flex-end">
<Button
variant="light"
variant="outline"
onClick={() => {
setUploadModalOpen(false);
setUploadFile(null);
@ -539,7 +574,7 @@ export default function BackupManager() {
>
Cancel
</Button>
<Button onClick={handleUploadSubmit} disabled={!uploadFile}>
<Button onClick={handleUploadSubmit} disabled={!uploadFile} variant="default">
Upload
</Button>
</Group>
@ -557,7 +592,6 @@ export default function BackupManager() {
message={`Are you sure you want to restore from "${selectedBackup?.name}"? This will replace all current data with the backup data. This action cannot be undone.`}
confirmLabel="Restore"
cancelLabel="Cancel"
color="orange"
/>
<ConfirmationDialog
@ -571,7 +605,6 @@ export default function BackupManager() {
message={`Are you sure you want to delete "${selectedBackup?.name}"? This action cannot be undone.`}
confirmLabel="Delete"
cancelLabel="Cancel"
color="red"
/>
</Stack>
);