Files
proc-rota/djangorota/rota/forms.py
T
Ross f6dd9285a5 .
2025-12-12 22:31:07 +00:00

366 lines
16 KiB
Python

from django import forms
from django.forms import widgets
from .models import Worker, Leave, RotaSchedule
import importlib
import json
class WorkerForm(forms.ModelForm):
class Meta:
model = Worker
fields = ["name", "email", "site", "grade", "fte", "active"]
class LeaveForm(forms.ModelForm):
class Meta:
model = Leave
fields = ["start_date", "end_date", "reason"]
widgets = {
"start_date": widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}),
"end_date": widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}),
}
class RotaScheduleForm(forms.ModelForm):
class Meta:
model = RotaSchedule
# Do not expose end_date as editable: computed from start_date + weeks
fields = ["name", "start_date", "description"]
widgets = {
"start_date": widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}),
}
weeks = forms.IntegerField(min_value=1, initial=4, help_text="Number of weeks to generate the rota for")
end_date = forms.DateField(required=False, disabled=True, widget=widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}))
def __init__(self, *args, **kwargs):
instance = kwargs.get("instance")
super().__init__(*args, **kwargs)
if instance and instance.start_date and instance.end_date:
delta = instance.end_date - instance.start_date
weeks = max(1, delta.days // 7)
self.fields["weeks"].initial = weeks
self.fields["end_date"].initial = instance.end_date
# Dynamically add RotaBuilder constraint option fields using the
# typed `RotaConstraintOptions` metadata (types + descriptions).
constraint_defaults = {}
constraint_meta = {}
try:
mod = importlib.import_module("rota_generator.shifts")
RotaBuilder = getattr(mod, "RotaBuilder")
rb = RotaBuilder()
opts_model = getattr(rb, "constraint_options_model", None)
if opts_model is not None:
# default values
constraint_defaults = opts_model.model_dump()
# Try to extract field descriptions from Pydantic model_fields
try:
mf = opts_model.__class__.model_fields
for k, info in mf.items():
desc = None
# field info may expose 'description' attribute or mapping
if hasattr(info, "description") and info.description:
desc = info.description
else:
try:
desc = info.get("description")
except Exception:
desc = None
constraint_meta[k] = desc
except Exception:
constraint_meta = {}
except Exception:
constraint_defaults = {}
constraint_meta = {}
self._constraint_defaults = constraint_defaults
self._constraint_meta = constraint_meta
# Load existing options from instance if present
existing_opts = {}
if instance and getattr(instance, "options", None):
existing_opts = instance.options or {}
for key, default in constraint_defaults.items():
field_name = f"opt__{key}"
initial = existing_opts.get(key, default)
help_text = constraint_meta.get(key) if isinstance(constraint_meta, dict) else None
label = key.replace("_", " ")
if isinstance(default, bool):
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=label, help_text=help_text)
elif isinstance(default, int):
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=label, help_text=help_text)
elif isinstance(default, float):
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=label, help_text=help_text)
elif isinstance(default, (list, dict)):
# JSON text area for structured defaults
self.fields[field_name] = forms.CharField(required=False, initial=json.dumps(initial), widget=forms.Textarea, label=label, help_text=(help_text or "Enter JSON"))
else:
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=label, help_text=help_text)
# Expose a small set of RotaBuilder constructor arguments on the form
# so they can be set per-rota. These are stored inside `instance.options`
# and will be passed through by `RotaSchedule.to_rota_builder()` as
# legacy builder args (the remaining keys in `options` are treated as
# constraint configuration for `RotaConstraintOptions`).
builder_args = {
"balance_offset_modifier": {"type": "int", "default": 1, "help": "Balance offset modifier used by the builder"},
"ltft_balance_offset": {"type": "int", "default": 1, "help": "LTFT balance offset used by the builder"},
"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_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
"allow_force_assignment_with_leave_conflict": {"type": "bool", "default": False, "help": "Allow force assignments even when leave conflicts exist"},
}
for k, meta in builder_args.items():
field_name = f"opt__{k}"
initial = existing_opts.get(k, meta["default"])
if meta["type"] == "bool":
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
elif meta["type"] == "int":
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
else:
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
def clean_start_date(self):
start = self.cleaned_data.get("start_date")
if start is None:
return start
if start.weekday() != 0:
raise forms.ValidationError("Start date must be a Monday")
return start
def clean(self):
cleaned = super().clean()
start = cleaned.get("start_date")
weeks = cleaned.get("weeks")
if start and weeks:
import datetime
end_date = start + datetime.timedelta(days=weeks * 7)
cleaned["end_date"] = end_date
# update the form data/display
try:
self.data = self.data.copy()
self.data["end_date"] = end_date.isoformat()
except Exception:
pass
self.fields["end_date"].initial = end_date
return cleaned
def save(self, commit=True):
instance = super().save(commit=False)
end_date = self.cleaned_data.get("end_date")
if end_date:
instance.end_date = end_date
# Collect option fields and persist into instance.options
opts = instance.options or {}
# constraint-based option fields
for key in getattr(self, "_constraint_defaults", {}).keys():
field_name = f"opt__{key}"
if field_name in self.cleaned_data:
val = self.cleaned_data[field_name]
default = self._constraint_defaults.get(key)
# Parse JSON for list/dict fields
if isinstance(default, (list, dict)):
try:
parsed = json.loads(val) if val is not None and val != "" else []
except Exception:
parsed = val
opts[key] = parsed
else:
opts[key] = val
# builder args (prefixed with opt__)
builder_keys = [
"balance_offset_modifier",
"ltft_balance_offset",
"use_previous_shifts",
"use_shift_balance_extra",
"use_bank_holiday_extra",
"allow_force_assignment_with_leave_conflict",
]
for k in builder_keys:
field_name = f"opt__{k}"
if field_name in self.cleaned_data:
opts[k] = self.cleaned_data[field_name]
instance.options = opts
if commit:
instance.save()
try:
self.save_m2m()
except Exception:
pass
return instance
class RotaOptionsForm(forms.Form):
options_json = forms.CharField(
widget=forms.Textarea(attrs={"rows": 10, "class": "textarea"}),
required=False,
help_text="Edit rota options as JSON. See available constraint keys below.",
)
class RotaConstraintOptionsForm(forms.Form):
"""Dynamically generated form exposing typed `RotaConstraintOptions` fields.
Fields are created as `opt__{key}` to match existing modal rendering logic
and to allow reuse of the `rota_options` endpoint/modal. Saving via
`save_for_rota(rota)` will update only the constraint keys inside
`RotaSchedule.options`, leaving other keys (e.g. legacy builder args)
untouched.
"""
def __init__(self, *args, initial_options=None, **kwargs):
"""initial_options: dict of existing rota.options to populate current values."""
super().__init__(*args, **kwargs)
initial_options = initial_options or {}
# Try to import the typed model from the rota package. Prefer the
# RotaConstraintOptions class (no heavy instantiation) to avoid any
# side-effects of creating a RotaBuilder instance during form init.
opts_model = None
try:
mod = importlib.import_module("rota_generator.shifts")
RotaConstraintOptions = getattr(mod, "RotaConstraintOptions", None)
if RotaConstraintOptions is not None:
opts_model = RotaConstraintOptions()
else:
# Last-resort: try to instantiate RotaBuilder and read its
# constraint_options_model attribute (may have side-effects).
RotaBuilder = getattr(mod, "RotaBuilder", None)
if RotaBuilder is not None:
try:
rb = RotaBuilder()
opts_model = getattr(rb, "constraint_options_model", None)
except Exception:
opts_model = None
except Exception:
opts_model = None
self._constraint_defaults = {}
self._constraint_meta = {}
if opts_model is not None:
try:
defaults = opts_model.model_dump()
except Exception:
defaults = {}
self._constraint_defaults = defaults
# try to read field descriptions
try:
mf = opts_model.__class__.model_fields
except Exception:
mf = {}
for k, v in defaults.items():
field_name = f"opt__{k}"
help_text = None
if isinstance(mf, dict) and k in mf:
info = mf[k]
try:
if hasattr(info, "description") and info.description:
help_text = info.description
else:
help_text = info.get("description")
except Exception:
help_text = None
initial = initial_options.get(k, v)
# Map Python types to Django form fields
if isinstance(v, bool):
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=help_text)
elif isinstance(v, int):
# Allow None for Optional[int]
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=help_text)
elif isinstance(v, float):
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=k.replace("_", " "), help_text=help_text)
elif isinstance(v, (list, dict)):
# JSON textarea for structured values
try:
init_val = json.dumps(initial) if initial is not None else json.dumps(v)
except Exception:
init_val = ""
self.fields[field_name] = forms.CharField(required=False, initial=init_val, widget=forms.Textarea, label=k.replace("_", " "), help_text=(help_text or "Enter JSON"))
else:
self.fields[field_name] = forms.CharField(required=False, initial=initial if initial is not None else v, label=k.replace("_", " "), help_text=help_text)
def save_for_rota(self, rota):
"""Persist cleaned constraint fields into `rota.options`.
Only updates keys that are present in the typed constraint defaults.
For structured fields (JSON), attempt to parse into Python objects.
"""
opts = rota.options or {}
for key in self._constraint_defaults.keys():
field_name = f"opt__{key}"
if field_name in self.cleaned_data:
val = self.cleaned_data[field_name]
default = self._constraint_defaults.get(key)
if isinstance(default, (list, dict)):
try:
parsed = json.loads(val) if val is not None and val != "" else []
except Exception:
parsed = val
opts[key] = parsed
else:
# For optional int/float fields allow empty -> None
if val == "" or val is None:
opts[key] = None
else:
opts[key] = val
rota.options = opts
rota.save(update_fields=["options"])
class ShiftForm(forms.Form):
name = forms.CharField(max_length=200)
sites = forms.CharField(
help_text="Comma separated list of sites (e.g. exeter,plymouth)",
required=True,
)
length = forms.DecimalField(max_digits=6, decimal_places=2, initial=12.5)
DAYS = [(d, d) for d in ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]]
days = forms.MultipleChoiceField(choices=DAYS, widget=forms.CheckboxSelectMultiple)
workers_required = forms.IntegerField(min_value=1, initial=1)
assign_as_block = forms.BooleanField(required=False, initial=False)
balance_offset = forms.FloatField(
required=False,
help_text="Maximum allowed difference in allocated shifts for this shift (leave blank for default)",
)
# Allow entering shift-specific constraints as JSON. This should be a
# list of constraint dicts compatible with the scheduler's SingleShift
# `constraints` field. Example: `[{"name": "night", "options": {...}}]`
constraints = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
)
def clean_sites(self):
val = self.cleaned_data["sites"]
sites = [s.strip() for s in val.split(",") if s.strip()]
if not sites:
raise forms.ValidationError("At least one site is required")
return sites
def clean_constraints(self):
val = self.cleaned_data.get("constraints")
if not val:
return []
try:
parsed = json.loads(val)
if not isinstance(parsed, list):
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
return parsed
except forms.ValidationError:
raise
except Exception as exc:
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")