From a89aa0a35b391734816dbcc6195819134b68e62c Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 13 Dec 2025 22:07:07 +0000 Subject: [PATCH] . --- .../rota/partials/worker_form_modal.html | 112 ++++++++ djangorota/rota/forms.py | 258 ++++++++++++++++++ .../rota/migrations/0004_worker_options.py | 18 ++ djangorota/rota/models.py | 19 ++ 4 files changed, 407 insertions(+) create mode 100644 djangorota/rota/migrations/0004_worker_options.py diff --git a/djangorota/djangorota/templates/rota/partials/worker_form_modal.html b/djangorota/djangorota/templates/rota/partials/worker_form_modal.html index b0dc5b9..c555f4e 100644 --- a/djangorota/djangorota/templates/rota/partials/worker_form_modal.html +++ b/djangorota/djangorota/templates/rota/partials/worker_form_modal.html @@ -14,6 +14,118 @@ {% endif %} {{ form|crispy }} + + +
+

Non-working days

+

Add non-working day rules. These will be serialized to the JSON field.

+
+
+ +
+
+ +
diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py index 8d9b035..ead17c3 100644 --- a/djangorota/rota/forms.py +++ b/djangorota/rota/forms.py @@ -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: diff --git a/djangorota/rota/migrations/0004_worker_options.py b/djangorota/rota/migrations/0004_worker_options.py new file mode 100644 index 0000000..b64e8e9 --- /dev/null +++ b/djangorota/rota/migrations/0004_worker_options.py @@ -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), + ), + ] diff --git a/djangorota/rota/models.py b/djangorota/rota/models.py index ba22f3f..0ff0619 100644 --- a/djangorota/rota/models.py +++ b/djangorota/rota/models.py @@ -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)