.
This commit is contained in:
+73
-11
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user