227 lines
10 KiB
Python
227 lines
10 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()`.
|
|
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 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)",
|
|
)
|
|
|
|
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
|