diff --git a/djangorota/djangorota/templates/rota/rota_detail.html b/djangorota/djangorota/templates/rota/rota_detail.html index 2d89bb8..b57f36c 100644 --- a/djangorota/djangorota/templates/rota/rota_detail.html +++ b/djangorota/djangorota/templates/rota/rota_detail.html @@ -57,40 +57,14 @@
-

Rota Settings

+

Constraint options

-

Configure rota constraint options (typed via RotaConstraintOptions) and a few builder args. Change values and click Save to persist.

- - {% if options_form %} -
- {% csrf_token %} -
- {% for field in options_form.visible_fields %} - {% if field.name|slice:":5" == "opt__" %} -
-
- -
{{ field }}
- {% if field.help_text %}

{{ field.help_text }}

{% endif %} - {% for err in field.errors %}

{{ err }}

{% endfor %} -
-
- {% endif %} - {% endfor %} -
-
-
- -
-
-
- {% else %} -

Constraint form unavailable.

- {% endif %} - +

Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.

+

+ +


-

Available constraint keys

-
+
@@ -106,14 +80,7 @@ - + {% empty %} @@ -124,6 +91,8 @@ + + {% csrf_token %}
diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py index 452a9fd..a7e3470 100644 --- a/djangorota/rota/forms.py +++ b/djangorota/rota/forms.py @@ -204,6 +204,120 @@ class RotaOptionsForm(forms.Form): ) +class RotaConstraintOptionsForm(forms.Form): + """Dynamically generated form exposing typed `RotaConstraintOptions` fields. + + Fields are created as `opt__{key}` to match existing modal rendering logic + and to allow reuse of the `rota_options` endpoint/modal. Saving via + `save_for_rota(rota)` will update only the constraint keys inside + `RotaSchedule.options`, leaving other keys (e.g. legacy builder args) + untouched. + """ + + def __init__(self, *args, initial_options=None, **kwargs): + """initial_options: dict of existing rota.options to populate current values.""" + super().__init__(*args, **kwargs) + initial_options = initial_options or {} + + # Try to import the typed model from the rota package. Prefer the + # RotaConstraintOptions class (no heavy instantiation) to avoid any + # side-effects of creating a RotaBuilder instance during form init. + opts_model = None + try: + mod = importlib.import_module("rota_generator.shifts") + RotaConstraintOptions = getattr(mod, "RotaConstraintOptions", None) + if RotaConstraintOptions is not None: + opts_model = RotaConstraintOptions() + else: + # Last-resort: try to instantiate RotaBuilder and read its + # constraint_options_model attribute (may have side-effects). + RotaBuilder = getattr(mod, "RotaBuilder", None) + if RotaBuilder is not None: + try: + rb = RotaBuilder() + opts_model = getattr(rb, "constraint_options_model", None) + except Exception: + opts_model = None + except Exception: + opts_model = None + + self._constraint_defaults = {} + self._constraint_meta = {} + + if opts_model is not None: + try: + defaults = opts_model.model_dump() + except Exception: + defaults = {} + self._constraint_defaults = defaults + # try to read field descriptions + try: + mf = opts_model.__class__.model_fields + except Exception: + mf = {} + + for k, v in defaults.items(): + field_name = f"opt__{k}" + help_text = None + if isinstance(mf, dict) and k in mf: + info = mf[k] + try: + if hasattr(info, "description") and info.description: + help_text = info.description + else: + help_text = info.get("description") + except Exception: + help_text = None + + initial = initial_options.get(k, v) + + # Map Python types to Django form fields + if isinstance(v, bool): + self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=help_text) + elif isinstance(v, int): + # Allow None for Optional[int] + self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=help_text) + elif isinstance(v, float): + self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=k.replace("_", " "), help_text=help_text) + elif isinstance(v, (list, dict)): + # JSON textarea for structured values + try: + init_val = json.dumps(initial) if initial is not None else json.dumps(v) + except Exception: + init_val = "" + self.fields[field_name] = forms.CharField(required=False, initial=init_val, widget=forms.Textarea, label=k.replace("_", " "), help_text=(help_text or "Enter JSON")) + else: + self.fields[field_name] = forms.CharField(required=False, initial=initial if initial is not None else v, label=k.replace("_", " "), help_text=help_text) + + def save_for_rota(self, rota): + """Persist cleaned constraint fields into `rota.options`. + + Only updates keys that are present in the typed constraint defaults. + For structured fields (JSON), attempt to parse into Python objects. + """ + opts = rota.options or {} + for key in 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) + 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: + # For optional int/float fields allow empty -> None + if val == "" or val is None: + opts[key] = None + else: + opts[key] = val + + rota.options = opts + rota.save(update_fields=["options"]) + + class ShiftForm(forms.Form): name = forms.CharField(max_length=200) sites = forms.CharField( diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index c347a60..fe44f3e 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -59,23 +59,24 @@ def rota_detail(request, rota_id): 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} + # iterate defaults and build rich info dict + for k, v in defaults.items(): + desc = None + if isinstance(meta, dict) and k in meta: + info = meta[k] + if hasattr(info, "description") and 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()} @@ -349,10 +350,10 @@ def rota_options(request, rota_id): return _oob_update_options_display(request, rota) return redirect(reverse("rota:rota_detail", args=(rota.id,))) - # Typed form submission - from .forms import RotaScheduleForm + # Typed form submission for constraint options only + from .forms import RotaConstraintOptionsForm - form = RotaScheduleForm(request.POST, instance=rota) + form = RotaConstraintOptionsForm(request.POST, initial_options=rota.options) if not form.is_valid(): if is_hx: # Re-render modal with errors @@ -364,14 +365,15 @@ def rota_options(request, rota_id): return HttpResponse(modal_html) return HttpResponseBadRequest("Invalid form submission") - form.save() + # Persist only constraint keys into rota.options + form.save_for_rota(rota) if is_hx: return _oob_update_options_display(request, rota) return redirect(reverse("rota:rota_detail", args=(rota.id,))) - # GET: render modal with typed form populated from the rota instance - from .forms import RotaScheduleForm - form = RotaScheduleForm(instance=rota) + # GET: render modal with typed constraint form populated from the rota instance + from .forms import RotaConstraintOptionsForm + form = RotaConstraintOptionsForm(initial_options=rota.options) modal_html = render_to_string( "rota/partials/rota_options_modal.html", {"form_action": reverse("rota:rota_options", args=(rota.id,)), "form": form, "rota": rota},
{{ key }}
{{ info.default|default:"" }}
{{ info.current|default:"" }}
- {{ info.description|default:"" }} - {% if info.current is None or info.current == "" %} -
- -
- {% endif %} -
{{ info.description|default:"" }}
No constraint metadata available