.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user