From 724e3ab6bd10600ade4c801c332b55a0bb69eb67 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 9 Dec 2025 18:07:54 +0000 Subject: [PATCH] . --- djangorota/rota/forms.py | 84 ++++++++++++++++++++++++++++++++++------ djangorota/rota/views.py | 76 ++++++++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 14 deletions(-) diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py index ebb943b..4860b4e 100644 --- a/djangorota/rota/forms.py +++ b/djangorota/rota/forms.py @@ -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() diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 7739a59..0c5c0bd 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -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)