From ae52d80f98fc9e62ef14e241889a0fea867b2198 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 9 Dec 2025 12:57:19 +0000 Subject: [PATCH] . --- djangorota/rota/forms.py | 59 ++++++++++++++++++++ djangorota/rota/urls.py | 3 + djangorota/rota/views.py | 115 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py index 018fc4e..ebb943b 100644 --- a/djangorota/rota/forms.py +++ b/djangorota/rota/forms.py @@ -2,6 +2,8 @@ from django import forms from django.forms import widgets from .models import Worker, Leave, RotaSchedule +import importlib +import json class WorkerForm(forms.ModelForm): @@ -40,6 +42,38 @@ class RotaScheduleForm(forms.ModelForm): weeks = max(1, delta.days // 7) self.fields["weeks"].initial = weeks self.fields["end_date"].initial = instance.end_date + # Dynamically add RotaBuilder constraint option fields so these can be edited + try: + mod = importlib.import_module("rota_generator.shifts") + RotaBuilder = getattr(mod, "RotaBuilder") + try: + rb = RotaBuilder() + constraint_defaults = getattr(rb, "constraint_options", {}) + except Exception: + constraint_defaults = {} + except Exception: + constraint_defaults = {} + + self._constraint_defaults = constraint_defaults + + # Load existing options from instance if present + existing_opts = {} + if instance and getattr(instance, "options", None): + existing_opts = instance.options or {} + + for key, default in constraint_defaults.items(): + field_name = f"opt__{key}" + initial = existing_opts.get(key, default) + if isinstance(default, bool): + self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=key.replace("_", " ")) + elif isinstance(default, int): + self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=key.replace("_", " ")) + elif isinstance(default, float): + self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=key.replace("_", " ")) + elif isinstance(default, (list, dict)): + self.fields[field_name] = forms.CharField(required=False, initial=json.dumps(initial), widget=forms.Textarea, label=key.replace("_", " "), help_text="Enter JSON") + else: + self.fields[field_name] = forms.CharField(required=False, initial=initial, label=key.replace("_", " ")) def clean_start_date(self): start = self.cleaned_data.get("start_date") @@ -72,6 +106,23 @@ class RotaScheduleForm(forms.ModelForm): end_date = self.cleaned_data.get("end_date") if end_date: instance.end_date = end_date + # Collect option fields and persist into instance.options + opts = instance.options or {} + for key in getattr(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) + # Parse JSON for list/dict fields + 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: + opts[key] = val + instance.options = opts if commit: instance.save() try: @@ -81,6 +132,14 @@ class RotaScheduleForm(forms.ModelForm): return instance +class RotaOptionsForm(forms.Form): + options_json = forms.CharField( + widget=forms.Textarea(attrs={"rows": 10, "class": "textarea"}), + required=False, + help_text="Edit rota options as JSON. See available constraint keys below.", + ) + + class ShiftForm(forms.Form): name = forms.CharField(max_length=200) sites = forms.CharField( diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index 59f3ebc..9bc7f82 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path("rota//shift/add/", views.shift_add, name="shift_add"), path("rota//shift//edit/", views.shift_edit, name="shift_edit"), path("rota//shift//delete/", views.shift_delete, name="shift_delete"), + path("rota//edit/", views.rota_edit, name="rota_edit"), path("worker/add/", views.worker_create, name="worker_add"), path("worker///edit/", views.worker_edit, name="worker_edit"), path("worker///delete/", views.worker_delete, name="worker_delete"), @@ -19,6 +20,8 @@ urlpatterns = [ path("run//export/html/", views.rota_run_export_html, name="rota_run_export_html"), path("run//export/download/", views.rota_run_export_download, name="rota_run_export_download"), path("rota//export/builder/", views.rota_export_builder, name="rota_export_builder"), + path("rota//options/update/", views.rota_options_update, name="rota_options_update"), + path("rota//options/", views.rota_options, name="rota_options"), path("rota//runs/clear/", views.rota_runs_clear, name="rota_runs_clear"), path("run//export/regenerate/", views.rota_run_export_regenerate, name="rota_run_export_regenerate"), ] diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 192cede..a4f6ad9 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -5,11 +5,14 @@ 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 from django.http import HttpResponse, HttpResponseBadRequest from django.views.decorators.http import require_POST from django.shortcuts import HttpResponseRedirect +import traceback def index(request): @@ -19,6 +22,23 @@ 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() + available_constraints = {} + try: + # Try to import RotaBuilder to surface its default constraint options + import importlib + + mod = importlib.import_module("rota_generator.shifts") + RotaBuilder = getattr(mod, "RotaBuilder") + try: + rb = RotaBuilder() + available_constraints = getattr(rb, "constraint_options", {}) + except Exception: + # fallback to class attribute if instantiation fails + available_constraints = {} + except Exception: + available_constraints = {} if request.method == "POST": # trigger a background run generate_builder = request.POST.get("generate_builder") == "1" @@ -26,7 +46,18 @@ 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,))) - return render(request, "rota/rota_detail.html", {"rota": rota}) + # Prefill options form with existing rota.options JSON + try: + opt_json = json.dumps(rota.options or {}, indent=4) + except Exception: + opt_json = "{}" + + options_form = RotaOptionsForm(initial={"options_json": opt_json}) + return render( + request, + "rota/rota_detail.html", + {"rota": rota, "options_form": options_form, "available_constraints": available_constraints}, + ) def rota_create(request): @@ -41,6 +72,19 @@ def rota_create(request): return render(request, "rota/rota_form.html", {"form": form}) +def rota_edit(request, rota_id): + rota = get_object_or_404(RotaSchedule, pk=rota_id) + if request.method == "POST": + form = RotaScheduleForm(request.POST, instance=rota) + if form.is_valid(): + form.save() + return redirect("rota:rota_detail", rota_id=rota.id) + else: + form = RotaScheduleForm(instance=rota) + + return render(request, "rota/rota_form.html", {"form": form, "editing": True}) + + def worker_create(request): # Support both full-page and HTMX modal flows. If the request comes from # HTMX (header `HX-Request: true`) we return the modal partial so it can @@ -222,6 +266,75 @@ def rota_run_export_html(request, run_id): return render(request, "rota/rota_run_export.html", {"run": run}) +@require_POST +def rota_options_update(request, rota_id): + """Update a rota's JSON `options` from a posted textarea `options_json`. + + This endpoint expects a POST with `options_json` containing a JSON object. + It validates JSON and saves it into `RotaSchedule.options`. + """ + rota = get_object_or_404(RotaSchedule, pk=rota_id) + options_json = request.POST.get("options_json", "") + try: + parsed = json.loads(options_json) if options_json.strip() else {} + except Exception as exc: + return HttpResponseBadRequest(f"Invalid JSON: {exc}") + + rota.options = parsed + rota.save(update_fields=["options"]) + return redirect("rota:rota_detail", rota_id=rota.id) + + +def rota_options(request, rota_id): + """HTMX-aware modal edit for rota.options JSON. + + GET: return modal partial containing the JSON textarea form. + POST: validate and save JSON; if HTMX, return OOB swap updating the options display and clear the modal. + """ + rota = get_object_or_404(RotaSchedule, pk=rota_id) + is_hx = request.headers.get("HX-Request", "false").lower() == "true" + + if request.method == "POST": + options_json = request.POST.get("options_json", "") + try: + parsed = json.loads(options_json) if options_json.strip() else {} + except Exception as exc: + if is_hx: + return HttpResponseBadRequest(f"Invalid JSON: {exc}") + return HttpResponseBadRequest(f"Invalid JSON: {exc}") + + rota.options = parsed + rota.save(update_fields=["options"]) + + if is_hx: + # render display snippet and send OOB swap to update it and clear modal + options_pretty = json.dumps(rota.options or {}, indent=4) + options_html = render_to_string( + "rota/partials/rota_options_display.html", + {"rota": rota, "options_pretty": options_pretty}, + request=request, + ) + resp = '
' + options_html + '
' + '' + response = HttpResponse(resp) + response["HX-Trigger"] = "closeModal" + return response + + return redirect(reverse("rota:rota_detail", args=(rota.id,))) + + # GET: return modal partial + try: + initial = {"options_json": json.dumps(rota.options or {}, indent=4)} + except Exception: + initial = {"options_json": "{}"} + + modal_html = render_to_string( + "rota/partials/rota_options_modal.html", + {"form_action": reverse("rota:rota_options", args=(rota.id,)), "initial": initial, "rota": rota}, + request=request, + ) + return HttpResponse(modal_html) + + def rota_run_export_download(request, run_id): from .models import RotaRun