Working Time, Calendars & Shifts
Almost every business app needs to answer the same question: when is this resource actually available to work? HR asks it of an employee (contract hours, attendance, leave), MRP asks it of a work center (finite-capacity scheduling), and Helpdesk asks it of an SLA clock (“4 business hours from now”). Fullfinity answers all of these with one shared availability primitive built from four engine models. New code should reuse it — not reinvent a calendar.
The models live under fullfinity/modules/core/models/: work_schedule.py,
work_schedule_line.py, work_schedule_holiday.py, and shift.py.
The four models
Section titled “The four models”WorkSchedule — the calendar
Section titled “WorkSchedule — the calendar”A WorkSchedule defines when a resource is available. It owns a set of WorkScheduleLine
records (the repeating weekly pattern) and carries a timezone, an optional company, and a
stored, computed hours_per_week.
class WorkSchedule(Model): _verbose_name = "Work Schedule"
name = Char(max_length=255, required=True) active = Boolean(default=True) timezone = Char(max_length=50, default="UTC") company = ManyToOne("Company", related_name="work_schedules", on_delete="CASCADE") hours_per_week = Float(calculate="compute_hours_per_week", store=True)
@Model.calculate("lines") async def compute_hours_per_week(self): await self.fetch_related("lines") self.hours_per_week = sum(l.get_duration_hours() for l in (self.lines or []))WorkScheduleLine — a weekly working block
Section titled “WorkScheduleLine — a weekly working block”Each line is one block of working time on one weekday. Times are stored as float hours:
8.0 = 08:00, 14.5 = 14:30, 23.0 = 23:00. day_of_week is a human-readable Selection
("Monday" … "Sunday").
class WorkScheduleLine(Model): schedule = ManyToOne("WorkSchedule", related_name="lines", required=True, on_delete="CASCADE") day_of_week = Selection(choices=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], required=True) start_time = Float(required=True) # 24h decimal, e.g. 8.0 end_time = Float(required=True) # 24h decimal, e.g. 16.0 shift = ManyToOne("Shift", related_name="schedule_lines", on_delete="SET NULL")Crossing midnight. A night shift is expressed with end_time <= start_time
(e.g. start_time=23.0, end_time=7.0). get_duration_hours() accounts for it
((24.0 - start) + end), and the scheduler splits such a line into two intervals — the part
on the start day and the part bleeding into the next.
line.crosses_midnight() # True when end_time <= start_timeline.get_duration_hours() # 23.0 -> 7.0 yields 8.0 hoursShift — cost & capacity factors
Section titled “Shift — cost & capacity factors”A named work period (Day Shift, Night Shift, Weekend) that a WorkScheduleLine can reference.
A shift carries a cost_multiplier (e.g. 1.5 for a night premium) and a capacity_factor
(e.g. 0.7 for a skeleton crew). The scheduler reads capacity_factor off the shift attached
to each interval (defaulting to 1.0 when there is no shift).
class Shift(Model): name = Char(max_length=100, required=True) default_start_time = Float(default=8.0) default_end_time = Float(default=16.0) cost_multiplier = Float(default=1.0) # must be > 0 capacity_factor = Float(default=1.0) # 0 < factor <= 1WorkScheduleHoliday — one-off closures that override the weekly pattern
Section titled “WorkScheduleHoliday — one-off closures that override the weekly pattern”A holiday or planned shutdown that subtracts from the weekly pattern on the days it covers.
- Scope — set
scheduleto apply it to one schedule; leavescheduleempty for a company-wide holiday that applies to every schedule (a public holiday entered once, not per work center). Ifcompanyis also empty it applies to all companies. - Duration — leave
start_time/end_timeempty for a full-day closure; set both for a half-day closure (e.g.start_time=13.0,end_time=18.0). A multi-day shutdown is a single record spanningdate_from..date_to.
class WorkScheduleHoliday(Model): name = Char(max_length=255, required=True) schedule = ManyToOne("WorkSchedule", related_name="holidays", on_delete="CASCADE") date_from = Date(required=True) date_to = Date(required=True) start_time = Float(default=None) # set both for a half-day closure end_time = Float(default=None) company = ManyToOne("Company", related_name="work_schedule_holidays", on_delete="CASCADE")Holidays are consumed by WorkSchedule.get_available_intervals_on_date, which subtracts the
closure window from the day’s intervals — so every scheduling consumer honours it with no
change to their own code. Fetch the applicable set (schedule-specific plus company-wide) with:
holidays = await schedule.get_applicable_holidays(company=work_center.company)The scheduling API
Section titled “The scheduling API”All scheduling methods take the schedule’s pre-fetched lines as their first argument and
an optional pre-fetched holidays list. Fetch both before calling:
await schedule.fetch_related("lines")lines = schedule.linesholidays = await schedule.get_applicable_holidays()get_available_intervals_on_date(lines, target_date, holidays=None)
Section titled “get_available_intervals_on_date(lines, target_date, holidays=None)”Returns a list of (start_dt, end_dt, shift, capacity_factor) tuples for the date, sorted by
start. Night shifts crossing midnight are split correctly, and any covering holidays are
subtracted (full-day removes all intervals; half-day trims or splits them). With holidays
omitted, no closures are applied. This is the building block the other three methods call.
intervals = schedule.get_available_intervals_on_date(lines, date(2026, 6, 26), holidays=holidays)for start_dt, end_dt, shift, capacity_factor in intervals: ...get_available_minutes_on_date(lines, target_date, holidays=None) sums those intervals into
total available minutes, scaled by each interval’s capacity_factor.
find_next_available_slot(lines, after_dt, duration_minutes, max_days=90, holidays=None)
Section titled “find_next_available_slot(lines, after_dt, duration_minutes, max_days=90, holidays=None)”Searches forward from after_dt for the next slot that fits duration_minutes of contiguous
work, scaling the required wall-clock duration by the interval’s capacity_factor. Returns
(start_dt, end_dt, shift) or None if nothing fits within max_days.
slot = schedule.find_next_available_slot(lines, datetime.now(), duration_minutes=240, holidays=holidays)if slot: start_dt, end_dt, shift = slotschedule_backward(lines, deadline_dt, duration_minutes, max_days=90, holidays=None)
Section titled “schedule_backward(lines, deadline_dt, duration_minutes, max_days=90, holidays=None)”The mirror image: searches backward from a deadline to find the latest start that still
finishes by deadline_dt. Returns (start_dt, end_dt, shift) or None. Use it for “must ship
by Friday — when is the latest I can start?” finite-capacity scheduling.
slot = schedule.schedule_backward(lines, deadline_dt, duration_minutes=480, holidays=holidays)add_working_hours(lines, start_dt, hours, holidays=None, max_days=365)
Section titled “add_working_hours(lines, start_dt, hours, holidays=None, max_days=365)”The SLA-clock primitive: advance hours of available working time forward from
start_dt, skipping nights, weekends, and closures, and return the datetime reached. “4
business hours from now” lands on the next working moment 4 open hours away, not 4
wall-clock hours. Unlike find_next_available_slot, it accumulates elapsed open time across
intervals and ignores capacity_factor — an SLA hour is an hour regardless of crew size.
Returns start_dt when hours <= 0, or None if the target can’t be reached within
max_days.
sla_due = schedule.add_working_hours(lines, ticket.created_date, hours=4, holidays=holidays)Reuse, don’t reinvent
Section titled “Reuse, don’t reinvent”This is the shared availability primitive across the suite:
- HR — employee/contract working time, attendance expectations, leave.
- MRP — work centers and work-order finite-capacity scheduling.
- Helpdesk — SLA clocks via
add_working_hours.
When you need “is this resource available / when next / by when” logic, attach a
WorkSchedule and call these methods. Do not hand-roll a weekday loop or a holiday check — the
midnight-crossing and closure-subtraction edge cases are already handled here.