diff --git a/.gitignore b/.gitignore index 466c485..20f56c9 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/README.md b/README.md index 604f0ec..42b5eb8 100644 --- a/README.md +++ b/README.md @@ -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) - **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 diff --git a/core/forms.py b/core/forms.py index 05f3d66..a9bc4c5 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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", @@ -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={ @@ -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): diff --git a/core/migrations/0039_scriptschedule_cron.py b/core/migrations/0039_scriptschedule_cron.py new file mode 100644 index 0000000..17ffa85 --- /dev/null +++ b/core/migrations/0039_scriptschedule_cron.py @@ -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, + ), + ), + ] diff --git a/core/models/schedule.py b/core/models/schedule.py index 0fb25ca..9eb1bbc 100644 --- a/core/models/schedule.py +++ b/core/models/schedule.py @@ -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" @@ -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, @@ -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" diff --git a/core/plugins/api.py b/core/plugins/api.py index 53200b1..ac1ed21 100644 --- a/core/plugins/api.py +++ b/core/plugins/api.py @@ -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 @@ -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) @@ -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() diff --git a/core/services/backup_service.py b/core/services/backup_service.py index 206c566..213784f 100644 --- a/core/services/backup_service.py +++ b/core/services/backup_service.py @@ -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), @@ -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 diff --git a/core/services/dashboard_service.py b/core/services/dashboard_service.py index 69e18f6..3cdd6e0 100644 --- a/core/services/dashboard_service.py +++ b/core/services/dashboard_service.py @@ -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) diff --git a/core/services/schedule_service.py b/core/services/schedule_service.py index a86a71a..c4c879d 100644 --- a/core/services/schedule_service.py +++ b/core/services/schedule_service.py @@ -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 @@ -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.""" @@ -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 + @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: """ @@ -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) diff --git a/core/test_cron_scheduling.py b/core/test_cron_scheduling.py new file mode 100644 index 0000000..a4beec4 --- /dev/null +++ b/core/test_cron_scheduling.py @@ -0,0 +1,173 @@ +""" +Tests for raw cron scheduling (RunMode.CRON). + +Covers the validation/preview helpers on ScheduleService, django-q2 schedule +creation, next-run computation, ScheduleForm validation, and the plugin +ScheduleAPI.sync cron path. +""" + +from unittest import mock + +from django.test import TestCase +from django.utils import timezone +from django_q.models import Schedule as QSchedule + +from core.forms import ScheduleForm +from core.models import Environment, Script, ScriptSchedule, Workspace +from core.plugins.api import ScheduleAPI +from core.services.schedule_service import ScheduleService + + +class CronValidationTests(TestCase): + def test_valid_expression(self): + ok, err = ScheduleService.validate_cron_expression("0 9 * * 1-5") + self.assertTrue(ok) + self.assertIsNone(err) + + def test_empty_expression(self): + ok, err = ScheduleService.validate_cron_expression("") + self.assertFalse(ok) + self.assertIn("required", err) + + def test_wrong_field_count(self): + ok, err = ScheduleService.validate_cron_expression("0 9 * *") + self.assertFalse(ok) + self.assertIn("5 fields", err) + + def test_named_shortcut_rejected(self): + ok, err = ScheduleService.validate_cron_expression("@daily") + self.assertFalse(ok) + self.assertIn("shortcut", err.lower()) + + def test_garbage_rejected(self): + ok, err = ScheduleService.validate_cron_expression("99 99 * * *") + self.assertFalse(ok) + + def test_preview_returns_three_future_runs(self): + runs = ScheduleService.preview_cron_runs("*/5 * * * *", count=3) + self.assertEqual(len(runs), 3) + now = timezone.localtime(timezone.now()) + self.assertTrue(all(r > now for r in runs)) + # Strictly increasing + self.assertTrue(runs[0] < runs[1] < runs[2]) + + def test_preview_invalid_returns_empty(self): + self.assertEqual(ScheduleService.preview_cron_runs("nope"), []) + + +class CronScheduleServiceTests(TestCase): + def setUp(self): + self.ws = Workspace.get_default() + self.env = Environment.objects.create(name="cronenv", path="cronenv") + self.script = Script.objects.create( + name="cron-script", code="print(1)", environment=self.env, workspace=self.ws + ) + + def _make(self, expr, is_active=True): + return ScriptSchedule.objects.create( + script=self.script, + workspace=self.ws, + run_mode=ScriptSchedule.RunMode.CRON, + cron_expression=expr, + is_active=is_active, + ) + + def test_sync_creates_cron_qschedule(self): + sched = self._make("0 9 * * 1-5") + ids = ScheduleService.sync_schedule(sched) + self.assertEqual(len(ids), 1) + q = QSchedule.objects.get(id=ids[0]) + self.assertEqual(q.schedule_type, QSchedule.CRON) + self.assertEqual(q.cron, "0 9 * * 1-5") + self.assertEqual(q.func, ScheduleService.TASK_FUNC) + + sched.refresh_from_db() + self.assertEqual(sched.q_schedule_ids, ids) + self.assertIsNotNone(sched.next_run) + + def test_sync_inactive_creates_nothing(self): + sched = self._make("0 9 * * *", is_active=False) + ids = ScheduleService.sync_schedule(sched) + self.assertEqual(ids, []) + self.assertFalse(QSchedule.objects.filter(name__contains=str(self.script.id)).exists()) + + def test_empty_expression_creates_nothing(self): + sched = self._make("") + ids = ScheduleService.sync_schedule(sched) + self.assertEqual(ids, []) + + def test_next_run_matches_croniter(self): + from croniter import croniter + from datetime import datetime + + sched = self._make("30 4 * * *") + nxt = ScheduleService._calculate_next_run(sched) + self.assertIsNotNone(nxt) + expected = croniter("30 4 * * *", timezone.now()).get_next(datetime) + # Allow tiny drift between the two now() reads. + self.assertLess(abs((nxt - expected).total_seconds()), 5) + + def test_schedule_display(self): + sched = self._make("0 0 * * 0") + self.assertEqual(sched.schedule_display, "Cron: 0 0 * * 0") + + +class CronFormTests(TestCase): + def setUp(self): + self.ws = Workspace.get_default() + self.env = Environment.objects.create(name="formenv", path="formenv") + self.script = Script.objects.create( + name="form-script", code="print(1)", environment=self.env, workspace=self.ws + ) + self.schedule = ScriptSchedule.objects.create(script=self.script, workspace=self.ws) + + def _post(self, expr): + return { + "run_mode": "cron", + "cron_expression": expr, + "timezone": "UTC", + "is_active": "on", + } + + def test_valid_cron_form_saves(self): + form = ScheduleForm(self._post("15 2 * * *"), instance=self.schedule) + self.assertTrue(form.is_valid(), form.errors) + saved = form.save() + self.assertEqual(saved.run_mode, "cron") + self.assertEqual(saved.cron_expression, "15 2 * * *") + + def test_invalid_cron_form_errors(self): + form = ScheduleForm(self._post("not a cron"), instance=self.schedule) + self.assertFalse(form.is_valid()) + self.assertIn("cron_expression", form.errors) + + def test_missing_cron_form_errors(self): + form = ScheduleForm(self._post(""), instance=self.schedule) + self.assertFalse(form.is_valid()) + self.assertIn("cron_expression", form.errors) + + +class CronPluginAPITests(TestCase): + def setUp(self): + self.ws = Workspace.get_default() + self.env = Environment.objects.create(name="pluginenv", path="pluginenv") + + def test_sync_cron_mode(self): + from core.plugins.api import ScriptAPI + + script = ScriptAPI("myplugin").upsert(key="c", code="x", environment=self.env) + with mock.patch("core.services.schedule_service.ScheduleService.sync_schedule"): + sched = ScheduleAPI("myplugin").sync( + script, mode=ScriptSchedule.RunMode.CRON, cron="0 * * * *" + ) + self.assertEqual(sched.cron_expression, "0 * * * *") + self.assertTrue(sched.is_active) + + def test_sync_cron_invalid_raises(self): + from core.plugins.api import ScriptAPI + + script = ScriptAPI("myplugin").upsert(key="c2", code="x", environment=self.env) + with self.assertRaises(ValueError): + ScheduleAPI("myplugin").sync( + script, mode=ScriptSchedule.RunMode.CRON, cron="bad" + ) diff --git a/core/urls/cpanel.py b/core/urls/cpanel.py index a8cbfb1..92bbae0 100644 --- a/core/urls/cpanel.py +++ b/core/urls/cpanel.py @@ -15,6 +15,7 @@ script_delete_view, schedule_toggle_view, schedule_history_view, + cron_preview_view, webhook_enable_view, webhook_disable_view, webhook_regenerate_view, @@ -161,6 +162,7 @@ path("scripts//toggle/", script_toggle_view, name="script_toggle"), path("scripts//schedule/toggle/", schedule_toggle_view, name="schedule_toggle"), path("scripts//schedule/history/", schedule_history_view, name="schedule_history"), + path("scripts/cron/preview/", cron_preview_view, name="cron_preview"), # Script archive/restore/delete path("scripts//archive/", script_archive_view, name="script_archive"), path("scripts//restore/", script_restore_view, name="script_restore"), diff --git a/core/views/scripts.py b/core/views/scripts.py index 6c66060..eed3f0c 100644 --- a/core/views/scripts.py +++ b/core/views/scripts.py @@ -224,6 +224,7 @@ def script_edit_view(request: HttpRequest, pk) -> HttpResponse: "run_mode": schedule.run_mode, "interval_minutes": schedule.interval_minutes, "daily_times": schedule.daily_times, + "cron_expression": schedule.cron_expression, "timezone": schedule.timezone, "is_active": schedule.is_active, } @@ -240,6 +241,7 @@ def script_edit_view(request: HttpRequest, pk) -> HttpResponse: "run_mode": schedule.run_mode, "interval_minutes": schedule.interval_minutes, "daily_times": schedule.daily_times, + "cron_expression": schedule.cron_expression, "timezone": schedule.timezone, "is_active": schedule.is_active, } @@ -333,6 +335,28 @@ def script_toggle_view(request: HttpRequest, pk) -> HttpResponse: return redirect("cpanel:script_detail", pk=pk) +@login_required +def cron_preview_view(request: HttpRequest) -> JsonResponse: + """ + Validate a cron expression and return its next few run times. + + Used by the schedule form for live feedback. Query param: ``expression``. + """ + expression = request.GET.get("expression", "") + is_valid, error = ScheduleService.validate_cron_expression(expression) + if not is_valid: + return JsonResponse({"valid": False, "error": error, "runs": []}) + + runs = ScheduleService.preview_cron_runs(expression, count=3) + return JsonResponse( + { + "valid": True, + "error": None, + "runs": [dt.strftime("%a %d %b %Y, %H:%M") for dt in runs], + } + ) + + @login_required @require_POST def schedule_toggle_view(request: HttpRequest, pk) -> HttpResponse: diff --git a/docs/scheduling.md b/docs/scheduling.md new file mode 100644 index 0000000..05f7bef --- /dev/null +++ b/docs/scheduling.md @@ -0,0 +1,167 @@ +# Scheduling scripts + +PyRunner can run a script automatically on a schedule. Every script has one +schedule, configured in the **Schedule** panel of the script edit form. There +are six run modes: + +| Mode | What it does | +|------|--------------| +| **Manual** | No automatic runs — you trigger it yourself (or via webhook). | +| **Interval** | Every N minutes, from a fixed list of intervals. | +| **Daily** | One or more `HH:MM` times, every day. | +| **Weekly** | One or more days of the week, at one or more `HH:MM` times. | +| **Monthly** | One or more days of the month, at one or more `HH:MM` times. | +| **Cron expression** | Any standard 5-field cron expression. | + +Under the hood all non-manual modes compile to a +[django-q2](https://django-q2.readthedocs.io/) schedule. The daily/weekly/monthly +modes build a cron expression for you; **Cron expression** mode simply lets you +type that expression directly, for schedules the guided modes can't express +(e.g. "every 15 minutes during business hours on weekdays"). + +--- + +## Cron expression mode + +### Using it + +1. Open a script → **Edit** → the **Schedule** panel. +2. Choose **Cron expression** as the run mode. +3. Type a standard **5-field** cron expression, for example `0 9 * * 1-5`. +4. As you type, PyRunner validates the expression and shows the **next three run + times** so you can confirm it before saving. +5. Make sure **Schedule Active** is ticked, then **Save**. + +The next run time is shown on the script detail page and in the dashboard's +**Upcoming runs** list. + +### Cron syntax + +A cron expression has five whitespace-separated fields: + +``` +┌───────────── minute (0-59) +│ ┌───────────── hour (0-23) +│ │ ┌───────────── day of month (1-31) +│ │ │ ┌───────────── month (1-12) +│ │ │ │ ┌───────────── day of week (0-6, 0 = Sunday) +│ │ │ │ │ +* * * * * +``` + +Each field accepts: + +- `*` — every value +- a single number — e.g. `5` +- a list — `1,15,30` +- a range — `1-5` +- a step — `*/15` (every 15), or `0-30/10` (0,10,20,30) + +**Examples:** + +| Expression | Meaning | +|------------|---------| +| `*/5 * * * *` | Every 5 minutes | +| `0 * * * *` | Every hour, on the hour | +| `0 9 * * 1-5` | 09:00, Monday–Friday | +| `30 8,17 * * *` | 08:30 and 17:30 every day | +| `0 0 1 * *` | Midnight on the 1st of every month | +| `0 0 * * 0` | Midnight every Sunday | +| `15 */2 * * *` | Every 2 hours, at quarter past | + +Need help building one? Try [crontab.guru](https://crontab.guru) — the form +also links to it. + +### What is *not* accepted + +To keep behaviour predictable, PyRunner intentionally rejects: + +- **Named shortcuts** like `@daily`, `@hourly`, `@reboot`. +- **6-field (seconds) expressions** — use exactly five fields. +- Anything `croniter` considers invalid (bad ranges, out-of-bounds values, etc.). + +You'll see the specific reason inline in the form. + +### Timezone + +Cron expressions are interpreted in the **server's timezone** (the Django +`TIME_ZONE` / `Q_CLUSTER` timezone of your deployment, UTC by default). This is +the same behaviour as the daily/weekly/monthly modes, whose cron expressions are +also generated in server time. The "Timezone" selector on the other modes is a +display/label aid and is **not** applied to the underlying cron schedule; if you +need runs pinned to a specific timezone, account for the offset in the +expression, or set your deployment's `TIME_ZONE`. + +The **next-run preview** in the form is computed in the same timezone the +scheduler uses, so what you preview is what you get. + +--- + +## Pausing & resuming + +- Untick **Schedule Active** (or use the toggle on the script detail page) to + pause a single schedule without losing its configuration. +- Admins can pause/resume **all** schedules globally from Settings; cron + schedules are included in the global pause/resume. + +Every change is recorded in the script's **schedule history** (including the +cron expression before/after), so you can audit who changed what and when. + +--- + +## Backup & restore + +Cron schedules — and the full weekly/monthly configuration — are included in +PyRunner backups and are recreated on restore. The django-q2 schedule objects +themselves are regenerated from the stored configuration after import, so +`next_run` is recomputed on the restored instance. + +--- + +## Scheduling from a plugin (SDK) + +Plugins can schedule the scripts they provision via `ScheduleAPI.sync()` in +`core.plugins.api`: + +```python +from core.plugins.api import ScheduleAPI +from core.models import ScriptSchedule + +# `script` is a Script the plugin created via ScriptAPI.upsert(...) +ScheduleAPI("my-plugin").sync( + script, + mode=ScriptSchedule.RunMode.CRON, + cron="0 9 * * 1-5", # weekday mornings +) +``` + +An invalid `cron` value raises `ValueError` with the validation reason. The +other modes are unchanged: + +```python +# interval +ScheduleAPI("my-plugin").sync(script, mode=ScriptSchedule.RunMode.INTERVAL, interval_minutes=60) +# daily +ScheduleAPI("my-plugin").sync(script, mode=ScriptSchedule.RunMode.DAILY, time_str="09:00") +``` + +--- + +## How it works (internals) + +| Piece | Where | +|-------|-------| +| `ScriptSchedule.run_mode` + `cron_expression` field | `core/models/schedule.py` | +| Validation & next-run preview helpers | `ScheduleService.validate_cron_expression()` / `preview_cron_runs()` in `core/services/schedule_service.py` | +| django-q2 schedule creation | `ScheduleService._create_cron_schedule()` | +| Form field + validation | `ScheduleForm` in `core/forms.py` | +| Live preview endpoint | `cron_preview_view` → `cpanel:cron_preview` (`/cpanel/scripts/cron/preview/`) | +| Form UI + preview JS | `templates/cpanel/scripts/_form_sidebar.html`, `static/js/script_form.js` | +| Migration | `core/migrations/0039_scriptschedule_cron.py` | +| Tests | `core/test_cron_scheduling.py` | + +`croniter` (already a dependency, used by django-q2) handles cron parsing and +next-run computation. When a schedule is saved, `ScheduleService.sync_schedule()` +deletes the script's old django-q2 schedules and creates a single `CRON`-type +schedule whose `cron` field is the raw expression. The django-q2 worker then +fires `core.tasks.execute_scheduled_run` at each matching time. diff --git a/static/js/script_form.js b/static/js/script_form.js index 81fe5f6..847f455 100644 --- a/static/js/script_form.js +++ b/static/js/script_form.js @@ -10,12 +10,77 @@ function toggleSection(sectionId) { // Show the option group that matches the selected schedule run mode. function toggleScheduleOptions(mode) { - ['interval', 'daily', 'weekly', 'monthly'].forEach(function (m) { + ['interval', 'daily', 'weekly', 'monthly', 'cron'].forEach(function (m) { var el = document.getElementById(m + '-options'); if (el) el.classList.toggle('hidden', m !== mode); }); + if (mode === 'cron') updateCronPreview(); } +// Live cron validation + "next runs" preview. +(function () { + var debounceTimer = null; + + function els() { + return { + input: document.getElementById('id_cron_expression'), + wrap: document.getElementById('cron-options'), + preview: document.getElementById('cron-preview'), + status: document.getElementById('cron-preview-status'), + runs: document.getElementById('cron-preview-runs'), + }; + } + + window.updateCronPreview = function () { + var e = els(); + if (!e.input || !e.wrap || !e.preview) return; + + var expr = e.input.value.trim(); + if (!expr) { + e.preview.classList.add('hidden'); + return; + } + + var url = e.wrap.getAttribute('data-preview-url') + + '?expression=' + encodeURIComponent(expr); + + fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then(function (r) { return r.json(); }) + .then(function (data) { + e.preview.classList.remove('hidden'); + if (!data.valid) { + e.status.textContent = data.error || 'Invalid cron expression.'; + e.status.className = 'font-medium text-fail'; + e.runs.innerHTML = ''; + return; + } + e.status.textContent = 'Next runs (server time):'; + e.status.className = 'font-medium text-ok'; + e.runs.innerHTML = ''; + (data.runs || []).forEach(function (run) { + var li = document.createElement('li'); + li.textContent = run; + e.runs.appendChild(li); + }); + }) + .catch(function () { + e.preview.classList.add('hidden'); + }); + }; + + document.addEventListener('DOMContentLoaded', function () { + var input = document.getElementById('id_cron_expression'); + if (!input) return; + input.addEventListener('input', function () { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(updateCronPreview, 350); + }); + // Show preview immediately if cron mode is the current selection. + var checked = document.querySelector('input[name="run_mode"]:checked'); + if (checked && checked.value === 'cron') updateCronPreview(); + }); +})(); + // Monaco code editor — initialised over the hidden