feat: add exact shifts functionality to Worker class and update related logic in RotaBuilder

This commit is contained in:
Ross
2026-07-08 22:33:33 +01:00
parent 02598cb665
commit 0f31dfe1a9
7 changed files with 398 additions and 102 deletions
+3
View File
@@ -45,6 +45,7 @@ class WorkerForm(forms.ModelForm):
# Complex structured options edited as JSON
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
exact_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->exact_count", label="Exact shifts (JSON)")
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
@@ -94,6 +95,7 @@ class WorkerForm(forms.ModelForm):
for json_fld in [
"assign_as_block_preferences",
"shift_fte_overrides",
"exact_shifts",
"previous_shifts",
"shift_balance_extra",
"allowed_multi_shift_sets",
@@ -146,6 +148,7 @@ class WorkerForm(forms.ModelForm):
json_fields = [
"assign_as_block_preferences",
"shift_fte_overrides",
"exact_shifts",
"previous_shifts",
"shift_balance_extra",
"allowed_multi_shift_sets",
+1
View File
@@ -229,6 +229,7 @@ class Worker(models.Model):
"max_shifts_per_week_by_shift_name",
"assign_as_block_preferences",
"shift_fte_overrides",
"exact_shifts",
]
list_keys = [
"oop",
+72 -34
View File
@@ -30,6 +30,10 @@ sites = (
"plymouth",
"taunton",
"proc",
"plymouth ir",
"truro ir",
"exeter ir",
"torbay ir",
)
from rota_generator.workers import (
@@ -49,7 +53,7 @@ def main(
time_to_run: int = 60 * 60 * 10,
ratio: float = 0.1,
start_date: datetime.datetime = "2026-09-07",
weeks: int = 26,
weeks: int = 12,
bom: float = 1,
):
rota_start_date = start_date.date()
@@ -65,13 +69,13 @@ def main(
Rota.constraint_options["balance_shifts"] = False
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.constraint_options["balance_weekends"] = True
#Rota.constraint_options["max_night_frequency"] = 4 # 3
# Rota.constraint_options["max_night_frequency"] = 4 # 3
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
#Rota.constraint_options["max_weekend_frequency"] = 3
# Rota.constraint_options["max_weekend_frequency"] = 3
#Rota.constraint_options["max_days_per_week_block"] = [(8,4)]
# Rota.constraint_options["max_days_per_week_block"] = [(8,4)]
#wr = [
# wr = [
# WorkerRequirement(
# end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
# number=3,
@@ -80,7 +84,7 @@ def main(
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
# number=4,
# ),
#]
# ]
Rota.add_shifts(
SingleShift(
@@ -89,6 +93,7 @@ def main(
"exeter twilights and weekends",
"exeter no nights",
"exeter twilights",
"exeter ir",
),
name="exeter_twilight",
length=12.5,
@@ -104,6 +109,7 @@ def main(
"truro twilights and weekends",
"truro twilights and weekend nights",
"truro twilights, weekends and weekend nights",
"truro ir",
),
name="truro_twilight",
length=12.5,
@@ -112,7 +118,12 @@ def main(
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
),
SingleShift(
sites=("torbay", "torbay twilights", "torbay twilights and weekends"),
sites=(
"torbay",
"torbay twilights",
"torbay twilights and weekends",
"torbay ir",
),
name="torbay_twilight",
length=12.5,
days=days[:4],
@@ -125,6 +136,7 @@ def main(
"plymouth twilights",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
"plymouth ir",
),
name="plymouth_twilight",
length=12.5,
@@ -139,12 +151,12 @@ def main(
"exeter twilights and weekends",
"exeter no nights",
"exeter weekends",
#"exeter twilights",
# "exeter twilights",
),
name="weekend_exeter",
length=12.5,
days=days[5:],
#balance_offset=2,
# balance_offset=2,
rota_on_nwds=True,
force_as_block=True,
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
@@ -161,10 +173,10 @@ def main(
name="weekend_truro",
length=12.5,
days=days[4:],
#balance_offset=3,
# balance_offset=3,
rota_on_nwds=True,
force_as_block=True,
#assign_as_block=True,
# assign_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
# force_as_block_unless_nwd=True
),
@@ -173,7 +185,7 @@ def main(
name="weekend_torbay",
length=12.5,
days=days[4:],
#balance_offset=2,
# balance_offset=2,
rota_on_nwds=True,
force_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
@@ -189,7 +201,7 @@ def main(
name="weekend_plymouth1",
length=8,
days=days[5:],
#balance_offset=2.9,
# balance_offset=2.9,
workers_required=1,
rota_on_nwds=True,
force_as_block=True,
@@ -204,7 +216,7 @@ def main(
name="weekend_plymouth2",
length=8,
days=days[5:],
#balance_offset=2.9,
# balance_offset=2.9,
workers_required=1,
rota_on_nwds=True,
force_as_block=True,
@@ -216,6 +228,7 @@ def main(
"plymouth twilights",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
"plymouth ir",
),
name="plymouth_bank_holidays",
length=8,
@@ -227,11 +240,13 @@ def main(
bank_holidays_only=True,
),
SingleShift(
sites=sites,
sites=[
*sites,
],
name="night_weekday",
length=12.25,
days=days[:4],
#balance_offset=3.9,
# balance_offset=3.9,
balance_weighting=1,
# hard_constrain_shift=False,
workers_required=4,
@@ -245,7 +260,7 @@ def main(
LimitGradeNumberConstraint(grade=2, max_number=2),
MinimumGradeNumberConstraint(grade=4, min_number=1),
],
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
),
SingleShift(
sites=[
@@ -258,7 +273,7 @@ def main(
name="night_weekend",
length=12.25,
days=days[4:],
#balance_offset=2.9,
# balance_offset=2.9,
balance_weighting=1,
# hard_constrain_shift=False,
workers_required=4,
@@ -272,15 +287,19 @@ def main(
LimitGradeNumberConstraint(grade=2, max_number=2),
MinimumGradeNumberConstraint(grade=4, min_number=1),
],
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
),
)
# Prevent shifts for ST2s for 2 weeks
#Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
# Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
["weekend_plymouth1", "weekend_plymouth2"], 6
)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
["plymouth_twilight", "plymouth_bank_holidays"], 6
)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
@@ -327,7 +346,7 @@ def main(
after_count = len(filtered)
if after_count != before_count:
print(
f"Removed {before_count-after_count} leave entries for '{name}' between {_from} and {_to}"
f"Removed {before_count - after_count} leave entries for '{name}' between {_from} and {_to}"
)
w["leave"] = filtered
@@ -375,9 +394,9 @@ def main(
nwd_end_date = Rota.rota_end_date
if "[" in i:
#print("Split nwd", worker_name, i)
# print("Split nwd", worker_name, i)
a, b = i.split("[")[1][:-1].split("-")
#print(f"{a=} {b=}")
# print(f"{a=} {b=}")
nwd_start_date = datetime.datetime.strptime(
a, "%d/%m/%Y"
).date()
@@ -406,7 +425,9 @@ def main(
elif "to" in raw:
s, e = raw.split("to", 1)
else:
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
raise ValueError(
f"Cannot parse OOP dates: '{oop}' for {worker}"
)
s = s.strip().strip("()[]\"' ")
e = e.strip().strip("()[]\"' ")
@@ -416,7 +437,9 @@ def main(
try:
start_dt = datetime.datetime.strptime(s, fmt).date()
end_dt = datetime.datetime.strptime(e, fmt).date()
formatted_oops.append({"start_date": start_dt, "end_date": end_dt})
formatted_oops.append(
{"start_date": start_dt, "end_date": end_dt}
)
parsed = True
break
except ValueError:
@@ -425,13 +448,15 @@ def main(
if not parsed:
print("WORKER", worker)
print("DATES", s, e)
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
raise ValueError(
f"Cannot parse OOP dates: '{oop}' for {worker}"
)
oop = formatted_oops
else:
oop = []
#print(f"{worker} OOP {oop}")
# print(f"{worker} OOP {oop}")
previous_shifts = {}
# if twi:
@@ -465,16 +490,29 @@ def main(
shift_fte_overrides = {}
#if worker_name == "Hadi Mohamed":
exact_shifts = {}
if worker in ["hannah.lewis29@nhs.net", "shruti.bodapati@nhs.net", "a.abouelatta@nnhs.net"]:
exact_shifts = {
"night_weekday": 4,
"night_weekend": 3,
}
elif worker in ["g.abdelhalim@nhs.net", ""]:
exact_shifts = {
"night_weekend": 3,
"night_weekday": 0,
}
# if worker_name == "Hadi Mohamed":
# shift_fte_overrides = {
# "weekend_exeter": 50,
# }
#elif worker_name == "Joel Lim":
# elif worker_name == "Joel Lim":
# shift_fte_overrides = {
# "plymouth_twilight": 100,
# "weekend_exeter": 50,
# }
#if worker_name == "Nang Thiriphoo":
# if worker_name == "Nang Thiriphoo":
# shift_fte_overrides = {
# "weekend_exeter": 40,
# }
@@ -497,9 +535,10 @@ def main(
shift_balance_extra=w["shift_balance_extra"],
bank_holiday_extra=w["bank_holiday_extra"],
shift_fte_overrides=shift_fte_overrides,
exact_shifts=exact_shifts,
)
#print(w)
# print(w)
Rota.add_worker(w)
leave.load_academy(Rota)
@@ -507,7 +546,6 @@ def main(
# Rota.build_workers()
# Rota.build_model()
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
# solver_options = {"seconds": time_to_run, "threads": 10}
+15
View File
@@ -219,6 +219,21 @@ def load_leave(Rota):
#elif row_title == "Academy":
# pass
#elif "night specific" in row_title:
# if lower_item:
# for shift in lower_item.split(","):
# shift = shift.strip()
# if shift == "nights":
# shift = "night_weekday"
# elif shift == "nights only":
# if date.weekday() < 5:
# shift = "night_weekday"
# else:
# shift = "night_weekend"
# worker["single_nights"].append(
# WorkRequests(date=date, shift=shift)
# )
elif re.match(date_re, row_date) is not None:
if lower_item != "":
+96 -67
View File
@@ -2648,89 +2648,102 @@ class RotaBuilder(object):
):
if (
worker.site in shift.sites
): # Each site specfies which sites self.workers can fullfill it
# calaculate the total number of shifts that need assigning over the rota
# total_shifts = (len(self.weeks) * len(shift.days) *
# TODO: check if shift_counts is still required?
# total_shifts = (
# self.shift_counts[shift.name] * shift.workers_required
# )
): # Each site specfies which sites self.workers can fullfill
total_shifts = self.shift_worker_counts[shift.name]
full_time_equivalent_joined = sum(
self.full_time_equivalent_sites[i][shift.name]
for i in shift.sites
)
# Find workers who have exact shifts for this shift type
exact_workers = [
w for w in self.workers
if w.site in shift.sites and shift.name in getattr(w, "exact_shifts", {})
]
exact_shifts_sum = sum(w.exact_shifts[shift.name] for w in exact_workers)
# Guard against division by zero: if no full-time
# equivalent is available for the shift's sites the
# denominator may be zero (for example when all site
# FTEs are 0). In that case emit a warning and set the
# target to 0 rather than raising an exception.
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
self.add_warning(
"Zero FTE for sites",
f"full_time_equivalent sum is zero for shift {shift.name}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
if exact_shifts_sum > total_shifts:
warning_msg = f"For shift {shift.name}, total exact shifts ({exact_shifts_sum}) exceeds total required shifts ({total_shifts})."
if not any(warn[1] == warning_msg for warn in self.warnings):
self.add_warning("Exact shifts exceed total shifts", warning_msg)
if worker in exact_workers:
target_shifts = worker.exact_shifts[shift.name]
else:
target_shifts = (
total_shifts
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
remaining_shifts = total_shifts - exact_shifts_sum
# Sum FTE only for workers who do not have exact shifts for this shift type
full_time_equivalent_joined = sum(
w.get_fte(shift=shift.name)
for w in self.workers
if w.site in shift.sites and w not in exact_workers
)
if self.use_previous_shifts:
if shift.name in worker.previous_shifts:
entry = worker.previous_shifts[shift.name]
# entry may be a tuple (worked, allocated) or a dict of per-day tuples
if isinstance(entry, dict):
# prefer overall tuple if present
if "__all__" in entry:
try:
worked = float(entry["__all__"][0])
allocated = float(entry["__all__"][1])
target_shifts = target_shifts + allocated - worked
except Exception:
pass
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
if remaining_shifts > 0:
self.add_warning(
"Zero FTE for sites with remaining shifts",
f"full_time_equivalent sum is zero for shift {shift.name} but remaining_shifts = {remaining_shifts}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
else:
target_shifts = (
max(0.0, float(remaining_shifts))
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
)
if worker not in exact_workers:
if self.use_previous_shifts:
if shift.name in worker.previous_shifts:
entry = worker.previous_shifts[shift.name]
# entry may be a tuple (worked, allocated) or a dict of per-day tuples
if isinstance(entry, dict):
# prefer overall tuple if present
if "__all__" in entry:
try:
worked = float(entry["__all__"][0])
allocated = float(entry["__all__"][1])
target_shifts = target_shifts + allocated - worked
except Exception:
pass
else:
# sum any per-day entries
try:
total_worked = 0.0
total_alloc = 0.0
for v in entry.values():
total_worked += float(v[0])
total_alloc += float(v[1])
target_shifts = target_shifts + total_alloc - total_worked
except Exception:
pass
else:
# sum any per-day entries
try:
total_worked = 0.0
total_alloc = 0.0
for v in entry.values():
total_worked += float(v[0])
total_alloc += float(v[1])
target_shifts = target_shifts + total_alloc - total_worked
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
else:
try:
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
# TODO look at how this affects allocation (how does it affect fte)
match extra:
case "double":
target_shifts = target_shifts * 2
case "half":
target_shifts = target_shifts / 2
case _:
target_shifts = target_shifts + extra
# TODO look at how this affects allocation (how does it affect fte)
match extra:
case "double":
target_shifts = target_shifts * 2
case "half":
target_shifts = target_shifts / 2
case _:
target_shifts = target_shifts + extra
# print(worker.name, shift.name, target_shifts)
worker.shift_target_number[shift.name] = target_shifts
if shift.hard_constrain_shift:
if worker in exact_workers:
self.model.constraints.add(
self.model.shift_count[worker.id, shift.name] == target_shifts
)
elif shift.hard_constrain_shift:
adjusted_balance_offset = shift.balance_offset
if worker.get_fte(shift=shift.name) < 100:
adjusted_balance_offset = (
@@ -4244,6 +4257,9 @@ class RotaBuilder(object):
Must be called prior to attempting to solve
"""
if not hasattr(self, "shifts_by_name"):
self.build_shifts()
if not self.workers:
raise NoWorkers("Workers must be added prior to calling build_workers")
@@ -4253,6 +4269,19 @@ class RotaBuilder(object):
for worker in track(self.workers, description="Building workers"):
print(worker.name)
worker.load_rota(self)
for s_name in getattr(worker, "exact_shifts", {}):
try:
s = self.get_shift_by_name(s_name)
if worker.site not in s.sites:
self.add_warning(
"Invalid exact shift site",
f"Worker {worker.name} requested exact shifts for shift {s_name} but their site {worker.site} is not in the shift sites {s.sites}",
)
except KeyError:
self.add_warning(
"Invalid exact shift",
f"Worker {worker.name} requested exact shifts for non-existent shift {s_name}",
)
wid = worker.id
if wid in self.workers_id_map:
message = f"Worker with id '{wid}' has been added twice"
+2
View File
@@ -213,6 +213,8 @@ class Worker(BaseModel):
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
exact_shifts: dict[str, int] = {} # Map shift_name to exact number of shifts
weekend_shift_target_number: float = 0.0
avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
+209 -1
View File
@@ -1624,4 +1624,212 @@ def test_worker_hard_day_exclusion2():
for week in range(16):
sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun")
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
def test_exact_shifts_distribution():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
workers = [
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
Worker(name="worker2", site="group1", grade=1, fte=100),
Worker(name="worker3", site="group1", grade=1, fte=50),
]
Rota.add_workers(workers)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0,
),
)
Rota.build_and_solve()
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 2
# Verify actual assigned counts are exactly correct
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
if shiftname == "a":
worker_shift_counts[worker.name] += 1
assert worker_shift_counts["worker1"] == 4
assert worker_shift_counts["worker2"] == 4
assert worker_shift_counts["worker3"] == 2
def test_exact_shifts_distribution_unbalanced():
weeks_to_rota = 16
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
workers = [
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
Worker(name="worker2", site="group1", grade=1, fte=100),
Worker(name="worker3", site="group1", grade=1, fte=50),
]
Rota.add_workers(workers)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0,
),
SingleShift(
sites=("group1",),
name="b",
length=12.5,
days=("Tue",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0.8,
),
)
Rota.build_and_solve()
Rota.export_rota_to_html("test_exact_shifts_distribution_unbalanced", folder="tests")
assert Rota.results.solver.status == "ok"
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 8
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker1"].shift_target_number["b"] == 6.4
assert Rota.workers_name_map["worker2"].shift_target_number["b"] == 6.4
assert Rota.workers_name_map["worker3"].shift_target_number["b"] == 3.2
# Verify actual assigned counts are exactly correct
worker_shift_counts_a = {w.name: 0 for w in Rota.get_workers()}
worker_shift_counts_b = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
if shiftname == "a":
worker_shift_counts_a[worker.name] += 1
elif shiftname == "b":
worker_shift_counts_b[worker.name] += 1
assert worker_shift_counts_a["worker1"] == 4
assert worker_shift_counts_a["worker2"] == 8
assert worker_shift_counts_a["worker3"] == 4
assert worker_shift_counts_b["worker1"] in (6, 7) # Allowing for rounding differences
assert worker_shift_counts_b["worker2"] in (6, 7) # Allowing for rounding differences
assert worker_shift_counts_b["worker3"] in (3, 4) # Allowing for rounding differences
def test_exact_shifts_invalid_site_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group2", grade=1, fte=100, exact_shifts={"a": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
SingleShift(
sites=("group2",),
name="b",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
warnings = Rota.get_warnings("Invalid exact shift site")
assert len(warnings) > 0
assert "group2 is not in the shift sites" in warnings[0][1]
def test_exact_shifts_invalid_shift_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"nonexistent": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
warnings = Rota.get_warnings("Invalid exact shift")
assert len(warnings) > 0
assert "non-existent shift nonexistent" in warnings[0][1]
def test_exact_shifts_exceed_total_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
Rota.build_model()
warnings = Rota.get_warnings("Exact shifts exceed total shifts")
assert len(warnings) > 0
assert "total exact shifts (4) exceeds total required shifts" in warnings[0][1]