Compare commits
8
Commits
ecd09f3701
...
1565241de6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1565241de6 | ||
|
|
d8ef96c486 | ||
|
|
1ce1b75739 | ||
|
|
4011fa3627 | ||
|
|
d28b441edd | ||
|
|
2de9986149 | ||
|
|
c94c204d51 | ||
|
|
3906ab3c55 |
@@ -0,0 +1,43 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<tr id="constraint-row-{{ initial.key|default:"new" }}">
|
||||
<td colspan="3">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#constraint-row-{{ initial.key|default:"new" }}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{% if error %}
|
||||
<div class="notification is-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div class="field">
|
||||
<label class="label">Key</label>
|
||||
<div class="control">
|
||||
<input class="input" name="key" type="text" value="{{ initial.key }}" {% if initial.key %}readonly{% endif %} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Type</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="type">
|
||||
<option value="string" {% if initial.type == 'string' %}selected{% endif %}>String</option>
|
||||
<option value="int" {% if initial.type == 'int' %}selected{% endif %}>Integer</option>
|
||||
<option value="float" {% if initial.type == 'float' %}selected{% endif %}>Float</option>
|
||||
<option value="bool" {% if initial.type == 'bool' %}selected{% endif %}>Boolean</option>
|
||||
<option value="json" {% if initial.type == 'json' %}selected{% endif %}>JSON (list/object)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Value</label>
|
||||
<div class="control">
|
||||
<textarea name="value" class="textarea" rows="3">{{ initial.value }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button class="button" type="button" onclick="this.closest('tr').outerHTML='';">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -9,6 +9,11 @@ function _close_rota_option_modal(){
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ initial.key|default:'Add option' }}</p>
|
||||
{% if description %}
|
||||
<div style="margin-left:1.25em; margin-top:0.25em;">
|
||||
<p class="is-size-7 has-text-grey">{{ description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button class="delete" aria-label="close" onclick="_close_rota_option_modal()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<div id="constraint-row-{{ key }}" class="constraint-row">
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control" style="min-width:25%;"><label class="label"><code title="{{ description }}">{{ key }}</code></label></div>
|
||||
<div class="control" style="min-width:65%;"><pre style="white-space:pre-wrap; margin:0;">{{ pretty }}</pre></div>
|
||||
<div class="control" style="min-width:10%;">
|
||||
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
|
||||
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="key" value="{{ key }}" />
|
||||
<input type="hidden" name="inline" value="1" />
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,18 +57,46 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Rota Settings</h2>
|
||||
<h2 class="subtitle">Constraint options</h2>
|
||||
<div class="box">
|
||||
<p class="help">View and edit the rota's `options` used by the builder. Click Edit to open a modal editor.</p>
|
||||
<div style="margin-bottom:0.5em;">
|
||||
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit options</button>
|
||||
</div>
|
||||
<div>
|
||||
{% include 'rota/partials/rota_options_display.html' %}
|
||||
<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">Configured constraint values</h3>
|
||||
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;">
|
||||
{% if configured_constraints %}
|
||||
<table class="table is-fullwidth is-striped is-narrow">
|
||||
<thead>
|
||||
<tr><th>Key</th><th>Value</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, info in configured_constraints.items %}
|
||||
<tr id="constraint-row-{{ key }}">
|
||||
<td style="vertical-align:middle;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
|
||||
<td style="vertical-align:middle;"><pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre></td>
|
||||
<td style="vertical-align:middle;">
|
||||
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
|
||||
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="key" value="{{ key }}" />
|
||||
<input type="hidden" name="inline" value="1" />
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="help">No typed constraint options have been configured for this rota.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -84,15 +112,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:"" }}
|
||||
{% comment %} Quick action: add default value if none set {% endcomment %}
|
||||
{% 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>
|
||||
@@ -103,6 +123,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
|
||||
+117
-1
@@ -101,7 +101,9 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
|
||||
# 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()`.
|
||||
# and will be passed through by `RotaSchedule.to_rota_builder()` as
|
||||
# legacy builder args (the remaining keys in `options` are treated as
|
||||
# constraint configuration for `RotaConstraintOptions`).
|
||||
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"},
|
||||
@@ -202,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(
|
||||
|
||||
@@ -57,10 +57,38 @@ class RotaSchedule(models.Model):
|
||||
delta = self.end_date - self.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
|
||||
# Allow passing options directly if they match RotaBuilder kwargs
|
||||
rb_kwargs = dict(self.options or {})
|
||||
# Build kwargs for RotaBuilder from any explicit builder args stored in
|
||||
# `self.options` (kept for backward compatibility), and treat the
|
||||
# remaining keys as constraint options which should map to
|
||||
# `RotaConstraintOptions`.
|
||||
opts = dict(self.options or {})
|
||||
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",
|
||||
]
|
||||
rb_kwargs = {k: opts.pop(k) for k in builder_keys if k in opts}
|
||||
rb = RotaBuilder(start_date=self.start_date, weeks_to_rota=weeks_to_rota, name=self.name, **rb_kwargs)
|
||||
|
||||
# If the rota package exposes a typed RotaConstraintOptions model, use it
|
||||
# to validate/coerce the remaining `opts` and attach it to the builder.
|
||||
try:
|
||||
RotaConstraintOptions = getattr(mod, "RotaConstraintOptions")
|
||||
if opts:
|
||||
# validate raw JSON into the typed model
|
||||
opts_model = RotaConstraintOptions.model_validate(opts)
|
||||
else:
|
||||
opts_model = RotaConstraintOptions()
|
||||
# attach the typed model to the builder (overrides defaults)
|
||||
setattr(rb, "constraint_options_model", opts_model)
|
||||
except Exception:
|
||||
# If the model isn't available or validation fails, keep builder
|
||||
# defaults and proceed (backwards compatible behaviour).
|
||||
pass
|
||||
|
||||
# Add shifts (expecting list of dicts compatible with SingleShift)
|
||||
shifts_objs = []
|
||||
for s in self.shifts or []:
|
||||
|
||||
@@ -24,6 +24,7 @@ urlpatterns = [
|
||||
path("rota/<int:rota_id>/options/", views.rota_options, name="rota_options"),
|
||||
path("rota/<int:rota_id>/option/add/", views.rota_option_add, name="rota_option_add"),
|
||||
path("rota/<int:rota_id>/option/edit/", views.rota_option_edit, name="rota_option_edit"),
|
||||
path("rota/<int:rota_id>/option/inline/", views.rota_option_inline, name="rota_option_inline"),
|
||||
path("rota/<int:rota_id>/option/delete/", views.rota_option_delete, name="rota_option_delete"),
|
||||
path("rota/<int:rota_id>/runs/clear/", views.rota_runs_clear, name="rota_runs_clear"),
|
||||
path("run/<int:run_id>/export/regenerate/", views.rota_run_export_regenerate, name="rota_run_export_regenerate"),
|
||||
|
||||
+268
-45
@@ -5,7 +5,6 @@ from .models import RotaSchedule, Worker
|
||||
from .forms import WorkerForm, LeaveForm, RotaScheduleForm
|
||||
from .services import run_rota_async
|
||||
from .forms import ShiftForm
|
||||
from .forms import RotaOptionsForm
|
||||
import json
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.template.loader import render_to_string
|
||||
@@ -15,6 +14,71 @@ from django.shortcuts import HttpResponseRedirect
|
||||
import traceback
|
||||
|
||||
|
||||
def _get_constraint_description(key: str):
|
||||
"""Return the description for a constraint key from the typed model metadata.
|
||||
|
||||
This is defensive because different pydantic versions expose field metadata
|
||||
to the Python API slightly differently.
|
||||
"""
|
||||
if not key:
|
||||
return None
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module("rota_generator.shifts")
|
||||
RotaConstraintOptions = getattr(mod, "RotaConstraintOptions", None)
|
||||
meta = {}
|
||||
if RotaConstraintOptions is not None:
|
||||
meta = getattr(RotaConstraintOptions, "model_fields", {}) or {}
|
||||
else:
|
||||
RotaBuilder = getattr(mod, "RotaBuilder", None)
|
||||
if RotaBuilder is not None:
|
||||
try:
|
||||
rb = RotaBuilder()
|
||||
opts_model = getattr(rb, "constraint_options_model", None)
|
||||
meta = getattr(opts_model.__class__, "model_fields", {}) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
# meta should be a dict-like mapping of field names -> field info
|
||||
if not isinstance(meta, dict):
|
||||
try:
|
||||
meta = dict(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
if key not in meta:
|
||||
return None
|
||||
info = meta[key]
|
||||
# Try several access patterns to extract a description
|
||||
desc = None
|
||||
try:
|
||||
if hasattr(info, "description") and info.description:
|
||||
desc = info.description
|
||||
except Exception:
|
||||
desc = None
|
||||
if not desc:
|
||||
try:
|
||||
# dict-like
|
||||
desc = info.get("description")
|
||||
except Exception:
|
||||
desc = None
|
||||
if not desc:
|
||||
try:
|
||||
fi = getattr(info, "field_info", None)
|
||||
if fi is not None and getattr(fi, "description", None):
|
||||
desc = fi.description
|
||||
except Exception:
|
||||
desc = None
|
||||
if not desc:
|
||||
try:
|
||||
extra = getattr(info, "extra", None)
|
||||
if isinstance(extra, dict) and extra.get("description"):
|
||||
desc = extra.get("description")
|
||||
except Exception:
|
||||
pass
|
||||
return desc
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def index(request):
|
||||
rotas = RotaSchedule.objects.all().order_by("-created_at")
|
||||
return render(request, "rota/index.html", {"rotas": rotas})
|
||||
@@ -22,8 +86,7 @@ def index(request):
|
||||
|
||||
def rota_detail(request, rota_id):
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
# Prepare options form and available builder constraint defaults
|
||||
options_form = RotaOptionsForm()
|
||||
# Prepare available builder constraint defaults
|
||||
available_constraints = {}
|
||||
try:
|
||||
# Try to import RotaBuilder to surface its default constraint options
|
||||
@@ -46,52 +109,89 @@ def rota_detail(request, rota_id):
|
||||
run = run_rota_async(rota.id, generate_builder=generate_builder, builder_solve=builder_solve)
|
||||
return redirect(reverse("rota:rota_run_detail", args=(run.id,)))
|
||||
|
||||
# Prefill options form with existing rota.options JSON
|
||||
try:
|
||||
opt_json = json.dumps(rota.options or {}, indent=4)
|
||||
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 = {}
|
||||
# Prefer reading metadata from the typed RotaConstraintOptions class
|
||||
RotaConstraintOptions = getattr(mod, "RotaConstraintOptions", None)
|
||||
meta = {}
|
||||
defaults = {}
|
||||
if RotaConstraintOptions is not None:
|
||||
# class-level model_fields avoids instantiating RotaBuilder
|
||||
try:
|
||||
meta = opts_model.__class__.model_fields
|
||||
meta = getattr(RotaConstraintOptions, "model_fields", {}) or {}
|
||||
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}
|
||||
try:
|
||||
# instantiate a model to obtain defaults
|
||||
defaults = RotaConstraintOptions().model_dump()
|
||||
except Exception:
|
||||
defaults = {}
|
||||
else:
|
||||
# Fallback: try to use RotaBuilder's constraint_options_model if present
|
||||
RotaBuilder = getattr(mod, "RotaBuilder", None)
|
||||
if RotaBuilder is not None:
|
||||
try:
|
||||
rb = RotaBuilder()
|
||||
opts_model = getattr(rb, "constraint_options_model", None)
|
||||
if opts_model is not None:
|
||||
defaults = opts_model.model_dump()
|
||||
meta = getattr(opts_model.__class__, "model_fields", {}) or {}
|
||||
except Exception:
|
||||
defaults = {}
|
||||
# iterate defaults and build rich info dict
|
||||
for k, v in defaults.items():
|
||||
# Use unified helper to extract a stable string description for the key
|
||||
desc = _get_constraint_description(k)
|
||||
# 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})
|
||||
# Build a mapping of configured constraint keys (from rota.options).
|
||||
# Include all keys present in rota.options so they can be displayed and
|
||||
# edited individually on the main page, even if the typed defaults are
|
||||
# unavailable. Mark whether each key appears in the typed defaults.
|
||||
configured_constraints = {}
|
||||
try:
|
||||
opts = rota.options or {}
|
||||
for k, v in opts.items():
|
||||
# pretty-print complex values where appropriate
|
||||
try:
|
||||
pretty = json.dumps(v, indent=2, default=str)
|
||||
except Exception:
|
||||
pretty = str(v)
|
||||
# Include description from the typed defaults when available so the
|
||||
# UI can show tooltips and the modal can display helpful text.
|
||||
desc = None
|
||||
try:
|
||||
desc = available_constraints_rich.get(k, {}).get("description")
|
||||
except Exception:
|
||||
desc = None
|
||||
configured_constraints[k] = {"value": v, "pretty": pretty, "typed": (k in available_constraints_rich), "description": desc}
|
||||
except Exception:
|
||||
configured_constraints = {}
|
||||
|
||||
# legacy RotaOptionsForm not used here; we provide a typed RotaScheduleForm below
|
||||
|
||||
# Provide a typed RotaScheduleForm instance so the template can render
|
||||
# the constraint fields (prefixed with `opt__`) inline and submit them
|
||||
# to the existing `rota_options` view which understands typed forms.
|
||||
from .forms import RotaScheduleForm
|
||||
try:
|
||||
typed_form = RotaScheduleForm(instance=rota)
|
||||
except Exception:
|
||||
typed_form = None
|
||||
|
||||
return render(
|
||||
request,
|
||||
"rota/rota_detail.html",
|
||||
{"rota": rota, "options_form": options_form, "available_constraints": available_constraints_rich},
|
||||
{"rota": rota, "options_form": typed_form, "available_constraints": available_constraints_rich, "configured_constraints": configured_constraints},
|
||||
)
|
||||
|
||||
|
||||
@@ -347,10 +447,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
|
||||
@@ -362,14 +462,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},
|
||||
@@ -463,14 +564,128 @@ def rota_option_add(request, rota_id):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compute description for the suggested key (if any) and pass into modal
|
||||
desc = _get_constraint_description(key_q) if key_q else None
|
||||
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/rota_option_modal_single.html",
|
||||
{"form_action": reverse("rota:rota_option_add", args=(rota.id,)), "initial": initial, "rota": rota},
|
||||
{"form_action": reverse("rota:rota_option_add", args=(rota.id,)), "initial": initial, "rota": rota, "description": desc},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
|
||||
def rota_option_inline(request, rota_id):
|
||||
"""Return an inline editor for a single option (or save it).
|
||||
|
||||
GET: return a small form fragment suitable for in-place replacement of a
|
||||
constraint row. POST: save the key/value and return the rendered row
|
||||
fragment to replace the editor.
|
||||
"""
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
|
||||
if request.method == "POST":
|
||||
key = request.POST.get("key", "").strip()
|
||||
val = request.POST.get("value", "")
|
||||
vtype = request.POST.get("type", "string")
|
||||
if not key:
|
||||
# Re-render the inline form with an error message
|
||||
initial = {"key": key, "value": val, "type": vtype}
|
||||
inline_html = render_to_string(
|
||||
"rota/partials/rota_option_inline.html",
|
||||
{"form_action": reverse("rota:rota_option_inline", args=(rota.id,)), "initial": initial, "error": "Key is required", "rota": rota},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(inline_html)
|
||||
|
||||
# Try to parse according to type; on failure re-render form with error
|
||||
parsed = None
|
||||
parse_error = None
|
||||
try:
|
||||
if vtype == "int":
|
||||
parsed = int(val)
|
||||
elif vtype == "float":
|
||||
parsed = float(val)
|
||||
elif vtype == "bool":
|
||||
parsed = val.lower() in ("1", "true", "yes", "on")
|
||||
elif vtype == "json":
|
||||
parsed = json.loads(val) if val.strip() else None
|
||||
else:
|
||||
parsed = val
|
||||
except Exception as exc:
|
||||
parse_error = str(exc)
|
||||
|
||||
if parse_error:
|
||||
initial = {"key": key, "value": val, "type": vtype}
|
||||
inline_html = render_to_string(
|
||||
"rota/partials/rota_option_inline.html",
|
||||
{"form_action": reverse("rota:rota_option_inline", args=(rota.id,)), "initial": initial, "error": f"Invalid value: {parse_error}", "rota": rota},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(inline_html)
|
||||
|
||||
opts = rota.options or {}
|
||||
opts[key] = parsed
|
||||
rota.options = opts
|
||||
rota.save(update_fields=["options"])
|
||||
|
||||
# Render and return the updated row fragment
|
||||
try:
|
||||
pretty = json.dumps(parsed, indent=2, default=str)
|
||||
except Exception:
|
||||
pretty = str(parsed)
|
||||
# Include description for the saved key so the updated row has a tooltip
|
||||
desc = _get_constraint_description(key)
|
||||
row_html = render_to_string(
|
||||
"rota/partials/rota_option_row.html",
|
||||
{"rota": rota, "key": key, "value": parsed, "pretty": pretty, "description": desc},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(row_html)
|
||||
|
||||
# GET: return inline form fragment prefilled from defaults if present
|
||||
key_q = request.GET.get("key")
|
||||
initial = {"key": "", "value": "", "type": "string"}
|
||||
if key_q:
|
||||
try:
|
||||
import importlib as _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
|
||||
|
||||
inline_html = render_to_string(
|
||||
"rota/partials/rota_option_inline.html",
|
||||
{"form_action": reverse("rota:rota_option_inline", args=(rota.id,)), "initial": initial, "rota": rota},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(inline_html)
|
||||
|
||||
|
||||
def rota_option_edit(request, rota_id):
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
||||
@@ -528,9 +743,11 @@ def rota_option_edit(request, rota_id):
|
||||
vtype = "string"
|
||||
value = str(cur_val)
|
||||
|
||||
# Include description for the key in the edit modal
|
||||
desc = _get_constraint_description(key)
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/rota_option_modal_single.html",
|
||||
{"form_action": reverse("rota:rota_option_edit", args=(rota.id,)), "initial": {"key": key, "value": value, "type": vtype}, "rota": rota},
|
||||
{"form_action": reverse("rota:rota_option_edit", args=(rota.id,)), "initial": {"key": key, "value": value, "type": vtype}, "rota": rota, "description": desc},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -548,6 +765,12 @@ def rota_option_delete(request, rota_id):
|
||||
rota.options = opts
|
||||
rota.save(update_fields=["options"])
|
||||
|
||||
# If this was an inline request, return an empty fragment so the
|
||||
# inline editor row can be removed client-side. Otherwise return the
|
||||
# standard HTMX OOB update to refresh the options display.
|
||||
if request.POST.get("inline") == "1":
|
||||
return HttpResponse("")
|
||||
|
||||
if request.headers.get("HX-Request", "false").lower() == "true":
|
||||
return _oob_update_options_display(request, rota)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../timetable.css
|
||||
@@ -1 +0,0 @@
|
||||
../timetable.js
|
||||
Reference in New Issue
Block a user