PyRunner / core /services /schedule_service.py
Akoda35's picture
Upload 15 files
56b2689 verified
Raw
History Blame Contribute Delete
15.7 kB
"""
Service for managing django-q2 schedules.
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from django.utils import timezone
from django_q.models import Schedule as QSchedule
logger = logging.getLogger(__name__)
class ScheduleService:
"""
Manages the creation, update, and deletion of django-q2 Schedule objects
based on ScriptSchedule configuration.
"""
TASK_FUNC = "core.tasks.execute_scheduled_run"
@classmethod
def sync_schedule(cls, script_schedule) -> list[int]:
"""
Synchronize django-q2 schedules with ScriptSchedule configuration.
Deletes old schedules and creates new ones based on current config.
Returns list of created django-q2 Schedule IDs.
"""
from core.models import ScriptSchedule, GlobalSettings
# Delete existing django-q2 schedules
cls.delete_q_schedules(script_schedule)
# If not active or manual mode, don't create new schedules
if (
not script_schedule.is_active
or script_schedule.run_mode == ScriptSchedule.RunMode.MANUAL
):
script_schedule.q_schedule_ids = []
script_schedule.next_run = None
script_schedule.save(update_fields=["q_schedule_ids", "next_run"])
return []
# Check global pause
settings = GlobalSettings.get_settings()
if settings.schedules_paused:
logger.info(
f"Schedules globally paused - not creating schedule for {script_schedule.script.name}"
)
script_schedule.q_schedule_ids = []
script_schedule.next_run = None
script_schedule.save(update_fields=["q_schedule_ids", "next_run"])
return []
q_schedule_ids = []
if script_schedule.run_mode == ScriptSchedule.RunMode.INTERVAL:
q_schedule_ids = cls._create_interval_schedule(script_schedule)
elif script_schedule.run_mode == ScriptSchedule.RunMode.DAILY:
q_schedule_ids = cls._create_daily_schedules(script_schedule)
elif script_schedule.run_mode == ScriptSchedule.RunMode.WEEKLY:
q_schedule_ids = cls._create_weekly_schedules(script_schedule)
elif script_schedule.run_mode == ScriptSchedule.RunMode.MONTHLY:
q_schedule_ids = cls._create_monthly_schedules(script_schedule)
# Update the ScriptSchedule with new IDs and next_run
script_schedule.q_schedule_ids = q_schedule_ids
script_schedule.next_run = cls._calculate_next_run(script_schedule)
script_schedule.save(update_fields=["q_schedule_ids", "next_run"])
return q_schedule_ids
@classmethod
def _create_interval_schedule(cls, script_schedule) -> list[int]:
"""Create a MINUTES type django-q2 schedule."""
q_schedule = QSchedule.objects.create(
name=f"pyrunner-{script_schedule.script.id}",
func=cls.TASK_FUNC,
args=f"'{script_schedule.script.id}'",
schedule_type=QSchedule.MINUTES,
minutes=script_schedule.interval_minutes,
repeats=-1, # Run forever
next_run=timezone.now(),
)
logger.info(
f"Created interval schedule {q_schedule.id} for script {script_schedule.script.name}"
)
return [q_schedule.id]
@classmethod
def _create_daily_schedules(cls, script_schedule) -> list[int]:
"""
Create CRON type django-q2 schedules for each daily time.
Returns list of created schedule IDs.
"""
q_schedule_ids = []
for time_str in script_schedule.daily_times:
hour, minute = map(int, time_str.split(":"))
# Create cron expression: minute hour * * *
cron_expr = f"{minute} {hour} * * *"
q_schedule = QSchedule.objects.create(
name=f"pyrunner-{script_schedule.script.id}-{time_str.replace(':', '')}",
func=cls.TASK_FUNC,
args=f"'{script_schedule.script.id}'",
schedule_type=QSchedule.CRON,
cron=cron_expr,
repeats=-1,
next_run=timezone.now(),
)
q_schedule_ids.append(q_schedule.id)
logger.info(
f"Created daily schedule {q_schedule.id} for script {script_schedule.script.name} at {time_str}"
)
return q_schedule_ids
@classmethod
def _create_weekly_schedules(cls, script_schedule) -> list[int]:
"""
Create CRON type django-q2 schedules for weekly execution.
Creates one schedule per time, with days combined in cron expression.
"""
q_schedule_ids = []
if not script_schedule.weekly_days or not script_schedule.weekly_times:
return q_schedule_ids
# Convert Python weekdays (0=Mon) to cron weekdays (0=Sun in standard cron, but django-q uses 1=Mon)
# django-q2 uses standard cron: 0=Sunday, 1=Monday, ..., 6=Saturday
# Our model uses: 0=Monday, ..., 6=Sunday
# Convert: add 1 and mod 7 (Mon=0 -> 1, Sun=6 -> 0)
cron_days = [(d + 1) % 7 for d in script_schedule.weekly_days]
days_str = ",".join(str(d) for d in sorted(cron_days))
for time_str in script_schedule.weekly_times:
hour, minute = map(int, time_str.split(":"))
# Cron format: minute hour * * day_of_week
cron_expr = f"{minute} {hour} * * {days_str}"
q_schedule = QSchedule.objects.create(
name=f"pyrunner-{script_schedule.script.id}-weekly-{time_str.replace(':', '')}",
func=cls.TASK_FUNC,
args=f"'{script_schedule.script.id}'",
schedule_type=QSchedule.CRON,
cron=cron_expr,
repeats=-1,
next_run=timezone.now(),
)
q_schedule_ids.append(q_schedule.id)
logger.info(
f"Created weekly schedule {q_schedule.id} for script {script_schedule.script.name} "
f"on days {days_str} at {time_str}"
)
return q_schedule_ids
@classmethod
def _create_monthly_schedules(cls, script_schedule) -> list[int]:
"""
Create CRON type django-q2 schedules for monthly execution.
Creates one schedule per time, with days combined in cron expression.
"""
q_schedule_ids = []
if not script_schedule.monthly_days or not script_schedule.monthly_times:
return q_schedule_ids
days_str = ",".join(str(d) for d in sorted(script_schedule.monthly_days))
for time_str in script_schedule.monthly_times:
hour, minute = map(int, time_str.split(":"))
# Cron format: minute hour day_of_month * *
cron_expr = f"{minute} {hour} {days_str} * *"
q_schedule = QSchedule.objects.create(
name=f"pyrunner-{script_schedule.script.id}-monthly-{time_str.replace(':', '')}",
func=cls.TASK_FUNC,
args=f"'{script_schedule.script.id}'",
schedule_type=QSchedule.CRON,
cron=cron_expr,
repeats=-1,
next_run=timezone.now(),
)
q_schedule_ids.append(q_schedule.id)
logger.info(
f"Created monthly schedule {q_schedule.id} for script {script_schedule.script.name} "
f"on days {days_str} at {time_str}"
)
return q_schedule_ids
@classmethod
def delete_q_schedules(cls, script_schedule) -> int:
"""Delete all django-q2 schedules associated with a ScriptSchedule."""
if not script_schedule.q_schedule_ids:
return 0
count = QSchedule.objects.filter(id__in=script_schedule.q_schedule_ids).delete()[
0
]
logger.info(
f"Deleted {count} django-q2 schedules for script {script_schedule.script.name}"
)
return count
@classmethod
def _calculate_next_run(cls, script_schedule) -> Optional[datetime]:
"""Calculate the next scheduled run time based on schedule configuration."""
from core.models import ScriptSchedule
if not script_schedule.is_active:
return None
now = timezone.now()
if script_schedule.run_mode == ScriptSchedule.RunMode.INTERVAL:
# Next run is interval_minutes from now
return now + timedelta(minutes=script_schedule.interval_minutes)
elif script_schedule.run_mode == ScriptSchedule.RunMode.DAILY:
# Calculate next occurrence from daily_times
if not script_schedule.daily_times:
return None
candidates = []
for time_str in script_schedule.daily_times:
hour, minute = map(int, time_str.split(":"))
# Today's occurrence
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= now:
# Already passed today, use tomorrow
candidate += timedelta(days=1)
candidates.append(candidate)
return min(candidates) if candidates else None
elif script_schedule.run_mode == ScriptSchedule.RunMode.WEEKLY:
# Calculate next occurrence from weekly_days and weekly_times
if not script_schedule.weekly_days or not script_schedule.weekly_times:
return None
candidates = []
current_weekday = now.weekday() # 0=Monday, 6=Sunday
for day in script_schedule.weekly_days:
for time_str in script_schedule.weekly_times:
hour, minute = map(int, time_str.split(":"))
# Calculate days until this weekday
days_ahead = day - current_weekday
if days_ahead < 0:
days_ahead += 7
candidate = now.replace(
hour=hour, minute=minute, second=0, microsecond=0
) + timedelta(days=days_ahead)
# If it's today but already passed, add a week
if candidate <= now:
candidate += timedelta(days=7)
candidates.append(candidate)
return min(candidates) if candidates else None
elif script_schedule.run_mode == ScriptSchedule.RunMode.MONTHLY:
# Calculate next occurrence from monthly_days and monthly_times
if not script_schedule.monthly_days or not script_schedule.monthly_times:
return None
candidates = []
for day in script_schedule.monthly_days:
for time_str in script_schedule.monthly_times:
hour, minute = map(int, time_str.split(":"))
# Try this month first
try:
candidate = now.replace(
day=day, hour=hour, minute=minute, second=0, microsecond=0
)
if candidate <= now:
# Already passed this month, try next month
if now.month == 12:
candidate = candidate.replace(year=now.year + 1, month=1)
else:
candidate = candidate.replace(month=now.month + 1)
except ValueError:
# Day doesn't exist in this month (e.g., 31st in Feb)
# Try next month
try:
if now.month == 12:
candidate = now.replace(
year=now.year + 1,
month=1,
day=day,
hour=hour,
minute=minute,
second=0,
microsecond=0,
)
else:
candidate = now.replace(
month=now.month + 1,
day=day,
hour=hour,
minute=minute,
second=0,
microsecond=0,
)
except ValueError:
# Day doesn't exist in next month either, skip
continue
candidates.append(candidate)
return min(candidates) if candidates else None
return None
@classmethod
def pause_all_schedules(cls, user=None) -> int:
"""
Pause all schedules globally by deleting all django-q2 Schedule objects.
Returns count of deleted schedules.
"""
from core.models import ScriptSchedule, GlobalSettings
# Update global settings
settings = GlobalSettings.get_settings()
settings.schedules_paused = True
settings.schedules_paused_at = timezone.now()
settings.schedules_paused_by = user
settings.save()
# Delete all PyRunner-related schedules
count = QSchedule.objects.filter(name__startswith="pyrunner-").delete()[0]
# Clear all q_schedule_ids
ScriptSchedule.objects.update(q_schedule_ids=[], next_run=None)
logger.info(f"Globally paused all schedules - deleted {count} django-q2 schedules")
return count
@classmethod
def resume_all_schedules(cls) -> int:
"""
Resume all schedules by recreating django-q2 Schedule objects.
Returns count of created schedules.
"""
from core.models import ScriptSchedule, GlobalSettings
settings = GlobalSettings.get_settings()
settings.schedules_paused = False
settings.schedules_paused_at = None
settings.schedules_paused_by = None
settings.save()
count = 0
for schedule in ScriptSchedule.objects.filter(
is_active=True,
run_mode__in=[
ScriptSchedule.RunMode.INTERVAL,
ScriptSchedule.RunMode.DAILY,
ScriptSchedule.RunMode.WEEKLY,
ScriptSchedule.RunMode.MONTHLY,
],
).select_related("script"):
ids = cls.sync_schedule(schedule)
count += len(ids)
logger.info(f"Resumed all schedules - created {count} django-q2 schedules")
return count
@classmethod
def ensure_heartbeat_schedule(cls) -> bool:
"""
Ensure the worker heartbeat schedule exists.
Creates the schedule if it doesn't exist.
Returns:
bool: True if schedule was created, False if it already exists
"""
HEARTBEAT_SCHEDULE_NAME = "pyrunner-worker-heartbeat"
HEARTBEAT_TASK_FUNC = "core.tasks.worker_heartbeat_task"
if QSchedule.objects.filter(name=HEARTBEAT_SCHEDULE_NAME).exists():
return False
QSchedule.objects.create(
name=HEARTBEAT_SCHEDULE_NAME,
func=HEARTBEAT_TASK_FUNC,
schedule_type=QSchedule.MINUTES,
minutes=1, # Run every minute
repeats=-1, # Run forever
next_run=timezone.now(),
)
logger.info("Created worker heartbeat schedule")
return True