.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user