Skip to content

Leave System

The leave management system handles absence requests from clinicians, tracks quota balances, validates requests against business rules, and modifies the rota when leave is approved. It interacts closely with the shift generation engine and the staffing alert system.

Leave Types

The system defines five leave types via the LeaveType enum in config/models.py.

Type Label Quota-based Requires admin approval Partner-only Shift behaviour on approval
ANNUAL_LEAVE Annual Leave Yes Auto-approved if quota available No Existing shifts deleted; no replacement shifts created
NOT_WORKING Day Not Working No Auto-approved Yes (partners only) Existing shifts deleted; no replacement shifts created
PLANNED_SICK Planned Sick Leave No Auto-approved No Existing shifts deleted; no replacement shifts created
STUDY_LEAVE Study Leave No Always manual admin review No Existing shifts deleted; pinned STUDY_LEAVE shifts created on weekdays
CORONERS Coroner's Court No Always manual admin review No Existing shifts deleted; pinned CORONERS shifts created on weekdays

Quota-based means the leave type counts against the clinician's annual leave entitlement. Only ANNUAL_LEAVE is quota-based. All other types bypass quota entirely.

Partner-only means the leave type is restricted to clinicians with an active PARTNER working term. Only NOT_WORKING carries this restriction.

Shift behaviour is handled by LeaveRequestProcessor._create_leave_shifts() and LeaveRequestProcessor._exclude_clinician_from_rota().

Source: config/models.py -- LeaveType, LeaveStatus, LeaveRequest, LeaveAdjustment; leave_processing/leave_request_processor.py -- _create_leave_shifts(), _exclude_clinician_from_rota().


Quota System

Leave quotas operate on a single-pool model. Each clinician has one total annual entitlement for the year (not split into reporting periods). The pool is reduced as annual leave is approved.

Entitlement Calculation

Entitlement is stored on the WorkingTerm model in the annual_leave_entitlement JSON field, under the key "total". The effective entitlement is:

effective_entitlement = base_total + adjustments

where adjustments is the sum of all LeaveAdjustment records for the working term, plus any legacy ManualAdjustment records.

Partner vs Salaried Units

The quota unit depends on the clinician's working term type:

  • Partners: Leave is measured in weeks. Any partial week of leave counts as a full week (ceiling rounding). Partners must request leave in full calendar weeks (Monday through Friday, excluding bank holidays).
  • Salaried clinicians: Leave is measured in working days. Only days the clinician is contracted to work (their fixed_working_days) count toward leave usage.

Pro-rata for Mid-Year Joiners

When a clinician starts partway through the year, their entitlement is calculated pro-rata:

pro_rata = (weeks_remaining / 52) * full_entitlement

The result is rounded up (ceiling) to favour the clinician. This is implemented in LeaveCalculator.calculate_pro_rata_entitlement().

LeaveAdjustment Model

LeaveAdjustment is the only mechanism for carry-over. There is no automatic carry-over of unused leave. An admin must manually create a positive LeaveAdjustment to carry forward unused entitlement from the previous term.

Fields: - working_term -- Foreign key to the WorkingTerm the adjustment applies to - amount -- Positive or negative Decimal (in the term's unit: weeks or days) - reason -- Free-text explanation - created_by -- Username of the admin who created it - created_at -- Timestamp

Pending-Inclusive Validation

To prevent over-booking from simultaneous requests, quota calculations include both approved and pending requests. The key method is LeaveCalculator.calculate_remaining_including_pending(), which:

  1. Locks the clinician's leave request rows with select_for_update() inside transaction.atomic() to prevent race conditions.
  2. Sums leave consumed by APPROVED requests.
  3. Sums leave consumed by REQUESTED (pending) requests.
  4. Subtracts both from the effective entitlement.

This means that when a new request is submitted, it is validated against a remaining balance that already accounts for all other in-flight requests.

Balance Calculation Flow

LeaveCalculator.calculate_total_remaining_leave(clinician)
    |
    +-- Get latest WorkingTerm
    +-- Read annual_leave_entitlement["total"]
    +-- Sum LeaveAdjustment amounts for term
    +-- Sum legacy ManualAdjustment amounts for term
    +-- effective_entitlement = total + adjustments
    +-- Sum leave_used for all APPROVED ANNUAL_LEAVE requests
    +-- remaining = effective_entitlement - used
    +-- Apply type-specific rounding (weeks ceiling / days)
    +-- Floor at 0 (no negative balances)

Source: calculations/leave_calculator.py.


Validation Rules

Validation is implemented in validators/leave_validator.py (LeaveValidator.validate()). Checks run in a fixed order. A request fails on the first rule that triggers.

Validation Pipeline

Order Rule Applies to Failure behaviour
1 Date range valid (end >= start) All types Auto-deny
2 6-week fixed period All types Auto-deny
3 Christmas/New Year restriction All types Auto-deny
4 Weekend restriction All types Auto-deny
5 Bank holiday restriction All types Auto-deny
6 Leave type restriction (e.g., NOT_WORKING partner-only) Type-specific Auto-deny
7 Admin approval required (STUDY_LEAVE, CORONERS) Study, Coroners Mark pending for admin review
8 Annual leave balance check Quota-based types only Auto-deny
9 Reporting period allocation Quota-based types only (Currently passthrough)
10 Conflicting leave (overlapping dates for same clinician) All types Auto-deny

6-Week Fixed Period Rule

The rota has a configurable "fixed period" (default: 6 weeks from today). During this window, the rota is locked -- no automatic recalculation occurs and no leave requests are allowed for dates within the fixed period. This ensures operational stability for the near-term schedule.

The fixed period boundary is calculated by FixedPeriodChecker in validators/fixed_period_checker.py:

fixed_period_end = today + timedelta(weeks=config.fixed_period_weeks)

Christmas Period Restriction

Leave cannot be requested during the Christmas/New Year restricted period. The bounds are configurable in SystemConfiguration:

  • christmas_period_start_month / christmas_period_start_day (default: Dec 24)
  • christmas_period_end_month / christmas_period_end_day (default: Jan 2)

Detection is done by is_date_in_christmas_period() in calculations/date_utils.py.

Weekend and Bank Holiday Restrictions

Leave requests cannot include weekends or UK bank holidays. These are non-working days by definition and do not need leave cover. Checks are performed by has_weekend_in_range() and has_bank_holiday_in_range().

Leave Type Restrictions

  • NOT_WORKING is restricted to clinicians with an active PARTNER working term. This is checked both in LeaveValidator._validate_leave_type() and in LeaveRequest.clean().

Quota Balance Check

For quota-based leave types (ANNUAL_LEAVE only), the validator calls _check_annual_leave_balance(), which:

  1. Calls LeaveCalculator.calculate_leave_used() to compute how much leave this request would consume.
  2. Calls LeaveCalculator.calculate_remaining_including_pending() to get the current remaining quota (including pending requests).
  3. Rejects if leave_required > remaining.

Date Overlap Check

_check_conflicting_leave() ensures a clinician does not already have an overlapping leave request (of any type) in APPROVED or REQUESTED status. Each date in the requested range is checked individually.

Source: validators/leave_validator.py.


Approval Workflow

Request Lifecycle

                    +------------+
                    | REQUESTED  |  <-- Created by clinician or admin
                    +-----+------+
                          |
             +------------+------------+
             |                         |
      Auto-validation            Manual review required
      (LeaveValidator)           (STUDY_LEAVE, CORONERS,
             |                   or quota borderline)
      +------+------+                  |
      |             |            +------+------+
   Valid         Invalid       Admin reviews |
      |             |            |            |
      v             v         Approve       Deny
+------------+  +--------+  +------------+  +--------+
| APPROVED   |  | DENIED |  | APPROVED   |  | DENIED |
+-----+------+  +--------+  +-----+------+  +--------+
      |                           |
  Clinician or admin          Shift deletion,
  can cancel                  alerts refreshed,
      |                       emails sent
      v
+------------+
| CANCELLED  |
+------------+

Submission

  1. Clinician selects dates and leave type on the calendar-based booking page (frontend/views/leave.py -- leave_booking).
  2. The frontend sends an AJAX POST to leave_booking_submit with leave_type and dates.
  3. For partners with ANNUAL_LEAVE and multiple dates, a single LeaveRequest is created spanning the full date range. For other types (or salaried clinicians), individual LeaveRequest records are created per date.
  4. LeaveRequest.objects.create() runs model-level validation via clean() (date range, partner-only types, full-week requirement for partners).
  5. Confirmation email is sent to the clinician. Admin notification email is sent via Celery.

Auto-Processing (LeaveRequestProcessor.process_request)

The LeaveRequestProcessor handles automated processing:

  1. Status check: Only processes requests in REQUESTED status.
  2. Manual review bypass: STUDY_LEAVE and CORONERS are immediately returned as pending for admin review, skipping further automated checks.
  3. Validation: LeaveValidator.validate() runs the full validation pipeline.
  4. Auto-deny: If validation fails, the request is set to DENIED with the validation error stored in denial_reason.
  5. Quota borderline check: If the request is valid but the quota is tight (would exceed if approved), it is returned as pending for admin review rather than auto-approved.
  6. Auto-approve: If all checks pass, the request is set to APPROVED, shifts are affected, and leave-related shifts are created.

Admin Approval (LeaveRequestProcessor.approve_request)

When an admin approves a request (from the admin leave management page at frontend/views/admin/leave.py -- admin_leave_request_approve):

  1. Quota re-validation: Quota is re-checked at approval time using validate_for_approval(). The request being approved is excluded from the pending count to avoid double-counting.
  2. Override option: If the approval would exceed quota, the admin can check a "confirm override" box. This sets request.exceeded_quota = True for audit purposes.
  3. Shift deletion: _exclude_clinician_from_rota() deletes all shifts (pinned and unpinned) for the clinician on the leave dates.
  4. Leave shift creation: For STUDY_LEAVE and CORONERS, new pinned shifts of the corresponding type are created on weekdays.
  5. Alert refresh: AlertService.refresh_alerts_for_date() is called for each date in the leave range to detect and create staffing alerts.
  6. Coverage simulation: CoverageSimulationService.simulate_single_request() runs to determine the staffing impact (green/yellow/red).
  7. Staffing alert email: If the simulation returns yellow or red status, an email is sent to admins who have opted in to operational notifications.
  8. Approval email: A confirmation email is sent to the clinician.

Coverage Simulation (Traffic-Light Classification)

The coverage simulation (CoverageSimulationService) analyses whether the rota can absorb the leave request. For each date in the leave range, it classifies the impact:

Status Meaning Staffing alert email?
Green Sufficient clinicians available below their weekly max No
Yellow Coverable, but requires clinicians at or near their weekly max Yes
Red Cannot be covered -- below minimum staffing Yes

The overall status for a request is the worst status across all dates.

Denial

On denial, the admin provides a denial_reason. The request status is set to DENIED, processed_at and processed_by are recorded, and a denial email is sent to the clinician. No shifts are affected.

Cancellation (LeaveRequestProcessor.cancel_request)

Both clinicians and admins can cancel requests in REQUESTED or APPROVED status.

Authorization (_can_cancel_request): - Superusers and staff members can cancel any request. - Users with the config.cancel_leave_request permission can cancel any request. - The clinician who owns the request can cancel their own request. - Anonymous cancellation is rejected.

On cancellation: 1. If the request was APPROVED, leave-related shifts (STUDY_LEAVE or CORONERS) are removed by _remove_leave_shifts(). 2. The request status is set to CANCELLED. 3. Cancellation emails are sent to both admins and the clinician.

Important: Cancelled approved leave does not restore deleted shifts. The rota moves forward. Gaps left by the original shift deletion are filled during the next rota generation cycle or through manual admin action.

Source: leave_processing/leave_request_processor.py -- process_request(), approve_request(), cancel_request().


Leave and Rota Interaction

Shift Deletion on Approval

When leave is approved, _exclude_clinician_from_rota() deletes all shifts (both pinned and unpinned) for the clinician on every date in the leave range. This applies to all leave types.

For STUDY_LEAVE and CORONERS, deletion is followed by creation of new pinned shifts of the appropriate type (see below).

Leave Shift Creation

After deletion, _create_leave_shifts() creates replacement shifts for specific types:

  • ANNUAL_LEAVE, NOT_WORKING, PLANNED_SICK: No shifts are created. The clinician is simply absent from the rota.
  • STUDY_LEAVE: Pinned ShiftType.STUDY_LEAVE shifts are created on weekdays. These are marked is_pinned=True and created_by='leave_processor'.
  • CORONERS: Pinned ShiftType.CORONERS shifts are created on weekdays. Same pinning rules.

Pinned shifts survive rota rebalancing -- they will not be removed during automatic recalculation.

Interaction with Fixed/Flexible Period Boundary

The fixed period boundary (see 6-Week Fixed Period Rule) prevents leave requests from being submitted for dates within the next 6 weeks. This means:

  • Leave can only be requested for the flexible period (dates beyond the fixed period).
  • Once leave is approved, shift deletion happens immediately regardless of period.
  • If the fixed period rolls forward to encompass dates where leave was previously approved, the rota generation engine respects the approved leave and does not schedule shifts on those dates.

Shift Recreation Patterns

When approved leave is cancelled: - STUDY_LEAVE and CORONERS pinned shifts are deleted. - Original working shifts are not restored. Gaps are left for the next rota generation cycle. - The system treats cancelled approved leave as "the rota moves forward" -- it does not attempt to reconstruct the previous state.

Source: leave_processing/leave_request_processor.py -- _exclude_clinician_from_rota(), _create_leave_shifts(), _remove_leave_shifts().


Key Source Files

File Purpose
config/models.py LeaveType, LeaveStatus, LeaveRequest, LeaveAdjustment models and enums
calculations/leave_calculator.py Entitlement calculation, quota tracking, leave usage computation
validators/leave_validator.py LeaveValidator with full validation pipeline
validators/fixed_period_checker.py Fixed vs flexible period boundary logic
leave_processing/leave_request_processor.py LeaveRequestProcessor -- processing, approval, cancellation
leave_processing/permissions.py Custom leave permissions (cancel, override quota)
leave_processing/interfaces.py NotificationService protocol for email notifications
leave_processing/notifications/ Celery-based and sync notification implementations
rota_generation/services/coverage_simulation_service.py Traffic-light coverage simulation for leave approval
frontend/views/leave.py Clinician-facing leave booking and cancellation views
frontend/views/admin/leave.py Admin leave management (approve, deny, cancel, create-on-behalf)
frontend/services/leave_validation_service.py Frontend service layer wrapping LeaveValidator
frontend/services/leave_request_service.py Frontend service layer for request creation
frontend/views/helpers.py Quota panel and staffing feasibility context builders