Skip to content

Add cron-expression scheduling (RunMode.CRON) - #5

Open
cmcau wants to merge 1 commit into
hassancs91:mainfrom
cmcau:pr/cron-scheduling
Open

Add cron-expression scheduling (RunMode.CRON)#5
cmcau wants to merge 1 commit into
hassancs91:mainfrom
cmcau:pr/cron-scheduling

Conversation

@cmcau

@cmcau cmcau commented Jul 15, 2026

Copy link
Copy Markdown

What this adds

A "Cron expression" run mode, so a script can be scheduled from a standard 5-field cron string (e.g. 0 9 * * 1-5 for weekday mornings) instead of only the preset manual/interval/daily/weekly/monthly modes.

This came out of my own self-hosted use: the presets cover most cases, but "weekdays at 9" or "every 15 minutes during business hours" aren't expressible, and cron already is the lingua franca for that.

Why the change is small

The existing daily/weekly/monthly modes already compile down to a django-q2 Schedule with schedule_type=CRON — they generate a cron string internally. So cron mode isn't a new scheduling engine, it just passes the user's expression through instead of generating one. croniter is already a dependency (django-q2 uses it for the CRON type), so there's no new package.

What's included

  • ModelRunMode.CRON + a cron_expression field. Migration 0039 is purely additive (AddField + an AlterField for the choices list); no data migration, no backfill, safe on existing installs.
  • Validation — 5-field expressions only. Rejects @daily-style shortcuts and anything croniter can't parse, with a specific error message per failure.
  • UI — the schedule form validates as you type and previews the next three run times before saving, so you can confirm the expression does what you think. Links out to crontab.guru.
  • Plugin SDKScheduleAPI.sync() takes a cron= kwarg.
  • Coverage — cron schedules appear in the dashboard's upcoming-runs list and survive backup/restore.
  • Docsdocs/scheduling.md covers all six run modes, cron syntax, and timezone behaviour. It needed a .gitignore exception to stay tracked, following the existing !docs/plugins.md carve-out.
  • Tests — 17 new tests in core/test_cron_scheduling.py.

Two pre-existing bugs fixed along the way

Both sit directly in the code cron touches, and cron would have inherited them. Happy to split these into a separate PR if you'd prefer:

  1. Dashboard upcoming-runs only matched INTERVAL and DAILY, so weekly and monthly schedules never showed up there.
  2. Backup export/import dropped the weekly/monthly config fields (weekly_days, weekly_times, monthly_days, monthly_times), so those schedules didn't round-trip through a restore.

Notes

  • Timezone: consistent with the existing modes, the schedule's timezone field stays a display label — generated cron schedules run in the cluster's timezone. I documented the current behaviour rather than changing it, since fixing it properly affects every mode and felt like a separate discussion.
  • No version bump or changelog entry — that's your call, not mine. Happy to add a changelog entry under whatever version you'd assign.

Testing

makemigrations --check reports no drift, manage.py check is clean, and the cron (17) plus plugin-SDK/backup (33) suites pass on Python 3.13 / Django 6.

Summary by CodeRabbit

  • New Features

    • Added Cron expression scheduling for scripts.
    • Added live validation and preview of the next three scheduled runs.
    • Included Cron, weekly, and monthly settings in upcoming runs and backup/restore operations.
    • Added comprehensive scheduling documentation.
  • Documentation

    • Updated the README with Cron scheduling details and a link to the scheduling guide.

Adds a "Cron expression" run mode so a script can be driven by a standard
5-field cron string (e.g. "0 9 * * 1-5" for weekday mornings), alongside the
existing manual/interval/daily/weekly/monthly modes.

The engine change is deliberately small: the daily/weekly/monthly modes already
compile down to a django-q2 Schedule with schedule_type=CRON, so cron mode just
passes the user's expression straight through instead of generating one.

- Model: new RunMode.CRON + cron_expression field (migration 0039, additive).
- Validation: 5-field expressions only; rejects "@daily"-style shortcuts and
  anything croniter can't parse. croniter is already a dependency.
- UI: the schedule form validates as you type and previews the next three run
  times before you save, with a link out to crontab.guru.
- Plugin SDK: ScheduleAPI.sync() accepts a cron= kwarg.
- Backup/restore and the dashboard's upcoming-runs list cover cron schedules.
- Docs: docs/scheduling.md documents all six run modes, cron syntax, and the
  server-timezone behaviour. A .gitignore exception keeps it tracked, matching
  the existing docs/plugins.md carve-out.
- Tests: 17 tests in core/test_cron_scheduling.py.

Two pre-existing gaps surfaced while working in this area and are fixed here,
since cron would otherwise inherit both:
- The dashboard's upcoming-runs query only matched INTERVAL and DAILY, so
  weekly and monthly schedules never appeared.
- Backup export/import dropped the weekly/monthly config fields.

Note: as with the existing modes, the schedule's timezone field remains a
display label - generated cron schedules run in the cluster's timezone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Cron scheduling

Layer / File(s) Summary
Cron model and form contracts
core/models/schedule.py, core/migrations/0039_scriptschedule_cron.py, core/forms.py
Adds the cron run mode, stored expression field, migration, form input, validation, normalization, and display formatting.
Cron schedule execution and persistence
core/services/schedule_service.py, core/services/dashboard_service.py, core/services/backup_service.py, core/test_cron_scheduling.py
Creates django-q2 cron schedules, calculates and previews runs, includes cron in resume and dashboard queries, persists it through backup/restore, and tests service behavior.
Plugin scheduling and history integration
core/plugins/api.py, core/views/scripts.py, core/test_cron_scheduling.py
Adds cron input to ScheduleAPI.sync, validates it, clears stale cron configuration, and tracks cron-expression changes in schedule history.
Cron preview endpoint and control-panel UI
core/urls/cpanel.py, core/views/scripts.py, templates/cpanel/scripts/_form_sidebar.html, static/js/script_form.js
Adds the preview endpoint, cron form controls, live validation, and upcoming-run rendering.
Cron scheduling documentation and discoverability
.gitignore, README.md, docs/scheduling.md, core/test_cron_scheduling.py
Publishes cron scheduling guidance, syntax and timezone behavior, plugin usage, operational details, and related test coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ScriptForm
  participant cron_preview_view
  participant ScheduleService
  User->>ScriptForm: Enter cron expression
  ScriptForm->>cron_preview_view: Request preview
  cron_preview_view->>ScheduleService: Validate and calculate next runs
  ScheduleService-->>cron_preview_view: Validation result and run times
  cron_preview_view-->>ScriptForm: Return JSON preview
  ScriptForm-->>User: Display status and upcoming runs
Loading

Suggested reviewers: hassancs91

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding cron-expression scheduling via RunMode.CRON.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/views/scripts.py (1)

224-247: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include weekly and monthly configuration fields in schedule history.

The previous_config and new_config dictionaries omit the weekly and monthly schedule fields. Although cron_expression was successfully added, updates to weekly or monthly schedules will still silently skip being tracked in ScheduleHistory if these fields remain absent.

🐛 Proposed fix
             # Capture previous config for history
             previous_config = {
                 "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,
             }
 
             script = form.save(commit=False)
             script.save()
             form.save_m2m()
             if script.injection_mode == Script.InjectionMode.SELECTED:
                 _reconcile_grants(script, request.POST.getlist("granted_secret_ids"), request.workspace)
             schedule = schedule_form.save()
 
             # Capture new config
             new_config = {
                 "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,
             }
🤖 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/views/scripts.py` around lines 224 - 247, Update the previous_config and
new_config dictionaries in the schedule history flow to include the schedule’s
weekly and monthly configuration fields, alongside the existing run_mode,
interval, daily, cron, timezone, and active fields. Use the exact weekly/monthly
attributes exposed by schedule and preserve identical keys in both dictionaries
so changes are tracked by ScheduleHistory.
🧹 Nitpick comments (1)
core/test_cron_scheduling.py (1)

42-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the unpacked err variable.

The unpacked variable err is never used. Consider asserting that it contains an error message to ensure validation fails for the right reason.

✨ Proposed fix
     def test_garbage_rejected(self):
         ok, err = ScheduleService.validate_cron_expression("99 99 * * *")
         self.assertFalse(ok)
+        self.assertIsNotNone(err)
🤖 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/test_cron_scheduling.py` around lines 42 - 45, Update
test_garbage_rejected to assert that the unpacked err value contains a
validation error message, while preserving the existing assertion that ok is
false.

Source: Linters/SAST tools

🤖 Prompt for all review comments with 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.

Inline comments:
In `@core/services/schedule_service.py`:
- Around line 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.

In `@docs/scheduling.md`:
- Around line 42-50: Update the fenced ASCII diagram in the scheduling
documentation to specify the text language identifier, changing the opening
fence to use text while preserving the diagram content unchanged.

In `@README.md`:
- 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.

In `@static/js/script_form.js`:
- Around line 47-68: Prevent stale live-preview responses from updating the DOM
in the fetch flow: capture the requested expression when starting the request,
and before modifying e.preview, e.status, or e.runs, confirm the current input
value still matches it. Leave newer-request behavior unchanged and ignore
responses for outdated expressions.

---

Outside diff comments:
In `@core/views/scripts.py`:
- Around line 224-247: Update the previous_config and new_config dictionaries in
the schedule history flow to include the schedule’s weekly and monthly
configuration fields, alongside the existing run_mode, interval, daily, cron,
timezone, and active fields. Use the exact weekly/monthly attributes exposed by
schedule and preserve identical keys in both dictionaries so changes are tracked
by ScheduleHistory.

---

Nitpick comments:
In `@core/test_cron_scheduling.py`:
- Around line 42-45: Update test_garbage_rejected to assert that the unpacked
err value contains a validation error message, while preserving the existing
assertion that ok is false.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2dccf2b-32fb-4f62-a9bd-84e4c3bd8a7f

📥 Commits

Reviewing files that changed from the base of the PR and between 03242d7 and 77778e3.

📒 Files selected for processing (15)
  • .gitignore
  • README.md
  • core/forms.py
  • core/migrations/0039_scriptschedule_cron.py
  • core/models/schedule.py
  • core/plugins/api.py
  • core/services/backup_service.py
  • core/services/dashboard_service.py
  • core/services/schedule_service.py
  • core/test_cron_scheduling.py
  • core/urls/cpanel.py
  • core/views/scripts.py
  • docs/scheduling.md
  • static/js/script_form.js
  • templates/cpanel/scripts/_form_sidebar.html

Comment on lines +361 to 375
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

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.

Comment thread docs/scheduling.md
Comment on lines +42 to +50
```
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, 0 = Sunday)
│ │ │ │ │
* * * * *
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to the fenced diagram.

markdownlint reports MD040 for this unlabeled code fence. Use text for the ASCII diagram.

Proposed fix
-```
+```text
📝 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
```
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, 0 = Sunday)
│ │ │ │ │
* * * * *
```
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 42-42: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@docs/scheduling.md` around lines 42 - 50, Update the fenced ASCII diagram in
the scheduling documentation to specify the text language identifier, changing
the opening fence to use text while preserving the diagram content unchanged.

Source: Linters/SAST tools

Comment thread README.md

- **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.

Comment thread static/js/script_form.js
Comment on lines +47 to +68
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');
});

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

Prevent race conditions in the live preview.

If a user types quickly, an older, slower network response could resolve after a newer, faster request completes. This would cause the preview UI to display results for an outdated expression.

Check if the current input value still matches the requested expression before updating the DOM, or use an AbortController to cancel stale requests.

🐛 Proposed fix (validation check)
             .then(function (r) { return r.json(); })
             .then(function (data) {
+                if (e.input.value.trim() !== expr) return;
+
                 e.preview.classList.remove('hidden');
                 if (!data.valid) {
📝 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
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');
});
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (e.input.value.trim() !== expr) return;
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');
});
🤖 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 `@static/js/script_form.js` around lines 47 - 68, Prevent stale live-preview
responses from updating the DOM in the fetch flow: capture the requested
expression when starting the request, and before modifying e.preview, e.status,
or e.runs, confirm the current input value still matches it. Leave newer-request
behavior unchanged and ignore responses for outdated expressions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant