Skip to content

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.

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_time
line.get_duration_hours() # 23.0 -> 7.0 yields 8.0 hours

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 <= 1

WorkScheduleHoliday — 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 schedule to apply it to one schedule; leave schedule empty for a company-wide holiday that applies to every schedule (a public holiday entered once, not per work center). If company is also empty it applies to all companies.
  • Duration — leave start_time/end_time empty 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 spanning date_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)

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.lines
holidays = 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 = slot

schedule_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)

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.