From 3f73316dbfd418a149417ff28b55e63cd08b81ff Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 9 Dec 2025 10:32:24 +0000 Subject: [PATCH] . --- .gitignore | 3 + djangorota/djangorota/settings.py | 8 +- djangorota/djangorota/urls.py | 2 +- djangorota/rota/forms.py | 82 ++++++++++- ...otaschedule_options_rotaschedule_shifts.py | 23 +++ djangorota/rota/models.py | 71 +++++++++ djangorota/rota/urls.py | 4 + djangorota/rota/views.py | 139 +++++++++++++++++- requirements.txt | 4 +- 9 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 djangorota/rota/migrations/0002_rotaschedule_options_rotaschedule_shifts.py diff --git a/.gitignore b/.gitignore index fb94c45..2cf93b1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ logs/ web/rota cons/ + + +*.sqlite3 \ No newline at end of file diff --git a/djangorota/djangorota/settings.py b/djangorota/djangorota/settings.py index 62aafeb..cba5d94 100644 --- a/djangorota/djangorota/settings.py +++ b/djangorota/djangorota/settings.py @@ -38,9 +38,11 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", ] -# Add local rota app +# Add local rota app and crispy form packs INSTALLED_APPS += [ "rota", + "crispy_forms", + "crispy_bulma", ] MIDDLEWARE = [ @@ -124,3 +126,7 @@ STATICFILES_DIRS = [BASE_DIR / "output"] # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# Crispy forms (Bulma) configuration +CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",) +CRISPY_TEMPLATE_PACK = "bulma" diff --git a/djangorota/djangorota/urls.py b/djangorota/djangorota/urls.py index 62061e1..995fa30 100644 --- a/djangorota/djangorota/urls.py +++ b/djangorota/djangorota/urls.py @@ -20,5 +20,5 @@ from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), - path("", include("rota.urls")), + path("", include(("rota.urls", "rota"), namespace="rota")), ] diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py index b3a98f2..95dbe76 100644 --- a/djangorota/rota/forms.py +++ b/djangorota/rota/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.forms import widgets from .models import Worker, Leave, RotaSchedule @@ -13,9 +14,88 @@ class LeaveForm(forms.ModelForm): class Meta: model = Leave fields = ["start_date", "end_date", "reason"] + widgets = { + "start_date": widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}), + "end_date": widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}), + } class RotaScheduleForm(forms.ModelForm): class Meta: model = RotaSchedule - fields = ["name", "start_date", "end_date", "description"] + # Do not expose end_date as editable: computed from start_date + weeks + fields = ["name", "start_date", "description"] + widgets = { + "start_date": widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"}), + } + + weeks = forms.IntegerField(min_value=1, initial=4, help_text="Number of weeks to generate the rota for") + end_date = forms.DateField(required=False, disabled=True, widget=widgets.DateInput(attrs={"class": "datepicker input", "autocomplete": "off"})) + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + super().__init__(*args, **kwargs) + if instance and instance.start_date and instance.end_date: + delta = instance.end_date - instance.start_date + weeks = max(1, delta.days // 7) + self.fields["weeks"].initial = weeks + self.fields["end_date"].initial = instance.end_date + + def clean_start_date(self): + start = self.cleaned_data.get("start_date") + if start is None: + return start + if start.weekday() != 0: + raise forms.ValidationError("Start date must be a Monday") + return start + + def clean(self): + cleaned = super().clean() + start = cleaned.get("start_date") + weeks = cleaned.get("weeks") + if start and weeks: + import datetime + + end_date = start + datetime.timedelta(days=weeks * 7) + cleaned["end_date"] = end_date + # update the form data/display + try: + self.data = self.data.copy() + self.data["end_date"] = end_date.isoformat() + except Exception: + pass + self.fields["end_date"].initial = end_date + return cleaned + + def save(self, commit=True): + instance = super().save(commit=False) + end_date = self.cleaned_data.get("end_date") + if end_date: + instance.end_date = end_date + if commit: + instance.save() + try: + self.save_m2m() + except Exception: + pass + return instance + + +class ShiftForm(forms.Form): + name = forms.CharField(max_length=200) + sites = forms.CharField( + help_text="Comma separated list of sites (e.g. exeter,plymouth)", + required=True, + ) + length = forms.DecimalField(max_digits=6, decimal_places=2, initial=12.5) + DAYS = [(d, d) for d in ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]] + days = forms.MultipleChoiceField(choices=DAYS, widget=forms.CheckboxSelectMultiple) + workers_required = forms.IntegerField(min_value=1, initial=1) + assign_as_block = forms.BooleanField(required=False, initial=False) + + def clean_sites(self): + val = self.cleaned_data["sites"] + sites = [s.strip() for s in val.split(",") if s.strip()] + if not sites: + raise forms.ValidationError("At least one site is required") + return sites diff --git a/djangorota/rota/migrations/0002_rotaschedule_options_rotaschedule_shifts.py b/djangorota/rota/migrations/0002_rotaschedule_options_rotaschedule_shifts.py new file mode 100644 index 0000000..f32bb66 --- /dev/null +++ b/djangorota/rota/migrations/0002_rotaschedule_options_rotaschedule_shifts.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2025-12-09 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("rota", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="rotaschedule", + name="options", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="rotaschedule", + name="shifts", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/djangorota/rota/models.py b/djangorota/rota/models.py index a5c0948..7d3b44e 100644 --- a/djangorota/rota/models.py +++ b/djangorota/rota/models.py @@ -14,10 +14,59 @@ class RotaSchedule(models.Model): description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) + # Store the SingleShift definitions as a list of dicts (pydantic -> dict) + shifts = models.JSONField(default=list, blank=True) + # Additional builder options / constraints (serialisable) + options = models.JSONField(default=dict, blank=True) def __str__(self): return self.name + def to_rota_builder(self): + """Construct a `rota.shifts.RotaBuilder` from this schedule and its shifts/workers. + + This will: + - instantiate a RotaBuilder with `start_date` and computed `weeks_to_rota`; + - convert stored `shifts` (list of dicts) to `SingleShift` pydantic models and add them; + - convert assigned Django `Worker` objects to pydantic `Worker` and add them. + + Returns the RotaBuilder instance. + """ + try: + from rota.shifts import RotaBuilder, SingleShift + except Exception: + raise RuntimeError("Unable to import rota.shifts; ensure the rota package is importable") + + # compute weeks_to_rota (integer number of weeks) + 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 {}) + rb = RotaBuilder(start_date=self.start_date, weeks_to_rota=weeks_to_rota, name=self.name, **rb_kwargs) + + # Add shifts (expecting list of dicts compatible with SingleShift) + shifts_objs = [] + for s in self.shifts or []: + try: + obj = SingleShift(**s) + except Exception as exc: + raise ValueError(f"Invalid shift data for rota {self.name}: {exc}") + shifts_objs.append(obj) + + if shifts_objs: + rb.add_shifts(*shifts_objs) + + # Add workers assigned to this rota + for w in self.workers.all(): + try: + pyd_worker = w.to_pydantic() + except Exception: + # skip workers that cannot be converted + continue + rb.add_worker(pyd_worker) + + return rb class Worker(models.Model): """Worker / staff member that can be assigned to one or more rotas.""" @@ -34,6 +83,28 @@ class Worker(models.Model): def __str__(self): return self.name + def to_pydantic(self): + """Return a `rota.workers.Worker` pydantic model populated from this Django model. + + Only maps a minimal set of fields required by the scheduler; additional + pydantic defaults will fill the rest. + """ + try: + from rota.workers import Worker as PydWorker + except Exception: + raise RuntimeError("Unable to import rota.workers.Worker; ensure project package is importable") + + # Map fte: Django stores percentage (100) while pydantic Worker expects int + pyd_kwargs = { + "name": self.name, + "site": self.site or "", + "grade": self.grade or 1, + "id": self.pk, + "fte": int(self.fte), + } + + return PydWorker(**pyd_kwargs) + class Assignment(models.Model): """Join model assigning a worker to a rota (can hold role / metadata).""" diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index b7e7821..1f21b12 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -6,7 +6,11 @@ app_name = "rota" urlpatterns = [ path("", views.index, name="index"), + path("rota/add/", views.rota_create, name="rota_add"), path("rota//", views.rota_detail, name="rota_detail"), + 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("worker/add/", views.worker_create, name="worker_add"), path("worker//", views.worker_detail, name="worker_detail"), path("run//", views.rota_run_detail, name="rota_run_detail"), diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index a05463d..b674b57 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -4,6 +4,10 @@ from django.urls import reverse from .models import RotaSchedule, Worker from .forms import WorkerForm, LeaveForm, RotaScheduleForm from .services import run_rota_async +from .forms import ShiftForm +from django.views.decorators.http import require_http_methods +from django.template.loader import render_to_string +from django.http import HttpResponse, HttpResponseBadRequest def index(request): @@ -16,11 +20,23 @@ def rota_detail(request, rota_id): if request.method == "POST": # trigger a background run run = run_rota_async(rota.id) - return redirect(reverse("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}) +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 worker_create(request): if request.method == "POST": form = WorkerForm(request.POST) @@ -41,7 +57,7 @@ def worker_detail(request, worker_id): leave = form.save(commit=False) leave.worker = worker leave.save() - return redirect("worker_detail", worker_id=worker.id) + return redirect("rota:worker_detail", worker_id=worker.id) else: form = LeaveForm() @@ -54,3 +70,122 @@ def rota_run_detail(request, run_id): run = get_object_or_404(RotaRun, pk=run_id) return render(request, "rota/rota_run_detail.html", {"run": run}) + +@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"]), + } + + 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 + resp = ( + f'
{shift_list_html}
' + + '' + ) + return HttpResponse(resp) + + # 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}, 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"]), + } + 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'
{shift_list_html}
' + + '' + ) + return HttpResponse(resp) + + 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), + } + form = ShiftForm(initial=initial) + 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_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'
{shift_list_html}
' + + '' + ) + return HttpResponse(resp) + + # 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) + diff --git a/requirements.txt b/requirements.txt index 885289f..3156109 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,6 @@ loguru numpy highspy typer -django>=6.0,<7 \ No newline at end of file +django>=6.0,<7 +django-crispy-forms +crispy-bulma \ No newline at end of file