Scheduler¶
Part of the user manual. Related: WORKFLOWS · PROJECTS · EDMS · PERMISSIONS · ACTIONS · VISIBILITY.
The Scheduler creates workflow events on a recurring schedule. Set up once, and the system materialises a new workflow event each time the schedule's recurrence pattern matches — for daily, weekly, monthly, quarterly, or annual rhythms.
Typical use cases:
- Recurring inspections — fire a new Fire Safety check workflow on the 1st of every month.
- Periodic reviews — review a contract document every 6 months.
- Compliance bakes — re-run a compliance workflow annually.
- Ad-hoc reminders — one-off scheduled event for a future date ("review this spec on the 15th").
TL;DR¶
- Scheduled events live in each project's Scheduler tab.
- Two recurrence modes:
- Calendar — fire on specific Day(s) + Month(s) + Year(s) combinations. Must match ALL three.
- Counter — fire N days / months / years after the last occurrence. Must match ANY one of the three counters.
- The scheduler runs daily at midnight (cron) AND looks ahead
SCHEDULER_LOOKAHEAD_DAYS(default 365) days from today. - When a schedule fires, it creates a new WorkflowEvent against the configured project + workflow + document + assignee. The event starts at the workflow's start transition.
- Apply Now flag forces an immediate run (don't wait for midnight).
- Create / Edit requires project_admin (or superuser) + the SCHEDULE CRUD group permission. Read-only for everyone else with project access.
What a scheduled event is¶
A ScheduleEvent row defines a recurring trigger. When the
recurrence matches, the scheduler creates a WorkflowEvent from the
schedule's configuration.
| Field | Purpose |
|---|---|
name |
Display name for this schedule (e.g. "Monthly Fire Inspection") |
project (FK) |
The project this schedule belongs to. Created events land in this project. |
begins |
Earliest date the schedule can fire. Schedules before this date never fire. Set to today (or earlier) for immediate eligibility. |
ends |
Last date the schedule can fire. Blank = forever. |
document (FK to EdmsDocument) |
Optional — the document the created event references (WorkflowEvent.content_document). Used when the schedule is about a specific recurring doc check. |
workflow (FK to WorkflowWorkflow) |
Optional — the workflow definition the created event runs against. When set, the new event starts at the workflow's is_start transition. |
assign_to (FK to User) |
Optional — the user the created event is assigned to. |
duration |
Days from start to due date on the created event. Sets WorkflowEvent.due_date = start_date + duration. |
enable_calendar |
Toggle calendar-mode recurrence on/off. |
calendar_days / calendar_months / calendar_years (M2M) |
The days/months/years the schedule fires on. |
enable_counter |
Toggle counter-mode recurrence on/off. |
counter_days / counter_months / counter_years |
The "N days / months / years since last fire" recurrence intervals. |
last_recurrance |
Tracking — the last date this schedule actually fired. Updated automatically. |
apply_now |
Force an immediate run instead of waiting for the next cron pass. |
tenants, owner |
Standard DataModel fields. |
Both enable_calendar and enable_counter can be on at the same
time — fires if EITHER pattern matches today. Typical setups use one
or the other, not both.
Recurrence mode 1 — Calendar¶
Calendar mode fires when all three of day-of-month, month, and
year match today's values. Each is an M2M list, and a value of 0
acts as a wildcard ("every day", "every month", "every year").
The lookup models:
ScheduleDay— values 0 (every) through 31 (specific day-of-month).ScheduleMonth— values 0 (every) through 12 (specific month).ScheduleYear— values 0 (every) through specific years (2026, 2027, …).
Examples¶
Quarterly review (15th of Jan / Apr / Jul / Oct, any year):
calendar_days = [15]calendar_months = [January, April, July, October]calendar_years = [0](every year)
Annual inspection (1st of June every year):
calendar_days = [1]calendar_months = [June]calendar_years = [0]
Monthly inspection (1st of every month):
calendar_days = [1]calendar_months = [0](every month)calendar_years = [0](every year)
One-off on a specific date (2026-12-25):
calendar_days = [25]calendar_months = [December]calendar_years = [2026]
The "must match ALL THREE" semantics means a calendar setup with no day match will never fire — common gotcha when building from scratch.
Recurrence mode 2 — Counter¶
Counter mode fires when any one of the three counter intervals
elapses since last_recurrance. Each counter is an integer.
The math (real calendar arithmetic — each non-zero counter yields a
target date counter units after last_recurrance, and the schedule
fires when today lands on it):
counter_daysnon-zero: fires whentoday == last_recurrance + counter_days (days).counter_monthsnon-zero: fires whentoday == last_recurrance + counter_months (months).counter_yearsnon-zero: fires whentoday == last_recurrance + counter_years (years).
These carry correctly across month and year boundaries — e.g. "every 6 months" from July fires the following January, and "every 40 days" fires 40 days later regardless of month length. (Earlier builds compared raw calendar components without carry, so such intervals silently never fired; fixed 2026-06.)
A value of 0 is "do nothing" — that counter is disabled.
If last_recurrance is null (the schedule has never fired), counter
mode fires immediately on next cron pass. This is the typical
"set up new recurring schedule, want it to start now" path.
Examples¶
Every 30 days (regardless of month/year):
counter_days = 30,counter_months = 0,counter_years = 0.
Every 6 months (regardless of day-of-month):
counter_days = 0,counter_months = 6,counter_years = 0.
Annually (counter version — fires when the same day-of-year comes around):
counter_days = 0,counter_months = 0,counter_years = 1.
Calendar vs counter — which to use?¶
- Use calendar when the schedule is anchored to specific human-calendar dates (1st of the month, 25th December, etc.).
- Use counter when the schedule is interval-based regardless of the calendar ("30 days after the last one fired").
Counter mode is more robust to "we set this up mid-month" cases — calendar mode requires the exact day-of-month to come around again.
What happens when a schedule fires¶
When the cron pass identifies a match for today, the scheduler:
- Creates a new
WorkflowEventwith: owner= the schedule's owner.project= the schedule's project.workflow_workflow= the schedule's workflow (may be null → ad-hoc event).name= the schedule's name.content_document= the schedule's document (may be null).assigned_to= the schedule's assign_to.start_date= today.due_date= today +durationdays (ifduration > 0).- If the schedule has a workflow:
- Looks up the workflow's
is_start=Truetransition. - Sets the new event's
statusto the start transition'sstatus_from(the workflow's first state). - Sets the new event's
workflow_typefrom the status's type (Task / Issue / Planned / etc.). - If the workflow has a
force_edms_statusset AND the schedule points at a document, also updates the document's status. - Copies the project's tenancy to the new event
(
new_event.tenants = project.tenants). - Updates the schedule's
last_recurrance = today.
Side effect: workflow signal handlers fire on the new event — so
notifications go out per the workflow's start-transition notify_*
toggles. See NOTIFICATIONS.
The new event then behaves like any other workflow event — it can be advanced, commented on, completed, etc.
The daily cron + lookahead window¶
The scheduler runs once per day via the scheduler_cron
management command, typically triggered by a cron job at midnight.
It processes:
- Today — every schedule with
begins <= todayAND (ends IS NULL OR ends >= today) AND (last_recurrance IS NULL OR last_recurrance != today). - Each of the next
SCHEDULER_LOOKAHEAD_DAYSdays — same filter, but evaluated against that future date.
SCHEDULER_LOOKAHEAD_DAYS is set in settings.py, defaults to 365.
This pre-creates workflow events for the next year of scheduled work,
so the events table shows the upcoming schedule in advance.
A schedule whose last_recurrance matches the date being processed
is skipped (the ~Q(last_recurrance__gte=today) filter). This is
why running the cron multiple times in a day is idempotent — each
schedule fires at most once per date.
What ELSE the cron does¶
The scheduler_cron command isn't only about creating workflow
events. The same nightly run also:
- Marks revisions overdue (
compliance_overdue) — flips compliance docs to the "overdue" status when theirnext_duedate has passed. - Computes the monthly compliance report (
compliance_report) — one row per project per month, counting total / compliant docs. - Computes the FRA report (
fra_report) — total / complete / overdue Fire Risk Assessment events per project per month.
So the same cron is doing scheduler work AND compliance reporting + overdue automation. If a user reports that scheduled events aren't firing OR compliance reports aren't updating, both are symptoms of the same problem — the cron isn't running.
Apply Now¶
ScheduleEvent.apply_now is a one-shot "fire immediately, don't
wait for midnight" toggle.
Mechanics:
- Edit a scheduled event → tick Apply now → save.
- The next cron pass processes this schedule unconditionally on today's run (irrespective of the calendar / counter check).
- The schedule then fires per the normal flow + updates
last_recurranceto today.
Note: apply_now is checked at cron-time. If the cron isn't running
(or is scheduled less frequently than the user expects), apply_now
won't fire immediately — it fires on the next cron pass. For truly
immediate testing, run the management command manually.
Visibility and permissions¶
Visibility¶
If a user can access the project (see PROJECTS § Project membership), they can see the schedule. There's no role-based visibility filter on schedules — they're project-scoped, not event-scoped.
The events the scheduler creates are subject to all the normal workflow event visibility rules — see ROLES and VISIBILITY. A scheduled inspection workflow's created events appear to the right roles per the workflow's transition definitions, just like manually-created events.
Permissions¶
See PERMISSIONS § Schedule. Summary:
- View the Scheduler tab — anyone with project access.
- Create / Edit / Delete scheduled events — project admin / superuser, AND they need the SCHEDULE CRUD group permission. Project admin alone is NOT sufficient — they also need the group perm. This catches operators out — adding someone as a project admin doesn't grant Scheduler edit rights automatically.
The kebab on each schedule row shows Edit only if the user passes both checks. See ACTIONS § Scheduler.
Practical operations¶
Create a new scheduled event¶
- Open the project → Scheduler tab → "Add Scheduled Event" (visible if project_admin/superuser + SCHEDULE CRUD group).
- Set
name,begins(today or earlier for immediate eligibility), optionalends. - Pick
workflow(the workflow definition to run) +document(if the schedule is about a specific document) +assign_to. - Set
duration(days from start to due date on the created event). - Pick recurrence mode:
- Calendar: tick
enable_calendar, set day/month/year M2Ms. - Counter: tick
enable_counter, set day/month/year counters. - Save.
The schedule is now eligible. On the next cron pass, if today matches, the schedule fires.
Edit a scheduled event¶
Schedule kebab → Edit. Most fields are editable. Changes to recurrence settings take effect on the next cron pass — historical fires aren't re-evaluated.
Force a schedule to fire today¶
Edit → tick Apply now → save. Fires on the next cron pass regardless of normal recurrence match.
Pause a schedule¶
REBIM doesn't have an explicit pause toggle. Options:
- Set
endsto yesterday — the schedule becomes ineligible until you change it back. - Set
beginsto a far-future date — same effect. - Untick both
enable_calendarANDenable_counter— the schedule has no active recurrence pattern and never fires.
The third option is the most reversible.
Stop a schedule permanently¶
Set ends to a date in the past. Or delete the schedule entirely
(superuser via admin panel — the frontend doesn't show a delete
button on schedules).
Deleting a schedule does NOT delete the workflow events it has previously created — those are independent records once made.
Run the scheduler manually¶
For testing or recovery (when cron didn't run):
python manage.py scheduler_cron
This runs the full pass: scheduled-event creation + overdue + compliance report + FRA report. The output is verbose — shows each schedule processed, whether it matched, and whether an event was created.
Troubleshooting¶
| Symptom | Likely cause | First check |
|---|---|---|
| Schedule shows as "should fire today" but no event was created | The cron didn't run today, OR the schedule was created AFTER today's cron pass already ran | Check last_recurrance (if it's today, the cron ran and fired); check apply_now to force next pass |
| Schedule fired multiple times for the same date | The cron ran multiple times AND last_recurrance failed to update between runs (rare — usually a DB write issue) |
Check last_recurrance field; admin panel can show repeated WorkflowEvent rows on the same date |
| Calendar-mode schedule never fires | The "must match ALL THREE" rule — one of day/month/year doesn't include today | Verify each M2M list. A 0 (wildcard) entry counts as a match. |
| Counter-mode schedule that's never fired before, set up months ago, still hasn't fired | last_recurrance is null → counter mode SHOULD fire immediately on next cron. If it's still not firing, the cron isn't running |
Run python manage.py scheduler_cron manually and watch the output |
| Edit kebab missing for a project admin | They don't have the SCHEDULE CRUD group permission — admin status alone isn't enough | Add the group via Django admin → Users → Groups |
| Created events don't appear in Workflows tab | Project / tenancy mismatch — the schedule fires creating a WorkflowEvent but its tenancy doesn't match the user's view | Created event inherits project tenants. If schedule's project has different tenants than what user sees, event won't show |
Schedule has a workflow but created event has no status |
The workflow has no is_start=True transition — schedule creates the event but can't set the initial status |
Fix the workflow definition (add a start transition) |
apply_now set but nothing happened |
Cron hasn't run since. apply_now is consumed at cron-time. |
Wait for the next cron OR run scheduler_cron manually |
| Compliance overdue not flipping | Same cron job that fires schedules also runs compliance_overdue. If schedules aren't firing, neither is the overdue automation |
Run the cron manually + check output |
| FRA reports not appearing | Same — fra_report is part of the cron pass |
Same |
For developers¶
This page is for users and admins. Implementation pointers:
- Models:
apps.schedule.models.ScheduleEvent(the schedule),ScheduleDay/ScheduleMonth/ScheduleYear(the recurrence lookup tables). - Cron entry point:
core/management/commands/scheduler_cron.py: Command.handle(). Runscreate_as_needed()for today + the nextSCHEDULER_LOOKAHEAD_DAYSdays, plus the compliance / FRA report passes. - Recurrence matching:
create_as_needed()evaluates both calendar and counter conditions in sequence; the first match wins (calendar first if enabled, then counter). - Event creation: Inside the matching block, builds a new
WorkflowEventdirectly viaWorkflowEvent(...)then.save(). Workflow signal handlers fire on the post-save — so notifications - bellicons + email queueing all kick in normally.
- Eager loads: the
select_related/prefetch_relatedon the ScheduleEvent queryset and the pre-fetchedstart_transitions_by_workflowmap both exist to avoid N+1 fires during the inner per-event loop. Don't undo without audit. - Setting:
SCHEDULER_LOOKAHEAD_DAYSinsettings.py. Default - Lower it for testing if the verbose output is overwhelming.
See devdocs/ for codebase-level material.