many improvements

This commit is contained in:
Ross
2022-07-11 19:46:40 +01:00
parent 0cebbfddf6
commit dc975b991c
25 changed files with 1949 additions and 863 deletions
+311
View File
@@ -0,0 +1,311 @@
import datetime
import os
import sys
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
sites = (
"truro",
"exeter",
"torbay",
"barnstaple",
"plymouth",
"taunton",
"proc",
"truro no proc",
)
from rota.workers import (
Worker,
NotAvailableToWork,
NonWorkingDays,
WorkRequests,
PreferenceNotToWork,
OutOfProgramme,
)
suspend_on_finish = False
solve = True
time_to_run = 258500
# allow = 5000
ratio = 0.5
start_date = datetime.date(2022, 9, 5)
Rota = RotaBuilder(start_date, weeks_to_rota=12)
Rota.constraint_options["balance_weekends"] = True
Rota.constraint_options["max_night_frequency"] = 1
Rota.constraint_options["max_weekend_frequency"] = 1
# Rota.constraint_options["avoid_st2_first_month"] = True
Rota.add_shifts(
SingleShift(
sites=("exeter", "exeter twilights and weekends"),
name="exeter_twilight",
length=12.5,
days=days[:5],
balance_offset=3,
constraint=[{"name": "max_shifts_per_week", "options": 2}],
),
SingleShift(
sites=("truro", "truro twilights"),
name="truro_twilight",
length=12.5,
days=days[:4],
balance_offset=3,
),
SingleShift(
sites=("torbay", "torbay twilights"),
name="torbay_twilight",
length=12.5,
days=days[:4],
balance_offset=5,
assign_as_block=True,
),
SingleShift(
sites=("plymouth", "plymouth twilights"),
name="plymouth_twilight",
length=12.5,
days=days[:5],
balance_offset=3,
workers_required=1,
constraint=[{"name": "max_shifts_per_week", "options": 2}],
),
SingleShift(
sites=("exeter", "exeter twilights and weekends"),
name="weekend_exeter",
length=12.5,
days=days[5:],
balance_offset=4,
rota_on_nwds=True,
force_as_block=True,
constraint=[{"name": "preclear2"}, {"name": "postclear2"}, {"name": "single_shift_block_per_week"}],
#constraint=[{"name": "preclear2"}, {"name": "postclear2"},],
),
SingleShift(
sites=("truro",),
name="weekend_truro",
length=12.5,
days=days[4:],
balance_offset=4,
rota_on_nwds=True,
force_as_block=True,
constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
# force_as_block_unless_nwd=True
),
SingleShift(
sites=("torbay",),
name="weekend_torbay",
length=12.5,
days=days[4:],
balance_offset=4,
rota_on_nwds=True,
force_as_block=True,
constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
# force_as_block_unless_nwd=True
),
SingleShift(
sites=("plymouth",),
name="weekend_plymouth1",
length=8,
days=days[5:],
balance_offset=4,
workers_required=1,
rota_on_nwds=True,
force_as_block=True,
constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
),
SingleShift(
sites=("plymouth",),
name="weekend_plymouth2",
length=8,
days=days[5:],
balance_offset=4,
workers_required=1,
rota_on_nwds=True,
force_as_block=True,
constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
),
SingleShift(
sites=("plymouth", "plymouth twilights"),
name="plymouth_bank_holidays",
length=8,
days=days[5:],
balance_offset=2,
workers_required=1,
rota_on_nwds=True,
force_as_block=False,
bank_holidays_only=True,
),
SingleShift(
sites=(sites[:-1]),
name="night_weekday",
length=12.25,
days=days[:4],
balance_offset=4.9,
balance_weighting=1,
# hard_constrain_shift=False,
workers_required=3,
force_as_block=True,
rota_on_nwds=True,
constraint=[
{"name": "night"},
{"name": "preclear2"},
{"name": "postclear2"},
{"name": "require_remote_site_presence_week", "options": ("plymouth", 1)},
],
),
SingleShift(
sites=(sites[:-1]),
name="night_weekend",
length=12.25,
days=days[4:],
balance_offset=3.9,
balance_weighting=1,
# hard_constrain_shift=False,
workers_required=3,
force_as_block=True,
rota_on_nwds=True,
constraint=[
{"name": "night"},
{"name": "preclear2"},
{"name": "postclear2"},
{"name": "require_remote_site_presence_week", "options": ("plymouth", 1)},
],
),
)
Rota.add_grade_constraint_by_week([2], [1, 2, 3, 4], ["night_weekday", "night_weekend"])
load_leave = True
Rota.build_shifts()
Rota.add_grade_constraint_by_week([2], [1, 2], Rota.get_shift_names())
if load_leave:
import leave
workers = leave.load_leave(Rota)
n = 0
for worker in workers:
n = n + 1
worker_name = worker
w = workers[worker]
site = w["site"]
grade = w["grade"]
fte = float(w["fte"]) * 100
nwd = w["nwd"]
end_date = w["end_date"]
start_date = w["start_date"]
oop = w["oop"]
grade = w["grade"]
pair = w["pair"]
# name, site, grade, fte, nwd, end_date, oop, twi, twi_we, night, night_we, pw1, pw2, extra = row
# Ignore trainees if fte == 0 (what are they doing here anyway)
if fte == 0 or fte == "0" or fte == "":
continue
nwds = []
if nwd:
for i in nwd.split(","):
nwd_start_date = Rota.start_date
nwd_end_date = Rota.rota_end_date
if "[" in i:
a, b = i.split("[")[1][:-1].split("-")
nwd_start_date = datetime.datetime.strptime(a, "%d/%m/%y").date()
nwd_end_date = datetime.datetime.strptime(b, "%d/%m/%y").date()
nwds.append(
NonWorkingDays(
day=i.split("[")[0].strip()[:3].capitalize(),
start_date=nwd_start_date,
end_date=nwd_end_date,
)
)
# nwds = [i.strip()[:3].capitalize() for i in nwd.split("/")] if nwd else None
end_date = end_date if end_date else None
oop = oop.split("-") if oop else None
previous_shifts = {}
# if twi:
# worked = twi.split(" ")[0]
# allocated = twi.split(" ")[1][1:-1]
# previous_shifts["{}_twilight".format(site.lower())] = (worked, allocated)
# if twi_we:
# worked = twi_we.split(" ")[0]
# allocated = twi_we.split(" ")[1][1:-1]
# previous_shifts["weekend_{}".format(site.lower())] = (worked, allocated)
# if night:
# worked = night.split(" ")[0]
# allocated = night.split(" ")[1][1:-1]
# previous_shifts["night_weekday".format(site.lower())] = (worked, allocated)
# if night_we:
# worked = night_we.split(" ")[0]
# allocated = night_we.split(" ")[1][1:-1]
# previous_shifts["night_weekend".format(site.lower())] = (worked, allocated)
# if pw1:
# worked = pw1.split(" ")[0]
# allocated = pw1.split(" ")[1][1:-1]
# previous_shifts["weekend_plymouth1"] = (worked, allocated)
# if pw2:
# worked = pw2.split(" ")[0]
# allocated = pw2.split(" ")[1][1:-1]
# previous_shifts["weekend_plymouth2"] = (worked, allocated)
leave_requests = w["leave"]
work_requests = w["requests"]
site_pref = w["site_pref"]
Rota.add_worker(
Worker(
name=worker_name,
site=site.lower(),
grade=int(grade[2]),
id=n,
fte=int(fte),
nwds=nwds,
start_date=start_date,
end_date=end_date,
opp=oop,
not_available_to_work=leave_requests,
work_requests=work_requests,
remote_site=site_pref,
previous_shifts=previous_shifts,
pair=pair,
shift_balance_extra=w["shift_balance_extra"],
bank_holiday_extra=w["bank_holiday_extra"],
)
)
Rota.build_workers()
Rota.build_model()
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
Rota.solve_model(options=solver_options)
# print(Rota.get_worker_details())
# optimizer = SolverFactory('cbc')
# result = optimizer.solve(prob,tee=True)
# result.Solver.Status = SolverStatus.warning
# prob.solutions.load_from(result)
# ResultsHolder = RotaResults(Rota)
# worker_timetable_brief = ResultsHolder.get_worker_timetable_brief(
# show_prefs=False, show_unavailable=False
# )
#
# print(worker_timetable_brief)
Rota.export_rota_to_html("proc_rota")
# Rota.export_rota_to_csv("rota")
if suspend_on_finish:
os.system("systemctl suspend")
+33 -11
View File
@@ -1,19 +1,25 @@
import csv import csv
from datetime import datetime
import re import re
from requests import Session
from rota.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme
date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}" date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}"
def load_leave(Rota, use_live_rota): def load_leave(Rota):
file_name = "leave.csv" with Session() as s:
with open(file_name, newline="") as f:
shifts = Rota.get_shift_names() shifts = Rota.get_shift_names()
workers = {} workers = {}
reader = csv.reader(f, delimiter=",", quotechar='"') download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vS49eVS0f86wUJIhXMq7tMPZfAEJZatER09JLu0xnlTjDBkC-54DkAPT3I0JVXDQXNG6AIh_fVeIRS3/pub?gid=0&single=true&output=csv")
decoded_content = download.content.decode('utf-8')
reader = csv.reader(decoded_content.splitlines(), delimiter=',')
leave = {} leave = {}
requests = {} requests = {}
@@ -21,7 +27,7 @@ def load_leave(Rota, use_live_rota):
order = [] order = []
n = 0 n = 1
for row in reader: for row in reader:
row_title = row[0] row_title = row[0]
r = row[2:] r = row[2:]
@@ -71,8 +77,20 @@ def load_leave(Rota, use_live_rota):
elif row_title == "Flexible NWD": elif row_title == "Flexible NWD":
worker["flexible_nwd"] = lower_item worker["flexible_nwd"] = lower_item
elif row_title == "End Date": elif row_title in ("End Date", "CCT date"):
worker["end_date"] = lower_item if lower_item:
date = datetime.strptime(lower_item, "%d/%m/%y").date()
worker["end_date"] = date
else:
worker["end_date"] = None
elif row_title in ("Start date",):
if lower_item:
date = datetime.strptime(lower_item, "%d/%m/%y").date()
worker["start_date"] = date
else:
worker["start_date"] = None
elif row_title == "OOP": elif row_title == "OOP":
worker["oop"] = lower_item worker["oop"] = lower_item
@@ -97,18 +115,22 @@ def load_leave(Rota, use_live_rota):
elif row_title == "bank_holidays_worked": elif row_title == "bank_holidays_worked":
if lower_item: if lower_item:
print("BH", lower_item)
worker["bank_holiday_extra"] = int(lower_item) worker["bank_holiday_extra"] = int(lower_item)
elif re.match(date_re, row_title) is not None: elif re.match(date_re, row_title) is not None:
if lower_item != "": if lower_item != "":
date = datetime.strptime(row[0], "%d/%m/%y").date()
# This may be easier to do as a dict # This may be easier to do as a dict
if lower_item in shifts: if lower_item in shifts:
worker["requests"].append((row[0], lower_item)) worker["requests"].append(WorkRequests(date=date, shift=lower_item))
elif lower_item in ("work_request", "happy to work"):
worker["requests"].append(WorkRequests(date=date, shift="*"))
else: else:
worker["leave"].append((row[0], lower_item)) worker["leave"].append(NotAvailableToWork(date=date, reason=lower_item))
n = n + 1 n = n + 1
+66 -2
View File
@@ -18,6 +18,14 @@ table {
} }
.shift-table {
padding-top: 10px;
}
.shift-table thead {
font-weight: bold;
}
.table-div { .table-div {
/* overflow-x: auto; */ /* overflow-x: auto; */
width: 80%; width: 80%;
@@ -107,10 +115,15 @@ th.bank-holiday {
color: #0074D9; color: #0074D9;
} }
.torquay { .torquay, .torbay {
color: #5FA8E8; color: #5FA8E8;
} }
.taunton {
color: #e85fdf;
}
.unavailable { .unavailable {
color: lightgray; color: lightgray;
} }
@@ -167,7 +180,7 @@ th.bank-holiday {
} }
.nwd { .nwd {
color: brown; color: lightcoral;
} }
.th {} .th {}
@@ -235,3 +248,54 @@ summary h2 {
color: red; color: red;
} }
/* table.transposed {
border-collapse: collapse;
}
table.transposed tr {
display: block;
float: left;
}
table.transposed th,
table.transposed td {
display: block;
border: 1px solid black;
max-width: 100px;
} */
table.transposed {
}
table.transposed tr {
}
table.transposed th,
table.transposed td {
width: fit-content;
max-width: unset;
position: relative;
border-top: solid 0.5px black;
border-right: solid 0.5px black;
}
table.transposed .Sat {
/* opacity: 30%; */
/* background-color: lightsteelblue; */
border-top: solid 2px lightsteelblue !important;
}
table.transposed .Sun {
border-bottom: solid 2px lightsteelblue !important;
}
table.transposed .bank-holiday {
border-bottom: dotted 2px blue !important;
}
table.transposed th.bank-holiday {
color: darkblue;
}
+53 -1
View File
@@ -161,7 +161,7 @@ function generateExtra() {
<span>Total shifts: ${total_shifts}, </span> <span>Total shifts: ${total_shifts}, </span>
<span>Weekends: ${weekends_worked}, </span> <span>Weekends: ${weekends_worked}, </span>
<span>Bank holidays: ${bank_holidays_worked}, </span> <span>Bank holidays: ${bank_holidays_worked}, </span>
<span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff}, </span> <span class='shift-diff-span'>Summed shift diff: ${summed_shift_diff.toPrecision(2)}, </span>
</div>`) </div>`)
@@ -377,9 +377,61 @@ function viewWorker(worker) {
</div>`) </div>`)
$("body").append(dlg) $("body").append(dlg)
console.log(ds.shiftCounts)
shifts = JSON.parse(ds.shiftCounts)
shift_targets = JSON.parse(ds.workerTargets)
shift_diffs = JSON.parse(ds.shiftDiff)
let shift_count_table = document.createElement("table")
shift_count_table.classList.add("shift-table")
let header_row = shift_count_table.createTHead().insertRow();
header_row.insertCell().appendChild(document.createTextNode("Shift"))
header_row.insertCell().appendChild(document.createTextNode("Count"))
header_row.insertCell().appendChild(document.createTextNode("Target"))
header_row.insertCell().appendChild(document.createTextNode("Diffs"))
table_body = shift_count_table.createTBody()
for (const shift in shifts) {
let row = table_body.insertRow();
row.insertCell().appendChild(document.createTextNode(shift))
row.insertCell().appendChild(document.createTextNode(shifts[shift]))
row.insertCell().appendChild(document.createTextNode(shift_targets[shift].toPrecision(2)))
row.insertCell().appendChild(document.createTextNode(shift_diffs[shift].toPrecision(2)))
//console.log(shift, shifts[shift]);
}
dlg.get(0).appendChild(shift_count_table)
dlg.dialog() dlg.dialog()
console.log(worker); console.log(worker);
} }
$("#export-div").append($("#main-table").clone().attr("id", "gen-table").addClass("transposed"))
$("#gen-table").each(function() {
var $this = $(this);
var newrows = [];
//$this.find("th.site-title").parent().remove();
$this.find("tr").each(function(rowToColIndex){
$(this).find("td, th").each(function(colToRowIndex){
if(newrows[colToRowIndex] === undefined) { newrows[colToRowIndex] = $("<tr></tr>"); }
while(newrows[colToRowIndex].find("td, th").length < rowToColIndex){
newrows[colToRowIndex].append($("<td></td>"));
}
newrows[colToRowIndex].append($(this));
});
});
$this.find("tr").remove();
$.each(newrows, function(){
$this.append(this);
});
$this.find("td.rota-day").each((n, el) => {
console.log(el)
$(el).text(el.dataset.shift)
});
});
View File
+6
View File
@@ -4,3 +4,9 @@ pytest
black black
rich rich
pydantic pydantic
fastapi
uvicorn
fastapi-sqlalchemy
alembic
psycopg2
python-dotenv
+3 -12
View File
@@ -41,18 +41,12 @@ rota_collections["current"].add_shifts(
SingleShift(("truro",), "weekend_truro", 12.5, days[5:], assign_as_block=True), SingleShift(("truro",), "weekend_truro", 12.5, days[5:], assign_as_block=True),
SingleShift(("torquay",), "weekend_torquay", 12.5, days[5:], assign_as_block=True), SingleShift(("torquay",), "weekend_torquay", 12.5, days[5:], assign_as_block=True),
SingleShift( SingleShift(
("plymouth",), sites=("plymouth",), name="weekend_plymouth", length= 8, days=days[5:],
"weekend_plymouth",
8,
days[5:],
workers_required=2, workers_required=2,
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
(sites), sites=(sites), name="night_weekday", length= 12.25, days=days[:4],
"night_weekday",
12.25,
days[:4],
balance_offset=5, balance_offset=5,
balance_weighting=1, balance_weighting=1,
workers_required=3, workers_required=3,
@@ -61,10 +55,7 @@ rota_collections["current"].add_shifts(
constraints=["night"], constraints=["night"],
), ),
SingleShift( SingleShift(
(sites), sites=(sites), name="night_weekend", length= 12.25, days=days[4:],
"night_weekend",
12.25,
days[4:],
balance_offset=4, balance_offset=4,
balance_weighting=1, balance_weighting=1,
workers_required=3, workers_required=3,
+11 -44
View File
@@ -78,10 +78,7 @@ Rota.constraint_options["require_presence_at_site_overnight"] = ["plymouth"]
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("exeter",), sites=("exeter",), name="exeter_twilight", length= 12.5, days=days[:4],
"exeter_twilight",
12.5,
days[:4],
balance_offset=3, balance_offset=3,
constraints=["max_2_shifts_per_week"], constraints=["max_2_shifts_per_week"],
), ),
@@ -89,76 +86,52 @@ Rota.add_shifts(
("truro", "truro no proc"), "truro_twilight", 12.5, days[:4], balance_offset=3 ("truro", "truro no proc"), "truro_twilight", 12.5, days[:4], balance_offset=3
), ),
SingleShift( SingleShift(
("torbay", "torbay twilights"), sites=("torbay", "torbay twilights"), name="torbay_twilight", length= 12.5, days=days[:4],
"torbay_twilight",
12.5,
days[:4],
balance_offset=3, balance_offset=3,
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
("plymouth", "plymouth_twilights"), sites=("plymouth", "plymouth_twilights"), name="plymouth_twilight", length= 12.5, days=days[:5],
"plymouth_twilight",
12.5,
days[:5],
balance_offset=3, balance_offset=3,
workers_required=1, workers_required=1,
constraints=["max_2_shifts_per_week"], constraints=["max_2_shifts_per_week"],
), ),
SingleShift( SingleShift(
("exeter",), sites=("exeter",), name="weekend_exeter", length= 12.5, days=days[4:],
"weekend_exeter",
12.5,
days[4:],
balance_offset=4, balance_offset=4,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
), ),
SingleShift( SingleShift(
("truro", "truro no proc"), sites=("truro", "truro no proc"), name="weekend_truro", length= 12.5, days=days[4:],
"weekend_truro",
12.5,
days[4:],
balance_offset=4, balance_offset=4,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
SingleShift( SingleShift(
("torbay",), sites=("torbay",), name="weekend_torbay", length= 12.5, days=days[4:],
"weekend_torbay",
12.5,
days[4:],
balance_offset=4, balance_offset=4,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
SingleShift( SingleShift(
("plymouth",), sites=("plymouth",), name="weekend_plymouth1", length= 8, days=days[5:],
"weekend_plymouth1",
8,
days[5:],
balance_offset=4, balance_offset=4,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
), ),
SingleShift( SingleShift(
("plymouth",), sites=("plymouth",), name="weekend_plymouth2", length= 8, days=days[5:],
"weekend_plymouth2",
8,
days[5:],
balance_offset=4, balance_offset=4,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
), ),
SingleShift( SingleShift(
("plymouth", "plymouth_twilights"), sites=("plymouth", "plymouth_twilights"), name="plymouth_bank_holidays", length= 8, days=days[5:],
"plymouth_bank_holidays",
8,
days[5:],
balance_offset=2, balance_offset=2,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
@@ -166,10 +139,7 @@ Rota.add_shifts(
bank_holidays_only=True, bank_holidays_only=True,
), ),
SingleShift( SingleShift(
(sites[:-1]), sites=(sites[:-1]), name="night_weekday", length= 12.25, days=days[:4],
"night_weekday",
12.25,
days[:4],
balance_offset=4, balance_offset=4,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
@@ -179,10 +149,7 @@ Rota.add_shifts(
constraints=["night"], constraints=["night"],
), ),
SingleShift( SingleShift(
(sites[:-1]), sites=(sites[:-1]), name="night_weekend", length= 12.25, days=days[4:],
"night_weekend",
12.25,
days[4:],
balance_offset=3, balance_offset=3,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
+298 -124
View File
@@ -1,9 +1,9 @@
from calendar import week from calendar import week
import datetime import datetime
import itertools import itertools
from typing import Dict, Iterable, List, Sequence, Tuple, Set from typing import Dict, Iterable, List, Sequence, Tuple, Set, Any, Type
from pydantic import BaseModel, Extra from pydantic import BaseModel, Extra, constr
from pathlib import Path from pathlib import Path
@@ -47,6 +47,11 @@ SHIFT_BOUNDS = {
} }
class ShiftConstraint(BaseModel):
name: str
options: bool | int | Dict | tuple = False
class SingleShift(BaseModel): class SingleShift(BaseModel):
"""Class to hold all details for a shift """Class to hold all details for a shift
@@ -70,40 +75,39 @@ class SingleShift(BaseModel):
""" """
sites: Iterable[str]
sites: List[str]
name: str name: str
length: float length: float
shift_days: Sequence[str] days: str | List[str]
balance_offset: float | None = None, balance_offset: float | None = None
balance_weighting: float = 1, balance_weighting: float = 1
workers_required: float = 1, workers_required: float = 1
rota_on_nwds: bool = False, rota_on_nwds: bool = False
assign_as_block: bool = False, assign_as_block: bool = False
force_as_block: bool = False, force_as_block: bool = False
force_as_block_unless_nwd: bool = False, force_as_block_unless_nwd: bool = False
hard_constrain_shift: bool = True, hard_constrain_shift: bool = True
bank_holidays_only: bool = False, bank_holidays_only: bool = False
constraint: Iterable[str] = [], constraint: List[ShiftConstraint] = []
class Config: class Config:
extra = Extra.allow extra = Extra.allow
orm_mode = True
def __init__(self, **data: Any): def __init__(self, **data: Any):
super().__init__(**data) super().__init__(**data)
# self.site = sites # self.site = sites
# self.name = name # self.name = name
# self.length = length # self.length = length
#self.shift_days = shift_days # self.days = days
# self.balance_by_site = balance_by_site # self.balance_by_site = balance_by_site
# balance_offset defines the max difference in allocated shifts # balance_offset defines the max difference in allocated shifts
# versus target shifts (hard constraint) # versus target shifts (hard constraint)
# if 0 exactly equal shifts must be assigend (unlikely to be possible) # if 0 exactly equal shifts must be assigend (unlikely to be possible)
if self.balance_offset is None: if self.balance_offset is None:
self.balance_offset = len(self.shift_days) self.balance_offset = len(self.days)
if self.force_as_block_unless_nwd: if self.force_as_block_unless_nwd:
self.assign_as_block = True self.assign_as_block = True
@@ -113,23 +117,33 @@ class SingleShift(BaseModel):
self.constraint_options = {} self.constraint_options = {}
self.constraints = [] self.constraints = []
for c in self.constraint: for c in self.constraint:
match c: self.constraints.append(c.name)
case (constraint, options):
self.constraints.append(constraint)
self.constraint_options[constraint] = options
case constraint:
self.constraints.append(constraint)
if c.options:
self.constraint_options[c.name] = c.options
# constraint = c.constraint
# match c:
# case (constraint, options):
# self.constraint_options[constraint] = options
# case constraint:
# self.constraints.append(constraint)
if isinstance(self.days, str):
self.days = [self.days]
def __str__(self): def __str__(self):
return f"name: {self.name}, site: {self.site}, length: {self.length}, balance weighting: {self.balance_weighting}, balance offset: {self.balance_offset}, hard_constrain: {self.hard_constrain_shift}, workers required : {self.workers_required}, rota on nwds {self.rota_on_nwds}, assign as block: {self.assign_as_block}, force as block: {self.force_as_block}, constraints: {self.constraints}, constraint_options: {self.constraint_options}" # , total shifts: {self.total_shifts}" return f"name: {self.name}, site: {self.sites}, length: {self.length}, balance weighting: {self.balance_weighting}, balance offset: {self.balance_offset}, hard_constrain: {self.hard_constrain_shift}, workers required : {self.workers_required}, rota on nwds {self.rota_on_nwds}, assign as block: {self.assign_as_block}, force as block: {self.force_as_block}, constraints: {self.constraints}, constraint_options: {self.constraint_options}" # , total shifts: {self.total_shifts}"
def get_shift_summary(self): def get_shift_summary(self):
""" """
Returns a text summary of the shift Returns a text summary of the shift
""" """
return f"{self.name}: {self.workers_required} workers for sites ({', '.join(self.site)}) on days ({', '.join(self.shift_days)})" return f"{self.name}: {self.workers_required} workers for sites ({', '.join(self.site)}) on days ({', '.join(self.days)})"
def get_shift_number(self):
return len(self.days)
class RotaBuilder(object): class RotaBuilder(object):
@@ -137,16 +151,14 @@ class RotaBuilder(object):
def __init__( def __init__(
self, self,
start_date: datetime.datetime = datetime.datetime.now(), start_date: datetime.date = datetime.date.today(),
weeks_to_rota: int = 26, weeks_to_rota: int = 26,
balance_offset_modifier: int = 1, balance_offset_modifier: int = 1,
ltft_balance_offset: int = 1, ltft_balance_offset: int = 1,
max_night_frequency: int = 2,
max_weekend_frequency: int = 1, # Requires balance weekends
use_previous_shifts: bool = False, use_previous_shifts: bool = False,
use_shift_balance_extra: bool = False, use_shift_balance_extra: bool = False,
use_bank_holiday_extra: bool = False, use_bank_holiday_extra: bool = False,
SHIFT_BOUNDS = SHIFT_BOUNDS SHIFT_BOUNDS=SHIFT_BOUNDS,
): ):
print(f"Start time: {datetime.datetime.now()}") print(f"Start time: {datetime.datetime.now()}")
@@ -155,7 +167,7 @@ class RotaBuilder(object):
print(f"Use previous shifts (balance_extra) {use_shift_balance_extra}") print(f"Use previous shifts (balance_extra) {use_shift_balance_extra}")
self.SHIFT_BOUNDS = SHIFT_BOUNDS self.SHIFT_BOUNDS = SHIFT_BOUNDS
self.shifts = [] # type List[SingleShift] self.shifts: List[SingleShift] = [] # type List[SingleShift]
self.days = days self.days = days
@@ -180,7 +192,7 @@ class RotaBuilder(object):
self.unavailable_to_work_reason = {} self.unavailable_to_work_reason = {}
self.pref_not_to_work = {} self.pref_not_to_work = {}
self.pref_not_to_work_reason = {} self.pref_not_to_work_reason = {}
self.work_requests = set() self.work_requests: set[Tuple[str, WeekInt, DayStr, ShiftName]] = set()
self.work_requests_map = {} self.work_requests_map = {}
self.workers: list[Worker] = [] self.workers: list[Worker] = []
@@ -193,9 +205,6 @@ class RotaBuilder(object):
self.balance_offset_modifier = balance_offset_modifier self.balance_offset_modifier = balance_offset_modifier
self.ltft_balance_offset = ltft_balance_offset self.ltft_balance_offset = ltft_balance_offset
# Don't assign multiple shifts every (n) weeks
self.max_night_frequency = max_night_frequency
self.max_weekend_frequency = max_weekend_frequency
self.constraint_options = { self.constraint_options = {
"balance_nights": True, "balance_nights": True,
@@ -209,6 +218,10 @@ class RotaBuilder(object):
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers "minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
"balance_weekends": True, "balance_weekends": True,
"max_weekends": 100, "max_weekends": 100,
# Don't assign multiple shifts every (n) weeks
"max_weekend_frequency": 1, # Requires balance weekends
# Don't assign multiple shifts every (n) weeks
"max_night_frequency": 4,
"max_shifts_per_week": 7, "max_shifts_per_week": 7,
"max_shifts_per_month": 40, "max_shifts_per_month": 40,
# The following will cause an unsolvable problem if a shift # The following will cause an unsolvable problem if a shift
@@ -219,6 +232,7 @@ class RotaBuilder(object):
"prevent_thursdays_before_full_weekends": [], "prevent_thursdays_before_full_weekends": [],
"avoid_st2_first_month": False, "avoid_st2_first_month": False,
"hard_constrain_pair_separation": False, "hard_constrain_pair_separation": False,
"avoid_shifts_by_grades": [],
} }
# Generate a map for week, day combinations to a given date # Generate a map for week, day combinations to a given date
@@ -263,7 +277,9 @@ class RotaBuilder(object):
if not results.solver.status: if not results.solver.status:
sys.exit(0) sys.exit(0)
def build_and_solve(self, options={"ratio": 0.1, "seconds": 1000, "threads": 10}, solve=True): def build_and_solve(
self, options={"ratio": 0.1, "seconds": 1000, "threads": 10}, solve=True
):
self.build_shifts() self.build_shifts()
self.build_workers() self.build_workers()
self.build_model() self.build_model()
@@ -611,11 +627,22 @@ class RotaBuilder(object):
initialize=availability_init, initialize=availability_init,
) )
work_request_sets = set()
for work_request in self.work_requests:
if work_request[3] == "*":
pass
else:
work_request_sets.add(work_request[3])
# Validate shifts are valid # Validate shifts are valid
work_request_sets = set([i[3] for i in self.work_requests]) #work_request_sets = set([i[3] for i in self.work_requests])
invalid_shifts = work_request_sets - set(self.get_shift_names()) invalid_shifts = work_request_sets - set(self.get_shift_names())
if invalid_shifts: if invalid_shifts:
raise InvalidShift(f"Invalid worker shift request [shift(s): ${invalid_shifts}]") raise InvalidShift(
f"Invalid worker shift request [shift(s): ${invalid_shifts}]"
)
def work_request_init(model, wid, week, day, shift): def work_request_init(model, wid, week, day, shift):
if (wid, week, day, shift) in self.work_requests: if (wid, week, day, shift) in self.work_requests:
@@ -692,7 +719,6 @@ class RotaBuilder(object):
# self.model.works[worker.id, week, day, shift] # self.model.works[worker.id, week, day, shift]
# for worker in self.workers if worker.site not in site_required)) # for worker in self.workers if worker.site not in site_required))
# Constraint: total hours worked hours worked # Constraint: total hours worked hours worked
# for worker in self.workers: # for worker in self.workers:
# self.model.constraints.add( # self.model.constraints.add(
@@ -735,8 +761,7 @@ class RotaBuilder(object):
return Constraint.Skip return Constraint.Skip
return ( return (
sum( sum(
model.shift_week_worker_assigned[shift.name, week, w.id] model.shift_week_worker_assigned[shift, week, w.id] for w in workers
for w in workers
) )
<= limit <= limit
) )
@@ -754,7 +779,7 @@ class RotaBuilder(object):
Constraint( Constraint(
[week for week in self.weeks], [week for week in self.weeks],
# [shift for shift in self.get_shifts_with_constraint("limit_grade_number")], # [shift for shift in self.get_shifts_with_constraint("limit_grade_number")],
[shift], [shift.name],
[grade], [grade],
[shift.constraint_options["limit_grade_number"][grade]], [shift.constraint_options["limit_grade_number"][grade]],
rule=limitGradesRule, rule=limitGradesRule,
@@ -769,8 +794,7 @@ class RotaBuilder(object):
raise ValueError("minimum_grade_number: not enough valid workers") raise ValueError("minimum_grade_number: not enough valid workers")
return ( return (
sum( sum(
model.shift_week_worker_assigned[shift.name, week, w.id] model.shift_week_worker_assigned[shift, week, w.id] for w in workers
for w in workers
) )
>= limit >= limit
) )
@@ -788,7 +812,7 @@ class RotaBuilder(object):
Constraint( Constraint(
[week for week in self.weeks], [week for week in self.weeks],
# [shift for shift in self.get_shifts_with_constraint("limit_grade_number")], # [shift for shift in self.get_shifts_with_constraint("limit_grade_number")],
[shift], [shift.name],
[grade], [grade],
[min_required], [min_required],
rule=minimumGradesRule, rule=minimumGradesRule,
@@ -796,7 +820,9 @@ class RotaBuilder(object):
), ),
) )
def presenceAtRemoteSiteWeek(model, week, shift, required_site, required_number): def presenceAtRemoteSiteWeek(
model, week, shift, required_site, required_number
):
required_site_workers = [ required_site_workers = [
w for w in self.workers if required_site == w.remote_site w for w in self.workers if required_site == w.remote_site
] ]
@@ -809,7 +835,9 @@ class RotaBuilder(object):
>= required_number >= required_number
) )
for shift in self.get_shifts_with_constraint("require_remote_site_presence_week"): for shift in self.get_shifts_with_constraint(
"require_remote_site_presence_week"
):
site, required_number = shift.constraint_options[ site, required_number = shift.constraint_options[
"require_remote_site_presence_week" "require_remote_site_presence_week"
] ]
@@ -826,7 +854,9 @@ class RotaBuilder(object):
), ),
) )
def presenceAtRemoteSite(model, day, week, shift, required_site, required_number): def presenceAtRemoteSite(
model, day, week, shift, required_site, required_number
):
required_site_workers = [ required_site_workers = [
w for w in self.workers if required_site == w.remote_site w for w in self.workers if required_site == w.remote_site
] ]
@@ -836,10 +866,7 @@ class RotaBuilder(object):
return Constraint.Skip return Constraint.Skip
return ( return (
sum( sum(model.works[w.id, week, day, shift] for w in required_site_workers)
model.works[w.id, week, day, shift]
for w in required_site_workers
)
>= required_number >= required_number
) )
@@ -886,7 +913,7 @@ class RotaBuilder(object):
# ) # )
for site in self.sites: for site in self.sites:
for shift in self.get_shifts_with_constraint("balance_across_groups"): for shift in self.get_shifts_with_constraint("balance_across_groups"):
if site in shift.site: if site in shift.sites:
block = shift.name block = shift.name
for week in self.weeks: for week in self.weeks:
self.model.constraints.add( self.model.constraints.add(
@@ -919,7 +946,7 @@ class RotaBuilder(object):
] ]
>= sum( >= sum(
self.model.works[worker.id, week, day, shift_name] self.model.works[worker.id, week, day, shift_name]
for day in shift.shift_days for day in shift.days
) )
) )
self.model.constraints.add( self.model.constraints.add(
@@ -941,8 +968,12 @@ class RotaBuilder(object):
for w in workers: for w in workers:
if w.non_working_day_list: if w.non_working_day_list:
l = [] l = []
for nwd, start_nwd_date, end_nwd_date in w.non_working_day_list: for (
if nwd in shift.shift_days: nwd,
start_nwd_date,
end_nwd_date,
) in w.non_working_day_list:
if nwd in shift.days:
if start_nwd_date > self.get_week_start_date(week): if start_nwd_date > self.get_week_start_date(week):
continue continue
if end_nwd_date < self.get_week_start_date(week): if end_nwd_date < self.get_week_start_date(week):
@@ -957,8 +988,6 @@ class RotaBuilder(object):
else: else:
full_workers.append(w) full_workers.append(w)
print(nwd_workers)
if not nwd_workers: # Just do the usual if not nwd_workers: # Just do the usual
self.model.constraints.add( self.model.constraints.add(
sum( sum(
@@ -1015,6 +1044,33 @@ class RotaBuilder(object):
) )
) )
for constraint_dict in self.constraint_options["avoid_shifts_by_grades"]:
if invalid_shifts := set(constraint_dict["shifts"]).difference(
set(self.get_shift_names())
):
raise ValueError(
f"avoid shifts by grade constraint contains a non existent shift: {invalid_shifts}"
)
if invalid_grades := set(constraint_dict["grades"]).difference(
set(self.get_worker_grades())
):
raise ValueError(
f"avoid shifts by grade constraint contains a non existent worker grade: {invalid_grades}"
)
if worker.grade in constraint_dict["grades"]:
self.model.constraints.add(
0
== sum(
self.model.works[worker.id, week, day, shift]
for week, day, shift in self.get_all_shiftname_combinations()
if week in constraint_dict["weeks"]
and shift in constraint_dict["shifts"]
)
)
# sys.exit(0)
if self.constraint_options["avoid_st2_first_month"] and worker.grade == 2: if self.constraint_options["avoid_st2_first_month"] and worker.grade == 2:
# Avoid ST1s on the first month # Avoid ST1s on the first month
try: try:
@@ -1049,15 +1105,17 @@ class RotaBuilder(object):
# no solution will be possible # no solution will be possible
for shift in self.get_shifts(): for shift in self.get_shifts():
if ( if (
worker.site in shift.site worker.site in shift.sites
): # Each site specfies which sites self.workers can fullfill it ): # Each site specfies which sites self.workers can fullfill it
# calaculate the total number of shifts that need assigning over the rota # calaculate the total number of shifts that need assigning over the rota
# total_shifts = (len(self.weeks) * len(shift.shift_days) * # total_shifts = (len(self.weeks) * len(shift.days) *
total_shifts = self.shift_counts[shift] * shift.workers_required total_shifts = (
self.shift_counts[shift.name] * shift.workers_required
)
full_time_equivalent_joined = sum( full_time_equivalent_joined = sum(
self.full_time_equivalent_sites[i] for i in shift.site self.full_time_equivalent_sites[i] for i in shift.sites
) )
target_shifts = ( target_shifts = (
@@ -1111,6 +1169,8 @@ class RotaBuilder(object):
) )
else: else:
# Make sure shifts aren't assigned to those from other sites # Make sure shifts aren't assigned to those from other sites
# Not needed if there are no shifts
if self.get_week_day_combinations_for_shift(shift):
self.model.constraints.add( self.model.constraints.add(
0 0
== sum( == sum(
@@ -1147,7 +1207,7 @@ class RotaBuilder(object):
if self.constraint_options["balance_shifts_quadratic"]: if self.constraint_options["balance_shifts_quadratic"]:
# This may need to be updated # This may need to be updated
xU = 10 xU = 25
xL = 1 xL = 1
self.model.constraints.add( self.model.constraints.add(
inequality( inequality(
@@ -1304,9 +1364,9 @@ class RotaBuilder(object):
# We use a similar method to balance the number of weekends worked # We use a similar method to balance the number of weekends worked
# This works as long as weekend shifts are assigned as blocks! # This works as long as weekend shifts are assigned as blocks!
weekend_shift_target_number = sum( weekend_shift_target_number = sum(
worker.shift_target_number[shift.name] / len(shift.shift_days) worker.shift_target_number[shift.name] / len(shift.days)
for shift in self.get_shifts() for shift in self.get_shifts()
if "Sat" in shift.shift_days or "Sun" in shift.shift_days if "Sat" in shift.days or "Sun" in shift.days
) )
worker.weekend_shift_target_number = weekend_shift_target_number worker.weekend_shift_target_number = weekend_shift_target_number
@@ -1374,7 +1434,7 @@ class RotaBuilder(object):
if self.constraint_options["balance_blocks"]: if self.constraint_options["balance_blocks"]:
for week_blocks in self.get_week_block_iterator( for week_blocks in self.get_week_block_iterator(
self.max_night_frequency self.constraint_options["max_night_frequency"]
): ):
if self.get_shifts_with_constraint("night"): if self.get_shifts_with_constraint("night"):
# Prevent nights more than once every n weeks # Prevent nights more than once every n weeks
@@ -1390,7 +1450,7 @@ class RotaBuilder(object):
) )
# if self.constraint_options["balance_weekends"]: # if self.constraint_options["balance_weekends"]:
for week_blocks in self.get_week_block_iterator(self.max_weekend_frequency): for week_blocks in self.get_week_block_iterator(self.constraint_options["max_weekend_frequency"]):
# Prevent weekend shifts more than once every n weeks # Prevent weekend shifts more than once every n weeks
self.model.constraints.add( self.model.constraints.add(
1 1
@@ -1400,12 +1460,55 @@ class RotaBuilder(object):
) )
) )
for constraint_shift in self.get_shifts_with_constraints(
"max_shifts_per_week_block"
):
constraint_options = constraint_shift.constraint_options[
"max_shifts_per_week_block"
]
# If not supplied, default to the number of days per shift
shift_number = constraint_shift.get_shift_number()
if "shift_number" in constraint_options:
shift_number = constraint_options["shift_number"]
for week_blocks in self.get_week_block_iterator(
constraint_options["weeks"]
):
# Prevent shifts more than once every n weeks
self.model.constraints.add(
shift_number
>= sum(
self.model.works[
worker.id, week, day, constraint_shift.name
]
for week in week_blocks
for day in constraint_shift.days
)
)
for week in self.weeks: for week in self.weeks:
for constraint_shift in self.get_shifts_with_constraints(
"max_shifts_per_week"
):
self.model.constraints.add(
constraint_shift.constraint_options["max_shifts_per_week"]
>= sum(
self.model.works[worker.id, w, day, constraint_shift.name]
# for shiftname in self.get_shift_names_by_week_day(week, day)
# for day in self.days
for w, day in self.get_week_day_combinations_for_shift(
constraint_shift
)
if w == week
)
)
self.model.constraints.add( self.model.constraints.add(
self.constraint_options["max_shifts_per_week"] self.constraint_options["max_shifts_per_week"]
>= sum( >= sum(
self.model.works[worker.id, week, day, shiftname] self.model.works[worker.id, w, day, shiftname]
# for shiftname in self.get_shift_names_by_week_day(week, day) # for shiftname in self.get_shift_names_by_week_day(week, day)
# for day in self.days # for day in self.days
for w, day, shiftname in self.get_all_shiftname_combinations( for w, day, shiftname in self.get_all_shiftname_combinations(
@@ -1485,21 +1588,15 @@ class RotaBuilder(object):
full_weekend_count full_weekend_count
>= sum( >= sum(
self.model.works[worker.id, week, "Sat", shift.name] self.model.works[worker.id, week, "Sat", shift.name]
for shift in self.get_shift_names_by_week_day( for shift in self.get_shift_names_by_week_day(week, "Sat")
week, "Sat"
)
) )
+ sum( + sum(
self.model.works[worker.id, week, "Sun", shift.name] self.model.works[worker.id, week, "Sun", shift.name]
for shift in self.get_shift_names_by_week_day( for shift in self.get_shift_names_by_week_day(week, "Sat")
week, "Sat"
)
) )
+ sum( + sum(
self.model.works[worker.id, week, "Fri", shift.name] self.model.works[worker.id, week, "Fri", shift.name]
for shift in self.get_shift_names_by_week_day( for shift in self.get_shift_names_by_week_day(week, "Sat")
week, "Sat"
)
) )
) )
if ( if (
@@ -1510,21 +1607,15 @@ class RotaBuilder(object):
full_weekend_count full_weekend_count
>= sum( >= sum(
self.model.works[worker.id, week, "Sat", shift.name] self.model.works[worker.id, week, "Sat", shift.name]
for shift in self.get_shift_names_by_week_day( for shift in self.get_shift_names_by_week_day(week, "Sat")
week, "Sat"
)
) )
+ sum( + sum(
self.model.works[worker.id, week, "Sun", shift.name] self.model.works[worker.id, week, "Sun", shift.name]
for shift in self.get_shift_names_by_week_day( for shift in self.get_shift_names_by_week_day(week, "Sun")
week, "Sun"
)
) )
+ sum( + sum(
self.model.works[worker.id, week, "Thu", shift.name] self.model.works[worker.id, week, "Thu", shift.name]
for shift in self.get_shift_names_by_week_day( for shift in self.get_shift_names_by_week_day(week, "Thu")
week, "Thu"
)
) )
) )
@@ -1576,6 +1667,27 @@ class RotaBuilder(object):
# self.model.shift_week_worker_assigned[s.name, week, worker.id] # self.model.shift_week_worker_assigned[s.name, week, worker.id]
# for s in self.get_shifts_with_constraint("night"))) # for s in self.get_shifts_with_constraint("night")))
#for shift in self.get_shifts_with_constraint("single_block_per_week"):
# if shift.force_as_block:
# # Force nights to be assigned in blocks
# # self.model.constraints.add(8* self.model.shift_week_worker_assigned[shift.name, week, worker.id] >= sum(self.model.works[worker.id, week, day, shift.name] for day in self.days)
# # )
# # Force night shifts to be assigned in blocks
# self.model.constraints.add(
# self.model.shift_week_worker_assigned[
# shift.name, week, worker.id
# ]
# * len(shift.days)
# == sum(
# self.model.works[worker.id, w, d, s]
# for w, d, s in self.get_all_shiftname_combinations(week=week)
# #for day in self.days
# )
# )
# else:
# raise ValueError(f"Requires {shift.name} to have force_as_block")
# for shift in self.get_shifts_with_constraint("night"): # for shift in self.get_shifts_with_constraint("night"):
for shift in self.get_shifts(): for shift in self.get_shifts():
if shift.force_as_block: if shift.force_as_block:
@@ -1588,10 +1700,10 @@ class RotaBuilder(object):
self.model.shift_week_worker_assigned[ self.model.shift_week_worker_assigned[
shift.name, week, worker.id shift.name, week, worker.id
] ]
* len(shift.shift_days) * len(shift.days)
== sum( == sum(
self.model.works[worker.id, week, day, shift.name] self.model.works[worker.id, week, day, shift.name]
for day in shift.shift_days for day in shift.days
) )
) )
@@ -1603,6 +1715,9 @@ class RotaBuilder(object):
p1 = 1 p1 = 1
p2 = 1 p2 = 1
p3 = 1
p4 = 1
p5 = 1
if n > 0: if n > 0:
pweek, pday = weeks_days[n - 1] pweek, pday = weeks_days[n - 1]
else: else:
@@ -1615,6 +1730,24 @@ class RotaBuilder(object):
p2week, p2day = weeks_days[n] p2week, p2day = weeks_days[n]
p2 = 0 p2 = 0
try:
p3week, p3day = weeks_days[n - 3]
except IndexError:
p3week, p3day = weeks_days[n]
p3 = 0
try:
p4week, p4day = weeks_days[n - 4]
except IndexError:
p4week, p4day = weeks_days[n]
p4 = 0
try:
p5week, p5day = weeks_days[n - 5]
except IndexError:
p5week, p5day = weeks_days[n]
p5 = 0
n1 = 1 n1 = 1
n2 = 1 n2 = 1
try: try:
@@ -1679,7 +1812,7 @@ class RotaBuilder(object):
for constraint_shift in self.get_shifts_with_constraints( for constraint_shift in self.get_shifts_with_constraints(
"preclear", "preclear2" "preclear", "preclear2"
): ):
if day in constraint_shift.shift_days: if day in constraint_shift.days:
self.model.constraints.add( self.model.constraints.add(
1 1
>= self.model.works[ >= self.model.works[
@@ -1696,7 +1829,7 @@ class RotaBuilder(object):
) )
for constraint_shift in self.get_shifts_with_constraint("preclear2"): for constraint_shift in self.get_shifts_with_constraint("preclear2"):
if day in constraint_shift.shift_days: if day in constraint_shift.days:
self.model.constraints.add( self.model.constraints.add(
1 1
>= self.model.works[ >= self.model.works[
@@ -1715,7 +1848,7 @@ class RotaBuilder(object):
for constraint_shift in self.get_shifts_with_constraints( for constraint_shift in self.get_shifts_with_constraints(
"postclear", "postclear2" "postclear", "postclear2"
): ):
if day in constraint_shift.shift_days: if day in constraint_shift.days:
self.model.constraints.add( self.model.constraints.add(
1 1
>= self.model.works[ >= self.model.works[
@@ -1732,7 +1865,7 @@ class RotaBuilder(object):
) )
for constraint_shift in self.get_shifts_with_constraints("postclear2"): for constraint_shift in self.get_shifts_with_constraints("postclear2"):
if day in constraint_shift.shift_days: if day in constraint_shift.days:
self.model.constraints.add( self.model.constraints.add(
1 1
>= self.model.works[ >= self.model.works[
@@ -1967,7 +2100,6 @@ class RotaBuilder(object):
Must be called prior to attempting to solve Must be called prior to attempting to solve
""" """
# self.build_shifts()
if not self.workers: if not self.workers:
raise NoWorkers("Workers must be added prior to calling build_workers") raise NoWorkers("Workers must be added prior to calling build_workers")
@@ -1982,11 +2114,11 @@ class RotaBuilder(object):
self.workers_id_map[wid] = worker self.workers_id_map[wid] = worker
if worker.name in self.workers_name_map: if worker.name in self.workers_name_map:
raise ValueError(f"Worker with name '{worker.name}' has been added twice") raise ValueError(
f"Worker with name '{worker.name}' has been added twice"
)
self.workers_name_map[worker.name] = worker self.workers_name_map[worker.name] = worker
self.workers = sorted(self.workers) self.workers = sorted(self.workers)
self.full_time_equivalent = sum(w.fte_adj for w in self.workers) self.full_time_equivalent = sum(w.fte_adj for w in self.workers)
@@ -2007,7 +2139,14 @@ class RotaBuilder(object):
for p in pairs: for p in pairs:
self.worker_pairs.append(tuple(pairs[p])) self.worker_pairs.append(tuple(pairs[p]))
def add_shift(self, shift) -> None: def add_grade_constraint_by_week(
self, grades: Iterable[int], weeks: Iterable[int], shifts
):
self.constraint_options["avoid_shifts_by_grades"].append(
{"grades": grades, "weeks": weeks, "shifts": shifts}
)
def add_shift(self, shift: SingleShift) -> None:
"""Add a shift to the collection """Add a shift to the collection
:param SingleShift shift: Shift object :param SingleShift shift: Shift object
@@ -2023,7 +2162,7 @@ class RotaBuilder(object):
""" """
self.shifts.extend(shifts) self.shifts.extend(shifts)
def get_shift_names_by_week_day(self, week, day: DayStr) -> set(): def get_shift_names_by_week_day(self, week, day: DayStr) -> set:
"""Returns the shifts required for a specific day """Returns the shifts required for a specific day
Returns: Returns:
@@ -2066,6 +2205,9 @@ class RotaBuilder(object):
self.week_day_shift_product = [] self.week_day_shift_product = []
self.week_day_shiftclass_product = [] self.week_day_shiftclass_product = []
for s in self.shifts:
pass
for s in self.shifts: for s in self.shifts:
if s.name in self.shift_names: if s.name in self.shift_names:
raise InvalidShift(f"Duplicate shift: {s.name}") raise InvalidShift(f"Duplicate shift: {s.name}")
@@ -2073,10 +2215,10 @@ class RotaBuilder(object):
self.shifts_by_name[s.name] = s self.shifts_by_name[s.name] = s
self.shift_names.append(s.name) self.shift_names.append(s.name)
# for day in s.shift_days: # for day in s.days:
# self.week_day_shifts_dict[(week, day)].add(s.name) # self.week_day_shifts_dict[(week, day)].add(s.name)
for site in s.site: for site in list(s.sites):
self.sites.add(site) self.sites.add(site)
self.shift_counts = defaultdict(int) self.shift_counts = defaultdict(int)
@@ -2085,16 +2227,17 @@ class RotaBuilder(object):
for s in self.shifts: for s in self.shifts:
if s.bank_holidays_only: if s.bank_holidays_only:
if self.week_day_date_map[(week, day)] in bank_holiday_map: if self.week_day_date_map[(week, day)] in bank_holiday_map:
print(s.name, self.week_day_date_map[(week, day)])
self.week_day_shift_product.append((week, day, s.name)) self.week_day_shift_product.append((week, day, s.name))
self.week_day_shiftclass_product.append((week, day, s)) self.week_day_shiftclass_product.append((week, day, s))
self.week_day_shifts_dict[(week, day)].add(s.name) self.week_day_shifts_dict[(week, day)].add(s.name)
self.shift_counts[s] = self.shift_counts[s] + 1 self.shift_counts[s.name] = self.shift_counts[s.name] + 1
else: else:
if day in s.shift_days: if day in s.days:
self.week_day_shift_product.append((week, day, s.name)) self.week_day_shift_product.append((week, day, s.name))
self.week_day_shiftclass_product.append((week, day, s)) self.week_day_shiftclass_product.append((week, day, s))
self.week_day_shifts_dict[(week, day)].add(s.name) self.week_day_shifts_dict[(week, day)].add(s.name)
self.shift_counts[s] = self.shift_counts[s] + 1 self.shift_counts[s.name] = self.shift_counts[s.name] + 1
# print(self.week_day_shift_product) # print(self.week_day_shift_product)
# # Todo replace with week_day..... # # Todo replace with week_day.....
@@ -2102,7 +2245,7 @@ class RotaBuilder(object):
# self.day_shiftclass_product = [] # self.day_shiftclass_product = []
# for s in self.shifts: # for s in self.shifts:
# for day in days: # for day in days:
# if day in s.shift_days: # if day in s.days:
# self.day_shift_product.append((day, s.name)) # self.day_shift_product.append((day, s.name))
# self.day_shiftclass_product.append((day, s)) # self.day_shiftclass_product.append((day, s))
# #
@@ -2193,17 +2336,27 @@ class RotaBuilder(object):
""" """
return self.shifts return self.shifts
def get_shifts_for_worker_site(self, worker_site):
shifts = [shift for shift in self.shifts if worker_site in shift.sites]
return shifts
def get_shifts_for_worker(self, worker_id):
worker = self.get_worker_by_id(worker_id)
shifts = [shift for shift in self.shifts if worker.site in shift.sites]
return shifts
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]: def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
return [shift for shift in self.shifts if constraint in shift.constraints] return [shift for shift in self.shifts if constraint in shift.constraints]
def get_shifts_with_constraints(self, *constraints) -> set[SingleShift]: def get_shifts_with_constraints(self, *constraints) -> List[SingleShift]:
shifts = set() shift_names = set()
for constraint in constraints: for constraint in constraints:
shifts.update( shift_names.update(
[shift for shift in self.shifts if constraint in shift.constraints] [shift.name for shift in self.shifts if constraint in shift.constraints]
) )
return shifts
return [self.get_shift_by_name(s) for s in shift_names]
def get_shift_names(self) -> List[ShiftName]: def get_shift_names(self) -> List[ShiftName]:
"""Returns a list of all the registered shift names """Returns a list of all the registered shift names
@@ -2239,12 +2392,12 @@ class RotaBuilder(object):
"""Returns a list of all possible shifts, the workers required and site """Returns a list of all possible shifts, the workers required and site
Returns: Returns:
list: list (week, day, shift name, workers required, shift site) list: list (week, day, shift name, workers required, shift.sites)
""" """
l = [] l = []
for week, day, shift in self.week_day_shiftclass_product: for week, day, shift in self.week_day_shiftclass_product:
# if day in shift.shift_days: # if day in shift.days:
l.append((week, day, shift.name, shift.workers_required, shift.site)) l.append((week, day, shift.name, shift.workers_required, shift.sites))
return l return l
@@ -2265,17 +2418,24 @@ class RotaBuilder(object):
return l - set(self.get_all_shiftclass_combinations()) return l - set(self.get_all_shiftclass_combinations())
def shifts_to_assign_as_blocks(self): def shifts_to_assign_as_blocks(self) -> List[str]:
return [shift.name for shift in self.get_shifts() if shift.assign_as_block] return [shift.name for shift in self.get_shifts() if shift.assign_as_block]
def shifts_to_force_as_blocks(self): def shifts_to_force_as_blocks(self) -> List[str]:
return [shift.name for shift in self.get_shifts() if (shift.force_as_block or shift.force_as_block_unless_nwd)] return [
shift.name
for shift in self.get_shifts()
if (shift.force_as_block or shift.force_as_block_unless_nwd)
]
def shifts_to_assign_or_force_as_blocks(self): def shifts_to_assign_or_force_as_blocks(self) -> List[str]:
s = self.shifts_to_assign_as_blocks() s = self.shifts_to_assign_as_blocks()
s.extend(self.shifts_to_force_as_blocks()) s.extend(self.shifts_to_force_as_blocks())
return s return s
def get_worker_by_id(self, worker_id: str) -> Worker:
return self.workers_id_map[worker_id]
def get_worker_by_name(self, name: str) -> Worker: def get_worker_by_name(self, name: str) -> Worker:
return self.workers_name_map[name] return self.workers_name_map[name]
@@ -2301,7 +2461,7 @@ class RotaBuilder(object):
return group_workers return group_workers
def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]: def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]:
return [worker for worker in self.workers if worker.site in shift.site] return [worker for worker in self.workers if worker.site in shift.sites]
def get_workers_total_fte(self) -> float: def get_workers_total_fte(self) -> float:
return self.full_time_equivalent return self.full_time_equivalent
@@ -2322,6 +2482,9 @@ class RotaBuilder(object):
def get_workers(self): def get_workers(self):
return self.workers return self.workers
def get_worker_grades(self) -> set[int]:
return set([worker.grade for worker in self.workers])
def get_bank_holiday_week_days(self): def get_bank_holiday_week_days(self):
return [ return [
(week, day) (week, day)
@@ -2385,7 +2548,6 @@ class RotaBuilder(object):
week_table[week][day][shift].append(worker.get_details()) week_table[week][day][shift].append(worker.get_details())
return week_table return week_table
def get_worker_timetable(self): def get_worker_timetable(self):
works = self.model.works works = self.model.works
timetable = { timetable = {
@@ -2481,7 +2643,9 @@ class RotaBuilder(object):
if d in bank_holiday_map: if d in bank_holiday_map:
th_class = "bank-holiday" th_class = "bank-holiday"
date_row.append(f"<th title='{d}' class='{th_class}' data-date='{d}'>Week {week}: {day}</th>") date_row.append(
f"<th title='{d}' class='{th_class}' data-date='{d}'>Week {week}: {day}</th>"
)
n = n + 1 n = n + 1
timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>") timetable.append(f"<tr class='data-row'>{''.join(date_row)}</tr>")
@@ -2667,7 +2831,7 @@ class RotaBuilder(object):
html = f""" html = f"""
<body> <body>
<div class="table-div" id="{table_name}"> <div class="table-div" id="{table_name}">
<table>{joined_timetable}</table> <table id="main-table">{joined_timetable}</table>
</div> </div>
<details> <details>
<summary><h2>Rota settings</h2></summary> <summary><h2>Rota settings</h2></summary>
@@ -2688,7 +2852,14 @@ class RotaBuilder(object):
<pre> <pre>
{result_string} {result_string}
</pre> </pre>
</details>
<div> <div>
<details>
<summary><h2>Export table</h2></summary>
<div id="export-div">
<div>
</details>
</div
</body> </body>
""" """
@@ -2738,12 +2909,12 @@ class RotaBuilder(object):
shifts = {} shifts = {}
for worker in self.workers: for worker in self.workers:
shifts[worker.name] = len([i for i in self.get_worker_shift_list(worker) if i != ""]) shifts[worker.name] = len(
[i for i in self.get_worker_shift_list(worker) if i != ""]
)
return shifts return shifts
def get_worker_shift_list(self, worker: Worker) -> List: def get_worker_shift_list(self, worker: Worker) -> List:
shifts = [] shifts = []
@@ -2822,14 +2993,17 @@ class RotaBuilder(object):
class NoActiveSites(Exception): class NoActiveSites(Exception):
"""Raised when there are no active sites""" """Raised when there are no active sites"""
pass pass
class NoWorkers(Exception): class NoWorkers(Exception):
"""Raised when there are no active sites""" """Raised when there are no active sites"""
pass pass
class InvalidShift(Exception): class InvalidShift(Exception):
"""Raised when there are no active sites""" """Raised when there are no active sites"""
pass pass
+43 -18
View File
@@ -1,7 +1,7 @@
import datetime import datetime
from collections import defaultdict from collections import defaultdict
from typing import Iterable, List, Literal, Optional from typing import Iterable, List, Literal, Optional
from pydantic import BaseModel, Extra from pydantic import BaseModel, Extra, validator
from rich.pretty import pprint from rich.pretty import pprint
@@ -15,29 +15,51 @@ if TYPE_CHECKING:
from rota.shifts import RotaBuilder from rota.shifts import RotaBuilder
days = Literal["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] days = Literal["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
whole_days = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
]
class NotAvailableToWork(BaseModel): class NotAvailableToWork(BaseModel):
date: datetime.date date: datetime.date
reason: str = "unknown" reason: str = "unknown"
class NonWorkingDays(BaseModel): class NonWorkingDays(BaseModel):
day: days day: days
start_date: datetime.date | None = None start_date: datetime.date | None = None
end_date: datetime.date | None = None end_date: datetime.date | None = None
@validator("day")
def day_in(cls, day):
for whole_day in whole_days:
if day.lower() in whole_day:
return whole_day[:3].capitalize()
raise ValueError("Invalid day")
class WorkRequests(BaseModel): class WorkRequests(BaseModel):
date: datetime.date date: datetime.date
shift: str = "" shift: str = ""
class PreferenceNotToWork(BaseModel): class PreferenceNotToWork(BaseModel):
date: datetime.date date: datetime.date
reason: str = "" reason: str = ""
class OutOfProgramme(BaseModel): class OutOfProgramme(BaseModel):
start_date: datetime.date start_date: datetime.date
end_date: datetime.date end_date: datetime.date
reason: str = "" reason: str = ""
class Worker(BaseModel): class Worker(BaseModel):
name: str name: str
site: str site: str
@@ -54,7 +76,7 @@ class Worker(BaseModel):
not_available_to_work: list[NotAvailableToWork] = [] not_available_to_work: list[NotAvailableToWork] = []
pref_not_to_work: list[PreferenceNotToWork] = [] pref_not_to_work: list[PreferenceNotToWork] = []
work_requests: list[WorkRequests] = [] work_requests: list[WorkRequests] = []
remote_site: str = "plymouth", # We set a default proc_site remote_site: str = ("plymouth",) # We set a default proc_site
previous_shifts: dict = {} previous_shifts: dict = {}
shift_balance_extra: dict = {} shift_balance_extra: dict = {}
bank_holiday_extra: int = 0 bank_holiday_extra: int = 0
@@ -96,6 +118,9 @@ class Worker(BaseModel):
if self.start_date >= self.end_date: if self.start_date >= self.end_date:
raise ValueError("End date must be after start date") raise ValueError("End date must be after start date")
if self.name == "Steph bailey":
print(self.start_date, self.end_date)
# if no start date default to the start of the rota # if no start date default to the start of the rota
if self.start_date is None: if self.start_date is None:
self.calculated_start_date = Rota.start_date self.calculated_start_date = Rota.start_date
@@ -144,9 +169,7 @@ class Worker(BaseModel):
if isinstance(end_oop, datetime.date): if isinstance(end_oop, datetime.date):
end_oop_date = end_oop end_oop_date = end_oop
else: else:
end_oop_date = datetime.datetime.strptime( end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date()
end_oop, "%d/%m/%y"
).date()
if start_oop_date >= end_oop_date: if start_oop_date >= end_oop_date:
raise ValueError("End OOP date must be after start date") raise ValueError("End OOP date must be after start date")
@@ -178,10 +201,7 @@ class Worker(BaseModel):
# loop throught dates converting to week / day combination # loop throught dates converting to week / day combination
for item in self.pref_not_to_work: for item in self.pref_not_to_work:
days_from_start = ( days_from_start = (item.date - Rota.start_date).days
item.date
- Rota.start_date
).days
week = days_from_start // 7 + 1 week = days_from_start // 7 + 1
day = Rota.days[(days_from_start % 7)] day = Rota.days[(days_from_start % 7)]
# Weight the value to take into account the number of preferences # Weight the value to take into account the number of preferences
@@ -195,22 +215,20 @@ class Worker(BaseModel):
# print(not_available_to_work) # print(not_available_to_work)
# loop throught dates converting to week / day combination # loop throught dates converting to week / day combination
for item in self.not_available_to_work: for item in self.not_available_to_work:
days_from_start = ( days_from_start = (item.date - Rota.start_date).days
item.date
- Rota.start_date
).days
week = days_from_start // 7 + 1 week = days_from_start // 7 + 1
day = Rota.days[(days_from_start % 7)] day = Rota.days[(days_from_start % 7)]
Rota.unavailable_to_work.add((self.id, week, day)) Rota.unavailable_to_work.add((self.id, week, day))
Rota.unavailable_to_work_reason[(self.id, week, day)] = item.reason Rota.unavailable_to_work_reason[(self.id, week, day)] = item.reason
for item in self.work_requests: for item in self.work_requests:
days_from_start = ( days_from_start = (item.date - Rota.start_date).days
item.date
- Rota.start_date
).days
week = days_from_start // 7 + 1 week = days_from_start // 7 + 1
day = Rota.days[(days_from_start % 7)] day = Rota.days[(days_from_start % 7)]
if item.shift == "*":
for shift in Rota.get_shifts_for_worker_site(self.site):
Rota.work_requests.add((self.id, week, day, shift.name))
else:
Rota.work_requests.add((self.id, week, day, item.shift)) Rota.work_requests.add((self.id, week, day, item.shift))
# Calculate the proportion of the rota that is being worked # Calculate the proportion of the rota that is being worked
@@ -236,6 +254,9 @@ class Worker(BaseModel):
self.non_working_day_list.append((item.day, start_date, end_date)) self.non_working_day_list.append((item.day, start_date, end_date))
if days_to_work < 1:
self.fte_adj = 0
def __lt__(self, other) -> bool: def __lt__(self, other) -> bool:
return (self.site, self.grade, self.fte_adj, self.name) < ( return (self.site, self.grade, self.fte_adj, self.name) < (
other.site, other.site,
@@ -246,7 +267,11 @@ class Worker(BaseModel):
def __str__(self) -> str: def __str__(self) -> str:
nwds = ", ".join([str(i) for i, start, end in self.non_working_day_list]) if self.non_working_day_list is not None else "" nwds = (
", ".join([str(i) for i, start, end in self.non_working_day_list])
if self.non_working_day_list is not None
else ""
)
return "{} {} [{}] REMOTE SITE:{}".format( return "{} {} [{}] REMOTE SITE:{}".format(
self.name, self.name,
(self.site, self.grade, self.fte_adj), (self.site, self.grade, self.fte_adj),
+11 -44
View File
@@ -78,10 +78,7 @@ Rota.constraint_options["max_weekends"] = 7
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("exeter",), sites=("exeter",), name="exeter_twilight", length= 12.5, days=days[:4],
"exeter_twilight",
12.5,
days[:4],
balance_offset=3, balance_offset=3,
constraints=["max_2_shifts_per_week"], constraints=["max_2_shifts_per_week"],
), ),
@@ -89,37 +86,25 @@ Rota.add_shifts(
("truro", "truro no proc"), "truro_twilight", 12.5, days[:4], balance_offset=3 ("truro", "truro no proc"), "truro_twilight", 12.5, days[:4], balance_offset=3
), ),
SingleShift( SingleShift(
("torbay", "torbay twilights"), sites=("torbay", "torbay twilights"), name="torbay_twilight", length= 12.5, days=days[:4],
"torbay_twilight",
12.5,
days[:4],
balance_offset=5, balance_offset=5,
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
("plymouth", "plymouth_twilights"), sites=("plymouth", "plymouth_twilights"), name="plymouth_twilight", length= 12.5, days=days[:5],
"plymouth_twilight",
12.5,
days[:5],
balance_offset=3, balance_offset=3,
workers_required=1, workers_required=1,
constraints=["max_2_shifts_per_week"], constraints=["max_2_shifts_per_week"],
), ),
SingleShift( SingleShift(
("exeter",), sites=("exeter",), name="weekend_exeter", length= 12.5, days=days[4:],
"weekend_exeter",
12.5,
days[4:],
balance_offset=4, balance_offset=4,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
constraints=["preclear2", "postclear2"], constraints=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("truro", "truro no proc"), sites=("truro", "truro no proc"), name="weekend_truro", length= 12.5, days=days[4:],
"weekend_truro",
12.5,
days[4:],
balance_offset=4, balance_offset=4,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
@@ -127,10 +112,7 @@ Rota.add_shifts(
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
SingleShift( SingleShift(
("torbay",), sites=("torbay",), name="weekend_torbay", length= 12.5, days=days[4:],
"weekend_torbay",
12.5,
days[4:],
balance_offset=4, balance_offset=4,
rota_on_nwds=True, rota_on_nwds=True,
force_as_block=True, force_as_block=True,
@@ -138,10 +120,7 @@ Rota.add_shifts(
# force_as_block_unless_nwd=True # force_as_block_unless_nwd=True
), ),
SingleShift( SingleShift(
("plymouth",), sites=("plymouth",), name="weekend_plymouth1", length= 8, days=days[5:],
"weekend_plymouth1",
8,
days[5:],
balance_offset=4, balance_offset=4,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
@@ -149,10 +128,7 @@ Rota.add_shifts(
constraints=["preclear2", "postclear2"], constraints=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("plymouth",), sites=("plymouth",), name="weekend_plymouth2", length= 8, days=days[5:],
"weekend_plymouth2",
8,
days[5:],
balance_offset=4, balance_offset=4,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
@@ -160,10 +136,7 @@ Rota.add_shifts(
constraints=["preclear2", "postclear2"], constraints=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("plymouth", "plymouth_twilights"), sites=("plymouth", "plymouth_twilights"), name="plymouth_bank_holidays", length= 8, days=days[5:],
"plymouth_bank_holidays",
8,
days[5:],
balance_offset=2, balance_offset=2,
workers_required=1, workers_required=1,
rota_on_nwds=True, rota_on_nwds=True,
@@ -171,10 +144,7 @@ Rota.add_shifts(
bank_holidays_only=True, bank_holidays_only=True,
), ),
SingleShift( SingleShift(
(sites[:-1]), sites=(sites[:-1]), name="night_weekday", length= 12.25, days=days[:4],
"night_weekday",
12.25,
days[:4],
balance_offset=4.9, balance_offset=4.9,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
@@ -184,10 +154,7 @@ Rota.add_shifts(
constraints=["night", "preclear2", "postclear2"], constraints=["night", "preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
(sites[:-1]), sites=(sites[:-1]), name="night_weekend", length= 12.25, days=days[4:],
"night_weekend",
12.25,
days[4:],
balance_offset=3.9, balance_offset=3.9,
balance_weighting=1, balance_weighting=1,
# hard_constrain_shift=False, # hard_constrain_shift=False,
View File
@@ -0,0 +1,195 @@
import datetime
import pytest
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
from rota.workers import Worker
import itertools
def generate_basic_rota(weeks_to_rota=9):
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
# Add a few workers
Rota.add_workers(
[
Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=2),
Worker(name="worker3", site="group1", grade=3),
]
)
return Rota
def date_generator(from_date, days):
n = 0
while True:
yield from_date
n = n + 1
if n >= days:
break
from_date = from_date + datetime.timedelta(days=1)
class TestWorkerRequests:
def test_avoid_grade_constraint(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1",), name="a", length= 12.5, days=days,
workers_required=2,
force_as_block=False,
),
)
Rota.add_grade_constraint_by_week([1], [1,2,3], ["a"])
Rota.add_grade_constraint_by_week([2], [4,5,6], ["a"])
Rota.add_grade_constraint_by_week([3], [7,8,9], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("avoid_grade_constraint")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
shift_string = Rota.get_worker_shift_list_string(worker)
match worker_name:
case "worker1":
assert shift_string.startswith("-"*21)
case "worker2":
assert shift_string.startswith(21*"a"+"-"*21)
case "worker3":
assert shift_string.endswith("-"*21)
def test_avoid_grade_constraint_invalid_shift(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1",), name="a", length= 12.5, days=days,
workers_required=1,
force_as_block=False,
),
)
with pytest.raises(ValueError):
Rota.add_grade_constraint_by_week([1], [1,2,3], ["b"])
Rota.build_and_solve(options={"ratio": 0.000})
Rota.add_shifts(
SingleShift(
sites=("group1",), name="b", length= 12.5, days=days,
workers_required=1,
force_as_block=False,
),
)
Rota.add_grade_constraint_by_week([1], [1,2,3], ["b"])
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
def test_avoid_grade_constraint_invalid_grade(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1",), name="a", length= 12.5, days=days,
workers_required=2,
force_as_block=False,
),
)
with pytest.raises(ValueError):
Rota.add_grade_constraint_by_week([4], [1,2,3], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
Rota.add_worker(
Worker(name="worker4", site="group1", grade=4),
)
Rota.add_grade_constraint_by_week([4], [1,2,3], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
def test_avoid_grade_constraint_multiple_grades(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1",), name="a", length= 12.5, days=days,
workers_required=1,
force_as_block=False,
),
)
Rota.add_grade_constraint_by_week([1,2], [1,2,3], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
Rota.add_grade_constraint_by_week([3], [1,2,3], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "warning"
def test_avoid_grade_constraint_multiple_shifts(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1",), name="a", length= 12.5, days=days,
workers_required=1,
force_as_block=False,
),
SingleShift(
sites=("group1",), name="b", length= 12.5, days=days,
workers_required=1,
force_as_block=False,
),
)
Rota.add_grade_constraint_by_week([1], [1,2,3], ["a", "b"])
Rota.add_grade_constraint_by_week([2], [4,5,6], ["a", "b"])
Rota.add_grade_constraint_by_week([3], [7,8,9], ["a", "b"])
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("avoid_grade_constraint")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
shift_string = Rota.get_worker_shift_list_string(worker)
match worker_name:
case "worker1":
assert shift_string.startswith("-"*21)
case "worker2":
assert "-"*21 in shift_string # hacky
case "worker3":
assert shift_string.endswith("-"*21)
Rota.add_grade_constraint_by_week([2], [7,8,9], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
Rota.add_grade_constraint_by_week([1], [7,8,9], ["a"])
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "warning"
+75 -73
View File
@@ -15,14 +15,16 @@ def generate_basic_rota(weeks_to_rota=10):
Rota.constraint_options["balance_weekends"] = True Rota.constraint_options["balance_weekends"] = True
# Add a few workers # Add a few workers
Rota.add_workers([ Rota.add_workers(
[
Worker(name="worker1", site="group1", grade=1), Worker(name="worker1", site="group1", grade=1),
Worker(name="worker2", site="group1", grade=1), Worker(name="worker2", site="group1", grade=1),
Worker(name="worker3", site="group2", grade=1), Worker(name="worker3", site="group2", grade=1),
# Worker(name="worker4", site="group2", grade=1), # Worker(name="worker4", site="group2", grade=1),
# Worker(name="worker5", site="group2", grade=1), # Worker(name="worker5", site="group2", grade=1),
# Worker(name="worker6", site="group2", grade=1, fte=50), # Worker(name="worker6", site="group2", grade=1, fte=50),
]) ]
)
return Rota return Rota
@@ -33,22 +35,22 @@ class TestBalancing:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"a", name="a",
12.5, length=12.5,
days, days=days,
balance_offset=2, balance_offset=2,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"b", name="b",
12.5, length=12.5,
days, days=days,
balance_offset=2, balance_offset=2,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
) )
@@ -68,30 +70,30 @@ class TestBalancing:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=["group1", "group2"],
"a", name="a",
12.5, length=12.5,
days, days=days,
balance_weighting=4, balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False, force_as_block=False,
constraint=["preclear2", "postclear2"], constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"b", name="b",
12.5, length=12.5,
days, days=days,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"c", name="c",
12.5, length=12.5,
days[0], days=days[0],
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
) )
@@ -111,31 +113,31 @@ class TestBalancing:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"a", name="a",
12.5, length=12.5,
days, days=days,
# balance_weighting=4, # balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False, force_as_block=False,
# constraint=["preclear2", "postclear2"], # constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"b", name="b",
12.5, length=12.5,
days, days=days,
balance_weighting=4, balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"c", name="c",
12.5, length=12.5,
days[0], days=days[0],
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
) )
@@ -156,32 +158,32 @@ class TestBalancing:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"a", name="a",
12.5, length=12.5,
days, days=days,
# balance_weighting=4, # balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False, force_as_block=False,
# constraint=["preclear2", "postclear2"], # constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"b", name="b",
12.5, length=12.5,
days, days=days,
balance_weighting=4, balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"c", name="c",
12.5, length=12.5,
days[0], days=days[0],
balance_weighting=4, balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
) )
@@ -206,10 +208,10 @@ class TestBalancing:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"a", name="a",
12.5, length=12.5,
days, days=days,
balance_offset=99, balance_offset=99,
balance_weighting=8, balance_weighting=8,
workers_required=2, workers_required=2,
@@ -217,14 +219,14 @@ class TestBalancing:
# constraint=["preclear2", "postclear2"], # constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("group2","group3"), sites=("group2", "group3"),
"b", name="b",
12.5, length=12.5,
days, days=days,
balance_offset=99, balance_offset=99,
# balance_weighting=4, # balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
) )
@@ -247,10 +249,10 @@ class TestBalancing:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"a", name="a",
12.5, length=12.5,
days, days=days,
balance_offset=99, balance_offset=99,
# balance_weighting=8, # balance_weighting=8,
workers_required=2, workers_required=2,
@@ -258,14 +260,14 @@ class TestBalancing:
# constraint=["preclear2", "postclear2"], # constraint=["preclear2", "postclear2"],
), ),
SingleShift( SingleShift(
("group2","group3"), sites=("group2", "group3"),
"b", name="b",
12.5, length=12.5,
days, days=days,
balance_offset=99, balance_offset=99,
balance_weighting=4, balance_weighting=4,
workers_required=1, workers_required=1,
force_as_block=False force_as_block=False,
), ),
) )
+11 -41
View File
@@ -32,25 +32,19 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
# balance_offset=10, # balance_offset=10,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
workers_required=2, workers_required=2,
), ),
) )
self.Rota.build_and_solve(options={"ratio": 0.10}) self.Rota.build_and_solve(options={"ratio": 0.10})
self.Rota.export_rota_to_html("nwd") self.Rota.export_rota_to_html("nwd1")
assert self.Rota.results.solver.status == "ok" assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert self.Rota.results.solver.termination_condition == "optimal"
@@ -86,10 +80,7 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
@@ -131,10 +122,7 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
@@ -192,10 +180,7 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
@@ -234,10 +219,7 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=2, workers_required=2,
force_as_block_unless_nwd=True, force_as_block_unless_nwd=True,
@@ -290,20 +272,14 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=2, workers_required=2,
force_as_block_unless_nwd=True, force_as_block_unless_nwd=True,
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
balance_offset=10, balance_offset=10,
workers_required=4, workers_required=4,
force_as_block_unless_nwd=True, force_as_block_unless_nwd=True,
@@ -359,20 +335,14 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
force_as_block_unless_nwd=True, force_as_block_unless_nwd=True,
assign_as_block=True, assign_as_block=True,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
balance_offset=10, balance_offset=10,
workers_required=2, workers_required=2,
force_as_block_unless_nwd=True, force_as_block_unless_nwd=True,
+8 -23
View File
@@ -30,14 +30,11 @@ class TestRemoteRotas:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=1, workers_required=1,
balance_offset=100, balance_offset=100,
force_as_block=False, force_as_block=False,
constraint=[("require_remote_site_presence_week", ("group2", 2))], constraint=[{"name":"require_remote_site_presence_week", "options":("group2", 2)}],
), ),
) )
@@ -69,20 +66,14 @@ class TestRemoteRotas:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days[:3],
"a",
12.5,
days[:3],
workers_required=1, workers_required=1,
balance_offset=10, balance_offset=10,
force_as_block=True, force_as_block=True,
constraint=[("require_remote_site_presence_week", ("group2", 1))], constraint=[{"name":"require_remote_site_presence_week", "options":("group2", 1)}],
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="b", length= 12.5, days=days[3:],
"b",
12.5,
days[3:],
workers_required=1, workers_required=1,
balance_offset=10, balance_offset=10,
force_as_block=False, force_as_block=False,
@@ -135,20 +126,14 @@ class TestRemoteRotas:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days[:3],
"a",
12.5,
days[:3],
workers_required=1, workers_required=1,
balance_offset=10, balance_offset=10,
force_as_block=False, force_as_block=False,
constraint=[("require_remote_site_presence", ("group2", 1))], constraint=[{"name":"require_remote_site_presence_week", "options":("group2", 1)}],
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="b", length= 12.5, days=days[3:],
"b",
12.5,
days[3:],
workers_required=1, workers_required=1,
balance_offset=10, balance_offset=10,
force_as_block=False, force_as_block=False,
+311 -226
View File
@@ -1,4 +1,5 @@
from copy import deepcopy from copy import deepcopy
from re import S
from black import main from black import main
import pytest import pytest
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
@@ -17,7 +18,6 @@ class TestDemoRota:
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1, balance_offset_modifier=1,
max_weekend_frequency=1,
) )
Rota.constraint_options["max_shifts_per_week"] = 5 Rota.constraint_options["max_shifts_per_week"] = 5
@@ -44,10 +44,10 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"weekday", name="weekday",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
# We use a different balance weighting for each shift as otherwise # We use a different balance weighting for each shift as otherwise
@@ -55,10 +55,10 @@ class TestDemoRota:
# balance_weighting=0.6, # balance_weighting=0.6,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",),
"weekend", name="weekend",
12.5, length=12.5,
days[5:], days=days[5:],
balance_offset=4, balance_offset=4,
# balance_weighting=0.5 # balance_weighting=0.5
), ),
@@ -191,7 +191,6 @@ class TestDemoRotaNights:
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1, balance_offset_modifier=1,
max_weekend_frequency=1,
) )
Rota.constraint_options["max_shifts_per_week"] = 5 Rota.constraint_options["max_shifts_per_week"] = 5
@@ -223,12 +222,12 @@ class TestDemoRotaNights:
# Add a weekday and weekend shift # Add a weekday and weekend shift
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"night_weekday", name="night_weekday",
12.5, length=12.5,
days[:4], days=days[:4],
workers_required=1, workers_required=1,
constraint=["preclear2", "postclear2"], constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
# constraint=["night"], # constraint=["night"],
force_as_block=True force_as_block=True
# We use a different balance weighting for each shift as otherwise # We use a different balance weighting for each shift as otherwise
@@ -236,20 +235,20 @@ class TestDemoRotaNights:
# balance_weighting=0.6, # balance_weighting=0.6,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"night_weekend", name="night_weekend",
12.5, length=12.5,
days[4:], days=days[4:],
# balance_weighting=0.5 # balance_weighting=0.5
constraint=["preclear2", "postclear2"], constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
# constraint=["night"], # constraint=["night"],
force_as_block=True, force_as_block=True,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"twilight", name="twilight",
12.5, length=12.5,
days[:5], days=days[:5],
# balance_weighting=0.5 # balance_weighting=0.5
workers_required=2, workers_required=2,
# constraint=["preclear2", "postclear2"], # constraint=["preclear2", "postclear2"],
@@ -271,11 +270,9 @@ class TestDemoRotaNights:
Rota.export_rota_to_html("test3") Rota.export_rota_to_html("test3")
def test_start_date(self): def test_start_date(self):
assert self.Rota.start_date == self.start_date assert self.Rota.start_date == self.start_date
def test_night_assignment(self): def test_night_assignment(self):
shift_summary = self.Rota.get_shift_summary_dict() shift_summary = self.Rota.get_shift_summary_dict()
@@ -303,7 +300,6 @@ class TestDemoRotaClear:
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1, balance_offset_modifier=1,
max_weekend_frequency=1,
) )
Rota.constraint_options["max_shifts_per_month"] = 20 Rota.constraint_options["max_shifts_per_month"] = 20
@@ -323,30 +319,30 @@ class TestDemoRotaClear:
def test_preclear(self): def test_preclear(self):
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"a", name="a",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=40, balance_offset=40,
workers_required=2, workers_required=2,
constraint=["preclear"], constraint=[{"name": "preclear"}],
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"b", name="b",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=40, balance_offset=40,
workers_required=2, workers_required=2,
), ),
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"c", name="c",
12.5, length=12.5,
days[3], days=days[3],
balance_offset=40, balance_offset=40,
workers_required=1, workers_required=1,
constraint=["preclear2", "postclear2"], constraint=[{"name": "preclear2"}, {"name": "postclear2"}],
), ),
) )
@@ -389,7 +385,6 @@ class TestDemoRotaShiftConstraints:
start_date, start_date,
weeks_to_rota=weeks_to_rota, weeks_to_rota=weeks_to_rota,
balance_offset_modifier=1, balance_offset_modifier=1,
max_weekend_frequency=1,
) )
# Rota.constraint_options["max_shifts_per_week"] = 5 # Rota.constraint_options["max_shifts_per_week"] = 5
# Rota.constraint_options["max_shifts_per_month"] = 20 # Rota.constraint_options["max_shifts_per_month"] = 20
@@ -399,10 +394,10 @@ class TestDemoRotaShiftConstraints:
Rota.add_workers((worker1, worker2)) Rota.add_workers((worker1, worker2))
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",),
"a", name="a",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=1, workers_required=1,
), ),
@@ -479,14 +474,14 @@ class TestDemoRotaBalanceShiftSites:
def test_balance_blocks_across_2_groups(self): def test_balance_blocks_across_2_groups(self):
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days[:4], days=days[:4],
balance_offset=40, balance_offset=40,
workers_required=2, workers_required=2,
force_as_block=True, force_as_block=True,
constraint=["balance_across_groups"], constraint=[{"name": "balance_across_groups"}],
), ),
) )
self.Rota.constraint_options["balance_nights_across_sites"] = False self.Rota.constraint_options["balance_nights_across_sites"] = False
@@ -515,14 +510,14 @@ class TestDemoRotaBalanceShiftSites:
self.Rota.shifts = [] self.Rota.shifts = []
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days[:4], days=days[:4],
balance_offset=40, balance_offset=40,
workers_required=3, workers_required=3,
force_as_block=True, force_as_block=True,
constraint=["balance_across_groups"], constraint=[{"name": "balance_across_groups"}],
), ),
) )
self.Rota.constraint_options["balance_nights_across_sites"] = False self.Rota.constraint_options["balance_nights_across_sites"] = False
@@ -549,14 +544,14 @@ class TestDemoRotaBalanceShiftSites:
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days[:4], days=days[:4],
balance_offset=40, balance_offset=40,
workers_required=3, workers_required=3,
force_as_block=True, force_as_block=True,
constraint=["balance_across_groups"], constraint=[{"name": "balance_across_groups"}],
), ),
) )
self.Rota.constraint_options["balance_nights_across_sites"] = False self.Rota.constraint_options["balance_nights_across_sites"] = False
@@ -580,8 +575,7 @@ class TestDemoRotaBalanceShiftSites:
assert self.Rota.results.solver.status == "ok" assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert self.Rota.results.solver.termination_condition == "optimal"
def generate_limit_constraint_rota():
class TestLimitConstraints:
weeks_to_rota = 8 weeks_to_rota = 8
start_date = datetime.date(2022, 3, 7) start_date = datetime.date(2022, 3, 7)
@@ -603,30 +597,41 @@ class TestLimitConstraints:
Rota.shifts = [] Rota.shifts = []
return Rota
class TestLimitConstraints:
def test_constraint_limit_grades(self): def test_constraint_limit_grades(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=60,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("limit_grade_number", {2: 1})], constraint=[{"name": "limit_grade_number", "options": {2: 1}}],
), ),
) )
# self.Rota.constraint_options["balance_nights_across_sites"] = False # Rota.constraint_options["balance_nights_across_sites"] = False
# self.Rota.constraint_options["balance_shifts_over_workers"] = False # Rota.constraint_options["balance_shifts_over_workers"] = False
self.Rota.constraint_options["balance_shifts_quadratic"] = True #Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("constraint_limit_grades")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
grade_workers = Rota.get_workers_by_grade()
grade_workers = self.Rota.get_workers_by_grade()
for grade in grade_workers: for grade in grade_workers:
shift_patterns = [] shift_patterns = []
for w in grade_workers[grade]: for w in grade_workers[grade]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -636,35 +641,33 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit assert day_shifts.count("weekday_night") == limit
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
def test_constraint_limit_grades2(self): def test_constraint_limit_grades2(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("limit_grade_number", {3: 2})], constraint=[{"name": "limit_grade_number", "options": {3: 2}}],
), ),
) )
# self.Rota.constraint_options["balance_nights_across_sites"] = False # Rota.constraint_options["balance_nights_across_sites"] = False
# self.Rota.constraint_options["balance_shifts_over_workers"] = False # Rota.constraint_options["balance_shifts_over_workers"] = False
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.1})
self.Rota.export_rota_to_html("test9") Rota.export_rota_to_html("test9")
grade_workers = self.Rota.get_workers_by_grade() grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers: for grade in grade_workers:
shift_patterns = [] shift_patterns = []
for w in grade_workers[grade]: for w in grade_workers[grade]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -674,35 +677,51 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit assert day_shifts.count("weekday_night") == limit
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert Rota.results.solver.termination_condition == "optimal"
def test_constraint_limit_grades3(self): def test_constraint_limit_grades3(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts(
Rota.workers = []
worker1 = Worker(name="worker1", site="group1", grade=2, remote_site="group1")
worker2 = Worker(name="worker2", site="group1", grade=2, remote_site="group1")
worker3 = Worker(name="worker3", site="group1", grade=2, remote_site="group1")
worker4 = Worker(name="worker4", site="group3", grade=3, remote_site="group2")
worker5 = Worker(name="worker5", site="group3", grade=3, remote_site="group2")
worker6 = Worker(name="worker6", site="group3", grade=3, remote_site="group2")
worker7 = Worker(name="worker7", site="group3", grade=2, remote_site="group2")
Rota.add_workers((worker1, worker2, worker3, worker4))
Rota.add_workers((worker5, worker6))
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=60,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("limit_grade_number", {2: 3, 3: 1})], constraint=[{"name": "limit_grade_number", "options": {2: 3, 3: 1}},]
), ),
) )
# self.Rota.constraint_options["balance_nights_across_sites"] = False # Rota.constraint_options["balance_nights_across_sites"] = False
# self.Rota.constraint_options["balance_shifts_over_workers"] = False # Rota.constraint_options["balance_shifts_over_workers"] = False
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.01})
self.Rota.export_rota_to_html("test9") Rota.export_rota_to_html("test9")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
grade_workers = self.Rota.get_workers_by_grade() grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers: for grade in grade_workers:
shift_patterns = [] shift_patterns = []
for w in grade_workers[grade]: for w in grade_workers[grade]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -712,55 +731,57 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit assert day_shifts.count("weekday_night") == limit
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
def test_constraint_limit_grades4(self): def test_constraint_limit_grades4(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("limit_grade_number", {2: 4, 3: 0})], constraint=[{"name": "limit_grade_number", "options": {2: 4, 3: 0}}],
), ),
) )
# self.Rota.constraint_options["balance_nights_across_sites"] = False # Rota.constraint_options["balance_nights_across_sites"] = False
# self.Rota.constraint_options["balance_shifts_over_workers"] = False # Rota.constraint_options["balance_shifts_over_workers"] = False
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.1})
assert self.Rota.results.solver.termination_condition == "infeasible" assert Rota.results.solver.termination_condition == "infeasible"
def test_constraint_minimum_grades(self): def test_constraint_minimum_grades(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=1, workers_required=1,
force_as_block=True, force_as_block=True,
constraint=[("minimum_grade_number", (3, 1))], constraint=[{"name": "minimum_grade_number", "options": (3, 1)}],
), ),
) )
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.1})
self.Rota.export_rota_to_html("test9") Rota.export_rota_to_html("test9")
grade_workers = self.Rota.get_workers_by_grade() assert Rota.results.solver.status == "ok"
grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers: for grade in grade_workers:
shift_patterns = [] shift_patterns = []
for w in grade_workers[grade]: for w in grade_workers[grade]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -770,33 +791,33 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit assert day_shifts.count("weekday_night") == limit
assert self.Rota.results.solver.status == "ok"
def test_constraint_minimum_grades2(self): def test_constraint_minimum_grades2(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("minimum_grade_number", (3, 3))], constraint=[{"name": "minimum_grade_number", "options": (3, 3)}],
), ),
) )
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.1})
self.Rota.export_rota_to_html("test9") Rota.export_rota_to_html("test9")
grade_workers = self.Rota.get_workers_by_grade() grade_workers = Rota.get_workers_by_grade()
for grade in grade_workers: for grade in grade_workers:
shift_patterns = [] shift_patterns = []
for w in grade_workers[grade]: for w in grade_workers[grade]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -806,71 +827,79 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("weekday_night") == limit assert day_shifts.count("weekday_night") == limit
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_constraint_minimum_grades3(self): def test_constraint_minimum_grades3(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("minimum_grade_number", (3, 0))], constraint=[{"name": "minimum_grade_number", "options": (3, 0)}],
), ),
) )
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.1})
self.Rota.export_rota_to_html("test9") Rota.export_rota_to_html("test9")
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
def test_constraint_minimum_grades_no_valid_worker(self): def test_constraint_minimum_grades_no_valid_worker(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"weekday_night", name="weekday_night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=4, workers_required=4,
force_as_block=True, force_as_block=True,
constraint=[("minimum_grade_number", (4, 1))], constraint=[{"name": "minimum_grade_number", "options": (4, 1)}],
), ),
) )
self.Rota.constraint_options["balance_shifts_quadratic"] = True Rota.constraint_options["balance_shifts_quadratic"] = True
with pytest.raises(ValueError): with pytest.raises(ValueError):
self.Rota.build_and_solve(options={"ratio": 0.1}) Rota.build_and_solve(options={"ratio": 0.1})
def test_constraint_require_remote_site_presence_week(self): def test_constraint_require_remote_site_presence_week(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"night", name="night",
12.5, length=12.5,
days, days=days,
balance_offset=40, balance_offset=40,
workers_required=2, workers_required=2,
force_as_block=True, force_as_block=True,
constraint=[("require_remote_site_presence_week", ("group1", 2))], constraint=[
{
"name": "require_remote_site_presence_week",
"options": ("group1", 2),
}
],
), ),
) )
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("remote1") Rota.export_rota_to_html("remote1")
group_workers = self.Rota.get_workers_by_group() group_workers = Rota.get_workers_by_group()
for group in group_workers: for group in group_workers:
shift_patterns = [] shift_patterns = []
for w in group_workers[group]: for w in group_workers[group]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -880,67 +909,79 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("night") == limit assert day_shifts.count("night") == limit
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert Rota.results.solver.termination_condition == "optimal"
def test_constraint_require_remote_site_presence_week2(self): def test_constraint_require_remote_site_presence_week2(self):
self.Rota.constraint_options["minimise_shift_diffs"] = False Rota = generate_limit_constraint_rota()
self.Rota.constraint_options["balance_shifts_quadratic"] = False Rota.constraint_options["minimise_shift_diffs"] = False
self.Rota.constraint_options["balance_shift"] = False Rota.constraint_options["balance_shifts_quadratic"] = False
self.Rota.constraint_options["balance_nights_across_sites"] = True Rota.constraint_options["balance_shift"] = False
self.Rota.shifts = [] Rota.constraint_options["balance_nights_across_sites"] = True
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"night", name="night",
12.5, length=12.5,
days, days=days,
balance_offset=20, balance_offset=20,
workers_required=3, workers_required=3,
force_as_block=True, force_as_block=True,
constraint=[("require_remote_site_presence_week", ("group2", 2))], constraint=[
{
"name": "require_remote_site_presence_week",
"options": ("group1", 2),
}
],
), ),
) )
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("remote2") Rota.export_rota_to_html("remote2")
group_workers = self.Rota.get_workers_by_remote_group() group_workers = Rota.get_workers_by_remote_group()
for group in group_workers: for group in group_workers:
shift_patterns = [] shift_patterns = []
for w in group_workers[group]: for w in group_workers[group]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
if group == "group2": if group == "group1":
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("night") >= 2 assert day_shifts.count("night") >= 2
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert Rota.results.solver.termination_condition == "optimal"
def test_constraint_require_remote_site_presence_week3(self): def test_constraint_require_remote_site_presence_week3(self):
self.Rota.shifts = [] Rota = generate_limit_constraint_rota()
self.Rota.add_shifts( Rota.shifts = []
Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2", "group3"), sites=("group1", "group2", "group3"),
"night", name="night",
12.5, length=12.5,
days, days=days,
balance_offset=20, balance_offset=20,
workers_required=3, workers_required=3,
force_as_block=True, force_as_block=True,
constraint=[("require_remote_site_presence_week", ("group2", 1))], constraint=[
{
"name": "require_remote_site_presence_week",
"options": ("group2", 1),
}
],
), ),
) )
self.Rota.build_and_solve(options={"ratio": 0.00}) Rota.build_and_solve(options={"ratio": 0.00})
self.Rota.export_rota_to_html("remote3") Rota.export_rota_to_html("remote3")
group_workers = self.Rota.get_workers_by_remote_group() group_workers = Rota.get_workers_by_remote_group()
for group in group_workers: for group in group_workers:
shift_patterns = [] shift_patterns = []
for w in group_workers[group]: for w in group_workers[group]:
shift_patterns.append(self.Rota.get_worker_shift_list(w)) shift_patterns.append(Rota.get_worker_shift_list(w))
zipped_lists = list(zip(*shift_patterns)) zipped_lists = list(zip(*shift_patterns))
@@ -948,8 +989,9 @@ class TestLimitConstraints:
for day_shifts in zipped_lists: for day_shifts in zipped_lists:
assert day_shifts.count("night") >= 1 assert day_shifts.count("night") >= 1
assert self.Rota.results.solver.status == "ok" assert Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert Rota.results.solver.termination_condition == "optimal"
# class TestNoWorkerRota: # class TestNoWorkerRota:
# weeks_to_rota = 10 # weeks_to_rota = 10
@@ -979,23 +1021,41 @@ class TestNightUnavailable:
# Add a few workers # Add a few workers
worker1 = Worker( worker1 = Worker(
name="worker1", site="group1", grade=1, not_available_to_work=[{ "date": datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(), "reason" : "****" },] name="worker1",
site="group1",
grade=1,
not_available_to_work=[
{
"date": datetime.datetime.strptime("15/03/22", "%d/%m/%y").date(),
"reason": "****",
},
],
) )
worker2 = Worker( worker2 = Worker(
name="worker2", site="group1", grade=1, not_available_to_work=[ { "date": datetime.datetime.strptime("14/03/22", "%d/%m/%y").date(), "reason": "****" } ] name="worker2",
site="group1",
grade=1,
not_available_to_work=[
{
"date": datetime.datetime.strptime("14/03/22", "%d/%m/%y").date(),
"reason": "****",
}
],
) )
Rota.add_workers((worker1, worker2)) Rota.add_workers((worker1, worker2))
def test_basic_assignment(self, capsys): def test_basic_assignment(self, capsys):
self.Rota.shifts = [] self.Rota.shifts = []
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), "night_weekend", 12.5, days[5:], constraint=["night"] sites =("group1",),
name = "night_weekend",
length = 12.5,
days = days[5:],
constraint=[{"name": "night"}],
), ),
) )
@@ -1005,7 +1065,6 @@ class TestNightUnavailable:
assert self.Rota.results.solver.status == "ok" assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal" assert self.Rota.results.solver.termination_condition == "optimal"
self.Rota.export_rota_to_html("testnight") self.Rota.export_rota_to_html("testnight")
summary = self.Rota.get_shift_summary_dict() summary = self.Rota.get_shift_summary_dict()
@@ -1018,7 +1077,12 @@ class TestNightUnavailable:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), "night_weekend", 12.5, days[5:], workers_required=2, constraint=["night"] sites =("group1",),
name = "night_weekend",
length = 12.5,
days = days[5:],
workers_required=2,
constraint=[{"name": "night"}],
), ),
) )
@@ -1032,7 +1096,12 @@ class TestNightUnavailable:
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), "night_weekend", 12.5, days[5:], workers_required=2, constraint=[] sites = ("group1",),
name = "night_weekend",
length = 12.5,
days = days[5:],
workers_required=2,
constraint=[],
), ),
) )
@@ -1044,16 +1113,31 @@ class TestNightUnavailable:
def test_assign_split(self): def test_assign_split(self):
self.Rota.shifts = [] self.Rota.shifts = []
worker3 = Worker( worker3 = Worker(
name="worker3", site="group1", grade=1, not_available_to_work=({"date":datetime.datetime.strptime("13/03/22", "%d/%m/%y").date()},) name="worker3",
site="group1",
grade=1,
not_available_to_work=(
{"date": datetime.datetime.strptime("13/03/22", "%d/%m/%y").date()},
),
) )
worker4 = Worker( worker4 = Worker(
name="worker4", site="group1", grade=1, not_available_to_work=({"date":datetime.datetime.strptime("12/03/22", "%d/%m/%y").date()},) name="worker4",
site="group1",
grade=1,
not_available_to_work=(
{"date": datetime.datetime.strptime("12/03/22", "%d/%m/%y").date()},
),
) )
self.Rota.add_workers((worker3, worker4)) self.Rota.add_workers((worker3, worker4))
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), "night_weekend", 12.5, days[5:], workers_required=3, constraint=[] sites=("group1",),
name="night_weekend",
length=12.5,
days=days[5:],
workers_required=3,
constraint=[],
), ),
) )
@@ -1067,6 +1151,7 @@ class TestNightUnavailable:
for worker in summary: for worker in summary:
assert summary[worker]["night_weekend"] == 15 assert summary[worker]["night_weekend"] == 15
# def test_assign_split_night_constraint(self): # def test_assign_split_night_constraint(self):
# self.Rota.shifts = [] # self.Rota.shifts = []
# worker3 = Worker( # worker3 = Worker(
+109 -8
View File
@@ -49,10 +49,7 @@ class TestShifts:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
), ),
) )
@@ -63,12 +60,116 @@ class TestShifts:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1"), sites=("group1",), name="a", length= 12.5, days=days,
"a",
12.5,
days,
), ),
) )
with pytest.raises(InvalidShift): with pytest.raises(InvalidShift):
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
class TestShiftConstraints:
def test_max_shifts(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 4}],
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_max_shifts_fail(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week", "options": 3}],
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.termination_condition == "infeasible"
Rota.add_worker(
Worker(
name="worker3", site="group1", grade=1,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_max_shifts_per_week_block(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week_block", "options": {"weeks": 2}}],
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("max_shifts_per_week_block")
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_max_shifts_per_week_block_fail(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week_block", "options": {"weeks": 3}}],
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("max_shifts_per_week_block")
assert Rota.results.solver.status == "warning"
def test_max_shifts_per_week_block_shift_number(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week_block", "options": {"weeks": 2, "shift_number": 7}}],
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("max_shifts_per_week_block")
assert Rota.results.solver.status == "ok"
def test_max_shifts_per_week_block_shift_number_fail(self):
Rota = generate_basic_rota()
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
constraint=[{"name": "max_shifts_per_week_block", "options": {"weeks": 2, "shift_number": 6}}],
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("max_shifts_per_week_block")
assert Rota.results.solver.status == "warning"
+18 -72
View File
@@ -33,18 +33,12 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="a", length= 12.5, days=days,
"a",
12.5,
days,
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="b", length= 12.5, days=days[5:],
"b",
12.5,
days[5:],
balance_offset=0, balance_offset=0,
workers_required=1 workers_required=1
), ),
@@ -97,19 +91,13 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="a", length= 12.5, days=days,
"a",
12.5,
days,
balance_offset=5, balance_offset=5,
workers_required=1, workers_required=1,
force_as_block=True force_as_block=True
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="b", length= 12.5, days=days[5:],
"b",
12.5,
days[5:],
balance_offset=0, balance_offset=0,
workers_required=2 workers_required=2
), ),
@@ -159,18 +147,12 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
workers_required=1 workers_required=1
), ),
) )
@@ -211,26 +193,17 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="c", length= 12.5, days=days[1],
"c",
12.5,
days[1],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
workers_required=1 workers_required=1
), ),
) )
@@ -259,26 +232,17 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="c", length= 12.5, days=days[1],
"c",
12.5,
days[1],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
workers_required=1 workers_required=1
), ),
) )
@@ -318,26 +282,17 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="c", length= 12.5, days=days[0],
"c",
12.5,
days[0],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
workers_required=1 workers_required=1
), ),
) )
@@ -368,26 +323,17 @@ class TestDemoRota:
# Add a weekday and weekend shift # Add a weekday and weekend shift
self.Rota.add_shifts( self.Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",), name="d", length= 12.5, days=days[:5],
"d",
12.5,
days[:5],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="c", length= 12.5, days=days[0],
"c",
12.5,
days[0],
balance_offset=1, balance_offset=1,
workers_required=1, workers_required=1,
), ),
SingleShift( SingleShift(
("group1",), sites=("group1",), name="w", length= 12.5, days=days[5:],
"w",
12.5,
days[5:],
workers_required=1, workers_required=1,
force_as_block=True force_as_block=True
), ),
+112 -44
View File
@@ -50,10 +50,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
force_as_block=False, force_as_block=False,
), ),
@@ -90,10 +87,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
force_as_block=False, force_as_block=False,
), ),
@@ -128,10 +122,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
force_as_block=False, force_as_block=False,
), ),
@@ -173,10 +164,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
force_as_block=False, force_as_block=False,
), ),
@@ -218,10 +206,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -255,10 +240,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -312,10 +294,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=3, workers_required=3,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -356,10 +335,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=2, workers_required=2,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -401,10 +377,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="a", length= 12.5, days=days,
"a",
12.5,
days,
workers_required=1, workers_required=1,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -417,10 +390,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="c", length= 12.5, days=days,
"c",
12.5,
days,
workers_required=1, workers_required=1,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -432,10 +402,7 @@ class TestWorkerRequests:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"), name="b", length= 12.5, days=days,
"b",
12.5,
days,
workers_required=1, workers_required=1,
balance_offset=1, balance_offset=1,
force_as_block=False, force_as_block=False,
@@ -461,3 +428,104 @@ class TestWorkerRequests:
with pytest.raises(InvalidShift): with pytest.raises(InvalidShift):
Rota.build_and_solve(options={"ratio": 0.000}) Rota.build_and_solve(options={"ratio": 0.000})
def test_work_requests_generic(self):
"""_summary_"""
Rota = generate_basic_rota()
Rota.constraint_options["balance_bank_holidays"] = False
Rota.add_workers(
[Worker(
name="worker3", site="group1", grade=1,
work_requests=(
[
{"date": i, "shift": "*"}
for i in list(date_generator(datetime.date(2022, 3, 7), 35))
]
),
),
Worker(
name="worker4", site="group1", grade=1,
work_requests=(
[
{"date": i, "shift": "*"}
for i in list(date_generator(datetime.date(2022, 3, 7), 35))
]
),
),]
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days,
workers_required=2,
balance_offset=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
Rota.export_rota_to_html("work_requests")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
assert worker_shifts["a"] == 35
shift_string = Rota.get_worker_shift_list_string(worker)
if worker.name in ("worker3", "worker4"):
assert shift_string.startswith("a"*35)
def test_work_requests_generic2(self):
"""_summary_"""
Rota = generate_basic_rota()
Rota.constraint_options["balance_bank_holidays"] = False
Rota.add_workers(
[Worker(
name="worker3", site="group1", grade=1,
work_requests=(
[
{"date": i, "shift": "*"}
for i in list(date_generator(datetime.date(2022, 3, 7), 20))
]
),
),
]
)
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"), name="a", length= 12.5, days=days[3:],
workers_required=2,
balance_offset=1,
force_as_block=False,
),
SingleShift(
sites=("group1", "group2"), name="b", length= 12.5, days=days[:3],
workers_required=1,
balance_offset=1,
force_as_block=False,
),
)
Rota.build_and_solve(options={"ratio": 0.1})
Rota.export_rota_to_html("work_requests")
shift_summary = Rota.get_shift_summary_dict()
for worker_name in shift_summary:
worker_shifts = shift_summary[worker_name]
worker = Rota.get_worker_by_name(worker_name)
assert worker_shifts["a"] in (26, 27)
assert worker_shifts["b"] == 10
shift_string = Rota.get_worker_shift_list_string(worker)
if worker.name in ("worker3",):
assert "-" not in shift_string[:20]
+25 -18
View File
@@ -22,14 +22,20 @@ class TestWorkers:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",),
"d", name="d",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
SingleShift(("group1",), "w", 12.5, days[5:], workers_required=1), SingleShift(
sites=("group1",),
name="w",
length=12.5,
days=days[5:],
workers_required=1,
),
) )
with pytest.raises(NoWorkers): with pytest.raises(NoWorkers):
@@ -49,10 +55,10 @@ class TestWorkers:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1",), sites=("group1",),
"d", name="d",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
@@ -128,10 +134,10 @@ class TestWorkers:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"d", name="d",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
@@ -206,7 +212,8 @@ class TestWorkers:
worker3_oop = [ worker3_oop = [
{ {
"start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2), "start_date": start_date + datetime.timedelta(weeks=weeks_to_rota / 2),
"end_date": start_date + datetime.timedelta(weeks=3 * weeks_to_rota / 4), "end_date": start_date
+ datetime.timedelta(weeks=3 * weeks_to_rota / 4),
"reason": "Quarter oop", "reason": "Quarter oop",
} }
] ]
@@ -282,10 +289,10 @@ class TestWorkers:
Rota.add_shifts( Rota.add_shifts(
SingleShift( SingleShift(
("group1", "group2"), sites=("group1", "group2"),
"d", name="d",
12.5, length=12.5,
days[:5], days=days[:5],
balance_offset=10, balance_offset=10,
workers_required=1, workers_required=1,
), ),
View File
+63
View File
@@ -0,0 +1,63 @@
import uvicorn
from fastapi import FastAPI, Query
from pydantic import BaseModel
from fastapi_sqlalchemy import DBSessionMiddleware, db
import os
from dotenv import load_dotenv
from rota.shifts import SingleShift, ShiftConstraint
from web.models import SingleShift as ModelSingleShift
from web.models import ShiftConstraint as ModelShiftConstraint
#from rota.wokers
load_dotenv('../.env')
app = FastAPI()
app.add_middleware(DBSessionMiddleware, db_url="sqlite:///test.db")
@app.post('/shifts/', response_model=SingleShift)
async def shifts(shift: ModelSingleShift):
db_shift = ModelSingleShift(name=shift.name, length=shift.length)
db.session.add(db_shift)
db.session.commit()
return db_shift
@app.get("/shifts/")
async def shifts():
shifts = db.session.query(ModelSingleShift).all()
return shifts
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/files/{file_path:path}")
async def read_file(file_path: str):
return {"file_path": file_path}
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float | None = None
@app.post("/items/")
async def create_item(item: Item):
item.name + item.price
return item
if __name__ == '__main__':
uvicorn.run(app, host='0.0.0.0', port=8008)
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Float, Boolean
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
Base = declarative_base()
class SingleShift(Base):
__tablename__ = 'single_shift'
id = Column(Integer, primary_key=True, index=True)
name = Column(String)
length = Column(Float)
balance_offset = Column(Float, nullable=True)
balance_weighting = Column(Float)
workers_required = Column(Float)
assign_as_block = Column(Boolean)
force_as_block = Column(Boolean)
hard_constrain_shift = Column(Boolean)
bank_holidays_only = Column(Boolean)
constraint_id = Column(Integer, ForeignKey('shift_constraint.id'))
constraint = relationship('ShiftConstraint')
class ShiftConstraint(Base):
__tablename__ = 'shift_constraint'
id = Column(Integer, primary_key=True)
name = Column(String)
options = Column(Integer, nullable=True)
+54
View File
@@ -0,0 +1,54 @@
from datetime import datetime, time, timedelta
from uuid import UUID
from fastapi import Body, FastAPI,Form, File, UploadFile
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.put("/items/{item_id}")
async def read_items(
item_id: UUID,
start_datetime: datetime | None = Body(default=None),
end_datetime: datetime | None = Body(default=None),
repeat_at: time | None = Body(default=None),
process_after: timedelta | None = Body(default=None),
):
start_process = start_datetime + process_after
duration = end_datetime - start_process
return {
"item_id": item_id,
"start_datetime": start_datetime,
"end_datetime": end_datetime,
"repeat_at": repeat_at,
"process_after": process_after,
"start_process": start_process,
"duration": duration,
}
@app.post("/files/")
async def create_files(files: list[bytes] = File()):
return {"file_sizes": [len(file) for file in files]}
@app.post("/uploadfiles/")
async def create_upload_files(files: list[UploadFile]):
return {"filenames": [file.filename for file in files]}
@app.get("/")
async def main():
content = """
<body>
<form action="/files/" enctype="multipart/form-data" method="post">
<input name="files" type="file" multiple>
<input type="submit">
</form>
<form action="/uploadfiles/" enctype="multipart/form-data" method="post">
<input name="files" type="file" multiple>
<input type="submit">
</form>
</body>
"""
return HTMLResponse(content=content)