diff --git a/surfsense_backend/app/routes/connector_schedules_routes.py b/surfsense_backend/app/routes/connector_schedules_routes.py index 5922f4db7..564a06f33 100644 --- a/surfsense_backend/app/routes/connector_schedules_routes.py +++ b/surfsense_backend/app/routes/connector_schedules_routes.py @@ -9,6 +9,7 @@ PATCH /connector-schedules/{schedule_id}/toggle - Activate/deactivate a schedule """ import logging +from datetime import timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.exc import IntegrityError @@ -87,15 +88,17 @@ async def create_connector_schedule( detail=f"A schedule already exists for connector {schedule.connector_id} and search space {schedule.search_space_id}", ) - # Calculate next run time - next_run_at = calculate_next_run( - schedule.schedule_type, - schedule.cron_expression, - schedule.daily_time, - schedule.weekly_day, - schedule.weekly_time, - schedule.hourly_minute - ) + # Calculate next run time only if schedule is active + next_run_at = None + if schedule.is_active: + next_run_at = calculate_next_run( + schedule.schedule_type, + schedule.cron_expression, + schedule.daily_time, + schedule.weekly_day, + schedule.weekly_time, + schedule.hourly_minute + ) # Create schedule (only DB columns) db_data = schedule.model_dump( @@ -242,34 +245,78 @@ async def update_connector_schedule( # Update fields that were provided update_data = schedule_update.model_dump(exclude_unset=True) + # Effective types/values after this update (for validation and next-run calc) + effective_type = update_data.get("schedule_type", schedule.schedule_type) + effective_cron = update_data.get("cron_expression", schedule.cron_expression) + effective_daily = update_data.get("daily_time", schedule.daily_time) + effective_weekly_day = update_data.get("weekly_day", schedule.weekly_day) + effective_weekly_time = update_data.get("weekly_time", schedule.weekly_time) + effective_hourly_minute = update_data.get("hourly_minute", schedule.hourly_minute) + will_be_active = update_data.get("is_active", schedule.is_active) + + # Disallow cron for non-CUSTOM schedules + if "cron_expression" in update_data and effective_type != ScheduleType.CUSTOM: + raise HTTPException( + status_code=400, + detail="cron_expression is only valid when schedule_type is CUSTOM", + ) + # Check if any time-related fields changed (requiring next_run recalc) time_fields_changed = any( field in update_data for field in ["daily_time", "weekly_day", "weekly_time", "hourly_minute"] ) - # If schedule_type is being updated, recalculate next_run_at + # If schedule_type is being updated or time fields changed, recalculate next_run_at if "schedule_type" in update_data or time_fields_changed: - # Use the new schedule_type and existing values for calculation - new_schedule_type = update_data.get("schedule_type", schedule.schedule_type) - cron_expr = update_data.get("cron_expression", schedule.cron_expression) - daily_time = update_data.get("daily_time", schedule.daily_time) - weekly_day = update_data.get("weekly_day", schedule.weekly_day) - weekly_time = update_data.get("weekly_time", schedule.weekly_time) - hourly_minute = update_data.get("hourly_minute", schedule.hourly_minute) - update_data["next_run_at"] = calculate_next_run( - new_schedule_type, cron_expr, daily_time, weekly_day, weekly_time, hourly_minute - ) + if will_be_active: + update_data["next_run_at"] = calculate_next_run( + effective_type, + effective_cron, + effective_daily, + effective_weekly_day, + effective_weekly_time, + effective_hourly_minute, + ) + # Ensure timezone-aware UTC + if update_data["next_run_at"].tzinfo is None: + update_data["next_run_at"] = update_data["next_run_at"].replace(tzinfo=timezone.utc) + else: + update_data["next_run_at"] = None elif "cron_expression" in update_data and schedule.schedule_type == ScheduleType.CUSTOM: # If only cron_expression is updated for CUSTOM schedule, recalculate next_run_at - update_data["next_run_at"] = calculate_next_run( - schedule.schedule_type, - update_data["cron_expression"], - schedule.daily_time, - schedule.weekly_day, - schedule.weekly_time, - schedule.hourly_minute - ) + if will_be_active: + update_data["next_run_at"] = calculate_next_run( + schedule.schedule_type, + update_data["cron_expression"], + schedule.daily_time, + schedule.weekly_day, + schedule.weekly_time, + schedule.hourly_minute, + ) + # Ensure timezone-aware UTC + if update_data["next_run_at"].tzinfo is None: + update_data["next_run_at"] = update_data["next_run_at"].replace(tzinfo=timezone.utc) + else: + update_data["next_run_at"] = None + + # Handle activation toggles + if "is_active" in update_data: + if update_data["is_active"] is False: + update_data["next_run_at"] = None + elif update_data["is_active"] is True and "next_run_at" not in update_data: + # Schedule is being activated and next_run_at wasn't already calculated above + next_run = calculate_next_run( + effective_type, + effective_cron, + effective_daily, + effective_weekly_day, + effective_weekly_time, + effective_hourly_minute, + ) + if next_run.tzinfo is None: + next_run = next_run.replace(tzinfo=timezone.utc) + update_data["next_run_at"] = next_run # Apply updates for field, value in update_data.items(): @@ -352,6 +399,23 @@ async def toggle_connector_schedule( # Toggle the active status schedule.is_active = not schedule.is_active + + # Update next_run_at based on new active status + if schedule.is_active: + # Schedule is being activated, calculate next_run_at if it's None + if schedule.next_run_at is None: + schedule.next_run_at = calculate_next_run( + schedule.schedule_type, + schedule.cron_expression, + schedule.daily_time, + schedule.weekly_day, + schedule.weekly_time, + schedule.hourly_minute + ) + else: + # Schedule is being deactivated, set next_run_at to None + schedule.next_run_at = None + await session.commit() await session.refresh(schedule) diff --git a/surfsense_backend/app/utils/schedule_helpers.py b/surfsense_backend/app/utils/schedule_helpers.py index ef2c8de9b..91f916775 100644 --- a/surfsense_backend/app/utils/schedule_helpers.py +++ b/surfsense_backend/app/utils/schedule_helpers.py @@ -1,6 +1,6 @@ """Helper utilities for calculating schedule next run times.""" -from datetime import datetime, timedelta, time +from datetime import datetime, timedelta, time, timezone from croniter import croniter @@ -27,18 +27,18 @@ def calculate_next_run( hourly_minute: Optional minute for HOURLY schedules (0-59, default: 0) Returns: - datetime: The next scheduled run time + datetime: The next scheduled run time (timezone-aware in UTC) Raises: ValueError: If schedule_type is CUSTOM but no cron_expression provided """ - now = datetime.now() + now = datetime.now(timezone.utc) if schedule_type == ScheduleType.HOURLY: # Run at the specified minute of the next hour minute = hourly_minute if hourly_minute is not None else 0 next_run = (now + timedelta(hours=1)).replace(minute=minute, second=0, microsecond=0) - return next_run + return next_run.replace(tzinfo=timezone.utc) elif schedule_type == ScheduleType.DAILY: # Run at the specified time next day @@ -46,7 +46,7 @@ def calculate_next_run( next_run = now.replace(hour=target_time.hour, minute=target_time.minute, second=0, microsecond=0) if next_run <= now: next_run += timedelta(days=1) - return next_run + return next_run.replace(tzinfo=timezone.utc) elif schedule_type == ScheduleType.WEEKLY: # Run on the specified day at the specified time @@ -61,14 +61,18 @@ def calculate_next_run( days_until_target = 7 next_run += timedelta(days=days_until_target) - return next_run + return next_run.replace(tzinfo=timezone.utc) elif schedule_type == ScheduleType.CUSTOM: if not cron_expression: raise ValueError("cron_expression is required for CUSTOM schedule type") try: cron = croniter(cron_expression, now) - return cron.get_next(datetime) + next_run = cron.get_next(datetime) + # Ensure the returned datetime is timezone-aware in UTC + if next_run.tzinfo is None: + next_run = next_run.replace(tzinfo=timezone.utc) + return next_run except Exception as e: raise ValueError(f"Invalid cron expression: {cron_expression}") from e