.
This commit is contained in:
@@ -2,6 +2,8 @@ from django import forms
|
|||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
|
|
||||||
from .models import Worker, Leave, RotaSchedule
|
from .models import Worker, Leave, RotaSchedule
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class WorkerForm(forms.ModelForm):
|
class WorkerForm(forms.ModelForm):
|
||||||
@@ -40,6 +42,38 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
weeks = max(1, delta.days // 7)
|
weeks = max(1, delta.days // 7)
|
||||||
self.fields["weeks"].initial = weeks
|
self.fields["weeks"].initial = weeks
|
||||||
self.fields["end_date"].initial = instance.end_date
|
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):
|
def clean_start_date(self):
|
||||||
start = self.cleaned_data.get("start_date")
|
start = self.cleaned_data.get("start_date")
|
||||||
@@ -72,6 +106,23 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
end_date = self.cleaned_data.get("end_date")
|
end_date = self.cleaned_data.get("end_date")
|
||||||
if end_date:
|
if end_date:
|
||||||
instance.end_date = 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:
|
if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
try:
|
try:
|
||||||
@@ -81,6 +132,14 @@ class RotaScheduleForm(forms.ModelForm):
|
|||||||
return instance
|
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):
|
class ShiftForm(forms.Form):
|
||||||
name = forms.CharField(max_length=200)
|
name = forms.CharField(max_length=200)
|
||||||
sites = forms.CharField(
|
sites = forms.CharField(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ urlpatterns = [
|
|||||||
path("rota/<int:rota_id>/shift/add/", views.shift_add, name="shift_add"),
|
path("rota/<int:rota_id>/shift/add/", views.shift_add, name="shift_add"),
|
||||||
path("rota/<int:rota_id>/shift/<int:idx>/edit/", views.shift_edit, name="shift_edit"),
|
path("rota/<int:rota_id>/shift/<int:idx>/edit/", views.shift_edit, name="shift_edit"),
|
||||||
path("rota/<int:rota_id>/shift/<int:idx>/delete/", views.shift_delete, name="shift_delete"),
|
path("rota/<int:rota_id>/shift/<int:idx>/delete/", views.shift_delete, name="shift_delete"),
|
||||||
|
path("rota/<int:rota_id>/edit/", views.rota_edit, name="rota_edit"),
|
||||||
path("worker/add/", views.worker_create, name="worker_add"),
|
path("worker/add/", views.worker_create, name="worker_add"),
|
||||||
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
||||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||||
@@ -19,6 +20,8 @@ urlpatterns = [
|
|||||||
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
||||||
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
||||||
path("rota/<int:rota_id>/export/builder/", views.rota_export_builder, name="rota_export_builder"),
|
path("rota/<int:rota_id>/export/builder/", views.rota_export_builder, name="rota_export_builder"),
|
||||||
|
path("rota/<int:rota_id>/options/update/", views.rota_options_update, name="rota_options_update"),
|
||||||
|
path("rota/<int:rota_id>/options/", views.rota_options, name="rota_options"),
|
||||||
path("rota/<int:rota_id>/runs/clear/", views.rota_runs_clear, name="rota_runs_clear"),
|
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"),
|
path("run/<int:run_id>/export/regenerate/", views.rota_run_export_regenerate, name="rota_run_export_regenerate"),
|
||||||
]
|
]
|
||||||
|
|||||||
+114
-1
@@ -5,11 +5,14 @@ from .models import RotaSchedule, Worker
|
|||||||
from .forms import WorkerForm, LeaveForm, RotaScheduleForm
|
from .forms import WorkerForm, LeaveForm, RotaScheduleForm
|
||||||
from .services import run_rota_async
|
from .services import run_rota_async
|
||||||
from .forms import ShiftForm
|
from .forms import ShiftForm
|
||||||
|
from .forms import RotaOptionsForm
|
||||||
|
import json
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest
|
from django.http import HttpResponse, HttpResponseBadRequest
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.shortcuts import HttpResponseRedirect
|
from django.shortcuts import HttpResponseRedirect
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
@@ -19,6 +22,23 @@ def index(request):
|
|||||||
|
|
||||||
def rota_detail(request, rota_id):
|
def rota_detail(request, rota_id):
|
||||||
rota = get_object_or_404(RotaSchedule, pk=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":
|
if request.method == "POST":
|
||||||
# trigger a background run
|
# trigger a background run
|
||||||
generate_builder = request.POST.get("generate_builder") == "1"
|
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)
|
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 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):
|
def rota_create(request):
|
||||||
@@ -41,6 +72,19 @@ def rota_create(request):
|
|||||||
return render(request, "rota/rota_form.html", {"form": form})
|
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):
|
def worker_create(request):
|
||||||
# Support both full-page and HTMX modal flows. If the request comes from
|
# 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
|
# 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})
|
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 = '<div id="rota-options-display" hx-swap-oob="true">' + options_html + '</div>' + '<div id="modal" hx-swap-oob="true"></div>'
|
||||||
|
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):
|
def rota_run_export_download(request, run_id):
|
||||||
from .models import RotaRun
|
from .models import RotaRun
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user