diff --git a/gen_proc.py b/gen_proc.py index 6dd9937..2a82003 100644 --- a/gen_proc.py +++ b/gen_proc.py @@ -49,7 +49,7 @@ def main( time_to_run: int = 60 * 60 * 4, ratio: float = 0.001, start_date: datetime.datetime = "2026-03-02", - weeks: int = 12, + weeks: int = 26, bom: int = 2, ): rota_start_date = start_date.date() @@ -276,7 +276,7 @@ def main( ) # Prevent shifts for ST2s for 2 weeks - Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts]) + #Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts]) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6) Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6) @@ -463,10 +463,12 @@ def main( print(w) Rota.add_worker(w) + leave.load_academy(Rota) # Rota.build_workers() # Rota.build_model() + solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10} # solver_options = {"seconds": time_to_run, "threads": 10} diff --git a/leave.py b/leave.py index 107e33a..72ab3fd 100644 --- a/leave.py +++ b/leave.py @@ -1,5 +1,5 @@ import csv -from datetime import datetime +from datetime import datetime, timedelta import re import sys 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. """ - url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1113837782&single=true&output=csv" \ No newline at end of file + 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" + ) \ No newline at end of file diff --git a/output/timetable.js b/output/timetable.js index 89547a7..9231436 100644 --- a/output/timetable.js +++ b/output/timetable.js @@ -259,6 +259,52 @@ function generateExtra() { $(table).before(summary_button); + // Add per-table avoid-toggle button (near the summary button) + avoid_button = $("").click((evt) => { + // ensure style exists once + if ($('#avoid-shift-style').length === 0) { + $('head').append(` + + `); + } + + 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(); }); +// Note: per-table avoid-toggle buttons are added next to each table's summary button inside generateExtra(). + $("table.tsummary").append(""); h = $("table.tsummary tr.header"); h.append(`Worker`); diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py index de8c3f8..fe1a1d5 100644 --- a/rota_generator/shifts.py +++ b/rota_generator/shifts.py @@ -4165,6 +4165,7 @@ class RotaBuilder(object): start_date: datetime.date | str | None = None, end_date: datetime.date | str | 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 on the provided date range or explicit dates. @@ -4190,7 +4191,7 @@ class RotaBuilder(object): sd = _coerce_date(start_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: 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 + # 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 = [] 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: 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 = "" if shift_name: shift = self.get_shift_by_name(shift_name) @@ -5267,7 +5321,7 @@ class RotaBuilder(object): f" data-available='{available}'" f" data-unavailable_reason='{unavailable_reason}'" f" data-date='{d}' data-week='{week}' data-day='{day}'" - f"{remote_site}{requests}{bank_holiday}>{shift_display_char}" + f"{remote_site}{requests}{bank_holiday}{avoid_attr}>{shift_display_char}" ) shift_count = "" diff --git a/rota_generator/workers.py b/rota_generator/workers.py index fbf915c..4c3cb2a 100644 --- a/rota_generator/workers.py +++ b/rota_generator/workers.py @@ -117,6 +117,7 @@ class AvoidShiftOnDates(BaseModel): start_date: Optional[datetime.date] = None end_date: Optional[datetime.date] = None dates: Optional[list[datetime.date]] = None + reason: Optional[str] = None @field_validator("start_date", "end_date", mode="before") def coerce_dates(cls, v): @@ -428,6 +429,7 @@ class Worker(BaseModel): "start_date": av.start_date, "end_date": av.end_date, "dates": av.dates, + "reason": getattr(av, "reason", None), } # append to rota-level list; the model builder will enforce these if hasattr(Rota.constraint_options_model, "avoid_shifts_by_worker_dates"):