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-within-one-working-term model (issue #206). A clinician's entitlement is not split into reporting periods, and it is scoped to a single working term: the quota term is resolved date-aware via WorkingTerm.get_term_for_quota (the term active on the relevant date, else the latest started term — never a future term), and usage is clipped to that term's own date window via sum_used_within_term. A clinician with two terms in a year therefore has two independent pools, not one annual one.

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.

For salaried terms the total value itself should be derived, not hand-entered: the Salaried Entitlement Calculator (calculations/salaried_entitlement.py, see Calculations) computes it from contracted sessions (out of 9), with the contractual NHS days added in (annual leave + NHS days) plus the net sessional bank holiday days ((sessions ÷ 10) per bank holiday in the term, minus one per bank holiday on a working day, clamped at zero), pro-rated by calendar days for partial terms and rounded half up in the doctor's favour. The admin Leave Calculator page and the AI API calculate-leave-entitlement endpoint both use that one implementation and can write the result back to the term (audited); both also report the leave booked against the term (approved/pending/remaining) using the quota system's own accounting.

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.

For salaried terms the calculator pro-rates by calendar days in the term over days in the year (per calendar-year segment, so a 01/04–31/03 term is exactly 1.0), rounding half up to whole days. The fraction applies to the combined entitlement (annual leave + NHS days); the bank holiday part scales by the count of bank holidays actually inside the term rather than the fraction, and everything is rounded once. This is implemented in calculations/salaried_entitlement.py and is the basis used by the admin Leave Calculator page and the AI API.

The older generic helper LeaveCalculator.calculate_pro_rata_entitlement() uses a weeks/52 fraction with ceiling rounding:

pro_rata = (weeks_remaining / 52) * full_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 (at approval time)

Quota is checked twice, against different pools:

  • At submission (_check_annual_leave_balance → calculate_total_remaining_leave), the balance counts approved requests only — pending requests from the same clinician do not consume quota (issue #50: two simultaneous in-flight requests must both be validatable).
  • At approval (validate_for_approval → calculate_remaining_including_pending), the balance counts approved and pending requests together, locking the clinician's leave rows with select_for_update() inside transaction.atomic(). This is the race window guard: when an admin approves, every other in-flight request for that clinician is accounted for, so two approvals cannot jointly overbook the term.

Balance Calculation Flow

LeaveCalculator.calculate_total_remaining_leave(clinician)
    |
    +-- Resolve the quota term via WorkingTerm.get_term_for_quota(date)
    |      (term active on the date, else the latest started term;
    |       never a future term — issue #206)
    +-- Read annual_leave_entitlement["total"]
    +-- Sum LeaveAdjustment amounts for term
    +-- Sum legacy ManualAdjustment amounts for term
    +-- effective_entitlement = total + adjustments
    +-- Sum leave_used for APPROVED ANNUAL_LEAVE requests
    |      clipped to the term's own date window (sum_used_within_term)
    +-- remaining = effective_entitlement - used
    +-- Apply type-specific rounding (weeks ceiling / days)
    +-- Floor at 0 (no negative balances)

Source: calculations/leave_calculator.py.

Do not use WorkingTerm.get_latest_term for quota — its docstring warns it is not date-aware (issue #206). get_term_for_quota / bulk_get_term_for_quota are the date-aware accessors.


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 except Study, Coroners (they route to manual review instead) Auto-deny
3 Holiday period 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 Leave booking cutoff (dates beyond SystemConfiguration.leave_booking_cutoff) Annual leave, Day Not Working, Planned Sick Auto-deny (admins bypass at the view level)
8 Admin approval required (STUDY_LEAVE, CORONERS) Study, Coroners Mark pending for admin review
9 Annual leave balance check (approved-only pool) Quota-based types only Auto-deny
10 Reporting period allocation Quota-based types only (Currently passthrough)
11 Conflicting leave (overlapping dates for same clinician) All types Auto-deny

Requests may also be routed to admin review by the staffing-policy service (NEEDS_PARTNERS_DISCUSSION) independently of the table above — see LeaveRequestProcessor.process_request.

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)

Holiday Period Restriction

Leave cannot be requested during a configured holiday period (e.g. the Christmas/New Year restricted window). Unlike a single fixed window, the bounds are now defined by the multi-row HolidayPeriod model (config/models/configuration.py), each row carrying a name, start_date, and end_date, so multiple disjoint periods can be blocked.

Detection is done by is_date_in_holiday_period() in calculations/date_utils.py. The older name is_date_in_christmas_period is kept only as a deprecated backwards-compatibility alias for the same function.

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.

Clinician-initiated cancellation approval. When a clinician cancels approved leave whose dates overlap the Fixed Period, the cancellation itself requires admin approval: a LeaveCancellationRequest (PENDING → APPROVED/DENIED) is created with the clinician's reason, reviewed on the admin Cancellation Requests page, and can be withdrawn by the clinician while pending. See the data model and ADR 0001.

Re-requesting cancelled leave (LeaveRequestProcessor.reinstate_request)

Cancelled requests can be put back by the clinician themselves (issue #64) via the Re-request button on the My Leave page (frontend:leave_request_reinstate), removing the need to email an administrator.

  • Authorization reuses the cancellation rules: admins, holders of config.cancel_leave_request, and the owning clinician; anonymous calls are rejected.
  • Only CANCELLED requests can be re-requested.
  • The request is re-validated as a fresh booking (quota, overlap with current REQUESTED/APPROVED leave, closure and calendar rules), with two deliberate exceptions:
  • the fixed-period (6-week) rule is skipped (LeaveValidator(skip_fixed_period_check=True)): a re-request inside the locked window is submitted as REQUESTED and left for admin review — the same human sign-off a within-window cancellation needs — instead of being refused outright, which previously made reinstatement admin-only;
  • the leave booking cutoff is not re-checked (the dates were bookable once already, and an admin still approves the result).
  • Like every clinician-submitted booking, a reinstated request is never auto-approved; it waits in the admin approval queue, where approval runs the usual approve_request path (quota re-check with override, shift deletion, staffing alerts).
  • processed_at/processed_by (which describe the cancellation) are cleared so the eventual approval stamps its own values.
  • The clinician confirmation and admin "request submitted" notification emails are sent, matching a fresh booking submission.

Outside the fixed period clinicians could always re-book the same dates from the booking page instead — cancellation frees quota and cancelled rows do not trip the overlap check — so re-request is primarily the path back for leave inside the locked window (and a one-click convenience for the rest).

Source: leave_processing/leave_request_processor.py -- process_request(), approve_request(), cancel_request(), reinstate_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().


Annual Leave Rounds

Next-year annual leave is allocated through a staged Annual Leave Round (the leave_round app, issues #179–#192) rather than first-come-first-served booking: clinicians submit ranked Week Selections during an open window, admins finalise the year on the Demand Board during Resolution, and agreed selections are converted into approved LeaveRequests (marked originating_round). While a round is pending, SystemConfiguration.leave_booking_cutoff can hold normal booking closed for the leave year.

The models (AnnualLeaveRound, WeekSelection, SelectionGhost, SchoolHolidayPeriod) are catalogued in the data model; the domain vocabulary is defined in CONTEXT.md; the administrator workflow is documented in the Admin Guide and the clinician experience in the User Guide.


Key Source Files

File Purpose
config/models/ LeaveType, LeaveStatus, LeaveRequest, LeaveAdjustment models and enums
leave_round/ Annual Leave Round models, services, eligibility, progress, emails
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