This commit is contained in:
Ross
2025-12-09 10:32:24 +00:00
parent 1d66ab185a
commit 3f73316dbf
9 changed files with 330 additions and 6 deletions
+3
View File
@@ -15,3 +15,6 @@ logs/
web/rota
cons/
*.sqlite3
+7 -1
View File
@@ -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"
+1 -1
View File
@@ -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")),
]
+81 -1
View File
@@ -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
@@ -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),
),
]
+71
View File
@@ -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)."""
+4
View File
@@ -6,7 +6,11 @@ app_name = "rota"
urlpatterns = [
path("", views.index, name="index"),
path("rota/add/", views.rota_create, name="rota_add"),
path("rota/<int:rota_id>/", views.rota_detail, name="rota_detail"),
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>/delete/", views.shift_delete, name="shift_delete"),
path("worker/add/", views.worker_create, name="worker_add"),
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
+137 -2
View File
@@ -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'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
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'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
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'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
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)
+2
View File
@@ -16,3 +16,5 @@ numpy
highspy
typer
django>=6.0,<7
django-crispy-forms
crispy-bulma