Files
proc-rota/djangorota/rota/views.py
T

1678 lines
55 KiB
Python

from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from .models import RotaSchedule, Worker
from .forms import WorkerForm, LeaveForm, RotaScheduleForm
from .forms import WorkerSelfServiceForm
from .services import run_rota_async
from .forms import ShiftForm
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.http import JsonResponse
from django.views.decorators.http import require_POST
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})
def rota_detail(request, rota_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
# Prepare available builder constraint defaults
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"
builder_solve = request.POST.get("builder_solve") == "1"
run = run_rota_async(rota.id, generate_builder=generate_builder, builder_solve=builder_solve)
return redirect(reverse("rota:rota_run_detail", args=(run.id,)))
# Build a richer representation of available constraints (default, type, description)
available_constraints_rich = {}
try:
mod = importlib.import_module("rota_generator.shifts")
# 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 = getattr(RotaConstraintOptions, "model_fields", {}) or {}
except Exception:
meta = {}
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()}
# 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():
# Only surface keys that correspond to the typed RotaConstraintOptions
# (exclude builder/constructor args and other non-constraint keys).
if k not in available_constraints_rich:
continue
# 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
default_val = available_constraints_rich.get(k, {}).get("default")
configured_constraints[k] = {
"value": v,
"pretty": pretty,
"typed": (k in available_constraints_rich),
"description": desc,
"default": default_val,
}
except Exception:
configured_constraints = {}
# Compute a small rota-builder summary for display in the template
try:
# Prefer explicit value stored in rota.options['weeks'] when present.
opts = rota.options or {}
if "weeks" in opts:
try:
weeks_to_rota = max(1, int(opts.get("weeks")))
except Exception:
weeks_to_rota = None
else:
delta = rota.end_date - rota.start_date
weeks_to_rota = max(1, int(delta.days // 7))
except Exception:
weeks_to_rota = None
try:
shift_count = len(rota.shifts or [])
except Exception:
shift_count = None
try:
worker_count = rota.workers.count()
except Exception:
worker_count = None
# Extract solver settings saved on the rota (if any) for display.
try:
opts = rota.options or {}
solver = opts.get("solver")
so = opts.get("solver_options")
# Normalize solver_options to a dict when possible and extract ratio
solver_options = None
solver_ratio = None
if isinstance(so, dict):
solver_options = so
solver_ratio = so.get("ratio")
elif isinstance(so, str):
try:
solver_options = json.loads(so)
except Exception:
solver_options = so
if isinstance(solver_options, dict):
solver_ratio = solver_options.get("ratio")
# fallback to legacy top-level 'ratio' key
if solver_ratio is None:
solver_ratio = opts.get("ratio")
# Pretty JSON for display when dict
try:
if isinstance(solver_options, dict):
solver_options_pretty = json.dumps(solver_options, indent=2)
else:
solver_options_pretty = str(solver_options) if solver_options is not None else None
except Exception:
solver_options_pretty = None
except Exception:
solver = None
solver_options_pretty = None
solver_ratio = None
# 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": typed_form,
"available_constraints": available_constraints_rich,
"configured_constraints": configured_constraints,
"weeks_to_rota": weeks_to_rota,
"shift_count": shift_count,
"worker_count": worker_count,
"solver": solver,
"solver_options": solver_options_pretty,
"solver_ratio": solver_ratio,
},
)
def rota_create(request):
if request.method == "POST":
form = RotaScheduleForm(request.POST)
if form.is_valid():
rota = form.save()
return redirect("rota:rota_detail", rota_id=rota.id)
else:
form = RotaScheduleForm()
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
# be injected into the `#modal` container. On successful HTMX POST we
# return an out-of-band swap to refresh the worker list and clear the
# modal.
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
if request.method == "POST":
form = WorkerForm(request.POST)
if form.is_valid():
worker = form.save()
if is_hx:
# Return updated worker list and close modal
# If a rota context was supplied via querystring, we do not need it
# here because the partial will re-render from DB.
# Render worker list OOB and clear modal container
# We need a rota to render the list — try to use `rota_id` query param
# rota_id may be provided as a querystring or as a hidden form field.
rota_id = request.GET.get("rota_id") or request.POST.get("rota_id")
rota = None
if rota_id:
try:
rota = get_object_or_404(RotaSchedule, pk=int(rota_id))
except Exception:
rota = None
# If we created a worker and a rota_id was provided, create Assignment
if rota is not None:
from .models import Assignment
try:
Assignment.objects.create(worker=worker, rota=rota)
except Exception:
# best-effort: try using the M2M add as fallback
try:
rota.workers.add(worker)
except Exception:
pass
if rota is not None:
worker_list_html = render_to_string(
"rota/partials/worker_list.html",
{"rota": rota},
request=request,
)
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
# close modal via HX-Trigger to avoid racing OOB swaps that target #modal
response["HX-Trigger"] = "closeModal"
return response
# If we couldn't render the worker list (no rota), just close modal via trigger
response = HttpResponse('')
response["HX-Trigger"] = "closeModal"
return response
# Non-HTMX POST -> redirect
return redirect("/")
else:
form = WorkerForm()
# HTMX GET: return modal partial
if is_hx:
modal_html = render_to_string(
"rota/partials/worker_form_modal.html",
{"form": form, "form_action": request.get_full_path()},
request=request,
)
return HttpResponse(modal_html)
# Non-HTMX GET: full page
return render(request, "rota/worker_form.html", {"form": form})
@require_http_methods(["GET", "POST"])
def worker_import(request, rota_id=None):
"""HTMX endpoint: import workers from CSV or pasted text.
Accepts a file upload named 'file' (CSV) or a textarea 'workers_text'.
CSV columns supported: name,email,site,grade,fte (headers optional).
When `rota_id` is provided, the created workers will be assigned to
that rota.
"""
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
rota = None
if rota_id is not None:
try:
rota = get_object_or_404(RotaSchedule, pk=rota_id)
except Exception:
rota = None
if request.method == "GET":
# render modal partial
return render(request, "rota/partials/worker_import_modal.html", {"rota": rota})
# POST: parse uploaded file or textarea
created = []
errors = []
assign_to_rota = request.POST.get("assign_to_rota") == "1"
import csv
try:
upload = request.FILES.get("file")
if upload:
# decode bytes to text
try:
text = upload.read().decode("utf-8")
except Exception:
text = upload.read().decode("latin-1")
else:
text = request.POST.get("workers_text", "")
# Normalize lines: if simple newline-separated names/emails, make a CSV
reader = csv.DictReader(text.splitlines())
rows = list(reader)
if not rows:
# Try parsing as simple CSV without headers
reader2 = csv.reader([line for line in text.splitlines() if line.strip()])
for r in reader2:
# Map positional columns: name,email,site,grade,fte
while len(r) < 5:
r.append("")
rows.append({"name": r[0].strip(), "email": r[1].strip(), "site": r[2].strip(), "grade": r[3].strip(), "fte": r[4].strip()})
from .models import Assignment
for i, row in enumerate(rows):
try:
# normalize keys lower-case
if not isinstance(row, dict):
continue
r = {k.strip().lower(): (v if v is not None else "") for k, v in row.items()}
name = r.get("name") or r.get("full_name") or r.get("username")
if not name or str(name).strip() == "":
errors.append(f"Row {i+1}: missing name")
continue
email = r.get("email") or ""
site = r.get("site") or ""
try:
grade = int(r.get("grade")) if r.get("grade") not in (None, "") else None
except Exception:
grade = None
try:
fte_raw = r.get("fte")
if fte_raw in (None, ""):
fte = 1.0
else:
fte = float(fte_raw)
except Exception:
fte = 1.0
w = Worker.objects.create(name=name.strip(), email=email.strip(), site=site.strip(), grade=grade, fte=fte)
created.append(w)
if assign_to_rota and rota is not None:
try:
Assignment.objects.create(worker=w, rota=rota)
except Exception:
try:
rota.workers.add(w)
except Exception:
pass
except Exception as exc:
errors.append(f"Row {i+1}: {exc}")
except Exception as exc:
errors.append(str(exc))
# If HTMX, return updated worker list partial and close modal
if is_hx and rota is not None:
worker_list_html = render_to_string(
"rota/partials/worker_list.html",
{"rota": rota},
request=request,
)
# Return both new worker list and a trigger to close modal
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
# Non-HTMX: redirect back to rota detail with message (flash messages not added here)
return redirect("rota:rota_detail", rota_id=rota.id if rota is not None else None)
def worker_detail(request, worker_id):
worker = get_object_or_404(Worker, pk=worker_id)
if request.method == "POST":
form = LeaveForm(request.POST)
if form.is_valid():
leave = form.save(commit=False)
leave.worker = worker
leave.save()
# For non-HTMX (full-page) submissions just redirect back to the
# worker detail page. Token-aware redirects are handled by the
# tokenized `request_leave` view (the public token page).
return redirect("rota:worker_detail", worker_id=worker.id)
else:
# Pre-fill dates when `date` query param is provided (ISO YYYY-MM-DD)
date_q = request.GET.get("date") or request.POST.get("date")
if date_q:
try:
import datetime
d = datetime.date.fromisoformat(date_q)
form = LeaveForm(initial={"start_date": d, "end_date": d})
except Exception:
form = LeaveForm()
else:
form = LeaveForm()
# Build a JSON-serializable list of leaves for the calendar widget to consume
leaves = []
try:
for leave in worker.leaves.all():
try:
s = leave.start_date.isoformat()
except Exception:
s = str(leave.start_date)
try:
e = leave.end_date.isoformat()
except Exception:
e = str(leave.end_date)
leaves.append({"start": s, "end": e})
except Exception:
leaves = []
# Provide a JSON-encoded string for safe embedding in the template
try:
leaves_json = json.dumps(leaves)
except Exception:
leaves_json = '[]'
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form, "leaves": leaves, "leaves_json": leaves_json})
@require_http_methods(["GET", "POST"])
def worker_leave_add(request, worker_id):
"""HTMX endpoint: show a leave form modal for a worker, or create the leave on POST."""
worker = get_object_or_404(Worker, pk=worker_id)
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
if request.method == "POST":
form = LeaveForm(request.POST)
if form.is_valid():
leave = form.save(commit=False)
leave.worker = worker
leave.save()
if is_hx:
# Close modal and optionally trigger an update; leave list can be refreshed separately
response = HttpResponse("")
response["HX-Trigger"] = "closeModal"
return response
return redirect("rota:worker_detail", worker_id=worker.id)
# invalid: return modal with errors
if is_hx:
modal_html = render_to_string("rota/partials/leave_form_modal.html", {"form": form, "worker": worker, "form_action": request.path}, request=request)
return HttpResponse(modal_html)
# non-HTMX fallthrough
# GET: render modal partial
form = LeaveForm()
if is_hx:
modal_html = render_to_string("rota/partials/leave_form_modal.html", {"form": form, "worker": worker, "form_action": request.path}, request=request)
return HttpResponse(modal_html)
return redirect("rota:worker_detail", worker_id=worker.id)
@require_http_methods(["GET"])
def worker_overview(request, rota_id, worker_id):
"""Return a read-only modal overview of the worker (HTMX GET)."""
rota = get_object_or_404(RotaSchedule, pk=rota_id)
worker = get_object_or_404(Worker, pk=worker_id)
try:
pretty_options = json.dumps(worker.options or {}, indent=2, default=str)
except Exception:
pretty_options = str(worker.options or {})
modal_html = render_to_string(
"rota/partials/worker_overview_modal.html",
{"worker": worker, "rota": rota, "pretty_options": pretty_options},
request=request,
)
return HttpResponse(modal_html)
@require_http_methods(["GET", "POST"])
def worker_edit(request, rota_id, worker_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
worker = get_object_or_404(Worker, pk=worker_id)
if request.method == "POST":
form = WorkerForm(request.POST, instance=worker)
if form.is_valid():
form.save()
# Return updated worker list OOB and close modal
worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request)
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
# invalid: return form modal with errors
modal_html = render_to_string(
"rota/partials/worker_form_modal.html",
{"form": form, "form_action": request.get_full_path(), "rota": rota, "focus": request.GET.get('focus') or request.POST.get('focus')},
request=request,
)
return HttpResponse(modal_html)
# GET: populate form and return modal
form = WorkerForm(instance=worker)
modal_html = render_to_string(
"rota/partials/worker_form_modal.html",
{"form": form, "form_action": request.path, "rota": rota, "worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if worker and getattr(worker, 'request_token', None) else '', "focus": request.GET.get('focus')},
request=request,
)
return HttpResponse(modal_html)
@require_POST
def regenerate_worker_token(request, worker_id):
# Only allow staff users to regenerate tokens from the admin modal
if not (request.user and request.user.is_authenticated and request.user.is_staff):
return HttpResponseBadRequest("Permission denied")
worker = get_object_or_404(Worker, pk=worker_id)
# generate unique token
import uuid
new_token = uuid.uuid4()
# guard against collision
while Worker.objects.filter(request_token=new_token).exclude(pk=worker.pk).exists():
new_token = uuid.uuid4()
worker.request_token = new_token
worker.save(update_fields=["request_token"])
# render only the token area so HTMX can swap it
token_html = render_to_string(
"rota/partials/worker_token_area.html",
{"worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if getattr(worker, 'request_token', None) else ''},
request=request,
)
response = HttpResponse(token_html)
response["HX-Trigger"] = "workerTokenRegenerated"
return response
@require_http_methods(["GET", "POST"])
def worker_delete(request, rota_id, worker_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
worker = get_object_or_404(Worker, pk=worker_id)
if request.method == "POST":
# remove assignment between worker and rota
try:
from .models import Assignment
Assignment.objects.filter(worker=worker, rota=rota).delete()
except Exception:
try:
rota.workers.remove(worker)
except Exception:
return HttpResponseBadRequest("Unable to remove worker from rota")
rota = get_object_or_404(RotaSchedule, pk=rota_id)
worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request)
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
# GET: confirmation modal
modal_html = render_to_string(
"rota/partials/worker_confirm_delete.html",
{"rota": rota, "worker": worker},
request=request,
)
return HttpResponse(modal_html)
def rota_run_detail(request, run_id):
from .models import RotaRun
run = get_object_or_404(RotaRun, pk=run_id)
# Prepare a pretty-printed JSON representation of the result for easier
# debugging in templates. Also expose any builder HTML present in the
# persisted `export_html` or embedded in the `result` under
# `builder_html` so it can be viewed inline.
result_pretty = None
builder_html = None
try:
if run.result is not None:
import json as _json
result_pretty = _json.dumps(run.result, indent=2, default=str)
# prefer persisted export_html if present
builder_html = getattr(run, "export_html", None) or (run.result.get("builder_html") if isinstance(run.result, dict) else None)
else:
builder_html = getattr(run, "export_html", None)
except Exception:
result_pretty = str(run.result)
return render(request, "rota/rota_run_detail.html", {"run": run, "result_pretty": result_pretty, "builder_html": builder_html})
def rota_run_export_html(request, run_id):
from .models import RotaRun
run = get_object_or_404(RotaRun, pk=run_id)
# If the export HTML was persisted with the run, return it directly.
if getattr(run, "export_html", None):
return HttpResponse(run.export_html, content_type="text/html; charset=utf-8")
# Fallback: render the export template which shows run.result.
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)
@require_http_methods(["GET", "POST"])
def request_leave(request, token=None):
"""Allow a worker to request leave for themselves.
Resolution strategy:
- If the user is authenticated, try to find a `Worker` with matching
email (first match). If found, use that worker.
- Otherwise, fall back to `worker_id` provided via GET/POST.
Supports HTMX modal flow when `HX-Request` header is present.
"""
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
worker = None
# 1) token link (preferred for unauthenticated users)
if token:
try:
worker = get_object_or_404(Worker, request_token=token)
except Exception:
worker = None
# 2) try authenticated user -> worker by email
if worker is None:
try:
if request.user and request.user.is_authenticated and getattr(request.user, "email", None):
worker = Worker.objects.filter(email=request.user.email).first()
except Exception:
worker = None
# 3) fallback to explicit worker_id param
if worker is None:
worker_id = request.GET.get("worker_id") or request.POST.get("worker_id")
if worker_id:
try:
worker = get_object_or_404(Worker, pk=int(worker_id))
except Exception:
worker = None
if request.method == "POST":
form = LeaveForm(request.POST)
if form.is_valid():
leave = form.save(commit=False)
if worker is None:
return HttpResponseBadRequest("Worker not specified or not found")
leave.worker = worker
leave.save()
# On HTMX modal post, return an OOB swap to close modal and optionally
# update worker detail partial if present.
if is_hx:
# Render updated leave list and return OOB swaps: update leave-list and clear modal.
leave_list_html = render_to_string(
"rota/partials/leave_list.html",
{"leaves": worker.leaves.all(), "token": token},
request=request,
)
resp = f'<div id="leave-list" hx-swap-oob="true">{leave_list_html}</div>'
response = HttpResponse(resp)
# Trigger modal close and a leaveUpdated event so client can refresh calendar highlights
response["HX-Trigger"] = "closeModal,leaveUpdated"
return response
# For full-page submissions: if this request was made using a tokenized
# URL (public leave request page), keep the user on that token page so
# they can see confirmation and existing requests. Otherwise go to the
# worker detail page.
if token:
try:
return redirect(reverse('rota:request_leave_token', args=[token]))
except Exception:
# fallback to worker detail
pass
return redirect("rota:worker_detail", worker_id=worker.id)
else:
# Prefill dates when provided via query params for both full page and HTMX modal
date_q = request.GET.get("date") or request.POST.get("date")
start_q = request.GET.get("start_date") or request.POST.get("start_date")
end_q = request.GET.get("end_date") or request.POST.get("end_date")
if date_q:
try:
import datetime
d = datetime.date.fromisoformat(date_q)
form = LeaveForm(initial={"start_date": d, "end_date": d})
except Exception:
form = LeaveForm()
elif start_q or end_q:
try:
import datetime
sd = datetime.date.fromisoformat(start_q) if start_q else None
ed = datetime.date.fromisoformat(end_q) if end_q else None
init = {}
if sd:
init["start_date"] = sd
if ed:
init["end_date"] = ed
form = LeaveForm(initial=init)
except Exception:
form = LeaveForm()
else:
form = LeaveForm()
# Render modal or full page
if is_hx:
modal_html = render_to_string(
"rota/partials/leave_request_modal.html",
{"form": form, "form_action": request.get_full_path(), "worker": worker},
request=request,
)
return HttpResponse(modal_html)
return render(request, "rota/leave_request.html", {"form": form, "worker": worker, "token": token})
@require_http_methods(["GET", "POST"])
def worker_settings_token(request, token):
"""Allow a worker identified by a request token to update simple settings
such as FTE and non-working days without requiring authentication.
"""
worker = None
if token:
try:
worker = get_object_or_404(Worker, request_token=token)
except Exception:
worker = None
if worker is None:
return HttpResponseBadRequest("Invalid token")
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
if request.method == "POST":
form = WorkerSelfServiceForm(request.POST, instance=worker)
if form.is_valid():
form.save()
if is_hx:
# Return minimal fragment confirming success and trigger a leaveUpdated
# so any calendars can refresh (if they rely on worker settings).
resp = render_to_string("rota/partials/worker_settings_saved.html", {"worker": worker}, request=request)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal,leaveUpdated"
return response
# Full page: redirect back to tokenized request page
return redirect(reverse('rota:request_leave_token', args=[token]))
else:
form = WorkerSelfServiceForm(instance=worker)
# HTMX modal request: return partial modal
if is_hx:
modal_html = render_to_string(
"rota/partials/worker_selfservice_modal.html",
{"form": form, "form_action": request.get_full_path(), "worker": worker},
request=request,
)
return HttpResponse(modal_html)
return render(request, "rota/worker_settings.html", {"form": form, "worker": worker, "token": token})
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":
# Support two POST modes: legacy `options_json` textarea, or the typed
# form generated by `RotaScheduleForm` (fields prefixed with `opt__`).
if "options_json" in request.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:
return _oob_update_options_display(request, rota)
return redirect(reverse("rota:rota_detail", args=(rota.id,)))
# Typed form submission for constraint options only
from .forms import RotaConstraintOptionsForm
form = RotaConstraintOptionsForm(request.POST, initial_options=rota.options)
if not form.is_valid():
if is_hx:
# Re-render modal with errors
modal_html = render_to_string(
"rota/partials/rota_options_modal.html",
{"form_action": reverse("rota:rota_options", args=(rota.id,)), "form": form, "rota": rota},
request=request,
)
return HttpResponse(modal_html)
return HttpResponseBadRequest("Invalid form submission")
# 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 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},
request=request,
)
return HttpResponse(modal_html)
def _oob_update_options_display(request, rota):
"""Helper: render options display and return HTMX OOB swap response."""
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>'
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
def rota_option_add(request, rota_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
if request.method == "POST":
key = request.POST.get("key", "").strip()
val = request.POST.get("value", "")
vtype = request.POST.get("type", "string")
if not key:
return HttpResponseBadRequest("Key is required")
# parse value according to type
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:
return HttpResponseBadRequest(f"Invalid value: {exc}")
opts = rota.options or {}
opts[key] = parsed
rota.options = opts
rota.save(update_fields=["options"])
if is_hx:
return _oob_update_options_display(request, rota)
return redirect("rota:rota_detail", rota_id=rota.id)
# GET: show modal partial. If a `key` query param is supplied, try to
# prefill the value and type from the builder defaults to make adding
# standard options easy.
key_q = request.GET.get("key")
initial = {"key": "", "value": "", "type": "string"}
if key_q:
try:
import 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
# 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, "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"
if request.method == "POST":
key = request.POST.get("key", "").strip()
val = request.POST.get("value", "")
vtype = request.POST.get("type", "string")
if not key:
return HttpResponseBadRequest("Key is required")
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:
return HttpResponseBadRequest(f"Invalid value: {exc}")
opts = rota.options or {}
opts[key] = parsed
rota.options = opts
rota.save(update_fields=["options"])
if is_hx:
return _oob_update_options_display(request, rota)
return redirect("rota:rota_detail", rota_id=rota.id)
# GET: prepare edit modal with existing value
key = request.GET.get("key")
if not key:
return HttpResponseBadRequest("Missing key")
current = rota.options or {}
cur_val = current.get(key, "")
# choose type hint
if isinstance(cur_val, bool):
vtype = "bool"
value = "1" if cur_val else "0"
elif isinstance(cur_val, int):
vtype = "int"
value = str(cur_val)
elif isinstance(cur_val, float):
vtype = "float"
value = str(cur_val)
elif isinstance(cur_val, (list, dict)):
vtype = "json"
value = json.dumps(cur_val)
else:
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, "description": desc},
request=request,
)
return HttpResponse(modal_html)
@require_POST
def rota_option_delete(request, rota_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
key = request.POST.get("key")
if not key:
return HttpResponseBadRequest("Missing key")
opts = rota.options or {}
if key in opts:
opts.pop(key)
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)
return redirect("rota:rota_detail", rota_id=rota.id)
def rota_run_export_download(request, run_id):
from .models import RotaRun
run = get_object_or_404(RotaRun, pk=run_id)
# Prefer persisted export HTML if available
if getattr(run, "export_html", None):
html = run.export_html
else:
html = render_to_string("rota/rota_run_export.html", {"run": run}, request=request)
response = HttpResponse(html, content_type="text/html; charset=utf-8")
filename = f"rota_run_{run.id}_export.html"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
def leaves_json(request, worker_id):
"""Return JSON list of leave ranges for a given worker."""
worker = get_object_or_404(Worker, pk=worker_id)
data = []
for leave in worker.leaves.all().order_by('start_date'):
data.append({
'start': leave.start_date.isoformat(),
'end': leave.end_date.isoformat(),
'reason': leave.reason,
})
return JsonResponse({'leaves': data})
def leaves_global_json(request, rota_id=None):
"""Return JSON list of all leave ranges across all workers."""
from .models import Leave
# Optionally filter by rota_id (provided as query param) so the global
# calendar can be scoped to a particular rota's workers.
rota_id = request.GET.get('rota_id') if rota_id is None else rota_id
qs = Leave.objects.select_related('worker')
if rota_id:
try:
rota_id_i = int(rota_id)
qs = qs.filter(worker__rotas=rota_id_i)
except Exception:
pass
# Build a worker->color map for workers present in the query so the
# client can color-code leaves by worker.
worker_colors = {}
def color_for_worker(pk):
# deterministic hue based on id
h = (int(pk) * 37) % 360
return f'hsl({h} 65% 85%)'
data = []
for leave in qs.all().order_by('start_date'):
worker = getattr(leave, 'worker', None)
wid = getattr(worker, 'id', None)
wname = getattr(worker, 'name', '') if worker else ''
if wid not in worker_colors and wid is not None:
worker_colors[wid] = color_for_worker(wid)
data.append({
'start': leave.start_date.isoformat(),
'end': leave.end_date.isoformat(),
'reason': wname or (leave.reason or ''),
'worker_id': wid,
'color': worker_colors.get(wid),
})
return JsonResponse({'leaves': data})
def leaves_global(request, rota_id=None):
"""Page showing a global calendar with all leave requests."""
# Provide an empty JSON payload initially; the calendar will fetch the authoritative list
leaves_json = '[]'
selected = rota_id if rota_id is not None else request.GET.get('rota_id')
return render(request, 'rota/leaves_global.html', {'leaves_json': leaves_json, 'rotas': RotaSchedule.objects.all().order_by('name'), 'selected_rota_id': selected})
@require_http_methods(["GET"])
def select_workers_modal(request, rota_id=None):
"""Return an HTMX modal partial that lists workers to apply a selected leave range to.
Query params: start_date, end_date, rota_id (optional)
"""
start_date = request.GET.get('start_date')
end_date = request.GET.get('end_date')
rota_id = request.GET.get('rota_id') if rota_id is None else rota_id
workers = Worker.objects.all()
if rota_id:
try:
workers = workers.filter(rotas__id=int(rota_id))
except Exception:
pass
workers = workers.order_by('name')
modal_html = render_to_string(
'rota/partials/select_workers_modal.html',
{'workers': workers, 'start_date': start_date, 'end_date': end_date},
request=request,
)
return HttpResponse(modal_html)
@require_http_methods(["POST"])
def apply_leave_multi(request):
"""Apply a leave range to multiple workers. Expects POST with worker_ids (list), start_date and end_date."""
from .models import Leave
worker_ids = request.POST.getlist('worker_id')
start_date = request.POST.get('start_date')
end_date = request.POST.get('end_date')
if not worker_ids or not start_date:
return HttpResponseBadRequest('Missing parameters')
created = []
for wid in worker_ids:
try:
w = Worker.objects.get(pk=int(wid))
except Exception:
continue
try:
leave = Leave(worker=w, start_date=start_date, end_date=end_date)
leave.save()
created.append(leave)
except Exception:
continue
# Return simple HTMX response: close modal and trigger calendar refresh
response = HttpResponse('')
response['HX-Trigger'] = 'closeModal,leaveUpdated'
return response
@require_POST
def leave_delete(request, leave_id):
"""Delete a Leave record. Allowed if the requester is staff, or if a valid token matches the worker, or the authenticated user's email matches the worker email."""
from .models import Leave
leave = get_object_or_404(Leave, pk=leave_id)
worker = leave.worker
# permission checks
allowed = False
token = request.POST.get('token') or request.GET.get('token')
try:
if request.user and request.user.is_authenticated and request.user.is_staff:
allowed = True
elif request.user and request.user.is_authenticated and getattr(request.user, 'email', None) and request.user.email == (worker.email or ''):
allowed = True
elif token:
try:
import uuid
token_uuid = uuid.UUID(token)
if worker.request_token == token_uuid:
allowed = True
except Exception:
allowed = False
except Exception:
allowed = False
if not allowed:
return HttpResponseBadRequest('Permission denied')
# perform delete
leave.delete()
# render updated leave list for the worker
leave_list_html = render_to_string(
"rota/partials/leave_list.html",
{"leaves": worker.leaves.all(), "token": token},
request=request,
)
resp = f'<div id="leave-list" hx-swap-oob="true">{leave_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "leaveUpdated"
return response
@require_POST
def leave_delete_all(request, worker_id):
"""Delete all Leave records for a worker. Permissions same as `leave_delete`.
Returns an OOB swap updating `#leave-list` and triggers `leaveUpdated`.
"""
worker = get_object_or_404(Worker, pk=worker_id)
# permission checks (same as leave_delete)
allowed = False
token = request.POST.get('token') or request.GET.get('token')
try:
if request.user and request.user.is_authenticated and request.user.is_staff:
allowed = True
elif request.user and request.user.is_authenticated and getattr(request.user, 'email', None) and request.user.email == (worker.email or ''):
allowed = True
elif token:
try:
import uuid
token_uuid = uuid.UUID(token)
if worker.request_token == token_uuid:
allowed = True
except Exception:
allowed = False
except Exception:
allowed = False
if not allowed:
return HttpResponseBadRequest('Permission denied')
# delete all leaves for this worker
worker.leaves.all().delete()
# render updated leave list for the worker
leave_list_html = render_to_string(
"rota/partials/leave_list.html",
{"leaves": worker.leaves.all(), "token": token},
request=request,
)
resp = f'<div id="leave-list" hx-swap-oob="true">{leave_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "leaveUpdated"
return response
@require_POST
def rota_run_export_regenerate(request, run_id):
"""Regenerate the HTML export for a completed run and persist it on the RotaRun.
This is a POST endpoint — regenerating may be slow if the solver is invoked.
"""
from .models import RotaRun
run = get_object_or_404(RotaRun, pk=run_id)
rota = run.rota
solve = request.POST.get("solve") == "1"
try:
rb = rota.to_rota_builder()
rb.build_and_solve(solve=solve)
html = rb.get_worker_timetable_html(include_html_tag=True)
run.export_html = html
run.save(update_fields=["export_html"])
except Exception as exc:
tb = traceback.format_exc()
run.log = (run.log or "") + "\n" + tb
run.save(update_fields=["log"])
return HttpResponse(f"Failed to regenerate export: {exc}", status=500)
# Redirect to the HTML view so user sees the generated page
return HttpResponseRedirect(reverse("rota:rota_run_export_html", args=(run.id,)))
def rota_export_builder(request, rota_id):
"""Build a `RotaBuilder` for the given `RotaSchedule` and return its HTML export.
Query parameters:
- solve=1 : attempt to solve the model (may require solver binaries and can be slow)
- solver=<name> : solver name passed to `build_and_solve`
"""
rota = get_object_or_404(RotaSchedule, pk=rota_id)
try:
rb = rota.to_rota_builder()
except Exception as exc:
return HttpResponseBadRequest(f"Unable to construct RotaBuilder: {exc}")
solve = request.GET.get("solve") == "1"
solver = request.GET.get("solver", "appsi_highs")
try:
# Build model (and optionally solve) - keep defaults safe
rb.build_and_solve(solve=solve, solver=solver)
except Exception as exc:
# Return error message in the page so it's visible in browser
content = f"<html><body><h1>Error running builder</h1><pre>{exc}</pre></body></html>"
return HttpResponse(content, status=500, content_type="text/html")
html = rb.get_worker_timetable_html(include_html_tag=True)
return HttpResponse(html, content_type="text/html; charset=utf-8")
@require_POST
def rota_runs_clear(request, rota_id):
"""Delete all RotaRun records for the given rota and redirect back.
This is a POST-only endpoint; the template uses an inline form with a
JavaScript confirmation to avoid accidental removal.
"""
rota = get_object_or_404(RotaSchedule, pk=rota_id)
# Delete all runs for this rota
from .models import RotaRun
RotaRun.objects.filter(rota=rota).delete()
return redirect("rota:rota_detail", rota_id=rota.id)
@require_http_methods(["GET", "POST"])
def shift_add(request, rota_id):
"""HTMX endpoint to render a shift form or accept submission to append a shift to the rota."""
rota = get_object_or_404(RotaSchedule, pk=rota_id)
if request.method == "POST":
form = ShiftForm(request.POST)
if form.is_valid():
data = form.cleaned_data
shift_dict = {
"sites": data["sites"],
"name": data["name"],
"length": float(data["length"]),
"days": data["days"],
"workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]),
"balance_offset": data.get("balance_offset"),
"constraints": data.get("constraints", []),
}
current = rota.shifts or []
current.append(shift_dict)
rota.shifts = current
rota.save()
# Return out-of-band swap: update shift-list and clear modal
shift_list_html = render_to_string("rota/partials/shift_list.html", {"rota": rota}, request=request)
# wrap shift list for OOB swap and clear form/modal containers
resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
# invalid: return modal with form and errors
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path, "focus": request.GET.get('focus') or request.POST.get('focus')}, request=request)
return HttpResponse(modal_html)
else:
form = ShiftForm()
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
return HttpResponse(modal_html)
@require_http_methods(["GET", "POST"])
def shift_edit(request, rota_id, idx):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
try:
shift = rota.shifts[idx]
except Exception:
return HttpResponseBadRequest("Shift not found")
if request.method == "POST":
form = ShiftForm(request.POST)
if form.is_valid():
data = form.cleaned_data
shift_dict = {
"sites": data["sites"],
"name": data["name"],
"length": float(data["length"]),
"days": data["days"],
"workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]),
"constraints": data.get("constraints", []),
}
current = rota.shifts or []
current[idx] = shift_dict
rota.shifts = current
rota.save()
shift_list_html = render_to_string("rota/partials/shift_list.html", {"rota": rota}, request=request)
resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
return HttpResponse(modal_html)
# GET: populate form with existing shift
initial = {
"name": shift.get("name"),
"sites": ",".join(shift.get("sites") or []),
"length": shift.get("length"),
"days": shift.get("days"),
"workers_required": shift.get("workers_required"),
"assign_as_block": shift.get("assign_as_block", False),
"balance_offset": shift.get("balance_offset", None),
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
}
form = ShiftForm(initial=initial)
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path, "focus": request.GET.get('focus')}, request=request)
return HttpResponse(modal_html)
@require_http_methods(["GET", "POST"])
def shift_delete(request, rota_id, idx):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
try:
shift = rota.shifts[idx]
except Exception:
return HttpResponseBadRequest("Shift not found")
if request.method == "POST":
current = rota.shifts or []
try:
current.pop(idx)
except Exception:
return HttpResponseBadRequest("Invalid index")
rota.shifts = current
rota.save()
shift_list_html = render_to_string("rota/partials/shift_list.html", {"rota": rota}, request=request)
resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
# GET: confirmation modal
modal_html = render_to_string("rota/partials/shift_confirm_delete.html", {"rota": rota, "idx": idx, "shift": shift}, request=request)
return HttpResponse(modal_html)