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_days non-zero: fires when today == last_recurrance + counter_days (days).
  • counter_months non-zero: fires when today == last_recurrance + counter_months (months).
  • counter_years non-zero: fires when today == 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:

  1. Creates a new WorkflowEvent with:
  2. owner = the schedule's owner.
  3. project = the schedule's project.
  4. workflow_workflow = the schedule's workflow (may be null → ad-hoc event).
  5. name = the schedule's name.
  6. content_document = the schedule's document (may be null).
  7. assigned_to = the schedule's assign_to.
  8. start_date = today.
  9. due_date = today + duration days (if duration > 0).
  10. If the schedule has a workflow:
  11. Looks up the workflow's is_start=True transition.
  12. Sets the new event's status to the start transition's status_from (the workflow's first state).
  13. Sets the new event's workflow_type from the status's type (Task / Issue / Planned / etc.).
  14. If the workflow has a force_edms_status set AND the schedule points at a document, also updates the document's status.
  15. Copies the project's tenancy to the new event (new_event.tenants = project.tenants).
  16. 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:

  1. Today — every schedule with begins <= today AND (ends IS NULL OR ends >= today) AND (last_recurrance IS NULL OR last_recurrance != today).
  2. Each of the next SCHEDULER_LOOKAHEAD_DAYS days — 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 their next_due date 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:

  1. Edit a scheduled event → tick Apply now → save.
  2. The next cron pass processes this schedule unconditionally on today's run (irrespective of the calendar / counter check).
  3. The schedule then fires per the normal flow + updates last_recurrance to 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

  1. Open the project → Scheduler tab → "Add Scheduled Event" (visible if project_admin/superuser + SCHEDULE CRUD group).
  2. Set name, begins (today or earlier for immediate eligibility), optional ends.
  3. Pick workflow (the workflow definition to run) + document (if the schedule is about a specific document) + assign_to.
  4. Set duration (days from start to due date on the created event).
  5. Pick recurrence mode:
  6. Calendar: tick enable_calendar, set day/month/year M2Ms.
  7. Counter: tick enable_counter, set day/month/year counters.
  8. 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 ends to yesterday — the schedule becomes ineligible until you change it back.
  • Set begins to a far-future date — same effect.
  • Untick both enable_calendar AND enable_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(). Runs create_as_needed() for today + the next SCHEDULER_LOOKAHEAD_DAYS days, 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 WorkflowEvent directly via WorkflowEvent(...) 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_related on the ScheduleEvent queryset and the pre-fetched start_transitions_by_workflow map both exist to avoid N+1 fires during the inner per-event loop. Don't undo without audit.
  • Setting: SCHEDULER_LOOKAHEAD_DAYS in settings.py. Default
  • Lower it for testing if the verbose output is overwhelming.

See devdocs/ for codebase-level material.