.
This commit is contained in:
@@ -15,3 +15,6 @@ logs/
|
||||
web/rota
|
||||
|
||||
cons/
|
||||
|
||||
|
||||
*.sqlite3
|
||||
@@ -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"
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
+3
-1
@@ -15,4 +15,6 @@ loguru
|
||||
numpy
|
||||
highspy
|
||||
typer
|
||||
django>=6.0,<7
|
||||
django>=6.0,<7
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
Reference in New Issue
Block a user