.
This commit is contained in:
+73
-11
@@ -42,19 +42,40 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
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 so these can be edited
|
||||
# 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")
|
||||
try:
|
||||
rb = RotaBuilder()
|
||||
constraint_defaults = getattr(rb, "constraint_options", {})
|
||||
except Exception:
|
||||
constraint_defaults = {}
|
||||
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 = {}
|
||||
@@ -64,16 +85,41 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
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=key.replace("_", " "))
|
||||
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=key.replace("_", " "))
|
||||
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=key.replace("_", " "))
|
||||
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=label, help_text=help_text)
|
||||
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:
|
||||
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):
|
||||
start = self.cleaned_data.get("start_date")
|
||||
@@ -108,6 +154,7 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
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:
|
||||
@@ -122,6 +169,21 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
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()
|
||||
|
||||
@@ -52,11 +52,46 @@ def rota_detail(request, rota_id):
|
||||
except Exception:
|
||||
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})
|
||||
return render(
|
||||
request,
|
||||
"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)
|
||||
|
||||
# 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(
|
||||
"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,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
Reference in New Issue
Block a user