Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba268f9b48 | ||
|
|
e7f27a8ee6 | ||
|
|
1d7202bf04 | ||
|
|
82868f8c22 |
@@ -397,8 +397,8 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
# legacy builder args (the remaining keys in `options` are treated as
|
# legacy builder args (the remaining keys in `options` are treated as
|
||||||
# constraint configuration for `RotaConstraintOptions`).
|
# constraint configuration for `RotaConstraintOptions`).
|
||||||
builder_args = {
|
builder_args = {
|
||||||
"balance_offset_modifier": {"type": "int", "default": 1, "help": "Balance offset modifier used by the builder"},
|
"balance_offset_modifier": {"type": "float", "default": 1.0, "help": "Balance offset modifier used by the builder"},
|
||||||
"ltft_balance_offset": {"type": "int", "default": 1, "help": "LTFT balance offset used by the builder"},
|
"ltft_balance_offset": {"type": "float", "default": 1.0, "help": "LTFT balance offset used by the builder"},
|
||||||
"use_previous_shifts": {"type": "bool", "default": False, "help": "Use previous shifts when building the rota"},
|
"use_previous_shifts": {"type": "bool", "default": False, "help": "Use previous shifts when building the rota"},
|
||||||
"use_shift_balance_extra": {"type": "bool", "default": False, "help": "Enable extra shift-balance penalties"},
|
"use_shift_balance_extra": {"type": "bool", "default": False, "help": "Enable extra shift-balance penalties"},
|
||||||
"use_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
|
"use_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
|
||||||
@@ -412,6 +412,8 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
|
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
elif meta["type"] == "int":
|
elif meta["type"] == "int":
|
||||||
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
|
elif meta["type"] == "float":
|
||||||
|
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
else:
|
else:
|
||||||
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -46,11 +46,11 @@ from rota_generator.workers import (
|
|||||||
def main(
|
def main(
|
||||||
suspend: bool = False,
|
suspend: bool = False,
|
||||||
solve: bool = True,
|
solve: bool = True,
|
||||||
time_to_run: int = 60 * 60 * 4,
|
time_to_run: int = 60 * 60 * 8,
|
||||||
ratio: float = 0.001,
|
ratio: float = 0.001,
|
||||||
start_date: datetime.datetime = "2026-03-02",
|
start_date: datetime.datetime = "2026-03-02",
|
||||||
weeks: int = 26,
|
weeks: int = 26,
|
||||||
bom: int = 2,
|
bom: float = 1.5,
|
||||||
):
|
):
|
||||||
rota_start_date = start_date.date()
|
rota_start_date = start_date.date()
|
||||||
suspend_on_finish = suspend
|
suspend_on_finish = suspend
|
||||||
@@ -59,17 +59,18 @@ def main(
|
|||||||
rota_start_date,
|
rota_start_date,
|
||||||
weeks_to_rota=weeks,
|
weeks_to_rota=weeks,
|
||||||
balance_offset_modifier=bom,
|
balance_offset_modifier=bom,
|
||||||
name="proc_rota",
|
name="proc_rota1",
|
||||||
)
|
)
|
||||||
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
||||||
Rota.constraint_options["balance_shifts"] = False
|
Rota.constraint_options["balance_shifts"] = False
|
||||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||||
Rota.constraint_options["balance_weekends"] = True
|
Rota.constraint_options["balance_weekends"] = True
|
||||||
Rota.constraint_options["max_night_frequency"] = 3 # 3
|
Rota.constraint_options["max_night_frequency"] = 4 # 3
|
||||||
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
||||||
Rota.constraint_options["max_weekend_frequency"] = 2
|
Rota.constraint_options["max_weekend_frequency"] = 3
|
||||||
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
||||||
# Rota.constraint_options["avoid_st2_first_month"] = True
|
|
||||||
|
Rota.constraint_options["max_days_per_week_block"] = [(8,4)]
|
||||||
|
|
||||||
#wr = [
|
#wr = [
|
||||||
# WorkerRequirement(
|
# WorkerRequirement(
|
||||||
@@ -93,7 +94,7 @@ def main(
|
|||||||
name="exeter_twilight",
|
name="exeter_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:5],
|
days=days[:5],
|
||||||
balance_offset=4,
|
balance_offset=2,
|
||||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -115,7 +116,7 @@ def main(
|
|||||||
name="torbay_twilight",
|
name="torbay_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:4],
|
days=days[:4],
|
||||||
balance_offset=4,
|
balance_offset=2,
|
||||||
assign_as_block=True,
|
assign_as_block=True,
|
||||||
),
|
),
|
||||||
SingleShift(
|
SingleShift(
|
||||||
@@ -128,7 +129,7 @@ def main(
|
|||||||
name="plymouth_twilight",
|
name="plymouth_twilight",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[:5],
|
days=days[:5],
|
||||||
balance_offset=4,
|
balance_offset=2,
|
||||||
workers_required=1,
|
workers_required=1,
|
||||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
||||||
),
|
),
|
||||||
@@ -143,7 +144,7 @@ def main(
|
|||||||
name="weekend_exeter",
|
name="weekend_exeter",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[5:],
|
days=days[5:],
|
||||||
balance_offset=3,
|
balance_offset=2,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
|
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
|
||||||
@@ -172,7 +173,7 @@ def main(
|
|||||||
name="weekend_torbay",
|
name="weekend_torbay",
|
||||||
length=12.5,
|
length=12.5,
|
||||||
days=days[4:],
|
days=days[4:],
|
||||||
balance_offset=3,
|
balance_offset=2,
|
||||||
rota_on_nwds=True,
|
rota_on_nwds=True,
|
||||||
force_as_block=True,
|
force_as_block=True,
|
||||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||||
|
|||||||
@@ -342,6 +342,127 @@ oshifts.forEach((s) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render formatted Shifts settings and Worker details with filtering
|
||||||
|
function renderShiftSettings() {
|
||||||
|
const $container = $("#shifts-container");
|
||||||
|
if (!$container.length) return;
|
||||||
|
let shiftsJson = $container.data("shifts-json");
|
||||||
|
try {
|
||||||
|
if (typeof shiftsJson === 'string') shiftsJson = JSON.parse(shiftsJson);
|
||||||
|
} catch (e) {
|
||||||
|
// fallback: no structured data
|
||||||
|
shiftsJson = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $table = $("<table class='shifts-table' style='width:100%; border-collapse:collapse; margin-bottom:8px;'></table>");
|
||||||
|
$table.append(`<tr><th style='text-align:left'>Name</th><th style='text-align:left'>Sites</th><th style='text-align:left'>Days</th><th style='text-align:left'>Balance offset</th></tr>`);
|
||||||
|
if (shiftsJson && Array.isArray(shiftsJson)) {
|
||||||
|
shiftsJson.forEach(s => {
|
||||||
|
const sites = (s.sites || []).join(", ");
|
||||||
|
const days = (s.days || []).join(", ");
|
||||||
|
$table.append(`<tr class='shift-row' data-shift-name='${s.name}'><td>${s.name}</td><td>${sites}</td><td>${days}</td><td>${s.balance_offset ?? ''}</td></tr>`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// fallback to existing content
|
||||||
|
const html = $("#shifts-table").html();
|
||||||
|
$("#shifts-table").empty().append(html);
|
||||||
|
}
|
||||||
|
$("#shifts-table").empty().append($table);
|
||||||
|
|
||||||
|
// filter input
|
||||||
|
$("#shift-filter").on("input", (e) => {
|
||||||
|
const v = $(e.target).val().toLowerCase();
|
||||||
|
$(".shift-row").each((i, el) => {
|
||||||
|
const name = $(el).data('shift-name') + '';
|
||||||
|
$(el).toggle(name.toLowerCase().indexOf(v) !== -1);
|
||||||
|
});
|
||||||
|
// update shift select in worker panel if present
|
||||||
|
renderWorkerFilterOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerFilterOptions() {
|
||||||
|
const $wd = $("#worker_details");
|
||||||
|
if (!$wd.length) return;
|
||||||
|
let workers = $wd.data("workers");
|
||||||
|
try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; }
|
||||||
|
// populate shift select with shifts present in current shifts table
|
||||||
|
const shiftNames = [];
|
||||||
|
$(".shift-row:visible").each((i, el) => shiftNames.push($(el).data('shift-name')));
|
||||||
|
const $select = $("#worker-shift-select");
|
||||||
|
if ($select.length === 0) {
|
||||||
|
$("#worker_details").prepend('<div style="margin-bottom:8px;"><input type="text" id="worker-filter" placeholder="Filter workers by name/site" style="margin-right:8px;"/> <select id="worker-shift-select"><option value="">(All shifts)</option></select></div>');
|
||||||
|
}
|
||||||
|
const $sel = $("#worker-shift-select");
|
||||||
|
$sel.empty().append('<option value="">(All shifts)</option>');
|
||||||
|
shiftNames.forEach(s => $sel.append(`<option value="${s}">${s}</option>`));
|
||||||
|
|
||||||
|
// attach events
|
||||||
|
$("#worker-filter").off('input').on('input', () => renderWorkerList());
|
||||||
|
$sel.off('change').on('change', () => renderWorkerList());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkerList() {
|
||||||
|
const $wd = $("#worker_details");
|
||||||
|
if (!$wd.length) return;
|
||||||
|
let workers = $wd.data("workers");
|
||||||
|
try { if (typeof workers === 'string') workers = JSON.parse(workers); } catch (e) { workers = null; }
|
||||||
|
const filter = ($("#worker-filter").val() || '').toLowerCase();
|
||||||
|
const shiftFilter = ($("#worker-shift-select").val() || '');
|
||||||
|
|
||||||
|
let $list = $("#worker-list");
|
||||||
|
if ($list.length === 0) {
|
||||||
|
$list = $("<div id='worker-list' style='margin-top:8px;'></div>");
|
||||||
|
$("#worker_details").append($list);
|
||||||
|
}
|
||||||
|
$list.empty();
|
||||||
|
if (!workers) return;
|
||||||
|
workers.forEach(w => {
|
||||||
|
const name = (w.name || '') + '';
|
||||||
|
const site = (w.site || '') + '';
|
||||||
|
if (filter && name.toLowerCase().indexOf(filter) === -1 && site.toLowerCase().indexOf(filter) === -1) return;
|
||||||
|
// if shiftFilter, ensure worker has target or assigned for that shift
|
||||||
|
// attempt to read per-worker targets and counts from DOM if not present in JSON
|
||||||
|
let targets = w.shift_target_number || {};
|
||||||
|
let counts = w.shift_counts || {};
|
||||||
|
const domRow = $(`td.worker[data-worker='${name}']`).first();
|
||||||
|
if (domRow && domRow.length) {
|
||||||
|
try {
|
||||||
|
const domTargets = domRow.data('worker-targets');
|
||||||
|
if (domTargets && Object.keys(targets).length === 0) targets = domTargets;
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
const domCounts = domRow.data('shift-counts');
|
||||||
|
if (domCounts && Object.keys(counts).length === 0) counts = domCounts;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (shiftFilter) {
|
||||||
|
const assigned = (counts && counts[shiftFilter]) ? counts[shiftFilter] : 0;
|
||||||
|
const hasTarget = Object.keys(targets || {}).indexOf(shiftFilter) !== -1 && (targets[shiftFilter] || 0) > 0;
|
||||||
|
const hasAssigned = assigned > 0;
|
||||||
|
if (!hasTarget && !hasAssigned) return;
|
||||||
|
}
|
||||||
|
const fte = w.fte || '';
|
||||||
|
const card = $(`<div class='worker-card' style='border:1px solid #ccc;padding:8px;margin-bottom:6px;'><strong>${name}</strong> <span style='color:#666'>${site}</span><div>FTE: ${fte}</div></div>`);
|
||||||
|
// show shift targets summary
|
||||||
|
if (w.shift_target_number) {
|
||||||
|
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
|
||||||
|
Object.keys(w.shift_target_number).forEach(s => {
|
||||||
|
const t = w.shift_target_number[s];
|
||||||
|
const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0;
|
||||||
|
tbl.append(`<tr><td style='width:60%'>${s}</td><td style='text-align:right'>${c} assigned</td><td style='text-align:right'>target ${parseFloat(t).toFixed(2)}</td></tr>`);
|
||||||
|
});
|
||||||
|
card.append(tbl);
|
||||||
|
}
|
||||||
|
$list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize panels
|
||||||
|
renderShiftSettings();
|
||||||
|
renderWorkerFilterOptions();
|
||||||
|
renderWorkerList();
|
||||||
|
|
||||||
// Column for prior-adjustment (hidden by default)
|
// Column for prior-adjustment (hidden by default)
|
||||||
h.append(`<th class='prior-adjust-col' style='display:none'>Prior Adjust</th>`);
|
h.append(`<th class='prior-adjust-col' style='display:none'>Prior Adjust</th>`);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,49 @@ from loguru import logger
|
|||||||
|
|
||||||
logger.add("rota.log", rotation="1 MB", level="DEBUG")
|
logger.add("rota.log", rotation="1 MB", level="DEBUG")
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import platform
|
||||||
|
|
||||||
|
# Optional dependency: prefer setproctitle if available, fall back to libc on macOS
|
||||||
|
try:
|
||||||
|
import setproctitle # type: ignore
|
||||||
|
except Exception:
|
||||||
|
setproctitle = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_proc_title() -> Optional[str]:
|
||||||
|
try:
|
||||||
|
if setproctitle is not None:
|
||||||
|
return setproctitle.getproctitle()
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
libc = ctypes.CDLL("libc.dylib")
|
||||||
|
libc.getprogname.restype = ctypes.c_char_p
|
||||||
|
res = libc.getprogname()
|
||||||
|
if res:
|
||||||
|
return res.decode()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not read current process title")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_proc_title(title: str) -> bool:
|
||||||
|
try:
|
||||||
|
if setproctitle is not None:
|
||||||
|
setproctitle.setproctitle(title)
|
||||||
|
return True
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
libc = ctypes.CDLL("libc.dylib")
|
||||||
|
# setprogname expects a C string
|
||||||
|
try:
|
||||||
|
libc.setprogname(ctypes.c_char_p(title.encode()))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# some platforms may not support setprogname via ctypes
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to set process title to %s", title)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _safe_constraint_info(obj):
|
def _safe_constraint_info(obj):
|
||||||
"""Return a tuple (type_name, safe_dump) for logging without calling repr()
|
"""Return a tuple (type_name, safe_dump) for logging without calling repr()
|
||||||
@@ -610,7 +653,7 @@ class RotaBuilder(object):
|
|||||||
self,
|
self,
|
||||||
start_date: datetime.date = datetime.date.today(),
|
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: float = 1.0,
|
||||||
ltft_balance_offset: int = 1,
|
ltft_balance_offset: int = 1,
|
||||||
use_previous_shifts: bool = False,
|
use_previous_shifts: bool = False,
|
||||||
use_shift_balance_extra: bool = False,
|
use_shift_balance_extra: bool = False,
|
||||||
@@ -618,10 +661,17 @@ class RotaBuilder(object):
|
|||||||
allow_force_assignment_with_leave_conflict: bool = False,
|
allow_force_assignment_with_leave_conflict: bool = False,
|
||||||
SHIFT_BOUNDS=SHIFT_BOUNDS,
|
SHIFT_BOUNDS=SHIFT_BOUNDS,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
|
set_process_title: bool = True,
|
||||||
bank_holidays: dict[datetime.date, str] = bank_holiday_map,
|
bank_holidays: dict[datetime.date, str] = bank_holiday_map,
|
||||||
constraint_options: RotaConstraintOptions | dict | None = None,
|
constraint_options: RotaConstraintOptions | dict | None = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.set_process_title = set_process_title
|
||||||
|
# record original process title so we can restore it after the run
|
||||||
|
try:
|
||||||
|
self._original_proc_title = _get_proc_title()
|
||||||
|
except Exception:
|
||||||
|
self._original_proc_title = None
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
Panel(
|
Panel(
|
||||||
@@ -909,6 +959,17 @@ class RotaBuilder(object):
|
|||||||
export_with_timestamp=False,
|
export_with_timestamp=False,
|
||||||
solver="appsi_highs",
|
solver="appsi_highs",
|
||||||
):
|
):
|
||||||
|
# Optionally set an informative process title so OS tools show which rota is running
|
||||||
|
self._proc_title_changed = False
|
||||||
|
if getattr(self, "set_process_title", False):
|
||||||
|
title = f"rota-gen:{self.name}" if self.name else "rota-gen"
|
||||||
|
try:
|
||||||
|
if _set_proc_title(title):
|
||||||
|
self._proc_title_changed = True
|
||||||
|
logger.debug("Process title set to %s", title)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not set process title")
|
||||||
|
|
||||||
self.run_start_time = datetime.datetime.now()
|
self.run_start_time = datetime.datetime.now()
|
||||||
|
|
||||||
self.build_shifts()
|
self.build_shifts()
|
||||||
@@ -925,6 +986,17 @@ class RotaBuilder(object):
|
|||||||
|
|
||||||
self.run_end_time = datetime.datetime.now()
|
self.run_end_time = datetime.datetime.now()
|
||||||
|
|
||||||
|
# Restore the original process title if we changed it
|
||||||
|
if getattr(self, "_proc_title_changed", False):
|
||||||
|
try:
|
||||||
|
if getattr(self, "_original_proc_title", None) is not None:
|
||||||
|
_set_proc_title(self._original_proc_title)
|
||||||
|
else:
|
||||||
|
# best-effort restore to a generic python name
|
||||||
|
_set_proc_title("python")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not restore original process title")
|
||||||
|
|
||||||
if export:
|
if export:
|
||||||
self.export_rota_to_html(self.name, timestamp_filename=export_with_timestamp)
|
self.export_rota_to_html(self.name, timestamp_filename=export_with_timestamp)
|
||||||
|
|
||||||
@@ -5722,9 +5794,14 @@ class RotaBuilder(object):
|
|||||||
</div>
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary><h2>Shifts settings</h2></summary>
|
<summary><h2>Shifts settings</h2></summary>
|
||||||
<div id="shifts-container" data-shifts='{json.dumps([i.name for i in self.shifts])}'>
|
<div id="shifts-container" data-shifts='{json.dumps([i.name for i in self.shifts])}' data-shifts-json='{json.dumps([_pydantic_to_dict(i) for i in self.shifts])}'>
|
||||||
|
<div id="shifts-controls">
|
||||||
|
<input type="text" id="shift-filter" placeholder="Filter shifts by name" style="width:100%; margin-bottom:8px;" />
|
||||||
|
</div>
|
||||||
|
<div id="shifts-table">
|
||||||
{"<br/>".join(str(i) for i in self.shifts)}
|
{"<br/>".join(str(i) for i in self.shifts)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary><h2>Worker details</h2></summary>
|
<summary><h2>Worker details</h2></summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user