This commit is contained in:
Ross
2025-12-09 18:07:54 +00:00
parent 78b6ba2a3b
commit 724e3ab6bd
2 changed files with 146 additions and 14 deletions
+73 -11
View File
@@ -42,19 +42,40 @@ class RotaScheduleForm(forms.ModelForm):
weeks = max(1, delta.days // 7) weeks = max(1, delta.days // 7)
self.fields["weeks"].initial = weeks self.fields["weeks"].initial = weeks
self.fields["end_date"].initial = instance.end_date self.fields["end_date"].initial = instance.end_date
# Dynamically add RotaBuilder constraint option fields so these can be edited # Dynamically add RotaBuilder constraint option fields using the
# typed `RotaConstraintOptions` metadata (types + descriptions).
constraint_defaults = {}
constraint_meta = {}
try: try:
mod = importlib.import_module("rota_generator.shifts") mod = importlib.import_module("rota_generator.shifts")
RotaBuilder = getattr(mod, "RotaBuilder") RotaBuilder = getattr(mod, "RotaBuilder")
try: rb = RotaBuilder()
rb = RotaBuilder() opts_model = getattr(rb, "constraint_options_model", None)
constraint_defaults = getattr(rb, "constraint_options", {}) if opts_model is not None:
except Exception: # default values
constraint_defaults = {} 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: except Exception:
constraint_defaults = {} constraint_defaults = {}
constraint_meta = {}
self._constraint_defaults = constraint_defaults self._constraint_defaults = constraint_defaults
self._constraint_meta = constraint_meta
# Load existing options from instance if present # Load existing options from instance if present
existing_opts = {} existing_opts = {}
@@ -64,16 +85,41 @@ class RotaScheduleForm(forms.ModelForm):
for key, default in constraint_defaults.items(): for key, default in constraint_defaults.items():
field_name = f"opt__{key}" field_name = f"opt__{key}"
initial = existing_opts.get(key, default) 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): if isinstance(default, bool):
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=key.replace("_", " ")) self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=label, help_text=help_text)
elif isinstance(default, int): elif isinstance(default, int):
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=key.replace("_", " ")) self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=label, help_text=help_text)
elif isinstance(default, float): elif isinstance(default, float):
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=key.replace("_", " ")) self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=label, help_text=help_text)
elif isinstance(default, (list, dict)): elif isinstance(default, (list, dict)):
self.fields[field_name] = forms.CharField(required=False, initial=json.dumps(initial), widget=forms.Textarea, label=key.replace("_", " "), help_text="Enter JSON") # 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: else:
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=key.replace("_", " ")) 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): def clean_start_date(self):
start = self.cleaned_data.get("start_date") start = self.cleaned_data.get("start_date")
@@ -108,6 +154,7 @@ class RotaScheduleForm(forms.ModelForm):
instance.end_date = end_date instance.end_date = end_date
# Collect option fields and persist into instance.options # Collect option fields and persist into instance.options
opts = instance.options or {} opts = instance.options or {}
# constraint-based option fields
for key in getattr(self, "_constraint_defaults", {}).keys(): for key in getattr(self, "_constraint_defaults", {}).keys():
field_name = f"opt__{key}" field_name = f"opt__{key}"
if field_name in self.cleaned_data: if field_name in self.cleaned_data:
@@ -122,6 +169,21 @@ class RotaScheduleForm(forms.ModelForm):
opts[key] = parsed opts[key] = parsed
else: else:
opts[key] = val 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 instance.options = opts
if commit: if commit:
instance.save() instance.save()
+73 -3
View File
@@ -52,11 +52,46 @@ def rota_detail(request, rota_id):
except Exception: except Exception:
opt_json = "{}" opt_json = "{}"
# Build a richer representation of available constraints (default, type, description)
available_constraints_rich = {}
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:
defaults = opts_model.model_dump()
# attempt to get field descriptions from pydantic metadata
meta = {}
try:
meta = opts_model.__class__.model_fields
except Exception:
meta = {}
for k, v in defaults.items():
desc = None
if isinstance(meta, dict) and k in meta:
info = meta[k]
if hasattr(info, "description"):
desc = info.description
else:
try:
desc = info.get("description")
except Exception:
desc = None
# include the current value saved on the rota (if any)
try:
current = (rota.options or {}).get(k, v)
except Exception:
current = v
available_constraints_rich[k] = {"default": v, "description": desc, "current": current}
except Exception:
available_constraints_rich = {k: {"default": v} for k, v in available_constraints.items()}
options_form = RotaOptionsForm(initial={"options_json": opt_json}) options_form = RotaOptionsForm(initial={"options_json": opt_json})
return render( return render(
request, request,
"rota/rota_detail.html", "rota/rota_detail.html",
{"rota": rota, "options_form": options_form, "available_constraints": available_constraints}, {"rota": rota, "options_form": options_form, "available_constraints": available_constraints_rich},
) )
@@ -384,10 +419,45 @@ def rota_option_add(request, rota_id):
return redirect("rota:rota_detail", rota_id=rota.id) return redirect("rota:rota_detail", rota_id=rota.id)
# GET: show modal partial # GET: show modal partial. If a `key` query param is supplied, try to
# prefill the value and type from the builder defaults to make adding
# standard options easy.
key_q = request.GET.get("key")
initial = {"key": "", "value": "", "type": "string"}
if key_q:
try:
import importlib
mod = importlib.import_module("rota_generator.shifts")
RotaBuilder = getattr(mod, "RotaBuilder")
rb = RotaBuilder()
defaults = rb.constraint_options_model.model_dump()
if key_q in defaults:
d = defaults[key_q]
initial["key"] = key_q
if isinstance(d, bool):
initial["type"] = "bool"
initial["value"] = "1" if d else "0"
elif isinstance(d, int):
initial["type"] = "int"
initial["value"] = str(d)
elif isinstance(d, float):
initial["type"] = "float"
initial["value"] = str(d)
elif isinstance(d, (list, dict)):
initial["type"] = "json"
try:
initial["value"] = json.dumps(d)
except Exception:
initial["value"] = ""
else:
initial["type"] = "string"
initial["value"] = str(d)
except Exception:
pass
modal_html = render_to_string( modal_html = render_to_string(
"rota/partials/rota_option_modal_single.html", "rota/partials/rota_option_modal_single.html",
{"form_action": reverse("rota:rota_option_add", args=(rota.id,)), "initial": {"key": "", "value": "", "type": "string"}, "rota": rota}, {"form_action": reverse("rota:rota_option_add", args=(rota.id,)), "initial": initial, "rota": rota},
request=request, request=request,
) )
return HttpResponse(modal_html) return HttpResponse(modal_html)