Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48c219281e | ||
|
|
724e3ab6bd | ||
|
|
78b6ba2a3b | ||
|
|
dfd37a7a62 | ||
|
|
069da08f5a | ||
|
|
05edb93427 | ||
|
|
dadc940993 | ||
|
|
ae52d80f98 | ||
|
|
3feb5fc7a3 | ||
|
|
40135a282d | ||
|
|
e6943ccfc7 | ||
|
|
c63d4d1585 | ||
|
|
eb95f5fa6d | ||
|
|
7fe44df476 | ||
|
|
5a2704b1d2 | ||
|
|
91b6fdd733 | ||
|
|
3f73316dbf | ||
|
|
1d66ab185a | ||
|
|
4927697621 | ||
|
|
a230c9d94c |
@@ -15,3 +15,6 @@ logs/
|
||||
web/rota
|
||||
|
||||
cons/
|
||||
|
||||
|
||||
*.sqlite3
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
ASGI config for djangorota project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
# Ensure repo root is importable when running via ASGI
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
if str(repo_root) not in sys.path:
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangorota.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Django settings for djangorota project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-!1lb2nkbnnl@du3s$a_zywfj1dlfym&y(3f^zqt%%*ypq+-vy&"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
# Add local rota app and crispy form packs
|
||||
INSTALLED_APPS += [
|
||||
"rota",
|
||||
"crispy_forms",
|
||||
"crispy_bulma",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "djangorota.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
# look for project-level templates at djangorota/templates
|
||||
"DIRS": [BASE_DIR / "djangorota" / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "djangorota.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
# Project-level output directory (app-level) plus repo-level `output` for legacy layout
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "output",
|
||||
# also look for output/ at the repository root (BASE_DIR.parent)
|
||||
BASE_DIR.parent / "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"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
URL configuration for djangorota project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include(("rota.urls", "rota"), namespace="rota")),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
WSGI config for djangorota project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
# Ensure repo root is importable when running via WSGI
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[2]
|
||||
if str(repo_root) not in sys.path:
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangorota.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
# Ensure the repository root is on sys.path so top-level modules (e.g.
|
||||
# `rota_generator` or `rota`) are importable when running Django from
|
||||
# the `djangorota/` directory.
|
||||
repo_root = pathlib.Path(__file__).resolve().parents[1]
|
||||
if str(repo_root) not in sys.path:
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangorota.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
from django.contrib import admin
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.RotaSchedule)
|
||||
class RotaScheduleAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "start_date", "end_date", "created_at")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(models.Worker)
|
||||
class WorkerAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "email", "site", "grade", "fte", "active")
|
||||
list_filter = ("site", "active")
|
||||
search_fields = ("name", "email")
|
||||
|
||||
|
||||
@admin.register(models.Assignment)
|
||||
class AssignmentAdmin(admin.ModelAdmin):
|
||||
list_display = ("worker", "rota", "role", "created_at")
|
||||
list_select_related = ("worker", "rota")
|
||||
|
||||
|
||||
@admin.register(models.Leave)
|
||||
class LeaveAdmin(admin.ModelAdmin):
|
||||
list_display = ("worker", "start_date", "end_date", "reason")
|
||||
list_filter = ("start_date", "end_date")
|
||||
search_fields = ("worker__name",)
|
||||
|
||||
|
||||
@admin.register(models.RotaRun)
|
||||
class RotaRunAdmin(admin.ModelAdmin):
|
||||
list_display = ("rota", "status", "created_at", "started_at", "finished_at")
|
||||
list_filter = ("status", "created_at")
|
||||
readonly_fields = ("log", "result")
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RotaConfig(AppConfig):
|
||||
name = "rota"
|
||||
@@ -0,0 +1,226 @@
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
|
||||
from .models import Worker, Leave, RotaSchedule
|
||||
import importlib
|
||||
import json
|
||||
|
||||
|
||||
class WorkerForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Worker
|
||||
fields = ["name", "email", "site", "grade", "fte", "active"]
|
||||
|
||||
|
||||
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
|
||||
# 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
|
||||
# Dynamically add RotaBuilder constraint option fields using the
|
||||
# typed `RotaConstraintOptions` metadata (types + descriptions).
|
||||
constraint_defaults = {}
|
||||
constraint_meta = {}
|
||||
try:
|
||||
mod = importlib.import_module("rota_generator.shifts")
|
||||
RotaBuilder = getattr(mod, "RotaBuilder")
|
||||
rb = RotaBuilder()
|
||||
opts_model = getattr(rb, "constraint_options_model", None)
|
||||
if opts_model is not None:
|
||||
# default values
|
||||
constraint_defaults = opts_model.model_dump()
|
||||
# Try to extract field descriptions from Pydantic model_fields
|
||||
try:
|
||||
mf = opts_model.__class__.model_fields
|
||||
for k, info in mf.items():
|
||||
desc = None
|
||||
# field info may expose 'description' attribute or mapping
|
||||
if hasattr(info, "description") and info.description:
|
||||
desc = info.description
|
||||
else:
|
||||
try:
|
||||
desc = info.get("description")
|
||||
except Exception:
|
||||
desc = None
|
||||
constraint_meta[k] = desc
|
||||
except Exception:
|
||||
constraint_meta = {}
|
||||
except Exception:
|
||||
constraint_defaults = {}
|
||||
constraint_meta = {}
|
||||
|
||||
self._constraint_defaults = constraint_defaults
|
||||
self._constraint_meta = constraint_meta
|
||||
|
||||
# Load existing options from instance if present
|
||||
existing_opts = {}
|
||||
if instance and getattr(instance, "options", None):
|
||||
existing_opts = instance.options or {}
|
||||
|
||||
for key, default in constraint_defaults.items():
|
||||
field_name = f"opt__{key}"
|
||||
initial = existing_opts.get(key, default)
|
||||
help_text = constraint_meta.get(key) if isinstance(constraint_meta, dict) else None
|
||||
label = key.replace("_", " ")
|
||||
if isinstance(default, bool):
|
||||
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=label, help_text=help_text)
|
||||
elif isinstance(default, int):
|
||||
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=label, help_text=help_text)
|
||||
elif isinstance(default, float):
|
||||
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=label, help_text=help_text)
|
||||
elif isinstance(default, (list, dict)):
|
||||
# JSON text area for structured defaults
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=json.dumps(initial), widget=forms.Textarea, label=label, help_text=(help_text or "Enter JSON"))
|
||||
else:
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=label, help_text=help_text)
|
||||
|
||||
# Expose a small set of RotaBuilder constructor arguments on the form
|
||||
# so they can be set per-rota. These are stored inside `instance.options`
|
||||
# and will be passed through by `RotaSchedule.to_rota_builder()`.
|
||||
builder_args = {
|
||||
"balance_offset_modifier": {"type": "int", "default": 1, "help": "Balance offset modifier used by the builder"},
|
||||
"ltft_balance_offset": {"type": "int", "default": 1, "help": "LTFT balance offset used by the builder"},
|
||||
"use_previous_shifts": {"type": "bool", "default": False, "help": "Use previous shifts when building the rota"},
|
||||
"use_shift_balance_extra": {"type": "bool", "default": False, "help": "Enable extra shift-balance penalties"},
|
||||
"use_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
|
||||
"allow_force_assignment_with_leave_conflict": {"type": "bool", "default": False, "help": "Allow force assignments even when leave conflicts exist"},
|
||||
}
|
||||
|
||||
for k, meta in builder_args.items():
|
||||
field_name = f"opt__{k}"
|
||||
initial = existing_opts.get(k, meta["default"])
|
||||
if meta["type"] == "bool":
|
||||
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
elif meta["type"] == "int":
|
||||
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
else:
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
|
||||
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
|
||||
# Collect option fields and persist into instance.options
|
||||
opts = instance.options or {}
|
||||
# constraint-based option fields
|
||||
for key in getattr(self, "_constraint_defaults", {}).keys():
|
||||
field_name = f"opt__{key}"
|
||||
if field_name in self.cleaned_data:
|
||||
val = self.cleaned_data[field_name]
|
||||
default = self._constraint_defaults.get(key)
|
||||
# Parse JSON for list/dict fields
|
||||
if isinstance(default, (list, dict)):
|
||||
try:
|
||||
parsed = json.loads(val) if val is not None and val != "" else []
|
||||
except Exception:
|
||||
parsed = val
|
||||
opts[key] = parsed
|
||||
else:
|
||||
opts[key] = val
|
||||
|
||||
# builder args (prefixed with opt__)
|
||||
builder_keys = [
|
||||
"balance_offset_modifier",
|
||||
"ltft_balance_offset",
|
||||
"use_previous_shifts",
|
||||
"use_shift_balance_extra",
|
||||
"use_bank_holiday_extra",
|
||||
"allow_force_assignment_with_leave_conflict",
|
||||
]
|
||||
for k in builder_keys:
|
||||
field_name = f"opt__{k}"
|
||||
if field_name in self.cleaned_data:
|
||||
opts[k] = self.cleaned_data[field_name]
|
||||
|
||||
instance.options = opts
|
||||
if commit:
|
||||
instance.save()
|
||||
try:
|
||||
self.save_m2m()
|
||||
except Exception:
|
||||
pass
|
||||
return instance
|
||||
|
||||
|
||||
class RotaOptionsForm(forms.Form):
|
||||
options_json = forms.CharField(
|
||||
widget=forms.Textarea(attrs={"rows": 10, "class": "textarea"}),
|
||||
required=False,
|
||||
help_text="Edit rota options as JSON. See available constraint keys below.",
|
||||
)
|
||||
|
||||
|
||||
class ShiftForm(forms.Form):
|
||||
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)
|
||||
balance_offset = forms.FloatField(
|
||||
required=False,
|
||||
help_text="Maximum allowed difference in allocated shifts for this shift (leave blank for default)",
|
||||
)
|
||||
|
||||
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 @@
|
||||
# package
|
||||
@@ -0,0 +1 @@
|
||||
# package
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from rota.models import RotaSchedule
|
||||
from rota.services import run_rota_async
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create a RotaRun for the given RotaSchedule id and start it in background."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("rota_id", type=int)
|
||||
parser.add_argument("--external", action="store_true", help="Use external generator hook if available")
|
||||
parser.add_argument("--no-export", action="store_true", help="Do not generate HTML export for this run")
|
||||
parser.add_argument("--no-solve", action="store_true", help="Do not run solver when generating export")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
rota_id = options["rota_id"]
|
||||
use_external = options.get("external", False)
|
||||
try:
|
||||
rota = RotaSchedule.objects.get(pk=rota_id)
|
||||
except RotaSchedule.DoesNotExist:
|
||||
raise CommandError(f"RotaSchedule with id={rota_id} not found")
|
||||
|
||||
no_export = options.get("no_export", False)
|
||||
no_solve = options.get("no_solve", False)
|
||||
run = run_rota_async(
|
||||
rota.id,
|
||||
use_external_generator=use_external,
|
||||
generate_builder=not no_export,
|
||||
builder_solve=not no_solve,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Started RotaRun id={run.id} for rota={rota.name}"))
|
||||
@@ -0,0 +1,164 @@
|
||||
# Generated by Django 6.0 on 2025-12-09 09:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RotaSchedule",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200, unique=True)),
|
||||
("start_date", models.DateField()),
|
||||
("end_date", models.DateField()),
|
||||
("description", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RotaRun",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("result", models.JSONField(blank=True, null=True)),
|
||||
("log", models.TextField(blank=True)),
|
||||
(
|
||||
"rota",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="runs",
|
||||
to="rota.rotaschedule",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Assignment",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("role", models.CharField(blank=True, max_length=100)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"rota",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="rota.rotaschedule",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Worker",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
("email", models.EmailField(blank=True, max_length=254)),
|
||||
("site", models.CharField(blank=True, max_length=100)),
|
||||
("grade", models.IntegerField(blank=True, null=True)),
|
||||
("fte", models.FloatField(default=1.0)),
|
||||
("active", models.BooleanField(default=True)),
|
||||
(
|
||||
"rotas",
|
||||
models.ManyToManyField(
|
||||
related_name="workers",
|
||||
through="rota.Assignment",
|
||||
to="rota.rotaschedule",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Leave",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField()),
|
||||
("end_date", models.DateField()),
|
||||
("reason", models.CharField(blank=True, max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"worker",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="leaves",
|
||||
to="rota.worker",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-start_date",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="assignment",
|
||||
name="worker",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="rota.worker"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="assignment",
|
||||
unique_together={("worker", "rota")},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Add export_html field to RotaRun.
|
||||
|
||||
Generated by assistant.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rota", "0002_rotaschedule_options_rotaschedule_shifts"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="rotarun",
|
||||
name="export_html",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,206 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class RotaSchedule(models.Model):
|
||||
"""Represents a named rota (collection of shifts over a date range).
|
||||
|
||||
This is the top-level object that groups workers and runs.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=200, unique=True)
|
||||
start_date = models.DateField()
|
||||
end_date = models.DateField()
|
||||
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 importing the project's rota generator implementation. Some setups
|
||||
# provide the implementation as `rota_generator.shifts` (script/package),
|
||||
# while others provide it as `rota.shifts` (library folder). Try both
|
||||
# so the Django app works in either layout.
|
||||
RotaBuilder = None
|
||||
SingleShift = None
|
||||
import importlib
|
||||
for mod_name in ("rota_generator.shifts", "rota.shifts"):
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
RotaBuilder = getattr(mod, "RotaBuilder")
|
||||
SingleShift = getattr(mod, "SingleShift")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if RotaBuilder is None or SingleShift is None:
|
||||
raise RuntimeError(
|
||||
"Unable to import rota generator (tried 'rota_generator.shifts' and 'rota.shifts'); "
|
||||
"ensure the rota package is importable and contains RotaBuilder/SingleShift"
|
||||
)
|
||||
|
||||
# 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."""
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
email = models.EmailField(blank=True)
|
||||
site = models.CharField(max_length=100, blank=True)
|
||||
grade = models.IntegerField(null=True, blank=True)
|
||||
fte = models.FloatField(default=1.0)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
|
||||
|
||||
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_generator.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)."""
|
||||
|
||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE)
|
||||
rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=100, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("worker", "rota")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.worker} -> {self.rota}"
|
||||
|
||||
|
||||
class Leave(models.Model):
|
||||
"""Leave record for a worker. Stored as inclusive start/end dates."""
|
||||
|
||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name="leaves")
|
||||
start_date = models.DateField()
|
||||
end_date = models.DateField()
|
||||
reason = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-start_date",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.worker}: {self.start_date} → {self.end_date}"
|
||||
|
||||
|
||||
class RotaRun(models.Model):
|
||||
"""Represents an execution of the scheduler for a RotaSchedule.
|
||||
|
||||
The run stores status and (optionally) JSON results. A background
|
||||
runner or management command can update this record as the job progresses.
|
||||
"""
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_SUCCESS = "success"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, "Pending"),
|
||||
(STATUS_RUNNING, "Running"),
|
||||
(STATUS_SUCCESS, "Success"),
|
||||
(STATUS_FAILED, "Failed"),
|
||||
]
|
||||
|
||||
rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE, related_name="runs")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||
result = models.JSONField(null=True, blank=True)
|
||||
log = models.TextField(blank=True)
|
||||
|
||||
# Optional HTML export produced by a run (stored inline for convenience).
|
||||
# May be large; keep nullable to avoid breaking existing runs.
|
||||
export_html = models.TextField(null=True, blank=True)
|
||||
|
||||
def mark_running(self):
|
||||
self.status = self.STATUS_RUNNING
|
||||
self.started_at = timezone.now()
|
||||
self.save(update_fields=["status", "started_at"])
|
||||
|
||||
def mark_finished(self, result=None):
|
||||
self.status = self.STATUS_SUCCESS
|
||||
self.finished_at = timezone.now()
|
||||
if result is not None:
|
||||
self.result = result
|
||||
self.save(update_fields=["status", "finished_at", "result"])
|
||||
|
||||
def mark_failed(self, message=""):
|
||||
self.status = self.STATUS_FAILED
|
||||
self.finished_at = timezone.now()
|
||||
self.log = (self.log or "") + "\n" + message
|
||||
self.save(update_fields=["status", "finished_at", "log"])
|
||||
|
||||
def __str__(self):
|
||||
return f"RotaRun({self.rota.name} - {self.status} @ {self.created_at.isoformat()})"
|
||||
@@ -0,0 +1,102 @@
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import RotaRun, RotaSchedule
|
||||
|
||||
|
||||
def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_builder: bool = True, builder_solve: bool = True):
|
||||
"""Background job that executes the scheduler and updates RotaRun.
|
||||
|
||||
If `use_external_generator` is True, callers should implement a project
|
||||
level `generate_rota_for_schedule` function and import it here. By
|
||||
default a minimal placeholder result is recorded so the UI and workflow
|
||||
can be tested without wiring the full solver.
|
||||
"""
|
||||
|
||||
run = RotaRun.objects.get(pk=run_id)
|
||||
try:
|
||||
run.mark_running()
|
||||
|
||||
rota: RotaSchedule = run.rota
|
||||
|
||||
# Placeholder: integrate your generator here.
|
||||
# Example integration point (not implemented in this scaffold):
|
||||
# from proc.integration import generate_rota_for_schedule
|
||||
# result = generate_rota_for_schedule(rota)
|
||||
|
||||
if use_external_generator:
|
||||
# Try to import a project-level generator hook. This allows you to
|
||||
# call into existing code (e.g. your `rota.run2` logic) without
|
||||
# hard-coding it into the Django app.
|
||||
try:
|
||||
from project_rota_hook import generate_rota_for_schedule # type: ignore
|
||||
|
||||
result = generate_rota_for_schedule(rota)
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
run.mark_failed(message=tb)
|
||||
return
|
||||
else:
|
||||
# Minimal demo result for initial testing
|
||||
result = {
|
||||
"rota": rota.name,
|
||||
"worker_count": rota.workers.count(),
|
||||
"generated_at": timezone.now().isoformat(),
|
||||
}
|
||||
|
||||
# Optionally build the RotaBuilder and attach generated HTML to the
|
||||
# result. This allows the web UI to present the generated HTML for a
|
||||
# run without requiring a separate on-demand build.
|
||||
if generate_builder:
|
||||
try:
|
||||
rb = rota.to_rota_builder()
|
||||
# build the model and optionally solve
|
||||
rb.build_and_solve(solve=builder_solve)
|
||||
html = rb.get_worker_timetable_html(include_html_tag=True)
|
||||
# store under result so it's accessible from UI
|
||||
result["builder_html"] = html
|
||||
# Persist the HTML on the RotaRun record for easy retrieval
|
||||
try:
|
||||
run.export_html = html
|
||||
run.save(update_fields=["export_html"])
|
||||
except Exception:
|
||||
# best-effort: if saving fails, include a log entry
|
||||
run.log = (run.log or "") + "\nFailed to persist export_html"
|
||||
except Exception as exc:
|
||||
# include failure information in run.log and result
|
||||
tb = traceback.format_exc()
|
||||
run.log = (run.log or "") + "\n" + tb
|
||||
result["builder_error"] = str(exc)
|
||||
|
||||
run.mark_finished(result=result)
|
||||
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
try:
|
||||
run.mark_failed(message=tb)
|
||||
except Exception:
|
||||
# last resort: log to stdout
|
||||
print("Failed to update RotaRun with failure:")
|
||||
print(tb)
|
||||
|
||||
|
||||
def run_rota_async(rota_id: int, use_external_generator: bool = False, generate_builder: bool = True, builder_solve: bool = True) -> RotaRun:
|
||||
"""Create a RotaRun and start background thread to run it.
|
||||
|
||||
Returns the created RotaRun instance.
|
||||
"""
|
||||
|
||||
rota = RotaSchedule.objects.get(pk=rota_id)
|
||||
run = RotaRun.objects.create(rota=rota)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_rota_job,
|
||||
args=(run.id, use_external_generator, generate_builder, builder_solve),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return run
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
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("rota/<int:rota_id>/edit/", views.rota_edit, name="rota_edit"),
|
||||
path("worker/add/", views.worker_create, name="worker_add"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
||||
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
|
||||
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
||||
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
||||
path("rota/<int:rota_id>/export/builder/", views.rota_export_builder, name="rota_export_builder"),
|
||||
path("rota/<int:rota_id>/options/update/", views.rota_options_update, name="rota_options_update"),
|
||||
path("rota/<int:rota_id>/options/", views.rota_options, name="rota_options"),
|
||||
path("rota/<int:rota_id>/option/add/", views.rota_option_add, name="rota_option_add"),
|
||||
path("rota/<int:rota_id>/option/edit/", views.rota_option_edit, name="rota_option_edit"),
|
||||
path("rota/<int:rota_id>/option/delete/", views.rota_option_delete, name="rota_option_delete"),
|
||||
path("rota/<int:rota_id>/runs/clear/", views.rota_runs_clear, name="rota_runs_clear"),
|
||||
path("run/<int:run_id>/export/regenerate/", views.rota_run_export_regenerate, name="rota_run_export_regenerate"),
|
||||
]
|
||||
@@ -0,0 +1,773 @@
|
||||
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 .services import run_rota_async
|
||||
from .forms import ShiftForm
|
||||
from .forms import RotaOptionsForm
|
||||
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.views.decorators.http import require_POST
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
import traceback
|
||||
|
||||
|
||||
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 options form and available builder constraint defaults
|
||||
options_form = RotaOptionsForm()
|
||||
available_constraints = {}
|
||||
try:
|
||||
# Try to import RotaBuilder to surface its default constraint options
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("rota_generator.shifts")
|
||||
RotaBuilder = getattr(mod, "RotaBuilder")
|
||||
try:
|
||||
rb = RotaBuilder()
|
||||
available_constraints = getattr(rb, "constraint_options", {})
|
||||
except Exception:
|
||||
# fallback to class attribute if instantiation fails
|
||||
available_constraints = {}
|
||||
except Exception:
|
||||
available_constraints = {}
|
||||
if request.method == "POST":
|
||||
# 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,)))
|
||||
|
||||
# Prefill options form with existing rota.options JSON
|
||||
try:
|
||||
opt_json = json.dumps(rota.options or {}, indent=4)
|
||||
except Exception:
|
||||
opt_json = "{}"
|
||||
|
||||
# Build a richer representation of available constraints (default, type, description)
|
||||
available_constraints_rich = {}
|
||||
try:
|
||||
mod = importlib.import_module("rota_generator.shifts")
|
||||
RotaBuilder = getattr(mod, "RotaBuilder")
|
||||
rb = RotaBuilder()
|
||||
opts_model = getattr(rb, "constraint_options_model", None)
|
||||
if opts_model is not None:
|
||||
defaults = opts_model.model_dump()
|
||||
# attempt to get field descriptions from pydantic metadata
|
||||
meta = {}
|
||||
try:
|
||||
meta = opts_model.__class__.model_fields
|
||||
except Exception:
|
||||
meta = {}
|
||||
for k, v in defaults.items():
|
||||
desc = None
|
||||
if isinstance(meta, dict) and k in meta:
|
||||
info = meta[k]
|
||||
if hasattr(info, "description"):
|
||||
desc = info.description
|
||||
else:
|
||||
try:
|
||||
desc = info.get("description")
|
||||
except Exception:
|
||||
desc = None
|
||||
# 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()}
|
||||
|
||||
options_form = RotaOptionsForm(initial={"options_json": opt_json})
|
||||
return render(
|
||||
request,
|
||||
"rota/rota_detail.html",
|
||||
{"rota": rota, "options_form": options_form, "available_constraints": available_constraints_rich},
|
||||
)
|
||||
|
||||
|
||||
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>'
|
||||
+ '<div id="modal" hx-swap-oob="true"></div>'
|
||||
)
|
||||
response = HttpResponse(resp)
|
||||
response["HX-Trigger"] = "closeModal"
|
||||
return response
|
||||
# If we couldn't render the worker list (no rota), just close modal
|
||||
response = HttpResponse('<div id="modal" hx-swap-oob="true"></div>')
|
||||
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})
|
||||
|
||||
|
||||
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()
|
||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||
else:
|
||||
form = LeaveForm()
|
||||
|
||||
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form})
|
||||
|
||||
|
||||
@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>'
|
||||
+ '<div id="modal" hx-swap-oob="true"></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},
|
||||
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},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
|
||||
@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>'
|
||||
+ '<div id="modal" hx-swap-oob="true"></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)
|
||||
return render(request, "rota/rota_run_detail.html", {"run": run})
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
from .forms import RotaScheduleForm
|
||||
|
||||
form = RotaScheduleForm(request.POST, instance=rota)
|
||||
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")
|
||||
|
||||
form.save()
|
||||
if is_hx:
|
||||
return _oob_update_options_display(request, rota)
|
||||
return redirect(reverse("rota:rota_detail", args=(rota.id,)))
|
||||
|
||||
# GET: render modal with typed form populated from the rota instance
|
||||
from .forms import RotaScheduleForm
|
||||
form = RotaScheduleForm(instance=rota)
|
||||
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>' + '<div id="modal" hx-swap-oob="true"></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
|
||||
|
||||
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},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_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)
|
||||
|
||||
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},
|
||||
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 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
|
||||
|
||||
|
||||
@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"),
|
||||
}
|
||||
|
||||
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>'
|
||||
+ '<div id="modal" 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}, 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="shift-form" hx-swap-oob="true"></div>'
|
||||
+ '<div id="modal" 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),
|
||||
}
|
||||
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="shift-form" hx-swap-oob="true"></div>'
|
||||
+ '<div id="modal" 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)
|
||||
|
||||
@@ -2,7 +2,7 @@ import datetime
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||
from rota_generator.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||
import typer
|
||||
import subprocess
|
||||
|
||||
@@ -19,7 +19,7 @@ sites = (
|
||||
"proc",
|
||||
)
|
||||
|
||||
from rota.workers import (
|
||||
from rota_generator.workers import (
|
||||
Worker,
|
||||
NotAvailableToWork,
|
||||
NonWorkingDays,
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@ import datetime
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from rota.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||
import typer
|
||||
import subprocess
|
||||
import pandas as pd
|
||||
|
||||
from rota.workers import (
|
||||
from rota_generator.workers import (
|
||||
Worker,
|
||||
NotAvailableToWork,
|
||||
NonWorkingDays,
|
||||
|
||||
@@ -7,7 +7,7 @@ from loguru import logger
|
||||
from rich.pretty import pprint
|
||||
|
||||
|
||||
from rota.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme
|
||||
from rota_generator.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme
|
||||
|
||||
date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}"
|
||||
|
||||
|
||||
@@ -15,3 +15,6 @@ loguru
|
||||
numpy
|
||||
highspy
|
||||
typer
|
||||
django>=6.0,<7
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
@@ -16,9 +16,9 @@ from pyomo.environ import *
|
||||
from pyomo.opt import SolverFactory, TerminationCondition, SolverStatus
|
||||
import urllib.parse
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota.console import console
|
||||
from rota.bank_holidays import bank_holiday_map
|
||||
from rota_generator.workers import Worker
|
||||
from rota_generator.console import console
|
||||
from rota_generator.bank_holidays import bank_holiday_map
|
||||
|
||||
import json
|
||||
|
||||
@@ -321,6 +321,52 @@ class SingleShift(BaseModel):
|
||||
return self.name[0]
|
||||
|
||||
|
||||
class RotaConstraintOptions(BaseModel):
|
||||
"""Pydantic model holding rota-level constraint options with types, defaults and descriptions.
|
||||
|
||||
Validate/coerce raw JSON (e.g. from DB) via `RotaConstraintOptions.model_validate(raw)`.
|
||||
Use `model_dump()` to produce a JSON-serializable dict for storage.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
balance_nights: bool = Field(True, description="Balance night shifts across workers")
|
||||
constrain_time_off_after_nights: bool = Field(False, description="Apply time-off constraints after night shifts")
|
||||
balance_nights_across_sites: bool = Field(True, description="Balance night shifts across sites")
|
||||
balance_bank_holidays: bool = Field(True, description="Balance bank-holiday shifts across staff")
|
||||
balance_blocks: bool = Field(True, description="Balance shifts across week-blocks")
|
||||
balance_shifts: bool = Field(True, description="Balance shifts across workers")
|
||||
balance_shifts_true_quadratic: bool = Field(False, description="Use true quadratic balancing for shifts")
|
||||
balance_shifts_quadratic: bool = Field(False, description="Use quadratic penalty to discourage spread across shifts")
|
||||
balance_shifts_over_workers: bool = Field(True, description="Balance shift distribution over workers")
|
||||
minimise_shift_diffs: bool = Field(False, description="Use simpler minimisation to reduce shift differences")
|
||||
maximum_allowed_shift_diff: Optional[int] = Field(None, description="Maximum allowed per-worker shift difference (None = no limit)")
|
||||
balance_weekends: bool = Field(True, description="Balance weekend allocations")
|
||||
max_weekends: int = Field(100, description="Maximum number of weekends considered")
|
||||
max_weekend_frequency: int = Field(1, description="Don't assign multiple shifts every N weeks (requires balance_weekends)")
|
||||
max_night_frequency: int = Field(1, description="Don't assign multiple night shifts every N weeks")
|
||||
max_night_frequency_week_exclusions: List[int] = Field(default_factory=list, description="Weeks to exclude from night frequency checks")
|
||||
max_shifts_per_week: int = Field(7, description="Max shifts per worker per week")
|
||||
max_shifts_per_month: int = Field(40, description="Max shifts per worker per month")
|
||||
max_days_per_week_block: List[tuple] = Field(default_factory=list, description="Optional list of (max_days, week_block) tuples to limit days in blocks")
|
||||
prevent_monday_after_full_weekends: List[int] = Field(default_factory=list, description="Prevent assignment on Monday after full weekends")
|
||||
prevent_monday_and_tuesday_after_full_weekends: List[int] = Field(default_factory=list, description="Prevent Monday/Tuesday after full weekends")
|
||||
prevent_fridays_before_full_weekends: List[int] = Field(default_factory=list, description="Prevent Fridays before full weekends")
|
||||
prevent_thursdays_before_full_weekends: List[int] = Field(default_factory=list, description="Prevent Thursdays before full weekends")
|
||||
hard_constrain_pair_separation: bool = Field(False, description="Enforce hard pair separation constraint")
|
||||
# These are lists of dicts with keys like {"grades": [...], "weeks": [...], "shifts": [...]}
|
||||
avoid_shifts_by_grades: List[dict] = Field(default_factory=list, description="List of rules to avoid assigning grades to shifts")
|
||||
avoid_shifts_by_worker_names: List[dict] = Field(default_factory=list, description="List of rules to avoid assigning specific workers to shifts")
|
||||
allocate_locum_shifts: bool = Field(True, description="Allow allocation of locum shifts")
|
||||
distribute_locum_shifts: bool = Field(True, description="Distribute locum shifts among available workers")
|
||||
balance_locum_shifts: bool = Field(False, description="Balance locum shifts similarly to normal shifts")
|
||||
maximum_allowed_locum_shifts_per_worker: Optional[int] = Field(None, description="Maximum locum shifts allowed per worker (None = no limit)")
|
||||
minimum_allowed_locum_shifts_per_worker: Optional[int] = Field(None, description="Minimum locum shifts required per worker (None = no minimum)")
|
||||
min_summed_grade_by_shifts_per_day: List[MinSummedGradeByShiftsPerDayConstraint] = Field(default_factory=list, description="List of minimum-summed-grade constraints per day")
|
||||
|
||||
# (Removed legacy dict-like convenience methods to enforce
|
||||
# attribute access on the typed `RotaConstraintOptions` model.)
|
||||
|
||||
class RotaBuilder(object):
|
||||
"""Class to hold and manipulate shifts"""
|
||||
|
||||
@@ -368,45 +414,21 @@ class RotaBuilder(object):
|
||||
self.balance_offset_modifier = balance_offset_modifier
|
||||
self.ltft_balance_offset = ltft_balance_offset
|
||||
|
||||
self.constraint_options = {
|
||||
"balance_nights": True,
|
||||
"constrain_time_off_after_nights": False,
|
||||
"balance_nights_across_sites": True,
|
||||
"balance_bank_holidays": True,
|
||||
"balance_blocks": True,
|
||||
"balance_shifts": True, # Does not use a quadratic function
|
||||
"balance_shifts_true_quadratic": False, # Does not use a quadratic function
|
||||
"balance_shifts_quadratic": False, # Will prevent spreading of spreading across different shifts
|
||||
"balance_shifts_over_workers": True,
|
||||
"minimise_shift_diffs": False, # less sophisticated version of balance_shifts_over_workers
|
||||
"maximum_allowed_shift_diff": None,
|
||||
"balance_weekends": True,
|
||||
"max_weekends": 100,
|
||||
# Don't assign multiple shifts every (n) weeks
|
||||
"max_weekend_frequency": 1, # Requires balance weekends
|
||||
# Don't assign multiple shifts every (n) weeks
|
||||
"max_night_frequency": 1,
|
||||
"max_night_frequency_week_exclusions": [],
|
||||
"max_shifts_per_week": 7,
|
||||
"max_shifts_per_month": 40,
|
||||
"max_days_per_week_block": [],
|
||||
#"max_days_per_week_block": [(3, 2)], # (max_days, week_block)
|
||||
# The following will cause an unsolvable problem if a shift
|
||||
# is forced as a block and spans the time peroid
|
||||
"prevent_monday_after_full_weekends": [],
|
||||
"prevent_monday_and_tuesday_after_full_weekends": [],
|
||||
"prevent_fridays_before_full_weekends": [],
|
||||
"prevent_thursdays_before_full_weekends": [],
|
||||
"hard_constrain_pair_separation": False,
|
||||
"avoid_shifts_by_grades": [],
|
||||
"avoid_shifts_by_worker_names": [],
|
||||
"allocate_locum_shifts": True,
|
||||
"distribute_locum_shifts": True,
|
||||
"balance_locum_shifts": False,
|
||||
"maximum_allowed_locum_shifts_per_worker": None,
|
||||
"minimum_allowed_locum_shifts_per_worker": None,
|
||||
}
|
||||
self.constraint_options["min_summed_grade_by_shifts_per_day"]: List[MinSummedGradeByShiftsPerDayConstraint] = []
|
||||
# Initialize constraint options with typed Pydantic model. Accept either
|
||||
# None (use defaults), a dict (raw values), or an existing model instance.
|
||||
# Store the model instance in `self.constraint_options_model` and also
|
||||
# expose it as `self.constraint_options` for minimal code changes.
|
||||
constraint_options = None
|
||||
if constraint_options is None:
|
||||
opts_model = RotaConstraintOptions()
|
||||
elif isinstance(constraint_options, RotaConstraintOptions):
|
||||
opts_model = constraint_options
|
||||
elif isinstance(constraint_options, dict):
|
||||
opts_model = RotaConstraintOptions.model_validate(constraint_options)
|
||||
else:
|
||||
opts_model = RotaConstraintOptions()
|
||||
|
||||
self.constraint_options_model: RotaConstraintOptions = opts_model
|
||||
|
||||
self.terminate_on_warning = [
|
||||
"Worker/duplicate id",
|
||||
@@ -442,10 +464,39 @@ class RotaBuilder(object):
|
||||
self.MAX_SHIFTS_PER_DAY = 2
|
||||
|
||||
def set_rota_constraint(self, constraint: str, value: Any):
|
||||
if constraint not in self.constraint_options:
|
||||
# Ensure the model has the attribute and set it on the typed model
|
||||
if not hasattr(self.constraint_options_model, constraint):
|
||||
raise ValueError(f"Constraint {constraint} not valid")
|
||||
|
||||
self.constraint_options[constraint] = value
|
||||
setattr(self.constraint_options_model, constraint, value)
|
||||
|
||||
@property
|
||||
def constraint_options(self):
|
||||
"""Backward-compat dict-like proxy for the typed
|
||||
`constraint_options_model`. This keeps older code/tests working
|
||||
while the canonical storage is `constraint_options_model`.
|
||||
"""
|
||||
|
||||
class _Proxy:
|
||||
def __init__(self, model):
|
||||
self._m = model
|
||||
|
||||
def __getitem__(self, key):
|
||||
return getattr(self._m, key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
setattr(self._m, key, value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return getattr(self._m, key, default)
|
||||
|
||||
def items(self):
|
||||
return self._m.model_dump().items()
|
||||
|
||||
def __contains__(self, key):
|
||||
return hasattr(self._m, key)
|
||||
|
||||
return _Proxy(self.constraint_options_model)
|
||||
|
||||
def set_rota_dates(self, start_date: datetime.date, weeks_to_rota: int):
|
||||
self.weeks_to_rota = weeks_to_rota
|
||||
@@ -694,7 +745,7 @@ class RotaBuilder(object):
|
||||
initialize=0,
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_bank_holidays"]:
|
||||
if self.constraint_options_model.balance_bank_holidays:
|
||||
# Bank holidays
|
||||
self.model.bank_holiday_count = Var(
|
||||
((worker.id) for worker in self.workers),
|
||||
@@ -725,7 +776,7 @@ class RotaBuilder(object):
|
||||
# Used to limit number of workers on night shift per site
|
||||
# if we force a binary it will in effect hard constrain to < 2
|
||||
# from a single site
|
||||
if self.constraint_options["balance_nights_across_sites"]:
|
||||
if self.constraint_options_model.balance_nights_across_sites:
|
||||
self.model.night_per_site = Var(
|
||||
(
|
||||
(week, shift.name, site)
|
||||
@@ -820,7 +871,7 @@ class RotaBuilder(object):
|
||||
initialize=0,
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_shifts_over_workers"]:
|
||||
if self.constraint_options_model.balance_shifts_over_workers:
|
||||
self.model.worker_shift_count_t1 = Var(
|
||||
((worker.id) for worker in self.workers),
|
||||
domain=NonNegativeReals,
|
||||
@@ -834,8 +885,8 @@ class RotaBuilder(object):
|
||||
)
|
||||
|
||||
if (
|
||||
self.constraint_options["balance_shifts"]
|
||||
or self.constraint_options["balance_shifts_quadratic"]
|
||||
self.constraint_options_model.balance_shifts
|
||||
or self.constraint_options_model.balance_shifts_quadratic
|
||||
):
|
||||
self.model.shift_count_t1 = Var(
|
||||
(
|
||||
@@ -857,7 +908,7 @@ class RotaBuilder(object):
|
||||
initialize=0,
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_shifts_quadratic"]:
|
||||
if self.constraint_options_model.balance_shifts_quadratic:
|
||||
self.model.shift_count_w = Var(
|
||||
(
|
||||
(worker.id, shift.name)
|
||||
@@ -868,7 +919,7 @@ class RotaBuilder(object):
|
||||
initialize=0,
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_nights"]:
|
||||
if self.constraint_options_model.balance_nights:
|
||||
# We also try to even out the night shifts seperately
|
||||
self.model.night_shift_count = Var(
|
||||
((worker.id) for worker in self.workers),
|
||||
@@ -941,7 +992,7 @@ class RotaBuilder(object):
|
||||
bounds=self.SHIFT_BOUNDS["weekend_count"],
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_weekends"]:
|
||||
if self.constraint_options_model.balance_weekends:
|
||||
self.model.weekend_shift_count_t1 = Var(
|
||||
((worker.id) for worker in self.workers),
|
||||
domain=NonNegativeReals,
|
||||
@@ -1149,7 +1200,7 @@ class RotaBuilder(object):
|
||||
self.model.constraints = ConstraintList() # Create a set of constraints
|
||||
|
||||
|
||||
for constraint in self.constraint_options.get("min_summed_grade_by_shifts_per_day", []):
|
||||
for constraint in self.constraint_options_model.min_summed_grade_by_shifts_per_day:
|
||||
weeks = constraint.weeks if constraint.weeks is not None else self.weeks
|
||||
days_list = constraint.days if constraint.days is not None else self.days
|
||||
for week in weeks:
|
||||
@@ -1564,7 +1615,7 @@ class RotaBuilder(object):
|
||||
# Use per-worker value if set, otherwise use global constraint option if set
|
||||
max_days_worked_per_week = getattr(worker, "max_days_worked_per_week", None)
|
||||
if max_days_worked_per_week is None:
|
||||
max_days_worked_per_week = self.constraint_options.get("max_days_worked_per_week", None)
|
||||
max_days_worked_per_week = getattr(self.constraint_options_model, "max_days_worked_per_week", None)
|
||||
if max_days_worked_per_week is not None:
|
||||
for week in self.weeks:
|
||||
# Create a binary variable for each day to indicate if the worker works any shift that day
|
||||
@@ -1790,7 +1841,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
if self.constraint_options["distribute_locum_shifts"]:
|
||||
if self.constraint_options_model.distribute_locum_shifts:
|
||||
self.model.constraints.add(
|
||||
sum(
|
||||
self.model.locum_required[week, day, shift]
|
||||
@@ -1801,32 +1852,26 @@ class RotaBuilder(object):
|
||||
)
|
||||
|
||||
if (
|
||||
self.constraint_options["maximum_allowed_locum_shifts_per_worker"]
|
||||
self.constraint_options_model.maximum_allowed_locum_shifts_per_worker
|
||||
is not None
|
||||
):
|
||||
self.model.constraints.add(
|
||||
self.constraint_options[
|
||||
"maximum_allowed_locum_shifts_per_worker"
|
||||
]
|
||||
self.constraint_options_model.maximum_allowed_locum_shifts_per_worker
|
||||
>= self.model.locum_shifts_by_worker[worker.id]
|
||||
)
|
||||
if worker.locum_availability:
|
||||
if (
|
||||
self.constraint_options[
|
||||
"minimum_allowed_locum_shifts_per_worker"
|
||||
]
|
||||
self.constraint_options_model.minimum_allowed_locum_shifts_per_worker
|
||||
is not None
|
||||
):
|
||||
self.model.constraints.add(
|
||||
self.constraint_options[
|
||||
"minimum_allowed_locum_shifts_per_worker"
|
||||
]
|
||||
self.constraint_options_model.minimum_allowed_locum_shifts_per_worker
|
||||
<= self.model.locum_shifts_by_worker[worker.id]
|
||||
)
|
||||
|
||||
if (
|
||||
self.constraint_options["minimise_shift_diffs"]
|
||||
or self.constraint_options["maximum_allowed_shift_diff"] is not None
|
||||
self.constraint_options_model.minimise_shift_diffs
|
||||
or self.constraint_options_model.maximum_allowed_shift_diff is not None
|
||||
):
|
||||
self.model.constraints.add(
|
||||
self.model.shift_count_diff_summed[worker.id]
|
||||
@@ -1836,17 +1881,17 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
if self.constraint_options["maximum_allowed_shift_diff"] is not None:
|
||||
if self.constraint_options_model.maximum_allowed_shift_diff is not None:
|
||||
self.model.constraints.add(
|
||||
self.model.shift_count_diff_summed[worker.id]
|
||||
<= self.constraint_options["maximum_allowed_shift_diff"]
|
||||
<= self.constraint_options_model.maximum_allowed_shift_diff
|
||||
)
|
||||
self.model.constraints.add(
|
||||
self.model.shift_count_diff_summed[worker.id]
|
||||
>= -self.constraint_options["maximum_allowed_shift_diff"]
|
||||
>= -self.constraint_options_model.maximum_allowed_shift_diff
|
||||
)
|
||||
|
||||
for max_days, weeks in self.constraint_options["max_days_per_week_block"]:
|
||||
for max_days, weeks in self.constraint_options_model.max_days_per_week_block:
|
||||
for week_blocks in self.get_week_block_iterator(weeks):
|
||||
self.model.constraints.add(
|
||||
sum(
|
||||
@@ -1861,7 +1906,7 @@ class RotaBuilder(object):
|
||||
# Prevent more than n number shifts per 4 weeks
|
||||
try:
|
||||
self.model.constraints.add(
|
||||
self.constraint_options["max_shifts_per_month"]
|
||||
self.constraint_options_model.max_shifts_per_month
|
||||
>= sum(
|
||||
self.model.works[worker.id, week, day, shiftname]
|
||||
for week, day, shiftname in self.get_all_shiftname_combinations()
|
||||
@@ -1877,9 +1922,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
pass
|
||||
|
||||
for constraint_dict in self.constraint_options[
|
||||
"avoid_shifts_by_worker_names"
|
||||
]:
|
||||
for constraint_dict in self.constraint_options_model.avoid_shifts_by_worker_names:
|
||||
if invalid_shifts := set(constraint_dict["shifts"]).difference(
|
||||
set(self.get_shift_names())
|
||||
):
|
||||
@@ -1901,7 +1944,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
for constraint_dict in self.constraint_options["avoid_shifts_by_grades"]:
|
||||
for constraint_dict in self.constraint_options_model.avoid_shifts_by_grades:
|
||||
if invalid_shifts := set(constraint_dict["shifts"]).difference(
|
||||
set(self.get_shift_names())
|
||||
):
|
||||
@@ -1936,9 +1979,9 @@ class RotaBuilder(object):
|
||||
== sum(self.model.works_weekend[worker.id, week] for week in self.weeks)
|
||||
)
|
||||
|
||||
if self.constraint_options["max_weekends"] > -1:
|
||||
if self.constraint_options_model.max_weekends > -1:
|
||||
self.model.constraints.add(
|
||||
self.constraint_options["max_weekends"]
|
||||
self.constraint_options_model.max_weekends
|
||||
>= self.model.worker_weekend_count[worker.id]
|
||||
)
|
||||
|
||||
@@ -2061,14 +2104,14 @@ class RotaBuilder(object):
|
||||
- worker.shift_target_number[shift.name]
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_shifts"]:
|
||||
if self.constraint_options_model.balance_shifts:
|
||||
self.model.constraints.add(
|
||||
self.model.shift_count_t1[worker.id, shift.name]
|
||||
- self.model.shift_count_t2[worker.id, shift.name]
|
||||
== self.model.shift_count_diff[worker.id, shift.name]
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_shifts_quadratic"]:
|
||||
if self.constraint_options_model.balance_shifts_quadratic:
|
||||
# This may need to be updated
|
||||
xU = 25
|
||||
xL = 1
|
||||
@@ -2115,7 +2158,7 @@ class RotaBuilder(object):
|
||||
|
||||
# NOTE: as this balances across the sum of the worker's shifts
|
||||
# they can cancel each other out if a high weighting is given to multiple shifts
|
||||
if self.constraint_options["balance_shifts_over_workers"]:
|
||||
if self.constraint_options_model.balance_shifts_over_workers:
|
||||
self.model.constraints.add(
|
||||
self.model.worker_shift_count_t1[worker.id]
|
||||
- self.model.worker_shift_count_t2[worker.id]
|
||||
@@ -2126,7 +2169,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_bank_holidays"]:
|
||||
if self.constraint_options_model.balance_bank_holidays:
|
||||
extra_bank_holiday = 0
|
||||
if self.use_bank_holiday_extra:
|
||||
extra_bank_holiday = worker.bank_holiday_extra
|
||||
@@ -2161,7 +2204,7 @@ class RotaBuilder(object):
|
||||
>= xU * (self.model.bank_holiday_count[worker.id] + 1) * 2 - xU * xU
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_nights"]:
|
||||
if self.constraint_options_model.balance_nights:
|
||||
self.model.constraints.add(
|
||||
self.model.night_shift_count[worker.id]
|
||||
== sum(
|
||||
@@ -2236,7 +2279,7 @@ class RotaBuilder(object):
|
||||
|
||||
worker.weekend_shift_target_number = weekend_shift_target_number
|
||||
|
||||
if self.constraint_options["balance_weekends"]:
|
||||
if self.constraint_options_model.balance_weekends:
|
||||
if weekend_shift_target_number > 0:
|
||||
self.model.constraints.add(
|
||||
self.model.weekend_shift_count_t1[worker.id]
|
||||
@@ -2245,7 +2288,7 @@ class RotaBuilder(object):
|
||||
- weekend_shift_target_number
|
||||
)
|
||||
|
||||
xU = self.constraint_options["max_weekends"]
|
||||
xU = self.constraint_options_model.max_weekends
|
||||
|
||||
xL = 1
|
||||
self.model.constraints.add(
|
||||
@@ -2313,17 +2356,15 @@ class RotaBuilder(object):
|
||||
]
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_blocks"]:
|
||||
if self.constraint_options["max_night_frequency"]:
|
||||
if self.constraint_options_model.balance_blocks:
|
||||
if self.constraint_options_model.max_night_frequency:
|
||||
for week_blocks in self.get_week_block_iterator(
|
||||
self.constraint_options["max_night_frequency"]
|
||||
self.constraint_options_model.max_night_frequency
|
||||
):
|
||||
# Ignore excluded weeks
|
||||
if set(week_blocks).intersection(
|
||||
set(
|
||||
self.constraint_options[
|
||||
"max_night_frequency_week_exclusions"
|
||||
]
|
||||
self.constraint_options_model.max_night_frequency_week_exclusions
|
||||
)
|
||||
):
|
||||
continue
|
||||
@@ -2343,10 +2384,10 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
# if self.constraint_options["balance_weekends"]:
|
||||
if self.constraint_options["max_weekend_frequency"]:
|
||||
# if self.constraint_options_model.balance_weekends:
|
||||
if self.constraint_options_model.max_weekend_frequency:
|
||||
for week_blocks in self.get_week_block_iterator(
|
||||
self.constraint_options["max_weekend_frequency"]
|
||||
self.constraint_options_model.max_weekend_frequency
|
||||
):
|
||||
# Prevent weekend shifts more than once every n weeks
|
||||
self.model.constraints.add(
|
||||
@@ -2430,7 +2471,7 @@ class RotaBuilder(object):
|
||||
|
||||
try:
|
||||
self.model.constraints.add(
|
||||
self.constraint_options["max_shifts_per_week"]
|
||||
self.constraint_options_model.max_shifts_per_week
|
||||
>= sum(
|
||||
self.model.works[worker.id, w, day, shiftname]
|
||||
# for shiftname in self.get_shift_names_by_week_day(week, day)
|
||||
@@ -2446,7 +2487,7 @@ class RotaBuilder(object):
|
||||
# TODO: consider excluding shifts that span relevant period
|
||||
if (
|
||||
worker.site
|
||||
in self.constraint_options["prevent_monday_after_full_weekends"]
|
||||
in self.constraint_options_model.prevent_monday_after_full_weekends
|
||||
):
|
||||
# Ignore last week
|
||||
if week + 1 in self.weeks:
|
||||
@@ -2473,9 +2514,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
if (
|
||||
worker.site
|
||||
in self.constraint_options[
|
||||
"prevent_monday_and_tuesday_after_full_weekends"
|
||||
]
|
||||
in self.constraint_options_model.prevent_monday_and_tuesday_after_full_weekends
|
||||
):
|
||||
# Ignore last week
|
||||
if week + 1 in self.weeks:
|
||||
@@ -2508,7 +2547,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
if (
|
||||
worker.site
|
||||
in self.constraint_options["prevent_fridays_before_full_weekends"]
|
||||
in self.constraint_options_model.prevent_fridays_before_full_weekends
|
||||
):
|
||||
self.model.constraints.add(
|
||||
full_weekend_count
|
||||
@@ -2527,7 +2566,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
if (
|
||||
worker.site
|
||||
in self.constraint_options["prevent_thursdays_before_full_weekends"]
|
||||
in self.constraint_options_model.prevent_thursdays_before_full_weekends
|
||||
):
|
||||
self.model.constraints.add(
|
||||
full_weekend_count
|
||||
@@ -2806,7 +2845,7 @@ class RotaBuilder(object):
|
||||
|
||||
# IF paired we check the following against both workers
|
||||
workers = [worker]
|
||||
if self.constraint_options["hard_constrain_pair_separation"]:
|
||||
if self.constraint_options_model.hard_constrain_pair_separation:
|
||||
for worker_pairs in self.worker_pairs:
|
||||
if worker_pairs[0] == worker:
|
||||
workers = worker_pairs
|
||||
@@ -2914,7 +2953,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
)
|
||||
|
||||
if self.constraint_options["hard_constrain_pair_separation"]:
|
||||
if self.constraint_options_model.hard_constrain_pair_separation:
|
||||
for worker_pairs in self.worker_pairs:
|
||||
if worker_pairs[0] == worker:
|
||||
self.model.constraints.add(
|
||||
@@ -3086,7 +3125,7 @@ class RotaBuilder(object):
|
||||
|
||||
locum_shift_balancing = 0
|
||||
if self.get_workers_who_require_locums():
|
||||
if self.constraint_options["balance_locum_shifts"]:
|
||||
if self.constraint_options_model.balance_locum_shifts:
|
||||
locum_shift_balancing = sum(
|
||||
locum_shift_balance_modifier_constant
|
||||
* (
|
||||
@@ -3096,7 +3135,7 @@ class RotaBuilder(object):
|
||||
for worker in self.workers
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_shifts_over_workers"]:
|
||||
if self.constraint_options_model.balance_shifts_over_workers:
|
||||
worker_shift_balancing = sum(
|
||||
balance_modifier_constant
|
||||
* (
|
||||
@@ -3108,7 +3147,7 @@ class RotaBuilder(object):
|
||||
else:
|
||||
worker_shift_balancing = 0
|
||||
|
||||
if self.constraint_options["balance_shifts"]:
|
||||
if self.constraint_options_model.balance_shifts:
|
||||
shift_balancing = sum(
|
||||
balance_modifier_constant
|
||||
* (
|
||||
@@ -3121,7 +3160,7 @@ class RotaBuilder(object):
|
||||
else:
|
||||
shift_balancing = 0
|
||||
|
||||
if self.constraint_options["balance_shifts_quadratic"]:
|
||||
if self.constraint_options_model.balance_shifts_quadratic:
|
||||
quadratic_shift_balancing = sum(
|
||||
balance_quadratic_shift_modifier_constant
|
||||
* (
|
||||
@@ -3135,7 +3174,7 @@ class RotaBuilder(object):
|
||||
quadratic_shift_balancing = 0
|
||||
|
||||
true_quadratic_shift_balancing = 0
|
||||
if self.constraint_options["balance_shifts_true_quadratic"]:
|
||||
if self.constraint_options_model.balance_shifts_true_quadratic:
|
||||
shift_diff_modifier_constant = 10
|
||||
true_quadratic_shift_balancing = sum(
|
||||
shift_diff_modifier_constant
|
||||
@@ -3143,7 +3182,7 @@ class RotaBuilder(object):
|
||||
for worker in self.workers
|
||||
)
|
||||
|
||||
if self.constraint_options["minimise_shift_diffs"]:
|
||||
if self.constraint_options_model.minimise_shift_diffs:
|
||||
shift_diff_modifier_constant = 10
|
||||
shift_diff_balancing = sum(
|
||||
shift_diff_modifier_constant
|
||||
@@ -3153,7 +3192,7 @@ class RotaBuilder(object):
|
||||
else:
|
||||
shift_diff_balancing = 0
|
||||
|
||||
if self.constraint_options["balance_nights"]:
|
||||
if self.constraint_options_model.balance_nights:
|
||||
night_balance_modifier_constant = 10
|
||||
night_shift_balancing = sum(
|
||||
night_balance_modifier_constant
|
||||
@@ -3165,7 +3204,7 @@ class RotaBuilder(object):
|
||||
else:
|
||||
night_shift_balancing = 0
|
||||
|
||||
if self.constraint_options["balance_bank_holidays"]:
|
||||
if self.constraint_options_model.balance_bank_holidays:
|
||||
bank_holiday_balance_modifier_constant = 1000
|
||||
bank_holiday_balancing = sum(
|
||||
bank_holiday_balance_modifier_constant
|
||||
@@ -3177,7 +3216,7 @@ class RotaBuilder(object):
|
||||
else:
|
||||
bank_holiday_balancing = 0
|
||||
|
||||
if self.constraint_options["balance_weekends"]:
|
||||
if self.constraint_options_model.balance_weekends:
|
||||
weekend_balance_modifier_constant = 5
|
||||
weekend_shift_balancing = sum(
|
||||
weekend_balance_modifier_constant
|
||||
@@ -3208,7 +3247,7 @@ class RotaBuilder(object):
|
||||
)
|
||||
|
||||
# # Spread nights
|
||||
if self.constraint_options["balance_nights_across_sites"]:
|
||||
if self.constraint_options_model.balance_nights_across_sites:
|
||||
nights_site_balancing = sum(
|
||||
(
|
||||
self.model.night_per_site_t1[week, shift.name, site]
|
||||
@@ -3238,7 +3277,7 @@ class RotaBuilder(object):
|
||||
else:
|
||||
block_site_balancing = 0
|
||||
|
||||
if self.constraint_options["balance_blocks"]:
|
||||
if self.constraint_options_model.balance_blocks:
|
||||
blocks_balancing = sum(
|
||||
block_shift_balancing_constant
|
||||
* self.model.blocks_assigned[week, shift]
|
||||
@@ -3402,14 +3441,14 @@ class RotaBuilder(object):
|
||||
def add_worker_name_constraint_by_week(
|
||||
self, names: Iterable[str], weeks: Iterable[int], shifts
|
||||
):
|
||||
self.constraint_options["avoid_shifts_by_worker_names"].append(
|
||||
self.constraint_options_model.avoid_shifts_by_worker_names.append(
|
||||
{"names": names, "weeks": weeks, "shifts": shifts}
|
||||
)
|
||||
|
||||
def add_grade_constraint_by_week(
|
||||
self, grades: Iterable[int], weeks: Iterable[int], shifts
|
||||
):
|
||||
self.constraint_options["avoid_shifts_by_grades"].append(
|
||||
self.constraint_options_model.avoid_shifts_by_grades.append(
|
||||
{"grades": grades, "weeks": weeks, "shifts": shifts}
|
||||
)
|
||||
|
||||
@@ -3429,7 +3468,7 @@ class RotaBuilder(object):
|
||||
weeks=weeks,
|
||||
days=days,
|
||||
)
|
||||
self.constraint_options["min_summed_grade_by_shifts_per_day"].append(constraint)
|
||||
self.constraint_options_model.min_summed_grade_by_shifts_per_day.append(constraint)
|
||||
|
||||
def add_shift(self, shift: SingleShift) -> None:
|
||||
"""Add a shift to the collection
|
||||
@@ -4279,7 +4318,7 @@ class RotaBuilder(object):
|
||||
return "\n".join(timetable)
|
||||
|
||||
def get_worker_timetable_html(
|
||||
self, include_html_tag=False, table_name="rota-table"
|
||||
self, include_html_tag=False, table_name="rota-table", html_static_prefix=None
|
||||
):
|
||||
model = self.model
|
||||
|
||||
@@ -4514,7 +4553,7 @@ class RotaBuilder(object):
|
||||
+ f"#weekends_worked: {model.worker_weekend_count[worker.id].value}\\#"
|
||||
)
|
||||
|
||||
if self.constraint_options["balance_bank_holidays"]:
|
||||
if self.constraint_options_model.balance_bank_holidays:
|
||||
bank_holiday_count = model.bank_holiday_count[worker.id].value
|
||||
|
||||
bank_holiday_count_w = model.bank_holiday_count_w[worker.id].value - 1
|
||||
@@ -4652,7 +4691,7 @@ class RotaBuilder(object):
|
||||
<summary><h2>Rota settings</h2></summary>
|
||||
<div>
|
||||
<pre>
|
||||
{json.dumps({k: _pydantic_to_dict(v) for k, v in self.constraint_options.items()}, indent=4)}
|
||||
{json.dumps({k: _pydantic_to_dict(v) for k, v in self.constraint_options_model.model_dump().items()}, indent=4)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
@@ -4718,13 +4757,43 @@ class RotaBuilder(object):
|
||||
"""
|
||||
|
||||
if include_html_tag:
|
||||
# html_static_prefix controls how CSS/JS are referenced:
|
||||
# - If html_static_prefix is None: try to use Django STATIC_URL when running inside Django, otherwise use relative links
|
||||
# - If html_static_prefix is a falsey value (e.g. empty string or False): use relative links ("timetable.css")
|
||||
# - If html_static_prefix is a string: use it as the prefix (ensuring trailing slash)
|
||||
static_prefix = None
|
||||
if html_static_prefix is not None:
|
||||
static_prefix = html_static_prefix
|
||||
|
||||
if static_prefix is None:
|
||||
# Attempt to detect Django settings; if not present, fall back to relative links
|
||||
try:
|
||||
from django.conf import settings
|
||||
|
||||
static_prefix = getattr(settings, "STATIC_URL", "")
|
||||
except Exception:
|
||||
static_prefix = ""
|
||||
|
||||
# If a prefix is provided and not empty, ensure trailing slash
|
||||
if isinstance(static_prefix, str) and static_prefix:
|
||||
if not static_prefix.endswith("/"):
|
||||
static_prefix = static_prefix + "/"
|
||||
|
||||
if static_prefix:
|
||||
css_href = f"{static_prefix}timetable.css"
|
||||
js_src = f"{static_prefix}timetable.js"
|
||||
else:
|
||||
# Relative links for standalone usage
|
||||
css_href = "timetable.css"
|
||||
js_src = "timetable.js"
|
||||
|
||||
html = f"""<html>
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="timetable.css">
|
||||
<link rel="stylesheet" type="text/css" href="{css_href}">
|
||||
<link rel="stylesheet" type="text/css" href="https://code.jquery.com/ui/1.13.0/themes/base/jquery-ui.css">
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||
<script src="https://code.jquery.com/ui/1.13.0/jquery-ui.min.js"></script>
|
||||
<script src="timetable.js" defer></script>
|
||||
<script src="{js_src}" defer></script>
|
||||
</head>
|
||||
{html}</html>"""
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Iterable, List, Literal, Optional, Set
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, Field
|
||||
|
||||
from rich.pretty import pprint
|
||||
from rota.console import console
|
||||
from rota_generator.console import console
|
||||
|
||||
# from .shifts import RotaBuilder, days, sites
|
||||
import uuid
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rota.shifts import RotaBuilder
|
||||
from rota_generator.shifts import RotaBuilder
|
||||
|
||||
days = Literal["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
whole_days = [
|
||||
@@ -3,8 +3,8 @@ from pyomo.opt import SolverFactory
|
||||
from pyomo.opt.results import SolverStatus
|
||||
|
||||
|
||||
from rota.shifts import SingleShift, RotaBuilder, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import SingleShift, RotaBuilder, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
|
||||
import datetime
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
|
||||
from rota_generator.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
import itertools
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
|
||||
from rota_generator.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
import itertools
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days, MinSummedGradeByShiftsPerDayConstraint
|
||||
from rota_generator.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days, MinSummedGradeByShiftsPerDayConstraint
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
import itertools
|
||||
|
||||
@@ -47,7 +47,7 @@ def test_summed_shift_grade_constraints():
|
||||
)
|
||||
|
||||
# Add summed shift grade constraints
|
||||
Rota.constraint_options["min_summed_grade_by_shifts_per_day"].append(
|
||||
Rota.constraint_options_model.min_summed_grade_by_shifts_per_day.append(
|
||||
MinSummedGradeByShiftsPerDayConstraint(
|
||||
shifts=["a", "b"],
|
||||
min_grade_sum=4,
|
||||
@@ -78,7 +78,7 @@ def test_summed_shift_grade_constraints():
|
||||
for summed_grade in summed_grades:
|
||||
assert summed_grade >= 4, f"Summed grade {summed_grade} is less than 4"
|
||||
|
||||
Rota.constraint_options["min_summed_grade_by_shifts_per_day"] = [
|
||||
Rota.constraint_options_model.min_summed_grade_by_shifts_per_day = [
|
||||
MinSummedGradeByShiftsPerDayConstraint(
|
||||
shifts=["a", "b"],
|
||||
min_grade_sum=5,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from rota.shifts import BalanceAcrossGroupsConstraint, RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import BalanceAcrossGroupsConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
@pytest.fixture
|
||||
def demo_rota_balance_sites():
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from pytest import approx
|
||||
from rota.shifts import PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
def generate_basic_rota(weeks_to_rota=10):
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from rota.shifts import MinimumGradeNumberConstraint, RequireRemoteSitePresenceConstraint, RotaBuilder, SingleShift, days, LimitGradeNumberConstraint
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import MinimumGradeNumberConstraint, RequireRemoteSitePresenceConstraint, RotaBuilder, SingleShift, days, LimitGradeNumberConstraint
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
@pytest.fixture
|
||||
def limit_constraint_rota():
|
||||
@@ -257,7 +257,7 @@ def test_constraint_require_remote_site_presence_week2(limit_constraint_rota):
|
||||
Rota = limit_constraint_rota
|
||||
Rota.constraint_options["minimise_shift_diffs"] = False
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = False
|
||||
Rota.constraint_options["balance_shift"] = False
|
||||
Rota.constraint_options["balance_shifts"] = False
|
||||
Rota.constraint_options["balance_nights_across_sites"] = True
|
||||
Rota.shifts = []
|
||||
Rota.add_shifts(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
import pytest
|
||||
import datetime
|
||||
from rota.shifts import RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
@pytest.fixture
|
||||
def basic_rota():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from rota.shifts import NightConstraint, RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import NightConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
@pytest.fixture
|
||||
def demo_rota_night_unavailable():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from rota.shifts import InvalidShift, NightConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import InvalidShift, NightConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
def generate_basic_rota(weeks_to_rota=10):
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
|
||||
from rota_generator.shifts import NoWorkers, RotaBuilder, SingleShift, days
|
||||
import datetime
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
def weeks_from_list(lst):
|
||||
"""Yield successive n-sized chunks from a lst."""
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
|
||||
import pytest
|
||||
from rota.shifts import NoWorkers, RequireRemoteSitePresenceConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.shifts import NoWorkers, RequireRemoteSitePresenceConstraint, RotaBuilder, SingleShift, days
|
||||
|
||||
import datetime
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
def setup_basic_rota():
|
||||
weeks_to_rota = 10
|
||||
@@ -51,7 +51,7 @@ class TestRemoteRotas:
|
||||
Rota.constraint_options["balance_blocks"] = False
|
||||
Rota.constraint_options["balance_weekends"] = False
|
||||
Rota.constraint_options["balance_nights"] = False
|
||||
Rota.constraint_options["minimse_shift_diffs"] = False
|
||||
Rota.constraint_options["minimise_shift_diffs"] = False
|
||||
|
||||
Rota.add_workers(
|
||||
[Worker(
|
||||
@@ -111,7 +111,7 @@ class TestRemoteRotas:
|
||||
Rota.constraint_options["balance_blocks"] = False
|
||||
Rota.constraint_options["balance_weekends"] = False
|
||||
Rota.constraint_options["balance_nights"] = False
|
||||
Rota.constraint_options["minimse_shift_diffs"] = False
|
||||
Rota.constraint_options["minimise_shift_diffs"] = False
|
||||
|
||||
Rota.add_workers(
|
||||
[Worker(
|
||||
|
||||
+12
-12
@@ -3,11 +3,11 @@ from re import S
|
||||
from black import main
|
||||
import pytest
|
||||
from pytest import approx
|
||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, days
|
||||
from rota_generator.shifts import NoWorkers, RotaBuilder, SingleShift, WarningTermination, days
|
||||
|
||||
import datetime
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -19,16 +19,16 @@ def demo_rota():
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
balance_offset_modifier=1,
|
||||
)
|
||||
Rota.constraint_options["max_shifts_per_week"] = 5
|
||||
Rota.constraint_options["max_shifts_per_month"] = 31
|
||||
Rota.constraint_options["constrain_time_off_after_nights"] = False
|
||||
Rota.constraint_options["balance_nights_across_site"] = False
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
Rota.constraint_options["balance_shifts_over_workers"] = True
|
||||
Rota.constraint_options["balance_nights"] = False
|
||||
Rota.constraint_options["minimise_shift_diffs"] = False
|
||||
Rota.constraint_options["balance_blocks"] = False
|
||||
Rota.constraint_options["balance_bank_holidays"] = False
|
||||
Rota.constraint_options_model.max_shifts_per_week = 5
|
||||
Rota.constraint_options_model.max_shifts_per_month = 31
|
||||
Rota.constraint_options_model.constrain_time_off_after_nights = False
|
||||
Rota.constraint_options_model.balance_nights_across_sites = False
|
||||
Rota.constraint_options_model.balance_shifts_quadratic = True
|
||||
Rota.constraint_options_model.balance_shifts_over_workers = True
|
||||
Rota.constraint_options_model.balance_nights = False
|
||||
Rota.constraint_options_model.minimise_shift_diffs = False
|
||||
Rota.constraint_options_model.balance_blocks = False
|
||||
Rota.constraint_options_model.balance_bank_holidays = False
|
||||
|
||||
# Add a few workers
|
||||
workers = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
from rota.shifts import PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
import datetime
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def demo_rota_clear():
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
balance_offset_modifier=1,
|
||||
)
|
||||
Rota.constraint_options["max_shifts_per_month"] = 20
|
||||
Rota.constraint_options_model.max_shifts_per_month = 20
|
||||
|
||||
workers = [
|
||||
Worker(name="worker1", site="group1", grade=1),
|
||||
|
||||
+14
-14
@@ -1,9 +1,9 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from pytest import approx
|
||||
from rota.shifts import PostShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from web.rota.shifts import PreShiftConstraint
|
||||
from rota_generator.shifts import PostShiftConstraint, RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
from rota_generator.shifts import PreShiftConstraint
|
||||
|
||||
@pytest.fixture
|
||||
def demo_rota_nights():
|
||||
@@ -16,17 +16,17 @@ def demo_rota_nights():
|
||||
balance_offset_modifier=1,
|
||||
)
|
||||
|
||||
Rota.constraint_options["max_shifts_per_week"] = 5
|
||||
Rota.constraint_options["max_shifts_per_month"] = 31
|
||||
Rota.constraint_options["limit_to_1_st2_on_nights"] = False
|
||||
Rota.constraint_options["constrain_time_off_after_nights"] = False
|
||||
Rota.constraint_options["balance_nights_across_site"] = False
|
||||
Rota.constraint_options["balance_shifts"] = True
|
||||
Rota.constraint_options["balance_shifts_over_workers"] = True
|
||||
Rota.constraint_options["balance_nights"] = False
|
||||
Rota.constraint_options["minimise_shift_diffs"] = False
|
||||
Rota.constraint_options["balance_blocks"] = False
|
||||
Rota.constraint_options["max_night_frequency"] = 0
|
||||
Rota.constraint_options_model.max_shifts_per_week = 5
|
||||
Rota.constraint_options_model.max_shifts_per_month = 31
|
||||
# legacy option `limit_to_1_st2_on_nights` removed; test omits it
|
||||
Rota.constraint_options_model.constrain_time_off_after_nights = False
|
||||
Rota.constraint_options_model.balance_nights_across_sites = False
|
||||
Rota.constraint_options_model.balance_shifts = True
|
||||
Rota.constraint_options_model.balance_shifts_over_workers = True
|
||||
Rota.constraint_options_model.balance_nights = False
|
||||
Rota.constraint_options_model.minimise_shift_diffs = False
|
||||
Rota.constraint_options_model.balance_blocks = False
|
||||
Rota.constraint_options_model.max_night_frequency = 0
|
||||
|
||||
# Add workers
|
||||
workers = [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from copy import deepcopy
|
||||
from rota.shifts import RotaBuilder, SingleShift, days
|
||||
from rota.workers import Worker
|
||||
from rota_generator.shifts import RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
@pytest.fixture
|
||||
def rota_2workers():
|
||||
@@ -36,36 +36,36 @@ def rota_3workers(rota_2workers):
|
||||
return Rota3
|
||||
|
||||
def test_max_shifts(rota_2workers):
|
||||
rota_2workers.constraint_options["max_shifts_per_month"] = 14
|
||||
rota_2workers.constraint_options_model.max_shifts_per_month = 14
|
||||
rota_2workers.build_and_solve()
|
||||
assert rota_2workers.results.solver.status == "ok"
|
||||
assert rota_2workers.results.solver.termination_condition == "optimal"
|
||||
|
||||
def test_max_shifts_fail(rota_2workers):
|
||||
rota_2workers.constraint_options["max_shifts_per_month"] = 13
|
||||
rota_2workers.constraint_options_model.max_shifts_per_month = 13
|
||||
rota_2workers.build_and_solve()
|
||||
assert rota_2workers.results.solver.status in ("warning", "error")
|
||||
assert rota_2workers.results.solver.termination_condition == "infeasible"
|
||||
|
||||
def test_max_shifts_extra_worker(rota_3workers):
|
||||
rota_3workers.constraint_options["max_shifts_per_month"] = 12
|
||||
rota_3workers.constraint_options_model.max_shifts_per_month = 12
|
||||
rota_3workers.build_and_solve()
|
||||
assert rota_3workers.results.solver.status == "ok"
|
||||
assert rota_3workers.results.solver.termination_condition == "optimal"
|
||||
|
||||
def test_max_shifts_per_week_fail(rota_2workers):
|
||||
rota_2workers.constraint_options["max_shifts_per_week"] = 3
|
||||
rota_2workers.constraint_options_model.max_shifts_per_week = 3
|
||||
rota_2workers.build_and_solve()
|
||||
assert rota_2workers.results.solver.status in ("warning", "error")
|
||||
assert rota_2workers.results.solver.termination_condition == "infeasible"
|
||||
|
||||
def test_max_shifts_per_week_pass(rota_2workers):
|
||||
rota_2workers.constraint_options["max_shifts_per_month"] = 14
|
||||
rota_2workers.constraint_options["max_shifts_per_week"] = 4
|
||||
rota_2workers.constraint_options_model.max_shifts_per_month = 14
|
||||
rota_2workers.constraint_options_model.max_shifts_per_week = 4
|
||||
rota_2workers.build_and_solve()
|
||||
assert rota_2workers.results.solver.status == "ok"
|
||||
|
||||
def test_max_shifts_per_week_extra_worker_pass(rota_3workers):
|
||||
rota_3workers.constraint_options["max_shifts_per_week"] = 3
|
||||
rota_3workers.constraint_options_model.max_shifts_per_week = 3
|
||||
rota_3workers.build_and_solve()
|
||||
assert rota_3workers.results.solver.status == "ok"
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from rota.shifts import (
|
||||
from rota_generator.shifts import (
|
||||
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
||||
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
||||
WarningTermination, days, WorkerRequirement
|
||||
)
|
||||
from rota.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
||||
from rota_generator.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
||||
|
||||
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||
Rota = RotaBuilder(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import pytest
|
||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
|
||||
from rota_generator.shifts import NoWorkers, RotaBuilder, SingleShift, days
|
||||
|
||||
import datetime
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
def weeks_from_list(lst):
|
||||
"""Yield successive n-sized chunks from a lst."""
|
||||
@@ -20,7 +20,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["balance_weekends"] = True
|
||||
self.Rota.constraint_options_model.balance_weekends = True
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
@@ -77,7 +77,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["balance_weekends"] = True
|
||||
self.Rota.constraint_options_model.balance_weekends = True
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
@@ -136,7 +136,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["prevent_monday_and_tuesday_after_full_weekends"] = ["group1",]
|
||||
self.Rota.constraint_options_model.prevent_monday_and_tuesday_after_full_weekends = ["group1",]
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
@@ -182,7 +182,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["prevent_monday_and_tuesday_after_full_weekends"] = ["group1",]
|
||||
self.Rota.constraint_options_model.prevent_monday_and_tuesday_after_full_weekends = ["group1",]
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
@@ -221,7 +221,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["prevent_monday_after_full_weekends"] = ["group1",]
|
||||
self.Rota.constraint_options_model.prevent_monday_after_full_weekends = ["group1",]
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
@@ -271,7 +271,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["prevent_monday_after_full_weekends"] = ["group1",]
|
||||
self.Rota.constraint_options_model.prevent_monday_after_full_weekends = ["group1",]
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
@@ -312,7 +312,7 @@ class TestDemoRota:
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
self.Rota.constraint_options["prevent_monday_after_full_weekends"] = ["group1",]
|
||||
self.Rota.constraint_options_model.prevent_monday_after_full_weekends = ["group1",]
|
||||
|
||||
# Add a few workers
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from pytest import approx
|
||||
from rota.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
|
||||
from rota_generator.shifts import InvalidShift, NoWorkers, RotaBuilder, SingleShift, days
|
||||
|
||||
from rota.workers import Worker
|
||||
from rota_generator.workers import Worker
|
||||
|
||||
import itertools
|
||||
|
||||
@@ -224,7 +224,7 @@ class TestWorkerRequests:
|
||||
"""_summary_"""
|
||||
Rota = generate_basic_rota()
|
||||
|
||||
Rota.constraint_options["balance_bank_holidays"] = False
|
||||
Rota.constraint_options_model.balance_bank_holidays = False
|
||||
|
||||
Rota.add_worker(
|
||||
Worker(
|
||||
@@ -311,7 +311,7 @@ class TestWorkerRequests:
|
||||
def test_work_requests(self):
|
||||
"""_summary_"""
|
||||
Rota = generate_basic_rota()
|
||||
Rota.constraint_options["balance_bank_holidays"] = False
|
||||
Rota.constraint_options_model.balance_bank_holidays = False
|
||||
|
||||
Rota.add_workers(
|
||||
[Worker(
|
||||
@@ -362,7 +362,7 @@ class TestWorkerRequests:
|
||||
def test_work_requests_invalid(self):
|
||||
"""_summary_"""
|
||||
Rota = generate_basic_rota()
|
||||
Rota.constraint_options["balance_bank_holidays"] = False
|
||||
Rota.constraint_options_model.balance_bank_holidays = False
|
||||
|
||||
Rota.add_worker(
|
||||
Worker(
|
||||
@@ -433,7 +433,7 @@ class TestWorkerRequests:
|
||||
def test_work_requests_generic(self):
|
||||
"""_summary_"""
|
||||
Rota = generate_basic_rota()
|
||||
Rota.constraint_options["balance_bank_holidays"] = False
|
||||
Rota.constraint_options_model.balance_bank_holidays = False
|
||||
|
||||
Rota.add_workers(
|
||||
[Worker(
|
||||
@@ -484,7 +484,7 @@ class TestWorkerRequests:
|
||||
def test_work_requests_generic2(self):
|
||||
"""_summary_"""
|
||||
Rota = generate_basic_rota()
|
||||
Rota.constraint_options["balance_bank_holidays"] = False
|
||||
Rota.constraint_options_model.balance_bank_holidays = False
|
||||
|
||||
Rota.add_workers(
|
||||
[Worker(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import pytest
|
||||
from rota.shifts import InvalidShift, MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
||||
from rota_generator.shifts import InvalidShift, MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
||||
|
||||
import datetime
|
||||
from rota.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
|
||||
from rota.workers import WorkRequests
|
||||
from web.rota.shifts import PreShiftConstraint
|
||||
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
|
||||
from rota_generator.workers import WorkRequests
|
||||
from rota_generator.shifts import PreShiftConstraint
|
||||
|
||||
def generate_basic_rota(
|
||||
weeks_to_rota=10, workers=0, start_date=datetime.date(2022, 3, 7)
|
||||
@@ -357,7 +357,7 @@ def test_worker_pairs():
|
||||
]
|
||||
)
|
||||
|
||||
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
||||
Rota.constraint_options_model.hard_constrain_pair_separation = True
|
||||
|
||||
Rota.build_and_solve()
|
||||
|
||||
@@ -919,7 +919,6 @@ def test_worker_assign_as_block_preference2():
|
||||
"""Test that a worker's assign_as_block preference is respected over the global setting."""
|
||||
Rota = generate_basic_rota(workers=4, weeks_to_rota=8)
|
||||
|
||||
# Worker 1 prefers to have shift 'a' assigned as a block, worker 2 does not
|
||||
workers = Rota.get_workers()
|
||||
worker1 = workers[0]
|
||||
worker2 = workers[1]
|
||||
@@ -952,10 +951,12 @@ def test_worker_assign_as_block_preference2():
|
||||
a_shift.assign_as_block = True # Set the shift 'a' to be assigned as a block globally
|
||||
Rota.build_and_solve(options={"ratio": 0.0})
|
||||
|
||||
Rota.export_rota_to_html("test_worker_assign_as_block_preference", folder="tests")
|
||||
|
||||
for worker in Rota.get_workers():
|
||||
shift_list = Rota.get_worker_shift_list_string(worker)
|
||||
# All workers should have 'a' shifts grouped together (block)
|
||||
assert shift_list.count("aaaa") == 3, f"Worker {worker.name} should have all 'a' shifts grouped together (block)"
|
||||
assert shift_list.count("aaaa") == 2, f"Worker {worker.name} should have all 'a' shifts grouped together (block)"
|
||||
|
||||
def test_worker_assign_as_block_preference_multiple_shifts():
|
||||
"""Test that a worker's assign_as_block preference is respected over the global setting."""
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from rota.shifts import SingleShift, ShiftConstraint
|
||||
from rota_generator.shifts import SingleShift, ShiftConstraint
|
||||
|
||||
from models import SingleShift as ModelSingleShift
|
||||
#from models import ShiftConstraint as ModelShiftConstraint
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
from rota.shifts import SingleShift as SingleShiftBase, ShiftConstraint as ShiftConstraintBase
|
||||
from rota.workers import Worker as WorkerBase
|
||||
from rota_generator.shifts import SingleShift as SingleShiftBase, ShiftConstraint as ShiftConstraintBase
|
||||
from rota_generator.workers import Worker as WorkerBase
|
||||
|
||||
|
||||
class SingleShift(SingleShiftBase):
|
||||
|
||||
Reference in New Issue
Block a user