Add support for avoiding shifts on specific dates with reasons
This commit is contained in:
+4
-2
@@ -49,7 +49,7 @@ def main(
|
|||||||
time_to_run: int = 60 * 60 * 4,
|
time_to_run: int = 60 * 60 * 4,
|
||||||
ratio: float = 0.001,
|
ratio: float = 0.001,
|
||||||
start_date: datetime.datetime = "2026-03-02",
|
start_date: datetime.datetime = "2026-03-02",
|
||||||
weeks: int = 12,
|
weeks: int = 26,
|
||||||
bom: int = 2,
|
bom: int = 2,
|
||||||
):
|
):
|
||||||
rota_start_date = start_date.date()
|
rota_start_date = start_date.date()
|
||||||
@@ -276,7 +276,7 @@ def main(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Prevent shifts for ST2s for 2 weeks
|
# Prevent shifts for ST2s for 2 weeks
|
||||||
Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
#Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
||||||
|
|
||||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
|
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
|
||||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
|
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
|
||||||
@@ -463,10 +463,12 @@ def main(
|
|||||||
print(w)
|
print(w)
|
||||||
|
|
||||||
Rota.add_worker(w)
|
Rota.add_worker(w)
|
||||||
|
leave.load_academy(Rota)
|
||||||
|
|
||||||
# Rota.build_workers()
|
# Rota.build_workers()
|
||||||
# Rota.build_model()
|
# Rota.build_model()
|
||||||
|
|
||||||
|
|
||||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
||||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
# solver_options = {"seconds": time_to_run, "threads": 10}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import csv
|
import csv
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from requests import Session
|
from requests import Session
|
||||||
@@ -264,4 +264,92 @@ def load_academy(Rota):
|
|||||||
"""
|
"""
|
||||||
Some trainees spend month blocks in the academy. This function loads that information from a google sheet that published it as a csv file.
|
Some trainees spend month blocks in the academy. This function loads that information from a google sheet that published it as a csv file.
|
||||||
"""
|
"""
|
||||||
url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1113837782&single=true&output=csv"
|
url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1113837782&single=true&output=csv"
|
||||||
|
|
||||||
|
with Session() as s:
|
||||||
|
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||||
|
download = s.get(url, headers=headers)
|
||||||
|
decoded = download.content.decode("utf-8")
|
||||||
|
|
||||||
|
reader = csv.reader(decoded.splitlines(), delimiter=',')
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
names = []
|
||||||
|
# collect date rows (these are the month start boundaries) and their cells
|
||||||
|
date_rows: list[datetime.date] = []
|
||||||
|
date_row_cells: list[list[str]] = []
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
n += 1
|
||||||
|
if n == 1:
|
||||||
|
# header row: first cell is column title, remaining are worker names
|
||||||
|
names = [c.strip() for c in row[1:]]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
row_title = row[0].strip()
|
||||||
|
cells = row[1:]
|
||||||
|
|
||||||
|
# If this row is a date row like '02/03/2026' then treat it as a month-start boundary
|
||||||
|
if re.match(date_re, row_title):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(row_title, "%d/%m/%Y").date()
|
||||||
|
except ValueError:
|
||||||
|
d = datetime.strptime(row_title, "%d/%m/%y").date()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_rows.append(d)
|
||||||
|
# normalize cells length to match names
|
||||||
|
row_cells = [ (cells[i] if i < len(cells) else "").strip() for i in range(len(names)) ]
|
||||||
|
date_row_cells.append(row_cells)
|
||||||
|
|
||||||
|
# If no date rows found, nothing to do
|
||||||
|
if not date_rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Identify twilight shift names available in the rota
|
||||||
|
twilight_shift_names = [s.name for s in Rota.get_shifts() if "twilight" in s.name]
|
||||||
|
|
||||||
|
# For each date boundary, compute its end (next boundary - 1 day) and register avoids where cell=='academy'
|
||||||
|
for idx, start_date in enumerate(date_rows):
|
||||||
|
if idx + 1 < len(date_rows):
|
||||||
|
end_date = date_rows[idx + 1] - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end_date = Rota.rota_end_date
|
||||||
|
|
||||||
|
cells = date_row_cells[idx]
|
||||||
|
for i, cell in enumerate(cells):
|
||||||
|
if i >= len(names):
|
||||||
|
break
|
||||||
|
v = (cell or "").lower()
|
||||||
|
if "academy" in v:
|
||||||
|
worker_name = names[i]
|
||||||
|
matching_workers = [w for w in Rota.workers if w.name.strip() == worker_name.strip()]
|
||||||
|
if not matching_workers:
|
||||||
|
logger.warning(f"Academy data: worker '{worker_name}' not found in rota, skipping")
|
||||||
|
continue
|
||||||
|
worker = matching_workers[0]
|
||||||
|
|
||||||
|
# choose twilight shifts relevant to the worker's site
|
||||||
|
candidate_shifts = []
|
||||||
|
for sh_name in twilight_shift_names:
|
||||||
|
try:
|
||||||
|
sh = Rota.get_shift_by_name(sh_name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if hasattr(worker, "site") and worker.site in sh.sites:
|
||||||
|
candidate_shifts.append(sh_name)
|
||||||
|
elif sh_name.startswith(f"{worker.site}_"):
|
||||||
|
candidate_shifts.append(sh_name)
|
||||||
|
|
||||||
|
if not candidate_shifts:
|
||||||
|
candidate_shifts = twilight_shift_names.copy()
|
||||||
|
|
||||||
|
# register as a date range with a reason 'academy'
|
||||||
|
Rota.add_avoid_shifts_for_workers_on_dates(
|
||||||
|
names=[worker.name], shifts=candidate_shifts, start_date=start_date, end_date=end_date, reason="academy"
|
||||||
|
)
|
||||||
@@ -259,6 +259,52 @@ function generateExtra() {
|
|||||||
|
|
||||||
$(table).before(summary_button);
|
$(table).before(summary_button);
|
||||||
|
|
||||||
|
// Add per-table avoid-toggle button (near the summary button)
|
||||||
|
avoid_button = $("<button class='auto-generated'>Toggle Avoids</button>").click((evt) => {
|
||||||
|
// ensure style exists once
|
||||||
|
if ($('#avoid-shift-style').length === 0) {
|
||||||
|
$('head').append(`
|
||||||
|
<style id='avoid-shift-style'>
|
||||||
|
td.rota-day.avoid-shift.avoid-highlight { border: 2px solid rgba(255,140,0,0.9) !important; box-shadow: 0 0 6px rgba(255,140,0,0.25); }
|
||||||
|
</style>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let $btn = $(evt.target);
|
||||||
|
let enabled = $btn.data('enabled') || false;
|
||||||
|
enabled = !enabled;
|
||||||
|
$btn.data('enabled', enabled);
|
||||||
|
|
||||||
|
let $table = $(table);
|
||||||
|
$table.find("td.rota-day.avoid-shift").each((i, el) => {
|
||||||
|
let $el = $(el);
|
||||||
|
if (enabled) {
|
||||||
|
$el.addClass('avoid-highlight');
|
||||||
|
// preserve original title
|
||||||
|
if (!$el.data('orig-title')) $el.data('orig-title', $el.attr('title') || '');
|
||||||
|
let avoid_shifts = $el.attr('data-avoid-shifts') || '';
|
||||||
|
let avoid_reason = $el.attr('data-avoid-reason') || '';
|
||||||
|
let suffix = [];
|
||||||
|
// make the constraint type explicit in the tooltip
|
||||||
|
suffix.push(`constraint: avoid_shifts_by_worker_dates`);
|
||||||
|
if (avoid_shifts) suffix.push(`shifts: ${avoid_shifts}`);
|
||||||
|
if (avoid_reason) suffix.push(`reason: ${avoid_reason}`);
|
||||||
|
if (suffix.length) {
|
||||||
|
let newTitle = $el.data('orig-title') + ' [' + suffix.join('; ') + ']';
|
||||||
|
$el.attr('title', newTitle);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$el.removeClass('avoid-highlight');
|
||||||
|
// restore original title
|
||||||
|
if ($el.data('orig-title') !== undefined) $el.attr('title', $el.data('orig-title'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$btn.text(enabled ? 'Hide Avoids' : 'Toggle Avoids');
|
||||||
|
});
|
||||||
|
|
||||||
|
$(table).before(avoid_button);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -281,6 +327,8 @@ $("#toggle-prior-adjust").on("click", (evt) => {
|
|||||||
$("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle();
|
$("table.tsummary").find(`td.prior-adjust, th.prior-adjust-col`).toggle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: per-table avoid-toggle buttons are added next to each table's summary button inside generateExtra().
|
||||||
|
|
||||||
$("table.tsummary").append("<tr class='header'></tr>");
|
$("table.tsummary").append("<tr class='header'></tr>");
|
||||||
h = $("table.tsummary tr.header");
|
h = $("table.tsummary tr.header");
|
||||||
h.append(`<th>Worker</th>`);
|
h.append(`<th>Worker</th>`);
|
||||||
|
|||||||
@@ -4165,6 +4165,7 @@ class RotaBuilder(object):
|
|||||||
start_date: datetime.date | str | None = None,
|
start_date: datetime.date | str | None = None,
|
||||||
end_date: datetime.date | str | None = None,
|
end_date: datetime.date | str | None = None,
|
||||||
dates: Iterable[datetime.date] | None = None,
|
dates: Iterable[datetime.date] | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
):
|
):
|
||||||
"""Add a rule preventing the given workers from being assigned the given shifts
|
"""Add a rule preventing the given workers from being assigned the given shifts
|
||||||
on the provided date range or explicit dates.
|
on the provided date range or explicit dates.
|
||||||
@@ -4190,7 +4191,7 @@ class RotaBuilder(object):
|
|||||||
sd = _coerce_date(start_date)
|
sd = _coerce_date(start_date)
|
||||||
ed = _coerce_date(end_date)
|
ed = _coerce_date(end_date)
|
||||||
|
|
||||||
entry = {"names": list(names), "shifts": list(shifts), "start_date": sd, "end_date": ed, "dates": None}
|
entry = {"names": list(names), "shifts": list(shifts), "start_date": sd, "end_date": ed, "dates": None, "reason": reason}
|
||||||
if dates is not None:
|
if dates is not None:
|
||||||
entry["dates"] = [d if isinstance(d, datetime.date) else _coerce_date(d) for d in dates]
|
entry["dates"] = [d if isinstance(d, datetime.date) else _coerce_date(d) for d in dates]
|
||||||
|
|
||||||
@@ -5104,6 +5105,49 @@ class RotaBuilder(object):
|
|||||||
):
|
):
|
||||||
model = self.model
|
model = self.model
|
||||||
|
|
||||||
|
# Build a lookup of per-worker/day avoid-shift rules (worker.id, week, day) -> {shifts: [...], reason: str}
|
||||||
|
avoid_lookup: dict[tuple, dict] = {}
|
||||||
|
try:
|
||||||
|
for entry in getattr(self.constraint_options_model, "avoid_shifts_by_worker_dates", []):
|
||||||
|
names = entry.get("names", []) or []
|
||||||
|
shifts = entry.get("shifts", []) or []
|
||||||
|
reason = entry.get("reason")
|
||||||
|
dates = entry.get("dates")
|
||||||
|
sd = entry.get("start_date")
|
||||||
|
ed = entry.get("end_date")
|
||||||
|
|
||||||
|
date_list = []
|
||||||
|
if dates:
|
||||||
|
date_list = dates
|
||||||
|
elif sd is not None or ed is not None:
|
||||||
|
try:
|
||||||
|
for d in self.get_date_range(sd, ed):
|
||||||
|
date_list.append(d)
|
||||||
|
except Exception:
|
||||||
|
# fall back to trying to iterate
|
||||||
|
pass
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
matching_workers = [w for w in self.workers if w.name.strip() == name.strip()]
|
||||||
|
if not matching_workers:
|
||||||
|
continue
|
||||||
|
wid = matching_workers[0].id
|
||||||
|
for d in date_list:
|
||||||
|
try:
|
||||||
|
wk, day = self.get_week_day_by_date(d)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
key = (wid, wk, day)
|
||||||
|
existing = avoid_lookup.get(key, {"shifts": [], "reason": None})
|
||||||
|
existing["shifts"].extend(shifts)
|
||||||
|
# preserve reason if provided
|
||||||
|
if reason:
|
||||||
|
existing["reason"] = reason
|
||||||
|
avoid_lookup[key] = existing
|
||||||
|
except Exception:
|
||||||
|
# If constraint options not present or parsing fails, continue without avoid info
|
||||||
|
avoid_lookup = {}
|
||||||
|
|
||||||
timetable = []
|
timetable = []
|
||||||
|
|
||||||
if self.run_start_time is not None and self.run_end_time is not None:
|
if self.run_start_time is not None and self.run_end_time is not None:
|
||||||
@@ -5252,6 +5296,16 @@ class RotaBuilder(object):
|
|||||||
if locum:
|
if locum:
|
||||||
css_class = " ".join((css_class, "locum-shift"))
|
css_class = " ".join((css_class, "locum-shift"))
|
||||||
|
|
||||||
|
# Add visual marker/data if this day/worker has avoid-shift rules
|
||||||
|
avoid_info = avoid_lookup.get((worker.id, week, day))
|
||||||
|
avoid_attr = ""
|
||||||
|
if avoid_info is not None:
|
||||||
|
# mark the cell as having avoids; include which shifts and the reason
|
||||||
|
avoid_shifts = ",".join(avoid_info.get("shifts", []))
|
||||||
|
avoid_reason = avoid_info.get("reason") or ""
|
||||||
|
css_class = " ".join((css_class, "avoid-shift"))
|
||||||
|
avoid_attr = f" data-avoid-shifts='{avoid_shifts}' data-avoid-reason='{avoid_reason}'"
|
||||||
|
|
||||||
remote_site = ""
|
remote_site = ""
|
||||||
if shift_name:
|
if shift_name:
|
||||||
shift = self.get_shift_by_name(shift_name)
|
shift = self.get_shift_by_name(shift_name)
|
||||||
@@ -5267,7 +5321,7 @@ class RotaBuilder(object):
|
|||||||
f" data-available='{available}'"
|
f" data-available='{available}'"
|
||||||
f" data-unavailable_reason='{unavailable_reason}'"
|
f" data-unavailable_reason='{unavailable_reason}'"
|
||||||
f" data-date='{d}' data-week='{week}' data-day='{day}'"
|
f" data-date='{d}' data-week='{week}' data-day='{day}'"
|
||||||
f"{remote_site}{requests}{bank_holiday}>{shift_display_char}</td>"
|
f"{remote_site}{requests}{bank_holiday}{avoid_attr}>{shift_display_char}</td>"
|
||||||
)
|
)
|
||||||
|
|
||||||
shift_count = ""
|
shift_count = ""
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class AvoidShiftOnDates(BaseModel):
|
|||||||
start_date: Optional[datetime.date] = None
|
start_date: Optional[datetime.date] = None
|
||||||
end_date: Optional[datetime.date] = None
|
end_date: Optional[datetime.date] = None
|
||||||
dates: Optional[list[datetime.date]] = None
|
dates: Optional[list[datetime.date]] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
@field_validator("start_date", "end_date", mode="before")
|
@field_validator("start_date", "end_date", mode="before")
|
||||||
def coerce_dates(cls, v):
|
def coerce_dates(cls, v):
|
||||||
@@ -428,6 +429,7 @@ class Worker(BaseModel):
|
|||||||
"start_date": av.start_date,
|
"start_date": av.start_date,
|
||||||
"end_date": av.end_date,
|
"end_date": av.end_date,
|
||||||
"dates": av.dates,
|
"dates": av.dates,
|
||||||
|
"reason": getattr(av, "reason", None),
|
||||||
}
|
}
|
||||||
# append to rota-level list; the model builder will enforce these
|
# append to rota-level list; the model builder will enforce these
|
||||||
if hasattr(Rota.constraint_options_model, "avoid_shifts_by_worker_dates"):
|
if hasattr(Rota.constraint_options_model, "avoid_shifts_by_worker_dates"):
|
||||||
|
|||||||
Reference in New Issue
Block a user