Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ Desktop.ini
# Project specific
data/
nul
# Internal docs stay local, except the public plugin author guide.
# Internal docs stay local, except the public plugin author guide and the
# user-facing scheduling guide.
docs/*
!docs/plugins.md
!docs/scheduling.md

# Plugins are installed at runtime onto the data volume — keep the package
# marker + readme in the repo, ignore everything actually uploaded.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A self-hosted Python script automation platform. Upload a script, schedule it, m
## Features

- **Script Management** — Create, edit, and organize Python scripts from your browser
- **Flexible Scheduling** — Run scripts manually, at intervals, or daily at specific times
- **Flexible Scheduling** — Run scripts manually, at intervals, daily/weekly/monthly at specific times, or on any [cron expression](docs/scheduling.md)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Describe the supported cron syntax precisely.

The implementation accepts standard five-field expressions, not “any cron expression”; @daily shortcuts and six-field expressions are explicitly rejected in docs/scheduling.md.

Proposed wording
-- **Flexible Scheduling** — Run scripts manually, at intervals, daily/weekly/monthly at specific times, or on any [cron expression](docs/scheduling.md)
+- **Flexible Scheduling** — Run scripts manually, at intervals, daily/weekly/monthly at specific times, or with any standard 5-field [cron expression](docs/scheduling.md)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **Flexible Scheduling** — Run scripts manually, at intervals, daily/weekly/monthly at specific times, or on any [cron expression](docs/scheduling.md)
- **Flexible Scheduling** — Run scripts manually, at intervals, daily/weekly/monthly at specific times, or with any standard 5-field [cron expression](docs/scheduling.md)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 13, Update the “Flexible Scheduling” bullet in README.md
to describe cron support as standard five-field expressions, avoiding the
inaccurate claim that any cron expression is accepted. Keep the existing
scheduling options and link unchanged, and do not imply support for `@daily`
shortcuts or six-field expressions.

- **Virtual Environments** — Isolated Python environments with custom pip packages per script
- **Run History & Logs** — Track every execution with stdout/stderr capture
- **Secrets Management** — Store encrypted environment variables and secrets
Expand Down
25 changes: 24 additions & 1 deletion core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,19 @@ class ScheduleForm(forms.ModelForm):
help_text="Comma-separated times in HH:MM format (24-hour)",
)

# Cron mode field (raw 5-field expression)
cron_expression = forms.CharField(
required=False,
widget=forms.TextInput(
attrs={
"class": INPUT_CLASS + " font-mono",
"placeholder": "0 9 * * 1-5",
}
),
label="Cron Expression",
help_text="5 fields: minute hour day-of-month month day-of-week (server timezone)",
)

timezone = forms.ChoiceField(
choices=get_timezone_choices,
initial="UTC",
Expand All @@ -295,7 +308,7 @@ class ScheduleForm(forms.ModelForm):

class Meta:
model = ScriptSchedule
fields = ["run_mode", "interval_minutes", "timezone", "is_active"]
fields = ["run_mode", "interval_minutes", "cron_expression", "timezone", "is_active"]
widgets = {
"run_mode": forms.RadioSelect(
attrs={
Expand Down Expand Up @@ -431,6 +444,16 @@ def clean(self):
"At least one time is required for monthly mode.",
)

elif run_mode == ScriptSchedule.RunMode.CRON:
from core.services.schedule_service import ScheduleService

expression = (cleaned_data.get("cron_expression") or "").strip()
is_valid, error = ScheduleService.validate_cron_expression(expression)
if not is_valid:
self.add_error("cron_expression", error)
else:
cleaned_data["cron_expression"] = expression

return cleaned_data

def save(self, commit=True):
Expand Down
44 changes: 44 additions & 0 deletions core/migrations/0039_scriptschedule_cron.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated manually for raw cron scheduling

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0038_plugin_ownership"),
]

operations = [
# Add the raw cron expression field
migrations.AddField(
model_name="scriptschedule",
name="cron_expression",
field=models.CharField(
blank=True,
default="",
help_text=(
'Raw cron expression, e.g. "0 9 * * 1-5" '
"(minute hour day-of-month month day-of-week)"
),
max_length=100,
),
),
# Update run_mode choices to include cron
migrations.AlterField(
model_name="scriptschedule",
name="run_mode",
field=models.CharField(
choices=[
("manual", "Manual"),
("interval", "Interval"),
("daily", "Daily"),
("weekly", "Weekly"),
("monthly", "Monthly"),
("cron", "Cron expression"),
],
default="manual",
max_length=20,
),
),
]
12 changes: 12 additions & 0 deletions core/models/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class RunMode(models.TextChoices):
DAILY = "daily", "Daily"
WEEKLY = "weekly", "Weekly"
MONTHLY = "monthly", "Monthly"
CRON = "cron", "Cron expression"

class IntervalChoice(models.IntegerChoices):
FIVE_MINUTES = 5, "Every 5 minutes"
Expand Down Expand Up @@ -109,6 +110,14 @@ class IntervalChoice(models.IntegerChoices):
help_text='List of times in HH:MM format for monthly mode',
)

# Cron mode configuration - a raw 5-field cron expression
cron_expression = models.CharField(
max_length=100,
blank=True,
default="",
help_text='Raw cron expression, e.g. "0 9 * * 1-5" (minute hour day-of-month month day-of-week)',
)

# Schedule state
is_active = models.BooleanField(
default=True,
Expand Down Expand Up @@ -183,6 +192,9 @@ def schedule_display(self) -> str:
days = ", ".join(str(d) for d in sorted(self.monthly_days)) if self.monthly_days else "No days set"
times = ", ".join(self.monthly_times) if self.monthly_times else "No times set"
return f"Monthly on day {days} at {times} ({self.timezone})"
elif self.run_mode == self.RunMode.CRON:
expr = self.cron_expression or "No expression set"
return f"Cron: {expr}"
return "Unknown"


Expand Down
10 changes: 9 additions & 1 deletion core/plugins/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,12 +519,14 @@ def sync(
time_str=None,
weekday=None,
interval_minutes=None,
cron=None,
tz="UTC",
):
"""Create/update the script's schedule and push it to django-q2.

``mode`` is a ``ScriptSchedule.RunMode`` value ('manual'/'interval'/'daily'
/'weekly'). Mirrors the hand-rolled qdrant sync_schedule, generalized.
/'weekly'/'monthly'/'cron'). Mirrors the hand-rolled qdrant sync_schedule,
generalized. For ``cron`` mode pass a raw 5-field expression via ``cron``.
"""
from core.models import ScriptSchedule
from core.services.schedule_service import ScheduleService
Expand All @@ -540,6 +542,7 @@ def sync(
sched.weekly_times = []
sched.monthly_days = []
sched.monthly_times = []
sched.cron_expression = ""

if mode == ScriptSchedule.RunMode.INTERVAL:
sched.interval_minutes = int(interval_minutes)
Expand All @@ -548,6 +551,11 @@ def sync(
elif mode == ScriptSchedule.RunMode.WEEKLY:
sched.weekly_days = [int(weekday)]
sched.weekly_times = [time_str]
elif mode == ScriptSchedule.RunMode.CRON:
is_valid, error = ScheduleService.validate_cron_expression(cron)
if not is_valid:
raise ValueError(f"Invalid cron expression: {error}")
sched.cron_expression = (cron or "").strip()

sched.is_active = mode != ScriptSchedule.RunMode.MANUAL
sched.save()
Expand Down
10 changes: 10 additions & 0 deletions core/services/backup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ def _export_schedules(cls) -> List[dict]:
"run_mode": schedule.run_mode,
"interval_minutes": schedule.interval_minutes,
"daily_times": schedule.daily_times,
"weekly_days": schedule.weekly_days,
"weekly_times": schedule.weekly_times,
"monthly_days": schedule.monthly_days,
"monthly_times": schedule.monthly_times,
"cron_expression": schedule.cron_expression,
"timezone": schedule.timezone,
"is_active": schedule.is_active,
"created_at": cls._serialize_datetime(schedule.created_at),
Expand Down Expand Up @@ -933,6 +938,11 @@ def _import_schedules(cls, schedules_data: List[dict], script_map: dict, user_ma
run_mode=schedule_data.get("run_mode", "manual"),
interval_minutes=schedule_data.get("interval_minutes"),
daily_times=schedule_data.get("daily_times", []),
weekly_days=schedule_data.get("weekly_days", []),
weekly_times=schedule_data.get("weekly_times", []),
monthly_days=schedule_data.get("monthly_days", []),
monthly_times=schedule_data.get("monthly_times", []),
cron_expression=schedule_data.get("cron_expression", ""),
timezone=schedule_data.get("timezone", "UTC"),
is_active=schedule_data.get("is_active", True),
q_schedule_ids=[], # Will be regenerated
Expand Down
8 changes: 7 additions & 1 deletion core/services/dashboard_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ def get_upcoming_scheduled_runs(cls, limit: int = 5, workspace=None) -> QuerySet
next_run__isnull=False,
next_run__gt=now,
is_active=True,
run_mode__in=[ScriptSchedule.RunMode.INTERVAL, ScriptSchedule.RunMode.DAILY],
run_mode__in=[
ScriptSchedule.RunMode.INTERVAL,
ScriptSchedule.RunMode.DAILY,
ScriptSchedule.RunMode.WEEKLY,
ScriptSchedule.RunMode.MONTHLY,
ScriptSchedule.RunMode.CRON,
],
)
if workspace is not None:
schedules = schedules.filter(script__workspace=workspace)
Expand Down
98 changes: 98 additions & 0 deletions core/services/schedule_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ def sync_schedule(cls, script_schedule) -> list[int]:
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)
elif script_schedule.run_mode == ScriptSchedule.RunMode.CRON:
q_schedule_ids = cls._create_cron_schedule(script_schedule)

# Update the ScriptSchedule with new IDs and next_run
script_schedule.q_schedule_ids = q_schedule_ids
Expand Down Expand Up @@ -196,6 +198,39 @@ def _create_monthly_schedules(cls, script_schedule) -> list[int]:

return q_schedule_ids

@classmethod
def _create_cron_schedule(cls, script_schedule) -> list[int]:
"""
Create a single CRON type django-q2 schedule from a raw cron expression.

The expression is passed straight through to django-q2, which uses
croniter to compute run times. It is interpreted in the cluster's
configured timezone (Django ``TIME_ZONE`` / ``Q_CLUSTER`` timezone),
consistent with the daily/weekly/monthly modes.
"""
cron_expr = (script_schedule.cron_expression or "").strip()
if not cron_expr:
logger.warning(
f"Cron schedule for script {script_schedule.script.name} has no "
f"expression - skipping"
)
return []

q_schedule = QSchedule.objects.create(
name=f"pyrunner-{script_schedule.script.id}-cron",
func=cls.TASK_FUNC,
args=f"'{script_schedule.script.id}'",
schedule_type=QSchedule.CRON,
cron=cron_expr,
repeats=-1, # Run forever
next_run=timezone.now(),
)
logger.info(
f"Created cron schedule {q_schedule.id} for script "
f"{script_schedule.script.name} ('{cron_expr}')"
)
return [q_schedule.id]

@classmethod
def delete_q_schedules(cls, script_schedule) -> int:
"""Delete all django-q2 schedules associated with a ScriptSchedule."""
Expand Down Expand Up @@ -323,8 +358,70 @@ def _calculate_next_run(cls, script_schedule) -> Optional[datetime]:

return min(candidates) if candidates else None

elif script_schedule.run_mode == ScriptSchedule.RunMode.CRON:
cron_expr = (script_schedule.cron_expression or "").strip()
if not cron_expr:
return None
try:
from croniter import croniter

return croniter(cron_expr, now).get_next(datetime)
except (ValueError, KeyError) as exc:
logger.warning(
f"Could not compute next run for cron '{cron_expr}': {exc}"
)
return None

return None
Comment on lines +361 to 375

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use local time for CRON next-run computations.

django-q2 evaluates cron expressions in the local timezone, and preview_cron_runs correctly aligns with this by using timezone.localtime(timezone.now()). However, the next-run calculation here directly passes now (which evaluates to UTC) to croniter. This mismatch will cause the next_run stored in the database and shown on the dashboard to differ dramatically from the actual execution time.

  • core/services/schedule_service.py#L361-L375: Update the base datetime so that cron logic evaluates against the cluster's local time: return croniter(cron_expr, timezone.localtime(now)).get_next(datetime)
  • core/test_cron_scheduling.py#L99-L108: Update the test expectation to mirror the local timezone behavior: expected = croniter("30 4 * * *", timezone.localtime(timezone.now())).get_next(datetime)
📍 Affects 2 files
  • core/services/schedule_service.py#L361-L375 (this comment)
  • core/test_cron_scheduling.py#L99-L108
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/services/schedule_service.py` around lines 361 - 375, Update the CRON
branch in core/services/schedule_service.py at lines 361-375 to pass
timezone.localtime(now) to croniter while preserving the existing error
handling. Update the expectation in core/test_cron_scheduling.py at lines 99-108
to compute the expected result using timezone.localtime(timezone.now()), so the
test matches local-time evaluation.


@staticmethod
def validate_cron_expression(expression: str) -> tuple[bool, Optional[str]]:
"""
Validate a raw 5-field cron expression.

Returns ``(is_valid, error_message)``. ``error_message`` is None when
the expression is valid.
"""
expr = (expression or "").strip()
if not expr:
return False, "Cron expression is required."

# django-q2 / croniter operate on the standard 5-field cron format.
# Reject 6-field (seconds) or named @-shortcuts to avoid surprising
# behaviour, since those are not what django-q2's scheduler expects.
if expr.startswith("@"):
return False, "Named shortcuts like '@daily' are not supported - use a 5-field expression."
if len(expr.split()) != 5:
return False, "Expected 5 fields: minute hour day-of-month month day-of-week."

try:
from croniter import croniter

if not croniter.is_valid(expr):
return False, "Not a valid cron expression."
except ImportError: # pragma: no cover - croniter ships with django-q2
return False, "Cron validation is unavailable (croniter not installed)."

return True, None

@classmethod
def preview_cron_runs(cls, expression: str, count: int = 3) -> list[datetime]:
"""
Return the next ``count`` run times for a cron expression.

Returns an empty list if the expression is invalid. Times are computed
in the cluster's timezone, matching how django-q2 will actually run it.
"""
is_valid, _ = cls.validate_cron_expression(expression)
if not is_valid:
return []

from croniter import croniter

base = timezone.localtime(timezone.now())
itr = croniter(expression.strip(), base)
return [itr.get_next(datetime) for _ in range(count)]

@classmethod
def pause_all_schedules(cls, user=None) -> int:
"""
Expand Down Expand Up @@ -371,6 +468,7 @@ def resume_all_schedules(cls) -> int:
ScriptSchedule.RunMode.DAILY,
ScriptSchedule.RunMode.WEEKLY,
ScriptSchedule.RunMode.MONTHLY,
ScriptSchedule.RunMode.CRON,
],
).select_related("script"):
ids = cls.sync_schedule(schedule)
Expand Down
Loading