.
This commit is contained in:
@@ -57,40 +57,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h2 class="subtitle">Rota Settings</h2>
|
<h2 class="subtitle">Constraint options</h2>
|
||||||
<div class="box">
|
<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>
|
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
|
||||||
|
<p>
|
||||||
{% if options_form %}
|
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
|
||||||
<form method="post" hx-post="{% url 'rota:rota_options' rota.id %}" hx-swap="none">
|
</p>
|
||||||
{% 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 %}
|
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<h3 class="subtitle is-6">Available constraint keys</h3>
|
<div style="max-height:300px; overflow:auto;">
|
||||||
<div style="max-height:400px; overflow:auto;">
|
|
||||||
<table class="table is-fullwidth is-striped is-narrow">
|
<table class="table is-fullwidth is-striped is-narrow">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -106,14 +80,7 @@
|
|||||||
<td><code>{{ key }}</code></td>
|
<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.default|default:"" }}</pre></td>
|
||||||
<td><pre style="white-space:pre-wrap;">{{ info.current|default:"" }}</pre></td>
|
<td><pre style="white-space:pre-wrap;">{{ info.current|default:"" }}</pre></td>
|
||||||
<td>
|
<td>{{ info.description|default:"" }}</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="4">No constraint metadata available</td></tr>
|
<tr><td colspan="4">No constraint metadata available</td></tr>
|
||||||
@@ -124,6 +91,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="field is-grouped is-grouped-multiline">
|
<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):
|
class ShiftForm(forms.Form):
|
||||||
name = forms.CharField(max_length=200)
|
name = forms.CharField(max_length=200)
|
||||||
sites = forms.CharField(
|
sites = forms.CharField(
|
||||||
|
|||||||
+26
-24
@@ -59,23 +59,24 @@ def rota_detail(request, rota_id):
|
|||||||
meta = opts_model.__class__.model_fields
|
meta = opts_model.__class__.model_fields
|
||||||
except Exception:
|
except Exception:
|
||||||
meta = {}
|
meta = {}
|
||||||
for k, v in defaults.items():
|
# iterate defaults and build rich info dict
|
||||||
desc = None
|
for k, v in defaults.items():
|
||||||
if isinstance(meta, dict) and k in meta:
|
desc = None
|
||||||
info = meta[k]
|
if isinstance(meta, dict) and k in meta:
|
||||||
if hasattr(info, "description"):
|
info = meta[k]
|
||||||
desc = info.description
|
if hasattr(info, "description") and info.description:
|
||||||
else:
|
desc = info.description
|
||||||
try:
|
else:
|
||||||
desc = info.get("description")
|
try:
|
||||||
except Exception:
|
desc = info.get("description")
|
||||||
desc = None
|
except Exception:
|
||||||
# include the current value saved on the rota (if any)
|
desc = None
|
||||||
try:
|
# include the current value saved on the rota (if any)
|
||||||
current = (rota.options or {}).get(k, v)
|
try:
|
||||||
except Exception:
|
current = (rota.options or {}).get(k, v)
|
||||||
current = v
|
except Exception:
|
||||||
available_constraints_rich[k] = {"default": v, "description": desc, "current": current}
|
current = v
|
||||||
|
available_constraints_rich[k] = {"default": v, "description": desc, "current": current}
|
||||||
except Exception:
|
except Exception:
|
||||||
available_constraints_rich = {k: {"default": v} for k, v in available_constraints.items()}
|
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 _oob_update_options_display(request, rota)
|
||||||
return redirect(reverse("rota:rota_detail", args=(rota.id,)))
|
return redirect(reverse("rota:rota_detail", args=(rota.id,)))
|
||||||
|
|
||||||
# Typed form submission
|
# Typed form submission for constraint options only
|
||||||
from .forms import RotaScheduleForm
|
from .forms import RotaConstraintOptionsForm
|
||||||
|
|
||||||
form = RotaScheduleForm(request.POST, instance=rota)
|
form = RotaConstraintOptionsForm(request.POST, initial_options=rota.options)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
if is_hx:
|
if is_hx:
|
||||||
# Re-render modal with errors
|
# Re-render modal with errors
|
||||||
@@ -364,14 +365,15 @@ def rota_options(request, rota_id):
|
|||||||
return HttpResponse(modal_html)
|
return HttpResponse(modal_html)
|
||||||
return HttpResponseBadRequest("Invalid form submission")
|
return HttpResponseBadRequest("Invalid form submission")
|
||||||
|
|
||||||
form.save()
|
# Persist only constraint keys into rota.options
|
||||||
|
form.save_for_rota(rota)
|
||||||
if is_hx:
|
if is_hx:
|
||||||
return _oob_update_options_display(request, rota)
|
return _oob_update_options_display(request, rota)
|
||||||
return redirect(reverse("rota:rota_detail", args=(rota.id,)))
|
return redirect(reverse("rota:rota_detail", args=(rota.id,)))
|
||||||
|
|
||||||
# GET: render modal with typed form populated from the rota instance
|
# GET: render modal with typed constraint form populated from the rota instance
|
||||||
from .forms import RotaScheduleForm
|
from .forms import RotaConstraintOptionsForm
|
||||||
form = RotaScheduleForm(instance=rota)
|
form = RotaConstraintOptionsForm(initial_options=rota.options)
|
||||||
modal_html = render_to_string(
|
modal_html = render_to_string(
|
||||||
"rota/partials/rota_options_modal.html",
|
"rota/partials/rota_options_modal.html",
|
||||||
{"form_action": reverse("rota:rota_options", args=(rota.id,)), "form": form, "rota": rota},
|
{"form_action": reverse("rota:rota_options", args=(rota.id,)), "form": form, "rota": rota},
|
||||||
|
|||||||
Reference in New Issue
Block a user