Add timezone support for backup scheduling

- Set CrontabSchedule timezone to system timezone for accurate scheduling
  - Replace time TextInput with hour/minute Select dropdowns for cleaner UX
  - Remove UTC/local time conversion logic (handled by Celery)
  - Add tests for timezone functionality in simple and advanced modes
This commit is contained in:
Jim McBride 2025-12-13 13:27:56 -06:00
parent 8388152d79
commit 1dc7700a62
No known key found for this signature in database
GPG key ID: 4E6EF989025D7695
3 changed files with 140 additions and 75 deletions

View file

@ -127,6 +127,7 @@ def _sync_periodic_task() -> None:
day_of_week=day_of_week,
day_of_month=day_of_month,
month_of_year=month_of_year,
timezone=CoreSettings.get_system_time_zone(),
)
except Exception as e:
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
@ -137,6 +138,7 @@ def _sync_periodic_task() -> None:
hour, minute = settings["time"].split(":")
# Build crontab based on frequency
system_tz = CoreSettings.get_system_time_zone()
if settings["frequency"] == "daily":
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
@ -144,6 +146,7 @@ def _sync_periodic_task() -> None:
day_of_week="*",
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
else: # weekly
crontab, _ = CrontabSchedule.objects.get_or_create(
@ -152,6 +155,7 @@ def _sync_periodic_task() -> None:
day_of_week=str(settings["day_of_week"]),
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
# Create or update the periodic task

View file

@ -738,6 +738,16 @@ class BackupAPITestCase(TestCase):
class BackupSchedulerTestCase(TestCase):
"""Test cases for backup scheduler"""
databases = {'default'}
@classmethod
def setUpClass(cls):
pass
@classmethod
def tearDownClass(cls):
pass
def setUp(self):
from core.models import CoreSettings
# Clean up any existing settings
@ -949,6 +959,63 @@ class BackupSchedulerTestCase(TestCase):
self.assertEqual(task.crontab.hour, '*/6')
self.assertEqual(task.crontab.day_of_week, '*')
def test_periodic_task_uses_system_timezone(self):
"""Test that CrontabSchedule is created with the system timezone"""
from . import scheduler
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings
original_tz = CoreSettings.get_system_time_zone()
try:
# Set a non-UTC timezone
CoreSettings.set_system_time_zone('America/New_York')
scheduler.update_schedule_settings({
'enabled': True,
'frequency': 'daily',
'time': '03:00',
})
task = PeriodicTask.objects.get(name='backup-scheduled-task')
self.assertEqual(str(task.crontab.timezone), 'America/New_York')
finally:
scheduler.update_schedule_settings({'enabled': False})
CoreSettings.set_system_time_zone(original_tz)
def test_periodic_task_timezone_updates_with_schedule(self):
"""Test that CrontabSchedule timezone is updated when schedule is modified"""
from . import scheduler
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings
original_tz = CoreSettings.get_system_time_zone()
try:
# Create initial schedule with one timezone
CoreSettings.set_system_time_zone('America/Los_Angeles')
scheduler.update_schedule_settings({
'enabled': True,
'frequency': 'daily',
'time': '02:00',
})
task = PeriodicTask.objects.get(name='backup-scheduled-task')
self.assertEqual(str(task.crontab.timezone), 'America/Los_Angeles')
# Change system timezone and update schedule
CoreSettings.set_system_time_zone('Europe/London')
scheduler.update_schedule_settings({
'enabled': True,
'time': '04:00',
})
task.refresh_from_db()
self.assertEqual(str(task.crontab.timezone), 'Europe/London')
finally:
scheduler.update_schedule_settings({'enabled': False})
CoreSettings.set_system_time_zone(original_tz)
class BackupTasksTestCase(TestCase):
"""Test cases for backup Celery tasks"""

View file

@ -98,37 +98,6 @@ function to24Hour(time12, period) {
return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
// Convert UTC time (HH:MM) to local time (HH:MM)
function utcToLocal(utcTime) {
if (!utcTime) return '00:00';
const [hours, minutes] = utcTime.split(':').map(Number);
// Create a date in UTC
const date = new Date();
date.setUTCHours(hours, minutes, 0, 0);
// Get local time components
const localHours = date.getHours();
const localMinutes = date.getMinutes();
return `${String(localHours).padStart(2, '0')}:${String(localMinutes).padStart(2, '0')}`;
}
// Convert local time (HH:MM) to UTC time (HH:MM)
function localToUtc(localTime) {
if (!localTime) return '00:00';
const [hours, minutes] = localTime.split(':').map(Number);
// Create a date in local time
const date = new Date();
date.setHours(hours, minutes, 0, 0);
// Get UTC time components
const utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes();
return `${String(utcHours).padStart(2, '0')}:${String(utcMinutes).padStart(2, '0')}`;
}
// Get default timezone (same as Settings page)
function getDefaultTimeZone() {
@ -376,20 +345,15 @@ export default function BackupManager() {
// 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);
}
setSchedule(settings);
// Initialize 12-hour display values
const { time, period } = to12Hour(settings.time);
setDisplayTime(time);
setTimePeriod(period);
setScheduleChanged(false);
} catch (error) {
// Ignore errors on initial load - settings may not exist yet
@ -447,27 +411,12 @@ export default function BackupManager() {
const handleSaveSchedule = async () => {
setScheduleSaving(true);
try {
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 scheduleToSave = advancedMode
? schedule
: { ...schedule, cron_expression: '' };
const updated = await API.updateBackupSchedule(scheduleToSave);
if (advancedMode) {
setSchedule(updated);
} else {
// Convert UTC time from backend response back to local time
const localTime = utcToLocal(updated.time);
setSchedule({ ...updated, time: localTime });
}
setSchedule(updated);
setScheduleChanged(false);
notifications.show({
@ -691,14 +640,36 @@ export default function BackupManager() {
)}
{is12Hour ? (
<Group grow align="flex-end" gap="xs">
<TextInput
label="Time"
value={displayTime}
onChange={(e) => handleTimeChange12h(e.currentTarget.value, null)}
placeholder="3:00"
<Select
label="Hour"
value={displayTime ? displayTime.split(':')[0] : '12'}
onChange={(value) => {
const minute = displayTime ? displayTime.split(':')[1] : '00';
handleTimeChange12h(`${value}:${minute}`, null);
}}
data={Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={displayTime ? displayTime.split(':')[1] : '00'}
onChange={(value) => {
const hour = displayTime ? displayTime.split(':')[0] : '12';
handleTimeChange12h(`${hour}:${value}`, null);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Period"
value={timePeriod}
onChange={(value) => handleTimeChange12h(null, value)}
data={[
@ -709,13 +680,36 @@ export default function BackupManager() {
/>
</Group>
) : (
<TextInput
label="Time"
value={schedule.time}
onChange={(e) => handleTimeChange24h(e.currentTarget.value)}
placeholder="03:00"
disabled={!schedule.enabled}
/>
<Group grow align="flex-end" gap="xs">
<Select
label="Hour"
value={schedule.time ? schedule.time.split(':')[0] : '00'}
onChange={(value) => {
const minute = schedule.time ? schedule.time.split(':')[1] : '00';
handleTimeChange24h(`${value}:${minute}`);
}}
data={Array.from({ length: 24 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
<Select
label="Minute"
value={schedule.time ? schedule.time.split(':')[1] : '00'}
onChange={(value) => {
const hour = schedule.time ? schedule.time.split(':')[0] : '00';
handleTimeChange24h(`${hour}:${value}`);
}}
data={Array.from({ length: 60 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}))}
disabled={!schedule.enabled}
searchable
/>
</Group>
)}
<NumberInput
label="Retention"
@ -739,7 +733,7 @@ export default function BackupManager() {
{/* Timezone info - only show in simple mode */}
{!advancedMode && schedule.enabled && schedule.time && (
<Text size="xs" c="dimmed" mt="xs">
Timezone: {userTimezone} Backup will run at {schedule.time}
System Timezone: {userTimezone} Backup will run at {schedule.time} {userTimezone}
</Text>
)}
</>