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:
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:
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:
- Locks the clinician's leave request rows with
select_for_update()insidetransaction.atomic()to prevent race conditions. - Sums leave consumed by
APPROVEDrequests. - Sums leave consumed by
REQUESTED(pending) requests. - 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:
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_WORKINGis restricted to clinicians with an activePARTNERworking term. This is checked both inLeaveValidator._validate_leave_type()and inLeaveRequest.clean().
Quota Balance Check¶
For quota-based leave types (ANNUAL_LEAVE only), the validator calls _check_annual_leave_balance(), which:
- Calls
LeaveCalculator.calculate_leave_used()to compute how much leave this request would consume. - Calls
LeaveCalculator.calculate_remaining_including_pending()to get the current remaining quota (including pending requests). - 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¶
- Clinician selects dates and leave type on the calendar-based booking page (
frontend/views/leave.py--leave_booking). - The frontend sends an AJAX POST to
leave_booking_submitwithleave_typeanddates. - For partners with ANNUAL_LEAVE and multiple dates, a single
LeaveRequestis created spanning the full date range. For other types (or salaried clinicians), individualLeaveRequestrecords are created per date. LeaveRequest.objects.create()runs model-level validation viaclean()(date range, partner-only types, full-week requirement for partners).- Confirmation email is sent to the clinician. Admin notification email is sent via Celery.
Auto-Processing (LeaveRequestProcessor.process_request)¶
The LeaveRequestProcessor handles automated processing:
- Status check: Only processes requests in
REQUESTEDstatus. - Manual review bypass:
STUDY_LEAVEandCORONERSare immediately returned aspendingfor admin review, skipping further automated checks. - Validation:
LeaveValidator.validate()runs the full validation pipeline. - Auto-deny: If validation fails, the request is set to
DENIEDwith the validation error stored indenial_reason. - Quota borderline check: If the request is valid but the quota is tight (would exceed if approved), it is returned as
pendingfor admin review rather than auto-approved. - 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):
- 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. - Override option: If the approval would exceed quota, the admin can check a "confirm override" box. This sets
request.exceeded_quota = Truefor audit purposes. - Shift deletion:
_exclude_clinician_from_rota()deletes all shifts (pinned and unpinned) for the clinician on the leave dates. - Leave shift creation: For
STUDY_LEAVEandCORONERS, new pinned shifts of the corresponding type are created on weekdays. - Alert refresh:
AlertService.refresh_alerts_for_date()is called for each date in the leave range to detect and create staffing alerts. - Coverage simulation:
CoverageSimulationService.simulate_single_request()runs to determine the staffing impact (green/yellow/red). - Staffing alert email: If the simulation returns yellow or red status, an email is sent to admins who have opted in to operational notifications.
- 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_LEAVEshifts are created on weekdays. These are markedis_pinned=Trueandcreated_by='leave_processor'. - CORONERS: Pinned
ShiftType.CORONERSshifts 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 |