On-Call Groups¶
Overview¶
On-call groups manage rotating duty shifts with:
- Automatic scheduling: pluggable strategies fill shifts up to 60 days ahead
- Google Calendar sync: events are created/updated for each shift
- Slack handovers: daily messages in the group's channel when a shift changes hands
- Roster management: manual roster via memberships or dynamic roster via smart groups
- User lifecycle integration: role grants automatically sync roster memberships
Data model¶
OncallGroup¶
Extends BaseAlanGroup (provides name, description, slack_handle, google_group, start_date, end_date, etc.).
| Field | Type | Description |
|---|---|---|
rotation_type |
String (enum) |
daily, triduum, weekly, or sprint |
rotation_size |
Integer |
Number of people on-call simultaneously (default 1) |
included_days |
JSONB[list[int]] |
ISO weekdays to schedule on (default [1,2,3,4,5] = Mon-Fri) |
shift_hours_start |
Time? |
Start time for calendar events |
shift_hours_end |
Time? |
End time for calendar events |
scheduling_strategy_id |
Text? |
Strategy for auto-filling gaps (see below) |
roster_smart_group_id |
UUID? (FK) |
Optional SmartGroup used as dynamic roster |
bank_holiday_countries |
JSONB[list[str]]? |
Country codes for bank holiday exclusion |
grace_period_after_long_ooo |
Boolean |
Skip first day back after 3+ consecutive OOO days (default True) |
disabled |
Boolean |
If true, future shifts are deleted and no new ones created |
use_calendar_invite |
Boolean |
Whether to create Google Calendar events (default True) |
calendar_id |
Text? |
Google Calendar ID |
calendar_watch_channel_id |
Text? |
Push notification channel ID |
calendar_watch_resource_id |
Text? |
Push notification resource ID |
calendar_next_sync_token |
Text? |
Incremental sync token |
calendar_invite_description |
Text? |
Extra text appended to calendar event description |
slack_channel_name |
Text? |
Slack channel for handovers (auto-lowercased, # prepended) |
slack_channel_id |
Text? |
Resolved Slack channel ID |
handover_message |
Text? |
Message posted to new on-call in handover |
has_load_estimation |
Boolean? |
Show load estimation reminder in handover |
wrap_up_additional_message |
Text? |
Extra message appended to wrap-up section |
dust_assistant |
String(255)? |
Dust AI assistant linked to this group |
assembled_integration |
Boolean? |
Add Assembled email to calendar invites |
area_id |
UUID? (FK) |
Link to an Area |
Relationships:
roster- activeAlanermembers viaOncallGroupMembershipexternal_users_roster- activeExternalUsermembers viaOncallGroupMembershipsmart_roster- activeAlanermembers viaSmartGroupMembership(whenroster_smart_group_idis set)members- alaners on-call today (current shifts, not declined)external_users- external users on-call todayshifts- allOncallGroupShiftrecords (ordered by date)area- optionalArearelationshipownership-Ownershiprecords
OncallGroupShift¶
Represents a single person assigned to a single date.
| Field | Type | Description |
|---|---|---|
date |
Date |
The shift date |
oncall_group_id |
UUID (FK) |
Parent on-call group |
alaner_id |
Integer? |
XOR with external_user_id |
external_user_id |
UUID? |
XOR with alaner_id |
calendar_event_id |
Text? |
Google Calendar event ID |
load |
Float? |
Self-reported load estimation |
declined_on |
Date? |
Date the shift was declined |
Constraints:
UniqueConstraint(oncall_group_id, date, alaner_id)UniqueConstraint(oncall_group_id, date, external_user_id)CheckConstraint: exactly one ofalaner_idorexternal_user_idmust be set
OncallGroupMembership¶
Extends HistorizableAlanerRelation + HistorizableExternalUserRelation (provides start_date, end_date, is_cancelled, alaner_id, external_user_id, is_active).
| Field | Type | Description |
|---|---|---|
oncall_group_id |
UUID (FK) |
Parent on-call group |
Constraints:
CheckConstraint: exactly one ofalaner_idorexternal_user_idmust be setExcludeConstraint: no overlapping active memberships per (alaner/external_user, oncall_group)
Roster types¶
On-call groups support two roster sources:
-
Manual roster (
OncallGroupMembership): members are added/removed via the API or user lifecycle sync. Therosterandexternal_users_rosterrelationships reflect active memberships. -
Smart roster (
SmartGroupviaroster_smart_group_id): the roster is dynamically computed from a smart group definition. Thesmart_rosterrelationship resolves active smart group memberships.
Strategies check smart_roster or (roster + external_users_roster) when building candidate pools, so smart roster takes precedence when configured.
Scheduling strategies¶
Strategies are pluggable via scheduling_strategy_id on the group. If None, defaults to default.
The strategy is resolved at runtime by iterating OncallSchedulingStrategy.__subclasses__() matching on strategy_id.
default¶
Round-robin by least-recent shift. Builds a candidate pool sorted by furthest distance to closest shift date. If grace_period_after_long_ooo is enabled, filters out alaners on their first day back.
distinct¶
Same as default but prevents double-booking across groups: excludes users who are already assigned to another distinct-strategy group on the same date.
manual¶
No auto-fill. fill_schedule_gap() is a no-op. Shifts must be managed entirely via the API.
part_time¶
Handles part-timers who may be OOO on specific days. Selects candidates for the full shift (including part-timers), then filters out those OOO on the specific date. If gaps remain, extends candidates from the previous shift.
office¶
Hardcoded day-of-week assignments for office on-call. Falls back to default candidate pool if the assigned person is OOO.
Adding a new strategy¶
- Create a new file in
business_logic/oncall_groups/strategies/ - Subclass
OncallSchedulingStrategy - Set
strategy_idanddescriptionclass attributes - Implement
fill_schedule_gap(cls, schedule, oncall_date, shift_start_date, shift_end_date, oncall_group) - Import the module in
scheduling.py(needed for subclass discovery)
class MyStrategy(OncallSchedulingStrategy):
strategy_id = "my_strategy"
description = "Description of the strategy"
@classmethod
def fill_schedule_gap(
cls,
schedule: dict[date, list[AlanUser]],
oncall_date: date,
shift_start_date: date,
shift_end_date: date,
oncall_group: OncallGroup,
) -> None:
# Fill schedule[oncall_date] with selected candidates
...
Schedule lifecycle¶
The flask on-call update_schedules command runs for each active on-call group (enqueued as background jobs).
flowchart TD
A[flask on-call update_schedules] --> B[For each OncallGroup]
B --> C[_clean_roster]
C --> D[_clean_schedule]
D --> E[_fill_schedule]
E --> F{is_production_mode?}
F -->|Yes| G[_update_calendar]
F -->|No| H[Done]
G --> H
_clean_roster¶
Removes memberships for ended alaners and external users.
_clean_schedule¶
- Deletes shifts before the group's
start_date - If
disabled, deletes all future shifts - Removes shifts where the alaner is OOO (full day, not remote)
- If
grace_period_after_long_ooo, removes shifts on first day back after 3+ OOO days - Removes shifts for users no longer in the roster
- Deletes shifts on dates outside the current rotation/included_days/bank holidays
_fill_schedule¶
- Builds a schedule map for the range
[today - 90 days, today + 60 days] - Loads existing non-declined shifts into the map
- For each future date with fewer assignments than
rotation_size, callsstrategy.fill_schedule_gap() - Creates
OncallGroupShiftrows for newly assigned users
_update_calendar¶
- Creates the Google Calendar if it doesn't exist
- Renews the push notification watch channel (7-day TTL)
- Syncs calendar events with scheduled shifts (creates, updates, deletes as needed)
- Adds attendees (including Assembled email if configured)
Rotation types¶
| Type | Shift span | Notes |
|---|---|---|
daily |
Single day | (date, date) |
triduum |
3 business days | Anchored to TRIDUUM_SHIFT_GENESIS (2023-01-02). Shifts crossing weekends span 5 calendar days |
weekly |
Monday to Friday | (MO(-1), FR) relative to the date |
sprint |
Wednesday to Tuesday | (WE(-1), TU) relative to the date |
Shift date filtering¶
Dates are filtered by:
included_days(ISO weekday numbers, default Mon-Fri)bank_holiday_countries(excluded viaBankHolidaytable)start_date(no shifts before the group's start date)- Business days only (pandas
freq="B")
Shift declination and swap¶
When an alaner declines a shift via Google Calendar, the system automatically detects the decline and attempts to find a swap.
sequenceDiagram
participant A as Alaner
participant GCal as Google Calendar
participant WH as Webhook<br>/webhooks/google/calendar
participant Worker as Background Worker
participant DB as Database
participant Slack as Slack Channel
A->>GCal: Decline calendar invite
GCal->>WH: Push notification<br>(X-Goog-Resource-State: exists)
WH->>Worker: Enqueue _process_refused_events<br>(deduplicated)
Worker->>GCal: Fetch incremental changes<br>(syncToken)
GCal-->>Worker: Changed events
loop For each declined attendee
Worker->>DB: Find OncallGroupShift by<br>calendar_event_id + email
Worker->>DB: Set shift.declined_on = today
Worker->>DB: find_shift_for_swap()
alt Swap candidate found
Worker->>DB: Create new shift for swap candidate<br>on declined date
Worker->>DB: Reassign swap candidate's original<br>shift to declining alaner
Worker->>Slack: Post swap notification
end
end
Worker->>DB: Update calendar_next_sync_token
find_shift_for_swap() looks for another shift in the same group where:
- The other person is not OOO on the declined date
- The declining person is not OOO on the swap date
- Neither person has already declined a shift on the target date
- The swap date is at least 7 days in the future
Grace period¶
When grace_period_after_long_ooo is enabled (default True):
- During
_clean_schedule: shifts on the first business day after 3+ consecutive OOO days are deleted - During
fill_schedule_gap(default/distinct strategies): alaners on their first day back are excluded from the candidate pool
This prevents assigning someone to on-call immediately after returning from a long absence.
Google Calendar integration¶
- Calendar creation: auto-created on first schedule update if
calendar_idisNone - Event sync: one calendar event per date, attendees are all on-call persons for that date
- Watch channels: push notification channels are renewed on each schedule update (7-day TTL). Webhook URL:
https://tools.alan.com/webhooks/google/calendar - Cleanup: orphaned calendar events (not referenced by shifts) are deleted; shift references to deleted events are cleared
- Disable behavior: if the group is disabled, the calendar is deleted
Slack integration¶
Handover messages¶
flask on-call post_handover_messages runs for groups with slack_handle, slack_channel_id, and handover_message set.
The message includes:
- Wrap-up section: thanks outgoing on-call, optionally reminds about load estimation
- Additional message:
wrap_up_additional_messageif configured - Handover section: mentions incoming on-call with the
handover_message
Only posted when there are new on-call members (compared to the last shift).
User lifecycle integration¶
CompanyOrgOncallGroupProvider syncs role grants to roster memberships:
- Role IDs:
org:oncall-group-{slugified_name}:member(generated byOncallGroupMemberRoleGenerator) - Provisioning: when a user gains the role, an
OncallGroupMembershipis created withstart_date=today - Deprovisioning: when a user loses the role, the membership is ended (
end_date=yesterday, or cancelled if started today) - Task:
CompanyOrgOncallGroupSyncruns as part of user lifecycle, comparing target roles vs current memberships
Adding a new on-call group¶
- Via API:
POST /oncall-groups/with the group configuration (name, rotation type, strategy, etc.) - Via admin: create in the admin interface
- Configure:
- Set
rotation_typeandrotation_size - Choose a
scheduling_strategy_id(or leaveNonefor default) - Set
included_daysandbank_holiday_countriesif needed - Configure Slack channel (
slack_channel_name) andhandover_messagefor handovers - Optionally link a smart group via
roster_smart_group_id
- Set
- Populate roster: add members via the roster API or set up role grant rules for automatic sync
- Schedule will auto-fill on the next
flask on-call update_schedulesrun