Skip to content

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 - active Alaner members via OncallGroupMembership
  • external_users_roster - active ExternalUser members via OncallGroupMembership
  • smart_roster - active Alaner members via SmartGroupMembership (when roster_smart_group_id is set)
  • members - alaners on-call today (current shifts, not declined)
  • external_users - external users on-call today
  • shifts - all OncallGroupShift records (ordered by date)
  • area - optional Area relationship
  • ownership - Ownership records

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 of alaner_id or external_user_id must 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 of alaner_id or external_user_id must be set
  • ExcludeConstraint: no overlapping active memberships per (alaner/external_user, oncall_group)

Roster types

On-call groups support two roster sources:

  1. Manual roster (OncallGroupMembership): members are added/removed via the API or user lifecycle sync. The roster and external_users_roster relationships reflect active memberships.

  2. Smart roster (SmartGroup via roster_smart_group_id): the roster is dynamically computed from a smart group definition. The smart_roster relationship 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

  1. Create a new file in business_logic/oncall_groups/strategies/
  2. Subclass OncallSchedulingStrategy
  3. Set strategy_id and description class attributes
  4. Implement fill_schedule_gap(cls, schedule, oncall_date, shift_start_date, shift_end_date, oncall_group)
  5. 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
Hold "Alt" / "Option" to enable pan & zoom

_clean_roster

Removes memberships for ended alaners and external users.

_clean_schedule

  1. Deletes shifts before the group's start_date
  2. If disabled, deletes all future shifts
  3. Removes shifts where the alaner is OOO (full day, not remote)
  4. If grace_period_after_long_ooo, removes shifts on first day back after 3+ OOO days
  5. Removes shifts for users no longer in the roster
  6. Deletes shifts on dates outside the current rotation/included_days/bank holidays

_fill_schedule

  1. Builds a schedule map for the range [today - 90 days, today + 60 days]
  2. Loads existing non-declined shifts into the map
  3. For each future date with fewer assignments than rotation_size, calls strategy.fill_schedule_gap()
  4. Creates OncallGroupShift rows for newly assigned users

_update_calendar

  1. Creates the Google Calendar if it doesn't exist
  2. Renews the push notification watch channel (7-day TTL)
  3. Syncs calendar events with scheduled shifts (creates, updates, deletes as needed)
  4. 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 via BankHoliday table)
  • 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
Hold "Alt" / "Option" to enable pan & zoom

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_id is None
  • 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:

  1. Wrap-up section: thanks outgoing on-call, optionally reminds about load estimation
  2. Additional message: wrap_up_additional_message if configured
  3. 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 by OncallGroupMemberRoleGenerator)
  • Provisioning: when a user gains the role, an OncallGroupMembership is created with start_date=today
  • Deprovisioning: when a user loses the role, the membership is ended (end_date=yesterday, or cancelled if started today)
  • Task: CompanyOrgOncallGroupSync runs as part of user lifecycle, comparing target roles vs current memberships

Adding a new on-call group

  1. Via API: POST /oncall-groups/ with the group configuration (name, rotation type, strategy, etc.)
  2. Via admin: create in the admin interface
  3. Configure:
    • Set rotation_type and rotation_size
    • Choose a scheduling_strategy_id (or leave None for default)
    • Set included_days and bank_holiday_countries if needed
    • Configure Slack channel (slack_channel_name) and handover_message for handovers
    • Optionally link a smart group via roster_smart_group_id
  4. Populate roster: add members via the roster API or set up role grant rules for automatic sync
  5. Schedule will auto-fill on the next flask on-call update_schedules run