From 5fbcaa91e0906069c95f5ae166a2a75a774cc26d Mon Sep 17 00:00:00 2001 From: Jim McBride Date: Tue, 9 Dec 2025 07:55:47 -0600 Subject: [PATCH] Add custom cron expression support for backup scheduling Frontend changes: - Add advanced mode toggle switch for cron expressions - Show cron expression input with helpful examples when enabled - Display format hints: "minute hour day month weekday" - Provide common examples (daily, weekly, every 6 hours, etc.) - Conditionally render simple or advanced scheduling UI - Support switching between simple and advanced modes Backend changes: - Add cron_expression to schedule settings (SETTING_KEYS, DEFAULTS) - Update get_schedule_settings to include cron_expression - Update update_schedule_settings to handle cron_expression - Extend _sync_periodic_task to parse and use cron expressions - Parse 5-part cron format: minute hour day_of_month month_of_year day_of_week - Create CrontabSchedule from cron expression or simple frequency - Add validation and error handling for invalid cron expressions This addresses maintainer feedback for "custom scheduler (cron style) for more control". Users can now schedule backups with full cron flexibility beyond daily/weekly. --- apps/backups/scheduler.py | 65 ++++--- .../src/components/backups/BackupManager.jsx | 160 +++++++++++++----- 2 files changed, 161 insertions(+), 64 deletions(-) diff --git a/apps/backups/scheduler.py b/apps/backups/scheduler.py index 52186e90..b0b37567 100644 --- a/apps/backups/scheduler.py +++ b/apps/backups/scheduler.py @@ -15,6 +15,7 @@ SETTING_KEYS = { "time": "backup_schedule_time", "day_of_week": "backup_schedule_day_of_week", "retention_count": "backup_retention_count", + "cron_expression": "backup_schedule_cron_expression", } DEFAULTS = { @@ -23,6 +24,7 @@ DEFAULTS = { "time": "03:00", "day_of_week": 0, # Sunday "retention_count": 0, + "cron_expression": "", } @@ -60,6 +62,7 @@ def get_schedule_settings() -> dict: "time": _get_setting("time"), "day_of_week": _get_setting("day_of_week"), "retention_count": _get_setting("retention_count"), + "cron_expression": _get_setting("cron_expression"), } @@ -88,7 +91,7 @@ def update_schedule_settings(data: dict) -> dict: raise ValueError("retention_count must be >= 0") # Update settings - for key in ("enabled", "frequency", "time", "day_of_week", "retention_count"): + for key in ("enabled", "frequency", "time", "day_of_week", "retention_count", "cron_expression"): if key in data: _set_setting(key, data[key]) @@ -108,26 +111,48 @@ def _sync_periodic_task() -> None: logger.info("Backup schedule disabled, removed periodic task") return - # Parse time - hour, minute = settings["time"].split(":") + # Check if using cron expression (advanced mode) + if settings["cron_expression"]: + # Parse cron expression: "minute hour day month weekday" + try: + parts = settings["cron_expression"].split() + if len(parts) != 5: + raise ValueError("Cron expression must have 5 parts: minute hour day month weekday") - # Build crontab based on frequency - if settings["frequency"] == "daily": - crontab, _ = CrontabSchedule.objects.get_or_create( - minute=minute, - hour=hour, - day_of_week="*", - day_of_month="*", - month_of_year="*", - ) - else: # weekly - crontab, _ = CrontabSchedule.objects.get_or_create( - minute=minute, - hour=hour, - day_of_week=str(settings["day_of_week"]), - day_of_month="*", - month_of_year="*", - ) + minute, hour, day_of_month, month_of_year, day_of_week = parts + + crontab, _ = CrontabSchedule.objects.get_or_create( + minute=minute, + hour=hour, + day_of_week=day_of_week, + day_of_month=day_of_month, + month_of_year=month_of_year, + ) + except Exception as e: + logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}") + raise ValueError(f"Invalid cron expression: {e}") + else: + # Use simple frequency-based scheduling + # Parse time + hour, minute = settings["time"].split(":") + + # Build crontab based on frequency + if settings["frequency"] == "daily": + crontab, _ = CrontabSchedule.objects.get_or_create( + minute=minute, + hour=hour, + day_of_week="*", + day_of_month="*", + month_of_year="*", + ) + else: # weekly + crontab, _ = CrontabSchedule.objects.get_or_create( + minute=minute, + hour=hour, + day_of_week=str(settings["day_of_week"]), + day_of_month="*", + month_of_year="*", + ) # Create or update the periodic task task, created = PeriodicTask.objects.update_or_create( diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 3732ae09..dd47f732 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -188,10 +188,12 @@ export default function BackupManager() { time: '03:00', day_of_week: 0, retention_count: 0, + cron_expression: '', }); const [scheduleLoading, setScheduleLoading] = useState(false); const [scheduleSaving, setScheduleSaving] = useState(false); const [scheduleChanged, setScheduleChanged] = useState(false); + const [advancedMode, setAdvancedMode] = useState(false); // For 12-hour display mode const [displayTime, setDisplayTime] = useState('3:00'); @@ -299,17 +301,24 @@ export default function BackupManager() { try { const settings = await API.getBackupSchedule(); - // Convert UTC time from backend to local time - const localTime = utcToLocal(settings.time); + // Check if using cron expression (advanced mode) + if (settings.cron_expression) { + setAdvancedMode(true); + setSchedule(settings); + } else { + // Convert UTC time from backend to local time + const localTime = utcToLocal(settings.time); + + // Store with local time for display + setSchedule({ ...settings, time: localTime }); + + // Initialize 12-hour display values from the local time + const { time, period } = to12Hour(localTime); + setDisplayTime(time); + setTimePeriod(period); + } - // Store with local time for display - setSchedule({ ...settings, time: localTime }); setScheduleChanged(false); - - // Initialize 12-hour display values from the local time - const { time, period } = to12Hour(localTime); - setDisplayTime(time); - setTimePeriod(period); } catch (error) { // Ignore errors on initial load - settings may not exist yet } finally { @@ -350,15 +359,27 @@ export default function BackupManager() { const handleSaveSchedule = async () => { setScheduleSaving(true); try { - // Convert local time to UTC before sending to backend - const utcTime = localToUtc(schedule.time); - const scheduleToSave = { ...schedule, time: utcTime }; + let scheduleToSave; + + if (advancedMode) { + // In advanced mode, send cron expression as-is + scheduleToSave = schedule; + } else { + // Convert local time to UTC before sending to backend + const utcTime = localToUtc(schedule.time); + scheduleToSave = { ...schedule, time: utcTime, cron_expression: '' }; + } const updated = await API.updateBackupSchedule(scheduleToSave); - // Convert UTC time from backend response back to local time - const localTime = utcToLocal(updated.time); - setSchedule({ ...updated, time: localTime }); + if (advancedMode) { + setSchedule(updated); + } else { + // Convert UTC time from backend response back to local time + const localTime = utcToLocal(updated.time); + setSchedule({ ...updated, time: localTime }); + } + setScheduleChanged(false); notifications.show({ @@ -509,17 +530,65 @@ export default function BackupManager() { ) : ( <> - - handleScheduleChange('frequency', value)} + data={[ + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + ]} + disabled={!schedule.enabled} + /> {schedule.frequency === 'weekly' && (