This commit is contained in:
Ross
2025-12-09 21:35:38 +00:00
parent 2de9986149
commit d28b441edd
3 changed files with 149 additions and 64 deletions
@@ -57,40 +57,14 @@
</div>
<div class="mb-4">
<h2 class="subtitle">Rota Settings</h2>
<h2 class="subtitle">Constraint options</h2>
<div class="box">
<p class="help">Configure rota constraint options (typed via <code>RotaConstraintOptions</code>) and a few builder args. Change values and click Save to persist.</p>
{% if options_form %}
<form method="post" hx-post="{% url 'rota:rota_options' rota.id %}" hx-swap="none">
{% csrf_token %}
<div class="columns is-multiline">
{% for field in options_form.visible_fields %}
{% if field.name|slice:":5" == "opt__" %}
<div class="column is-half">
<div class="field">
<label class="label">{{ field.label }}</label>
<div class="control">{{ field }}</div>
{% if field.help_text %}<p class="help">{{ field.help_text }}</p>{% endif %}
{% for err in field.errors %}<p class="help is-danger">{{ err }}</p>{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Save constraint options</button>
</div>
</div>
</form>
{% else %}
<p class="help">Constraint form unavailable.</p>
{% endif %}
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
<p>
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
</p>
<hr />
<h3 class="subtitle is-6">Available constraint keys</h3>
<div style="max-height:400px; overflow:auto;">
<div style="max-height:300px; overflow:auto;">
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr>
@@ -106,14 +80,7 @@
<td><code>{{ key }}</code></td>
<td><pre style="white-space:pre-wrap;">{{ info.default|default:"" }}</pre></td>
<td><pre style="white-space:pre-wrap;">{{ info.current|default:"" }}</pre></td>
<td>
{{ info.description|default:"" }}
{% if info.current is None or info.current == "" %}
<div style="margin-top:0.4em;">
<button class="button is-small is-light" hx-get="{% url 'rota:rota_option_add' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Add default</button>
</div>
{% endif %}
</td>
<td>{{ info.description|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No constraint metadata available</td></tr>
@@ -124,6 +91,8 @@
</div>
</div>
<form method="post">
{% csrf_token %}
<div class="field is-grouped is-grouped-multiline">
+114
View File
@@ -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(
+26 -24
View File
@@ -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},