Compare commits

...
8 Commits
Author SHA1 Message Date
Ross 1565241de6 . 2025-12-09 22:18:46 +00:00
Ross d8ef96c486 . 2025-12-09 22:16:34 +00:00
Ross 1ce1b75739 . 2025-12-09 21:50:04 +00:00
Ross 4011fa3627 . 2025-12-09 21:48:32 +00:00
Ross d28b441edd . 2025-12-09 21:35:38 +00:00
Ross 2de9986149 . 2025-12-09 21:20:39 +00:00
Ross c94c204d51 . 2025-12-09 21:12:05 +00:00
Ross 3906ab3c55 . 2025-12-09 20:59:40 +00:00
10 changed files with 518 additions and 67 deletions
@@ -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
View File
@@ -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(
+30 -2
View File
@@ -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 []:
+1
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
../timetable.css
-1
View File
@@ -1 +0,0 @@
../timetable.js