This commit is contained in:
Ross
2025-12-13 22:07:07 +00:00
parent bd5ca8f64c
commit a89aa0a35b
4 changed files with 407 additions and 0 deletions
@@ -14,6 +14,118 @@
<input type="hidden" name="rota_id" value="{{ rota_id }}" />
{% endif %}
{{ form|crispy }}
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
<div class="box" id="nwds-editor">
<h4 class="title is-6">Non-working days</h4>
<p class="help">Add non-working day rules. These will be serialized to the JSON field.</p>
<div id="nwds-list"></div>
<div style="margin-top:0.5em;">
<button type="button" class="button is-small" id="add-nwd">Add non-working day</button>
</div>
</div>
<script>
(function(){
const textarea = document.getElementById('id_nwds');
// hide the raw JSON textarea to avoid confusing users; editor will keep it in sync
if(textarea){ textarea.style.display = 'none'; }
const listEl = document.getElementById('nwds-list');
const addBtn = document.getElementById('add-nwd');
const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
function parseInitial(){
let val = textarea ? textarea.value : '';
if(!val) return [];
try{ const p = JSON.parse(val); return Array.isArray(p)?p:[]; }catch(e){ return []; }
}
function render(){
listEl.innerHTML = '';
const data = parseInitial();
data.forEach((item, idx)=>{
listEl.appendChild(renderRow(item, idx));
});
syncTextarea();
}
function renderRow(item, idx){
const wrap = document.createElement('div'); wrap.className='box';
const row = document.createElement('div'); row.style.display='flex'; row.style.gap='0.5em'; row.style.alignItems='flex-end';
// day select
const dayDiv = document.createElement('div'); dayDiv.className='field';
const dayLabel = document.createElement('label'); dayLabel.className='label'; dayLabel.textContent='Day'; dayDiv.appendChild(dayLabel);
const control = document.createElement('div'); control.className='control';
// Bulma expects a wrapper div.select containing the <select>
const selWrap = document.createElement('div'); selWrap.className='select'; selWrap.style.minWidth='120px';
const inner = document.createElement('select');
const empty = document.createElement('option'); empty.value=''; empty.text='(any)'; inner.appendChild(empty);
DAYS.forEach(d=>{ const o=document.createElement('option'); o.value=d; o.text=d; inner.appendChild(o); });
inner.value = item.day || '';
inner.onchange = syncAll;
selWrap.appendChild(inner);
control.appendChild(selWrap);
dayDiv.appendChild(control);
row.appendChild(dayDiv);
// start date
const sdWrap = document.createElement('div'); sdWrap.className='field';
const sdLabel = document.createElement('label'); sdLabel.className='label'; sdLabel.textContent='Start date'; sdWrap.appendChild(sdLabel);
const sdControl = document.createElement('div'); sdControl.className='control';
const sdInput = document.createElement('input'); sdInput.type='date'; sdInput.className='datepicker input'; sdInput.setAttribute('autocomplete','off'); sdInput.value = item.start_date || '';
sdInput.onchange = syncAll;
sdControl.appendChild(sdInput); sdWrap.appendChild(sdControl); row.appendChild(sdWrap);
// end date
const edWrap = document.createElement('div'); edWrap.className='field';
const edLabel = document.createElement('label'); edLabel.className='label'; edLabel.textContent='End date'; edWrap.appendChild(edLabel);
const edControl = document.createElement('div'); edControl.className='control';
const edInput = document.createElement('input'); edInput.type='date'; edInput.className='datepicker input'; edInput.setAttribute('autocomplete','off'); edInput.value = item.end_date || '';
edInput.onchange = syncAll;
edControl.appendChild(edInput); edWrap.appendChild(edControl); row.appendChild(edWrap);
// remove button
const rmDiv = document.createElement('div'); rmDiv.className='field';
const rmLabel = document.createElement('label'); rmLabel.className='label'; rmLabel.textContent=''; rmDiv.appendChild(rmLabel);
const rmControl = document.createElement('div'); rmControl.className='control';
const rmBtn = document.createElement('button'); rmBtn.type='button'; rmBtn.className='button is-danger is-light is-small'; rmBtn.textContent='Remove';
rmBtn.onclick = ()=>{ removeAt(idx); };
rmControl.appendChild(rmBtn); rmDiv.appendChild(rmControl); row.appendChild(rmDiv);
wrap.appendChild(row);
// initialize flatpickr for these newly added inputs if available
try{ if(window.initFlatpickr) initFlatpickr(wrap); }catch(e){}
function syncAll(){
const data = parseInitial();
const dayVal = inner.value || null;
const sd = sdInput.value || null;
const ed = edInput.value || null;
const obj = {};
if(dayVal) obj.day = dayVal;
if(sd) obj.start_date = sd;
if(ed) obj.end_date = ed;
data[idx] = obj;
textarea.value = JSON.stringify(data);
}
// expose helper
wrap._sync = syncAll;
return wrap;
}
function removeAt(i){ const data = parseInitial(); data.splice(i,1); textarea.value = JSON.stringify(data); render(); }
addBtn.addEventListener('click', ()=>{ const data = parseInitial(); data.push({}); textarea.value = JSON.stringify(data); render(); });
// initial render
try{ render(); }catch(e){ console && console.error && console.error(e); }
})();
</script>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save</button>
+258
View File
@@ -5,12 +5,270 @@ from .models import Worker, Leave, RotaSchedule
import importlib
import json
# Try to import typed helper models from rota_generator to validate complex
# worker option structures. If unavailable, fall back to permissive behavior.
try:
from rota_generator.workers import (
NonWorkingDays,
OutOfProgramme,
NotAvailableToWork,
PreferenceNotToWork,
WorkRequests,
HardDayDependency,
HardDayExclusion,
)
except Exception:
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
WorkRequests = HardDayDependency = HardDayExclusion = None
class WorkerForm(forms.ModelForm):
# Expose core model fields plus a set of richer worker options. Complex
# structures (lists/dicts) are edited as JSON in textareas; common simple
# options are provided as dedicated form controls for convenience.
class Meta:
model = Worker
fields = ["name", "email", "site", "grade", "fte", "active"]
# Simple option fields
locum = forms.BooleanField(required=False, label="Locum")
locum_max_shifts = forms.IntegerField(required=False, label="Locum max shifts")
locum_max_shifts_per_week = forms.IntegerField(required=False, label="Locum max shifts per week")
locum_on_nwds = forms.BooleanField(required=False, label="Locum on non-working days")
remote_site = forms.CharField(required=False, label="Remote site")
pair = forms.CharField(required=False, label="Pair")
bank_holiday_extra = forms.IntegerField(required=False, label="Bank holiday extra")
weekend_shift_target_number = forms.IntegerField(required=False, label="Weekend shift target number")
prefer_multi_shift_together = forms.IntegerField(required=False, label="Prefer multi-shift together weight")
max_days_worked_per_week = forms.IntegerField(required=False, label="Max days worked per week")
# Complex structured options edited as JSON
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
hard_day_dependencies = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of HardDayDependency objects", label="Hard day dependencies (JSON)")
hard_day_exclusions = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of HardDayExclusion objects", label="Hard day exclusions (JSON)")
force_assign_with = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping of shift->list", label="Force assign with (JSON)")
max_shifts_per_week_by_shift_name = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->max", label="Max shifts per week by shift (JSON)")
forced_assignments = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of tuples (week, day, shift)", label="Forced assignments (JSON)")
forced_assignments_by_date = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of tuples (date, shift)", label="Forced assignments by date (JSON)")
# Availability / request lists
nwds = forms.CharField(required=False, widget=forms.Textarea, help_text="Non-working days JSON list", label="Non-working days (JSON)")
oop = forms.CharField(required=False, widget=forms.Textarea, help_text="Out of programme JSON list", label="Out of programme (JSON)")
not_available_to_work = forms.CharField(required=False, widget=forms.Textarea, help_text="Not-available JSON list", label="Not available to work (JSON)")
pref_not_to_work = forms.CharField(required=False, widget=forms.Textarea, help_text="Preference not to work JSON list", label="Preferred not to work (JSON)")
work_requests = forms.CharField(required=False, widget=forms.Textarea, help_text="Work requests JSON list", label="Work requests (JSON)")
locum_availability = forms.CharField(required=False, widget=forms.Textarea, help_text="Locum availability JSON list", label="Locum availability (JSON)")
def __init__(self, *args, **kwargs):
instance = kwargs.get("instance")
super().__init__(*args, **kwargs)
# Populate initial values for the extra fields from instance.options
initial_opts = {}
if instance is not None:
try:
initial_opts = instance.options or {}
except Exception:
initial_opts = {}
# Simple field initialisation
for fld in [
"locum",
"locum_max_shifts",
"locum_max_shifts_per_week",
"locum_on_nwds",
"remote_site",
"pair",
"bank_holiday_extra",
"weekend_shift_target_number",
"prefer_multi_shift_together",
"max_days_worked_per_week",
]:
if fld in self.fields:
self.fields[fld].initial = initial_opts.get(fld, self.fields[fld].initial)
# JSON fields: pretty-print if present
for json_fld in [
"assign_as_block_preferences",
"shift_fte_overrides",
"previous_shifts",
"shift_balance_extra",
"allowed_multi_shift_sets",
"hard_day_dependencies",
"hard_day_exclusions",
"force_assign_with",
"max_shifts_per_week_by_shift_name",
"forced_assignments",
"forced_assignments_by_date",
"nwds",
"oop",
"not_available_to_work",
"pref_not_to_work",
"work_requests",
"locum_availability",
]:
if json_fld in self.fields:
val = initial_opts.get(json_fld, None)
if val is None:
self.fields[json_fld].initial = ""
else:
try:
self.fields[json_fld].initial = json.dumps(val, indent=2, default=str)
except Exception:
self.fields[json_fld].initial = str(val)
def save(self, commit=True):
"""Save Worker model and persist extra options into `instance.options`."""
instance = super().save(commit=False)
opts = instance.options or {}
# Simple options
simple_opts = [
"locum",
"locum_max_shifts",
"locum_max_shifts_per_week",
"locum_on_nwds",
"remote_site",
"pair",
"bank_holiday_extra",
"weekend_shift_target_number",
"prefer_multi_shift_together",
"max_days_worked_per_week",
]
for k in simple_opts:
if k in self.cleaned_data:
opts[k] = self.cleaned_data.get(k)
# JSON fields: parse where possible
json_fields = [
"assign_as_block_preferences",
"shift_fte_overrides",
"previous_shifts",
"shift_balance_extra",
"allowed_multi_shift_sets",
"hard_day_dependencies",
"hard_day_exclusions",
"force_assign_with",
"max_shifts_per_week_by_shift_name",
"forced_assignments",
"forced_assignments_by_date",
"nwds",
"oop",
"not_available_to_work",
"pref_not_to_work",
"work_requests",
"locum_availability",
]
for k in json_fields:
if k in self.cleaned_data:
val = self.cleaned_data.get(k)
if val is None or val == "":
opts[k] = []
else:
try:
parsed = json.loads(val)
except Exception:
parsed = val
opts[k] = parsed
instance.options = opts
if commit:
instance.save()
try:
self.save_m2m()
except Exception:
pass
return instance
def clean(self):
"""Validate and parse typed JSON worker option fields when possible.
This will parse JSON strings into Python structures and, where we can
import the typed helper models from `rota_generator.workers`, will
validate each list item against the corresponding Pydantic model.
"""
cleaned = super().clean()
def _parse_and_validate(field_name, model_cls=None):
val = cleaned.get(field_name)
if val is None or val == "":
return []
# If already parsed (not a str), accept it
if not isinstance(val, str):
parsed = val
else:
try:
parsed = json.loads(val)
except Exception:
# leave as raw string to preserve old behaviour
parsed = val
# If a model class is provided, validate each element
if model_cls and parsed and isinstance(parsed, (list, tuple)):
validated = []
for i, item in enumerate(parsed):
try:
# If item is already an instance, accept it
if hasattr(model_cls, "model_dump") or hasattr(model_cls, "__fields__"):
# pydantic v2 or v1 compatible
validated.append(model_cls(**item) if isinstance(item, dict) else model_cls.parse_obj(item) if hasattr(model_cls, 'parse_obj') else model_cls(**item))
else:
validated.append(item)
except Exception as e:
self.add_error(field_name, forms.ValidationError(f"Invalid item in {field_name}[{i}]: {e}"))
validated.append(item)
return validated
return parsed
# Typed list fields
cleaned["nwds"] = _parse_and_validate("nwds", NonWorkingDays)
cleaned["oop"] = _parse_and_validate("oop", OutOfProgramme)
cleaned["not_available_to_work"] = _parse_and_validate("not_available_to_work", NotAvailableToWork)
cleaned["pref_not_to_work"] = _parse_and_validate("pref_not_to_work", PreferenceNotToWork)
cleaned["work_requests"] = _parse_and_validate("work_requests", WorkRequests)
cleaned["locum_availability"] = _parse_and_validate("locum_availability", WorkRequests)
# Hard day dependency/exclusion structures
cleaned["hard_day_dependencies"] = _parse_and_validate("hard_day_dependencies", HardDayDependency)
cleaned["hard_day_exclusions"] = _parse_and_validate("hard_day_exclusions", HardDayExclusion)
# forced assignments: expect list of tuples [week, day, shift]
fa = cleaned.get("forced_assignments")
if isinstance(fa, str):
try:
fa_parsed = json.loads(fa)
except Exception:
fa_parsed = fa
else:
fa_parsed = fa
if isinstance(fa_parsed, list):
# basic validation
for i, t in enumerate(fa_parsed):
if not (isinstance(t, (list, tuple)) and len(t) == 3):
self.add_error("forced_assignments", forms.ValidationError(f"forced_assignments[{i}] must be a tuple/list of (week, day, shift)"))
cleaned["forced_assignments"] = fa_parsed
# forced_assignments_by_date: expect list of tuples [date, shift]
fabd = cleaned.get("forced_assignments_by_date")
if isinstance(fabd, str):
try:
fabd_parsed = json.loads(fabd)
except Exception:
fabd_parsed = fabd
else:
fabd_parsed = fabd
if isinstance(fabd_parsed, list):
for i, t in enumerate(fabd_parsed):
if not (isinstance(t, (list, tuple)) and len(t) == 2):
self.add_error("forced_assignments_by_date", forms.ValidationError(f"forced_assignments_by_date[{i}] must be a tuple/list of (date, shift)"))
cleaned["forced_assignments_by_date"] = fabd_parsed
return cleaned
class LeaveForm(forms.ModelForm):
class Meta:
@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-13 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rota", "0003_rotarun_export_html"),
]
operations = [
migrations.AddField(
model_name="worker",
name="options",
field=models.JSONField(blank=True, default=dict),
),
]
+19
View File
@@ -122,6 +122,12 @@ class Worker(models.Model):
fte = models.FloatField(default=1.0)
active = models.BooleanField(default=True)
# Store additional worker settings / metadata that map to the
# pydantic `Worker` model used by the rota generator. This allows the
# web UI to expose richer worker options without changing the core
# Django schema for frequently used fields.
options = models.JSONField(default=dict, blank=True)
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
def __str__(self):
@@ -147,6 +153,19 @@ class Worker(models.Model):
"fte": int(self.fte),
}
# Merge any additional options stored on the Django model into the
# kwargs passed to the pydantic Worker model. The pydantic model will
# honour its own defaults and types; storing these as a dict keeps the
# Django DB schema simple while exposing full worker settings in the UI.
try:
opts = getattr(self, "options", None) or {}
if isinstance(opts, dict):
# shallow merge; keys in `options` override defaults above when present
pyd_kwargs.update(opts)
except Exception:
# ignore any issues reading options and proceed with core fields
pass
return PydWorker(**pyd_kwargs)