Compare commits
176
Commits
cf48c3c168
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d577b2cdf8 | ||
|
|
0874f05126 | ||
|
|
6ca3db86cb | ||
|
|
96e6a0d625 | ||
|
|
fc2413538c | ||
|
|
5ff78faa07 | ||
|
|
d1fbbe2dc9 | ||
|
|
b5525bd3fb | ||
|
|
33f50c7278 | ||
|
|
981cc314f0 | ||
|
|
0861e371e9 | ||
|
|
13c5ae598a | ||
|
|
0f31dfe1a9 | ||
|
|
02598cb665 | ||
|
|
09c8124cdd | ||
|
|
2949327d39 | ||
|
|
ea68885ff3 | ||
|
|
e288585cd3 | ||
|
|
4cad1158c6 | ||
|
|
f90e6895b4 | ||
|
|
827753644f | ||
|
|
935b4baaba | ||
|
|
dee80ed4fa | ||
|
|
a071b1b027 | ||
|
|
ee263aade1 | ||
|
|
68b80ace23 | ||
|
|
4bb4f5b97a | ||
|
|
d741d7e23c | ||
|
|
64c4189d5f | ||
|
|
277f14747a | ||
|
|
e637c5ac60 | ||
|
|
c71ae4285a | ||
|
|
c248e67304 | ||
|
|
628114ad91 | ||
|
|
f8c6e38841 | ||
|
|
ba268f9b48 | ||
|
|
e7f27a8ee6 | ||
|
|
1d7202bf04 | ||
|
|
82868f8c22 | ||
|
|
d66ddf29ad | ||
|
|
e6bdd4a09e | ||
|
|
62e48ef205 | ||
|
|
b98c28401f | ||
|
|
94b921192f | ||
|
|
23529ba441 | ||
|
|
fe1414a7bf | ||
|
|
0f9cd52911 | ||
|
|
cf9e9ee854 | ||
|
|
120dd16845 | ||
|
|
62010dd09f | ||
|
|
0cbe968aa5 | ||
|
|
5cf2480602 | ||
|
|
1bae1246f9 | ||
|
|
f4ab0be347 | ||
|
|
dadf035627 | ||
|
|
023e345591 | ||
|
|
8458279e19 | ||
|
|
4230e2af6a | ||
|
|
e2c3eecc63 | ||
|
|
105b37feae | ||
|
|
86201ec1d7 | ||
|
|
c59a03fa31 | ||
|
|
3fd0e1ed99 | ||
|
|
b6f5288eed | ||
|
|
a2d22c34fd | ||
|
|
ba154f680a | ||
|
|
32886da33d | ||
|
|
ea38a25fa1 | ||
|
|
1e6d1d5f5b | ||
|
|
60bd8073ee | ||
|
|
3ba4467812 | ||
|
|
9d187dfa75 | ||
|
|
a54b5db5d2 | ||
|
|
0851b992fb | ||
|
|
710a2aa7c8 | ||
|
|
a347ad7777 | ||
|
|
bf3b685923 | ||
|
|
378caf63ff | ||
|
|
83444ac402 | ||
|
|
81b2c85d61 | ||
|
|
108bb572a7 | ||
|
|
51ed25d04c | ||
|
|
8e580440e0 | ||
|
|
0b732a0b09 | ||
|
|
f019d2f565 | ||
|
|
d2e128006a | ||
|
|
761f52eafa | ||
|
|
2482f6a04a | ||
|
|
e99ab2c1fa | ||
|
|
cf966573de | ||
|
|
6674306306 | ||
|
|
a7e898f0a7 | ||
|
|
bc7036859e | ||
|
|
5b4838e67d | ||
|
|
b48382e990 | ||
|
|
9fc2ba3210 | ||
|
|
c5850eb1b2 | ||
|
|
638fe17c60 | ||
|
|
a783ce1f3e | ||
|
|
00c737becf | ||
|
|
b0af2daca4 | ||
|
|
9b5f52035d | ||
|
|
0db4881d6f | ||
|
|
008c28f68e | ||
|
|
6d4e335a90 | ||
|
|
e3cbde0986 | ||
|
|
792349b2ea | ||
|
|
55df9da8cb | ||
|
|
f0ebc0d01a | ||
|
|
ff2b2ebb95 | ||
|
|
046f3a5a4a | ||
|
|
de54e03fcf | ||
|
|
90b957fb78 | ||
|
|
d6ede37e0b | ||
|
|
26d3ff7a2d | ||
|
|
5165fe78bd | ||
|
|
c43ff7fc73 | ||
|
|
2889825734 | ||
|
|
1dd7c8d19e | ||
|
|
e4eaca378b | ||
|
|
249e09766d | ||
|
|
d509922450 | ||
|
|
a89aa0a35b | ||
|
|
bd5ca8f64c | ||
|
|
18a45ff87e | ||
|
|
f6dd9285a5 | ||
|
|
00f09d5e5b | ||
|
|
1565241de6 | ||
|
|
d8ef96c486 | ||
|
|
1ce1b75739 | ||
|
|
4011fa3627 | ||
|
|
d28b441edd | ||
|
|
2de9986149 | ||
|
|
c94c204d51 | ||
|
|
3906ab3c55 | ||
|
|
ecd09f3701 | ||
|
|
48c219281e | ||
|
|
724e3ab6bd | ||
|
|
78b6ba2a3b | ||
|
|
dfd37a7a62 | ||
|
|
069da08f5a | ||
|
|
05edb93427 | ||
|
|
dadc940993 | ||
|
|
ae52d80f98 | ||
|
|
3feb5fc7a3 | ||
|
|
40135a282d | ||
|
|
e6943ccfc7 | ||
|
|
c63d4d1585 | ||
|
|
eb95f5fa6d | ||
|
|
7fe44df476 | ||
|
|
5a2704b1d2 | ||
|
|
91b6fdd733 | ||
|
|
3f73316dbf | ||
|
|
1d66ab185a | ||
|
|
4927697621 | ||
|
|
a230c9d94c | ||
|
|
116cc1af64 | ||
|
|
cd52d53381 | ||
|
|
4a8c478a73 | ||
|
|
3f4d8068eb | ||
|
|
fdce572f6d | ||
|
|
331d57dd8f | ||
|
|
e1aec5011c | ||
|
|
47bfb16891 | ||
|
|
7cd2cec65c | ||
|
|
d55b1d38ea | ||
|
|
0599586f47 | ||
|
|
f31a54d512 | ||
|
|
7b16f8d82a | ||
|
|
3b3d432694 | ||
|
|
45837597d9 | ||
|
|
b72d33189c | ||
|
|
76f3b48c1d | ||
|
|
d7bcfce78c | ||
|
|
48710f50e8 | ||
|
|
6bd47ba5b4 |
+6
-1
@@ -1,5 +1,4 @@
|
||||
sample.txt
|
||||
*.html
|
||||
*.log
|
||||
*.pyc
|
||||
*.bak
|
||||
@@ -13,3 +12,9 @@ logs/
|
||||
|
||||
*.db
|
||||
web/rota
|
||||
|
||||
cons/
|
||||
|
||||
output/*.html
|
||||
output/tests/*.html
|
||||
*.sqlite3
|
||||
Vendored
+8
@@ -20,5 +20,13 @@
|
||||
"program": "${workspaceFolder}/gen.py",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Python: cons",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/gen_cons.py",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
@@ -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,152 @@
|
||||
"""
|
||||
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",
|
||||
# Debug toolbar for local development
|
||||
"debug_toolbar",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
# Debug toolbar middleware should run as early as possible
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"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 static directories so the dev server serves files from these locations.
|
||||
# Include the repository `static/` (project-level) and legacy `output/` directories.
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "static",
|
||||
BASE_DIR / "output",
|
||||
# also look for output/ at the repository root (BASE_DIR.parent)
|
||||
BASE_DIR.parent / "output",
|
||||
# repository-level static folder (workspace root)
|
||||
BASE_DIR.parent / "static",
|
||||
]
|
||||
|
||||
# django-debug-toolbar settings (development only)
|
||||
INTERNAL_IPS = ["127.0.0.1", "::1"]
|
||||
# Simple debug toolbar config: show when DEBUG is True and request comes from INTERNAL_IPS
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG,
|
||||
}
|
||||
|
||||
# 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,295 @@
|
||||
html {
|
||||
|
||||
/* color: red; */
|
||||
}
|
||||
|
||||
table {
|
||||
/* margin-top: 50px; */
|
||||
text-align: center;
|
||||
/* table-layout : fixed; */
|
||||
/* width:150px */
|
||||
/* width: 200%; */
|
||||
/* overflow-x: auto;
|
||||
display: block;
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
}
|
||||
|
||||
#main-table {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.shift-table {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.shift-table thead {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table-div {
|
||||
/* overflow-x: auto; */
|
||||
/* width: 80%; */
|
||||
overflow-x: scroll;
|
||||
margin-left: 200px;
|
||||
overflow-y: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#main-table th {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
transform: rotate(90deg) translateX(-100px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
}
|
||||
|
||||
#main-table th p {
|
||||
margin: 0 -100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#main-table th p:before {
|
||||
content: '';
|
||||
width: 0;
|
||||
padding-top: 110%;
|
||||
/* takes width as reference, + 10% for faking some extra padding */
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#main-table td,
|
||||
th {
|
||||
max-width: 10px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
#main-table tr {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
#main-table tr:hover {
|
||||
background-color: lightblue;
|
||||
}
|
||||
|
||||
#main-table tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.Sat {
|
||||
/* opacity: 30%; */
|
||||
/* background-color: lightsteelblue; */
|
||||
border-left: solid 1px lightsteelblue;
|
||||
}
|
||||
|
||||
.Sun {
|
||||
border-right: solid 1px lightsteelblue;
|
||||
|
||||
}
|
||||
|
||||
.bank-holiday {
|
||||
border-bottom: dotted 1px blue;
|
||||
|
||||
}
|
||||
|
||||
#main-table th.bank-holiday {
|
||||
color: darkblue;
|
||||
|
||||
}
|
||||
|
||||
.plymouth {
|
||||
color: #9169FF;
|
||||
}
|
||||
|
||||
.exeter {
|
||||
color: #5F60E8;
|
||||
}
|
||||
|
||||
.truro {
|
||||
color: #0074D9;
|
||||
}
|
||||
|
||||
.torquay,
|
||||
.torbay {
|
||||
color: #5FA8E8;
|
||||
}
|
||||
|
||||
.taunton {
|
||||
color: #e85fdf;
|
||||
}
|
||||
|
||||
|
||||
.unavailable {
|
||||
color: lightgray;
|
||||
}
|
||||
|
||||
.shift-requested {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.worker-summary {
|
||||
position: absolute;
|
||||
display: none;
|
||||
overflow-x: visible;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.site-summary {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.site-summary .site {
|
||||
border: 1px solid black;
|
||||
padding: 5px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.worker-summary span {
|
||||
min-width: 160px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.training-days-lost {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.tdl-settings {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.table-div+pre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shift-breakdown-button+pre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shift-breakdown-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nwd {
|
||||
color: lightcoral;
|
||||
}
|
||||
|
||||
#main-table th.worker,
|
||||
#main-table td.worker {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
left: 0;
|
||||
top: auto;
|
||||
border-top-width: 1px;
|
||||
/*only relevant for first row*/
|
||||
margin-top: -1px;
|
||||
/*compensate for top border*/
|
||||
}
|
||||
|
||||
.hidden {
|
||||
opacity: 0%;
|
||||
}
|
||||
|
||||
.tsummary {
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
#main-table.header th {
|
||||
max-width: 200px;
|
||||
|
||||
}
|
||||
|
||||
table.transposed {}
|
||||
|
||||
table.transposed tr {}
|
||||
|
||||
table.transposed th,
|
||||
table.transposed td {
|
||||
width: fit-content;
|
||||
max-width: unset;
|
||||
position: relative;
|
||||
border-top: solid 0.5px black;
|
||||
border-right: solid 0.5px black;
|
||||
}
|
||||
|
||||
table.transposed .Sat {
|
||||
/* opacity: 30%; */
|
||||
/* background-color: lightsteelblue; */
|
||||
border-top: solid 2px lightsteelblue !important;
|
||||
}
|
||||
|
||||
table.transposed .Sun {
|
||||
border-bottom: solid 2px lightsteelblue !important;
|
||||
|
||||
}
|
||||
|
||||
table.transposed .bank-holiday {
|
||||
border-bottom: dotted 2px blue !important;
|
||||
|
||||
}
|
||||
|
||||
table.transposed th.bank-holiday {
|
||||
color: darkblue;
|
||||
|
||||
}
|
||||
|
||||
.target-assigned.no-colour {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.locum-shift {
|
||||
background-color: lightgreen;
|
||||
}
|
||||
|
||||
.multi-shift {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button.selected {
|
||||
background: #0074d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#main-table th.col-hover {
|
||||
background: #ffe066 !important;
|
||||
color: #222 !important;
|
||||
}
|
||||
|
||||
.highlight-day-btn.highlighted {
|
||||
background: #ffe066 !important;
|
||||
color: #222 !important;
|
||||
border: 2px solid #bfa600 !important;
|
||||
}
|
||||
|
||||
.highlight-day-btn.highlighted {
|
||||
background: #ffe066 !important;
|
||||
color: #222 !important;
|
||||
border: 2px solid #bfa600 !important;
|
||||
}
|
||||
|
||||
#main-table td.day-highlighted,
|
||||
#main-table th.day-highlighted {
|
||||
/* background is set inline for custom color */
|
||||
}
|
||||
|
||||
#display-settings-modal {
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
#main-table td.force-assigned.force-assigned-highlight {
|
||||
background-color: #ffe066 !important;
|
||||
border: 2px solid #ff8800 !important;
|
||||
}
|
||||
|
||||
#linear-shift-timetable td, #linear-shift-timetable th {
|
||||
max-width: unset;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{% block title %}Proc Rota{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'output/timetable.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/forms.css' %}">
|
||||
<!-- Inline fallback for critical layout in case static file serving is unavailable -->
|
||||
<style>
|
||||
/* minimal critical styles from timetable.css to avoid broken layout when static not found */
|
||||
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; padding:0 }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 1rem }
|
||||
.button { display:inline-block; padding:0.4rem 0.6rem; border-radius:4px; border:1px solid #ccc; background:#f5f5f5 }
|
||||
</style>
|
||||
<!-- simple SVG favicon embedded to avoid 404 on /favicon.ico -->
|
||||
<link rel="icon" href='data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="%230074d9"/><text x="50" y="55" font-size="50" text-anchor="middle" fill="white" font-family="Arial,Helvetica,sans-serif">R</text></svg>'>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{% block header %}Proc Rota{% endblock %}</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<small>{% block footer %}© Proc Rota{% endblock %}</small>
|
||||
</footer>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
<!-- Modal swap target for HTMX -->
|
||||
<div id="modal" style="display:none;"></div>
|
||||
<style>
|
||||
/* minimal modal container styles: hidden by default, HTMX-inserted content can show it */
|
||||
#modal { position:fixed; left:0; top:0; right:0; bottom:0; display:none; align-items:center; justify-content:center; z-index:1200 }
|
||||
#modal .modal-backdrop { position:absolute; left:0; top:0; right:0; bottom:0; background:rgba(0,0,0,0.4); }
|
||||
#modal .modal-content { position:relative; background:white; padding:1rem; border-radius:6px; box-shadow:0 6px 24px rgba(0,0,0,0.2); max-width:90%; max-height:90%; overflow:auto; z-index:2 }
|
||||
</style>
|
||||
<script>
|
||||
// Show modal when HTMX swaps content into #modal, and provide backdrop/close behavior.
|
||||
// Important: avoid destructive DOM moves (innerHTML replacement) while HTMX may still hold
|
||||
// references to nodes. Instead, add a backdrop and tag the first returned element as
|
||||
// the modal content to minimize structural changes and avoid racing HTMX internals.
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
const target = evt.detail && evt.detail.target;
|
||||
// DEBUG: log HTMX swap target and modal children to help diagnose intermittent swapErrors
|
||||
try{ console.debug('[DEBUG] htmx:afterSwap target=', target && (target.id || target), ' evt.detail=', evt.detail); }catch(e){}
|
||||
if(!target || target.id !== 'modal') return;
|
||||
const modal = document.getElementById('modal');
|
||||
if(!modal) return;
|
||||
|
||||
// If server returned empty content, hide modal and clear selections
|
||||
const raw = modal.innerHTML || '';
|
||||
try{ console.debug('[DEBUG] modal.innerHTML length=', raw.length, ' modal.children=', Array.from(modal.children).map(c => c.nodeName + (c.id ? '#'+c.id : '') + (c.className ? '.'+c.className.split(' ').join('.') : ''))); }catch(e){}
|
||||
if(!raw.trim()){
|
||||
try{ console.debug('[DEBUG] modal content empty — hiding modal'); }catch(e){}
|
||||
modal.style.display='none';
|
||||
try{ if(window.clearAllCalendarSelections) window.clearAllCalendarSelections(); else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection()); }catch(e){}
|
||||
return;
|
||||
}
|
||||
|
||||
// If no element with .modal-content exists, try a non-destructive approach:
|
||||
// 1) ensure a backdrop element exists (insert if missing)
|
||||
// 2) find the first returned element (excluding backdrop) and mark it as modal-content
|
||||
if(!modal.querySelector('.modal-content')){
|
||||
if(!modal.querySelector('.modal-backdrop')){
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
// insert backdrop as first child to sit behind content
|
||||
modal.insertBefore(backdrop, modal.firstChild);
|
||||
}
|
||||
|
||||
// choose an existing child element to be the content. Prefer the first element
|
||||
// that isn't the backdrop. Do not recreate nodes if possible — just add a class.
|
||||
let contentElem = null;
|
||||
for(const child of Array.from(modal.children)){
|
||||
if(child.classList && child.classList.contains('modal-backdrop')) continue;
|
||||
if(child.classList && child.classList.contains('modal-content')){ contentElem = child; break; }
|
||||
// skip empty text nodes
|
||||
if(child.nodeType === Node.ELEMENT_NODE){ contentElem = child; break; }
|
||||
}
|
||||
|
||||
if(contentElem){
|
||||
try{ console.debug('[DEBUG] tagging existing element as modal-content:', contentElem); }catch(e){}
|
||||
contentElem.classList.add('modal-content');
|
||||
} else {
|
||||
// fallback: if there was no element to mark (unlikely), safely create a wrapper
|
||||
// and move other nodes inside it. This is a last-resort path guarded in try/catch.
|
||||
try{
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'modal-content';
|
||||
// move existing children into wrapper
|
||||
while(modal.firstChild){
|
||||
wrapper.appendChild(modal.firstChild);
|
||||
}
|
||||
// ensure backdrop is first child
|
||||
const backdrop = document.createElement('div'); backdrop.className='modal-backdrop';
|
||||
modal.appendChild(backdrop);
|
||||
modal.appendChild(wrapper);
|
||||
}catch(e){
|
||||
// If moving nodes fails, log but don't throw — keep modal visible with whatever was returned.
|
||||
console.error('[DEBUG] modal wrapping fallback failed', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}catch(e){ console.error('modal afterSwap handler error', e); }
|
||||
});
|
||||
// Helper to close the app modal and clear calendar selections
|
||||
window.closeProcRotaModal = function(){
|
||||
try{
|
||||
if(window.clearAllCalendarSelections) window.clearAllCalendarSelections();
|
||||
else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection());
|
||||
}catch(e){/* ignore */}
|
||||
const modal = document.getElementById('modal'); if(modal){ modal.style.display='none'; modal.innerHTML=''; }
|
||||
};
|
||||
// Close when clicking backdrop
|
||||
document.body.addEventListener('click', function(e){
|
||||
const modal = document.getElementById('modal'); if(!modal) return;
|
||||
if(e.target === modal || e.target.classList.contains('modal-backdrop')){
|
||||
modal.style.display='none'; modal.innerHTML='';
|
||||
// clear calendar selections when modal cancelled by backdrop click
|
||||
try{ if(window.clearAllCalendarSelections) window.clearAllCalendarSelections(); else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection()); }catch(e){}
|
||||
}
|
||||
});
|
||||
// Also respond to HTMX-triggered closeModal custom event (server may send HX-Trigger: closeModal)
|
||||
document.body.addEventListener('closeModal', function(){
|
||||
try{ if(window.clearAllCalendarSelections) window.clearAllCalendarSelections(); else if(window._calendars) Object.values(window._calendars).forEach(c=>c.clearSelection && c.clearSelection()); }catch(e){}
|
||||
// ensure modal is cleared visually as well
|
||||
const modal = document.getElementById('modal'); if(modal){ modal.style.display='none'; modal.innerHTML=''; }
|
||||
});
|
||||
// When leave list updated server-side, HTMX may trigger 'leaveUpdated'.
|
||||
document.body.addEventListener('leaveUpdated', function(){
|
||||
try{
|
||||
if(window._calendars){ const cs = Object.values(window._calendars).filter(c=>c.refreshLeaves); console.debug('[DEBUG] leaveUpdated -> refreshing', cs.length, 'calendars'); cs.forEach(c => c.refreshLeaves && c.refreshLeaves()); }
|
||||
}catch(e){ console.error('leaveUpdated handler', e); }
|
||||
});
|
||||
|
||||
// As a robustness measure, also listen to HTMX afterRequest and inspect
|
||||
// the HX-Trigger header. Some flows use hx-swap="none" which may not
|
||||
// produce an htmx:afterSwap event for #modal; reading the header here
|
||||
// ensures we still honor closeModal/leaveUpdated triggers.
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt){
|
||||
try{
|
||||
const xhr = evt.detail && evt.detail.xhr;
|
||||
if(!xhr) return;
|
||||
const trig = xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger');
|
||||
if(!trig) return;
|
||||
// HX-Trigger may be a JSON string or comma-separated names; handle both
|
||||
let triggers = [];
|
||||
try{ triggers = JSON.parse(trig); }catch(e){ triggers = String(trig).split(/\s*,\s*/); }
|
||||
try{ console.debug('[DEBUG] htmx:afterRequest HX-Trigger parsed ->', triggers); }catch(e){}
|
||||
if(triggers.includes('closeModal')){
|
||||
try{ console.debug('[DEBUG] htmx:afterRequest -> closeModal'); if(window.closeProcRotaModal) window.closeProcRotaModal(); }catch(e){}
|
||||
}
|
||||
if(triggers.includes('leaveUpdated')){
|
||||
try{ console.debug('[DEBUG] htmx:afterRequest -> leaveUpdated'); if(window._calendars){ const cs = Object.values(window._calendars).filter(c=>c.refreshLeaves); console.debug('[DEBUG] afterRequest -> refreshing', cs.length, 'calendars'); cs.forEach(c => c.refreshLeaves && c.refreshLeaves()); } }catch(e){}
|
||||
}
|
||||
if(triggers.includes('runCompleted')){
|
||||
try{
|
||||
// Stop further HTMX polling by removing polling attributes from
|
||||
// the run status element and its container. The server will return
|
||||
// out-of-band (OOB) swaps to update the log/result areas in-place
|
||||
// and to insert a "View final output" button, so a full page
|
||||
// reload is no longer required.
|
||||
const el = document.querySelector('#run-status');
|
||||
if(el){ el.removeAttribute('hx-get'); el.removeAttribute('hx-trigger'); el.removeAttribute('hx-swap'); }
|
||||
const container = document.querySelector('#run-status-container');
|
||||
if(container){ container.removeAttribute('hx-get'); container.removeAttribute('hx-trigger'); }
|
||||
}catch(e){ console.error('runCompleted handler failed', e); }
|
||||
}
|
||||
}catch(e){ console.error('htmx:afterRequest robustness handler', e); }
|
||||
});
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% load static %}
|
||||
<html>
|
||||
<head>
|
||||
<title>Rotas</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">Rotas</h1>
|
||||
<p>
|
||||
<a class="button is-link" href="{% url 'rota:worker_add' %}">Add worker</a>
|
||||
<a class="button is-primary" href="{% url 'rota:rota_add' %}">Create rota</a>
|
||||
</p>
|
||||
<div class="box">
|
||||
<ul>
|
||||
{% for rota in rotas %}
|
||||
<li><a href="{% url 'rota:rota_detail' rota.id %}">{{ rota.name }}</a> ({{ rota.start_date }} → {{ rota.end_date }})</li>
|
||||
{% empty %}
|
||||
<li>No rotas yet</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags static %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2 class="title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</h2>
|
||||
|
||||
<div class="box">
|
||||
<h3 class="subtitle">Request leave on calendar</h3>
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
|
||||
<div id="calendar" class="calendar" data-worker-id="{% if worker %}{{ worker.id }}{% endif %}" data-mode="inline"></div>
|
||||
<p class="help">Click a start date then a second date to select a range and prefill the leave form.</p>
|
||||
{% if token %}
|
||||
<p style="margin-top:0.5rem"><a class="button is-light" hx-get="{% url 'rota:worker_settings_token' token %}" hx-target="#modal" hx-swap="innerHTML">Update your settings</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Navigation back to the first rota this worker belongs to (if available) #}
|
||||
{% if worker %}
|
||||
{% with back_rota=worker.rotas.all.0 %}
|
||||
{% if back_rota %}
|
||||
<p style="margin-top:0.5rem"><a class="button" href="{% url 'rota:rota_detail' back_rota.id %}">← Back to rota: {{ back_rota.name }}</a></p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if token %}
|
||||
<input type="hidden" name="token" value="{{ token }}">
|
||||
{% endif %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Request</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if worker %}
|
||||
<h2 class="subtitle">Existing leave for {{ worker.name }}</h2>
|
||||
<div class="content">
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="help">If you are requesting leave for a specific worker, open this page using the worker link so existing requests will be shown.</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:0.5rem;">
|
||||
<strong>Legend:</strong>
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'js/calendar.js' %}"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const container = document.getElementById('calendar');
|
||||
if(!container) return;
|
||||
const leaves = [
|
||||
{% if worker %}
|
||||
{% for l in worker.leaves.all %}
|
||||
{start:'{{ l.start_date|date:"Y-m-d" }}', end:'{{ l.end_date|date:"Y-m-d" }}'},
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
];
|
||||
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% if token %}{% url "rota:request_leave_token" token %}{% else %}{% url "rota:request_leave" %}{% endif %}', leavesUrl: '{% if worker %}{% url "rota:worker_leaves_json" worker.id %}{% endif %}', mode: container.dataset.mode || 'inline' });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,201 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags static %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2 class="title">All leave requests</h2>
|
||||
|
||||
<div class="box">
|
||||
<h3 class="subtitle">Global leave calendar</h3>
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
|
||||
|
||||
{# Rota selection is handled by route scoping; no dropdown needed here #}
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;align-items:center;">
|
||||
<div>
|
||||
<button id="view-calendar-btn" class="button is-small is-link">Calendar</button>
|
||||
<button id="view-list-btn" class="button is-small">List</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="calendar" class="calendar" data-mode="inline"></div>
|
||||
<div id="calendar-list-view" style="display:none; margin-top:0.5rem;"></div>
|
||||
<p class="help">View all leave requests across workers. Choose a rota to scope the calendar, or select "All rotas" to see everything. Click and drag to select dates to open the leave form (you'll need to choose workers in the modal).</p>
|
||||
</div>
|
||||
|
||||
<div id="global-legend" class="box" style="margin-top:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;justify-content:space-between;">
|
||||
<div><strong>Legend:</strong></div>
|
||||
<div>
|
||||
{% if selected_rota_id %}
|
||||
<a class="button is-small is-light" href="{% url 'rota:rota_detail' selected_rota_id %}">Back to rota</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-legend-content" style="margin-top:0.5rem;">
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="worker_leaves_json" type="application/json">{{ leaves_json|safe }}</script>
|
||||
<script id="selected_rota_json" type="application/json">{{ selected_rota_id|default:"null" }}</script>
|
||||
<script src="{% static 'js/calendar.js' %}"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const container = document.getElementById('calendar');
|
||||
if(!container) return;
|
||||
let leaves = [];
|
||||
try {
|
||||
const raw = document.getElementById('worker_leaves_json');
|
||||
if (raw) {
|
||||
leaves = JSON.parse(raw.textContent || raw.innerText || '[]');
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Unable to parse embedded leaves JSON', e);
|
||||
}
|
||||
// Base URL for leaves JSON
|
||||
let leavesUrlBase = '{% url "rota:leaves_global_json" %}';
|
||||
// Read initial rota selection provided by the view (if any)
|
||||
let initialRota = null;
|
||||
try{
|
||||
const sel = document.getElementById('selected_rota_json');
|
||||
if(sel) initialRota = JSON.parse(sel.textContent || sel.innerText || 'null');
|
||||
}catch(e){ initialRota = null; }
|
||||
// expose initial rota id so other scripts (list view) can access it
|
||||
window._initialRota = initialRota;
|
||||
window.initCalendar('calendar', { leaves: leaves, leavesUrl: leavesUrlBase, rotaId: initialRota, requestUrl: '{% url "rota:request_leave" %}', mode: container.dataset.mode || 'inline' });
|
||||
function refreshLegendFor(id){
|
||||
const globalLegendContent = document.getElementById('global-legend-content');
|
||||
if(!globalLegendContent) return;
|
||||
if(!id){
|
||||
globalLegendContent.innerHTML = '<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave'
|
||||
+ '<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range';
|
||||
return;
|
||||
}
|
||||
globalLegendContent.innerText = 'Loading legend…';
|
||||
fetch(leavesUrlBase + '?rota_id=' + encodeURIComponent(id), {credentials:'same-origin'}).then(r=>r.json()).then(data=>{
|
||||
const byWorker = {};
|
||||
(data.leaves||[]).forEach(l=>{ if(l.worker_id){ byWorker[l.worker_id] = {name:l.reason, color:l.color}; } });
|
||||
if(Object.keys(byWorker).length===0){
|
||||
globalLegendContent.innerText = 'No workers with leaves in this rota.';
|
||||
return;
|
||||
}
|
||||
const workerHtml = Object.entries(byWorker).map(([id,info])=> `<div style="display:inline-flex;align-items:center;gap:0.5rem;margin-right:0.5rem"><span style="width:14px;height:14px;background:${info.color};border:1px solid rgba(0,0,0,0.1);display:inline-block"></span><span>${info.name}</span></div>`).join('');
|
||||
globalLegendContent.innerHTML = workerHtml + '<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range';
|
||||
}).catch(e=>{ globalLegendContent.innerText = 'Unable to load legend'; console.debug(e); });
|
||||
}
|
||||
// Refresh legend for the initially selected rota (or show all rotas)
|
||||
// Ensure the legend element exists (script is before the legend markup)
|
||||
if(document.readyState === 'loading'){
|
||||
document.addEventListener('DOMContentLoaded', function(){ refreshLegendFor(initialRota); });
|
||||
} else {
|
||||
refreshLegendFor(initialRota);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const listEl = document.getElementById('calendar-list-view');
|
||||
const btnCal = document.getElementById('view-calendar-btn');
|
||||
const btnList = document.getElementById('view-list-btn');
|
||||
let isList = false;
|
||||
|
||||
function showCalendar(){
|
||||
isList = false;
|
||||
if(listEl) listEl.style.display = 'none';
|
||||
if(calendarEl) calendarEl.style.display = '';
|
||||
if(btnCal) btnCal.classList.add('is-link');
|
||||
if(btnList) btnList.classList.remove('is-link');
|
||||
}
|
||||
|
||||
async function renderList(){
|
||||
if(!listEl) return;
|
||||
listEl.innerHTML = '<p>Loading…</p>';
|
||||
try{
|
||||
let url = '{% url "rota:leaves_global_json" %}';
|
||||
const rotaForList = (typeof window !== 'undefined' && window._initialRota) ? window._initialRota : null;
|
||||
if(rotaForList) url += (url.includes('?') ? '&' : '?') + 'rota_id=' + encodeURIComponent(rotaForList);
|
||||
const res = await fetch(url, {credentials:'same-origin'});
|
||||
if(!res.ok) throw new Error('Failed to fetch leaves');
|
||||
const data = await res.json();
|
||||
const leavesArr = (data.leaves||[]).map(l => ({ start: l.start, end: l.end, reason: l.reason, worker_id: l.worker_id, color: l.color }));
|
||||
|
||||
// find min/max date across all leaves
|
||||
if(leavesArr.length===0){ listEl.innerHTML = '<p>No leaves found.</p>'; return; }
|
||||
function toDate(s){ const p = s.split('-'); return new Date(parseInt(p[0]), parseInt(p[1],10)-1, parseInt(p[2],10)); }
|
||||
function isoDate(d){ return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); }
|
||||
let minD = null, maxD = null;
|
||||
leavesArr.forEach(l=>{
|
||||
try{
|
||||
const s = toDate(l.start);
|
||||
const e = toDate(l.end || l.start);
|
||||
if(!minD || s < minD) minD = s;
|
||||
if(!maxD || e > maxD) maxD = e;
|
||||
}catch(e){}
|
||||
});
|
||||
if(!minD || !maxD){ listEl.innerHTML = '<p>No valid leave dates found.</p>'; return; }
|
||||
|
||||
// build a map of dates -> leaves that cover that date
|
||||
const dateMap = {};
|
||||
for(let d = new Date(minD); d <= maxD; d.setDate(d.getDate()+1)){
|
||||
dateMap[isoDate(new Date(d))] = [];
|
||||
}
|
||||
leavesArr.forEach(l=>{
|
||||
const s = toDate(l.start);
|
||||
const e = toDate(l.end || l.start);
|
||||
for(let d = new Date(s); d <= e; d.setDate(d.getDate()+1)){
|
||||
const key = isoDate(new Date(d));
|
||||
if(key in dateMap) dateMap[key].push(l);
|
||||
else dateMap[key] = [l];
|
||||
}
|
||||
});
|
||||
|
||||
// Render dates in ascending order
|
||||
const parts = [];
|
||||
const keys = Object.keys(dateMap).sort();
|
||||
for(const dateKey of keys){
|
||||
const items = dateMap[dateKey];
|
||||
const header = `<div style="margin-top:0.5rem; font-weight:bold">${dateKey}</div>`;
|
||||
if(!items || items.length===0){
|
||||
parts.push(header + `<div style="margin-left:1.5rem;margin-top:0.25rem;color:#666">No leaves</div>`);
|
||||
continue;
|
||||
}
|
||||
const rows = items.map(l=>{
|
||||
const name = l.reason || 'Unknown';
|
||||
const color = l.color || '#ddd';
|
||||
return `<div style="margin-left:1.5rem;margin-top:0.25rem;display:flex;align-items:center;gap:0.5rem"><span style="width:12px;height:12px;background:${color};display:inline-block;border:1px solid rgba(0,0,0,0.1)"></span><span>${name}</span></div>`;
|
||||
}).join('');
|
||||
parts.push(header + rows);
|
||||
}
|
||||
listEl.innerHTML = parts.join('');
|
||||
}catch(err){ console.error('renderList failed', err); listEl.innerHTML = '<p>Unable to load list view.</p>'; }
|
||||
}
|
||||
|
||||
function showList(){
|
||||
isList = true;
|
||||
if(calendarEl) calendarEl.style.display = 'none';
|
||||
if(listEl) listEl.style.display = '';
|
||||
if(btnList) btnList.classList.add('is-link');
|
||||
if(btnCal) btnCal.classList.remove('is-link');
|
||||
renderList();
|
||||
}
|
||||
|
||||
if(btnCal) btnCal.addEventListener('click', showCalendar);
|
||||
if(btnList) btnList.addEventListener('click', showList);
|
||||
// default view
|
||||
showCalendar();
|
||||
|
||||
// Refresh views when leaves change
|
||||
window.addEventListener('leaveUpdated', function(){
|
||||
if(isList) renderList();
|
||||
else try{ if(window._calendars && window._calendars['calendar'] && window._calendars['calendar'].refreshLeaves) window._calendars['calendar'].refreshLeaves(); }catch(e){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Consolidated legend above replaces the separate 'Workers on this rota' box #}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script>
|
||||
function initFlatpickr(context=document) {
|
||||
try {
|
||||
context.querySelectorAll('.datepicker').forEach(function(el) {
|
||||
// prevent double-init
|
||||
if (el._flatpickr) return;
|
||||
flatpickr(el, {dateFormat: 'Y-m-d'});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('flatpickr init error', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () { initFlatpickr(); });
|
||||
|
||||
// HTMX: re-run init after swaps so dynamically loaded forms get datepickers
|
||||
// Use `document` so this script can be included before `<body>` exists.
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
// evt.detail ? htmx fires on the document; evt.target is the swapped element
|
||||
initFlatpickr(evt.target || document);
|
||||
});
|
||||
|
||||
// Ensure HTMX includes Django CSRF token on non-GET requests by reading cookie
|
||||
// This helps POSTs from modal forms succeed under Django's CSRF protection.
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:configRequest', function(evt){
|
||||
try {
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
if(csrftoken){
|
||||
evt.detail.headers['X-CSRFToken'] = csrftoken;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('htmx csrf attach error', e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="leave-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Add leave for {{ worker.name }}</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field" style="margin-top:0.75rem;">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% comment %}Render a simple leave list with data attributes so client JS can read them{% endcomment %}
|
||||
<div id="leave-list">
|
||||
<ul>
|
||||
{% for l in leaves %}
|
||||
<li data-start="{{ l.start_date|date:'Y-m-d' }}" data-end="{{ l.end_date|date:'Y-m-d' }}">
|
||||
<span class="leave-range">{{ l.start_date }} → {{ l.end_date }}</span>{% if l.reason %} <em>({{ l.reason }})</em>{% endif %}
|
||||
<!-- Rely on the server to return an out-of-band (OOB) swap that updates
|
||||
the #leave-list. Prevent htmx from attempting the main swap by
|
||||
disabling the default swap for this request. -->
|
||||
{% comment %}Only show delete control when the current request is allowed to delete:{% endcomment %}
|
||||
{% if request.user.is_staff or request.user.is_authenticated and request.user.email and request.user.email == l.worker.email or token %}
|
||||
<form method="post" action="{% url 'rota:leave_delete' l.id %}" hx-post="{% url 'rota:leave_delete' l.id %}" hx-swap="none" style="display:inline;margin-left:0.5rem">
|
||||
{% csrf_token %}
|
||||
{% if token %}<input type="hidden" name="token" value="{{ token }}">{% endif %}
|
||||
<button class="button is-small is-danger" type="submit" onclick="return confirm('Delete this leave request?');">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No leave recorded</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="leave-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Request leave{% if worker %} for {{ worker.name }}{% endif %}</p>
|
||||
<button class="delete" aria-label="close" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<!-- Use hx-swap="none" and rely on server HX-Trigger to close the modal
|
||||
and OOB swaps to update the leave list. This avoids HTMX attempting a
|
||||
main-swap into #modal when the server only returns OOB fragments. -->
|
||||
<form method="post" hx-post="{{ form_action }}" hx-swap="none">
|
||||
{% csrf_token %}
|
||||
{% if worker %}
|
||||
<input type="hidden" name="worker_id" value="{{ worker.id }}" />
|
||||
{% endif %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Request</button>
|
||||
<button type="button" class="button" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<tr id="constraint-row-{{ initial.key|default:"new" }}">
|
||||
<td colspan="3">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#constraint-row-{{ initial.key|default:"new" }}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{% if error %}
|
||||
<div class="notification is-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div class="field">
|
||||
<label class="label">Key</label>
|
||||
<div class="control">
|
||||
<input class="input" name="key" type="text" value="{{ initial.key }}" {% if initial.key %}readonly{% endif %} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Type</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="type">
|
||||
<option value="string" {% if initial.type == 'string' %}selected{% endif %}>String</option>
|
||||
<option value="int" {% if initial.type == 'int' %}selected{% endif %}>Integer</option>
|
||||
<option value="float" {% if initial.type == 'float' %}selected{% endif %}>Float</option>
|
||||
<option value="bool" {% if initial.type == 'bool' %}selected{% endif %}>Boolean</option>
|
||||
<option value="json" {% if initial.type == 'json' %}selected{% endif %}>JSON (list/object)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Value</label>
|
||||
<div class="control">
|
||||
<textarea name="value" class="textarea" rows="3">{{ initial.value }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button class="button" type="button" onclick="this.closest('tr').outerHTML='';">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,57 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<script>
|
||||
function _close_rota_option_modal(){
|
||||
try{ var m=document.getElementById('modal'); if(m) m.innerHTML=''; }catch(e){}
|
||||
}
|
||||
</script>
|
||||
<div class="modal is-active" id="rota-option-modal">
|
||||
<div class="modal-background" onclick="_close_rota_option_modal()"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ initial.key|default:'Add option' }}</p>
|
||||
{% if description %}
|
||||
<div style="margin-left:1.25em; margin-top:0.25em;">
|
||||
<p class="is-size-7 has-text-grey">{{ description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button class="delete" aria-label="close" onclick="_close_rota_option_modal()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label">Key</label>
|
||||
<div class="control">
|
||||
<input class="input" name="key" type="text" value="{{ initial.key }}" {% if initial.key %}readonly{% endif %} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Type</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="type">
|
||||
<option value="string" {% if initial.type == 'string' %}selected{% endif %}>String</option>
|
||||
<option value="int" {% if initial.type == 'int' %}selected{% endif %}>Integer</option>
|
||||
<option value="float" {% if initial.type == 'float' %}selected{% endif %}>Float</option>
|
||||
<option value="bool" {% if initial.type == 'bool' %}selected{% endif %}>Boolean</option>
|
||||
<option value="json" {% if initial.type == 'json' %}selected{% endif %}>JSON (list/object)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Value</label>
|
||||
<div class="control">
|
||||
<textarea name="value" class="textarea" rows="4">{{ initial.value }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="_close_rota_option_modal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div id="constraint-row-{{ key }}" class="constraint-row">
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control" style="min-width:25%;"><label class="label"><code title="{{ description }}">{{ key }}</code></label></div>
|
||||
<div class="control" style="min-width:65%;"><pre style="white-space:pre-wrap; margin:0;">{{ pretty }}</pre></div>
|
||||
<div class="control" style="min-width:10%;">
|
||||
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
|
||||
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="key" value="{{ key }}" />
|
||||
<input type="hidden" name="inline" value="1" />
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<div id="rota-options-display">
|
||||
<div style="margin-bottom:0.5em;">
|
||||
<button class="button is-small" hx-get="{% url 'rota:rota_option_add' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Add option</button>
|
||||
</div>
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% if rota.options %}
|
||||
{% for k,v in rota.options.items %}
|
||||
<tr>
|
||||
<td><code>{{ k }}</code></td>
|
||||
<td><pre style="white-space:pre-wrap;">{{ v|json_script:k }}</pre></td>
|
||||
<td>
|
||||
<button class="button is-small is-info" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ k }}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<form method="post" action="{% url 'rota:rota_option_delete' rota.id %}" style="display:inline" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="key" value="{{ k }}" />
|
||||
<button class="button is-small is-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3"><em>No options set</em></td></tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3"><em>No options set</em></td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<script>
|
||||
function _close_rota_options_modal(){
|
||||
try{ var m=document.getElementById('modal'); if(m) m.innerHTML=''; }catch(e){}
|
||||
}
|
||||
</script>
|
||||
<div class="modal is-active" id="rota-options-modal">
|
||||
<div class="modal-background" onclick="_close_rota_options_modal()"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Edit Rota Options</p>
|
||||
<button class="delete" aria-label="close" onclick="_close_rota_options_modal()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
{% if form %}
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{% comment %} Render only option fields (those prefixed with `opt__`) so the modal
|
||||
focuses on rota-builder options and constraints. {% endcomment %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name|slice:":5" == "opt__" %}
|
||||
<div class="field">
|
||||
<label class="label">{{ field.label }}</label>
|
||||
<div class="control">{{ field }}</div>
|
||||
{% if field.help_text %}<p class="help">{{ field.help_text }}</p>{% endif %}
|
||||
{% for err in field.errors %}<p class="help is-danger">{{ err }}</p>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="_close_rota_options_modal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label">Options (JSON)</label>
|
||||
<div class="control">
|
||||
<textarea name="options_json" class="textarea" rows="10">{{ initial.options_json }}</textarea>
|
||||
</div>
|
||||
<p class="help">Edit rota options as JSON. See available constraint keys on the rota detail page.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="_close_rota_options_modal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Apply leave to workers</p>
|
||||
<button class="delete" aria-label="close" onclick="(function(){ if(window.closeProcRotaModal) window.closeProcRotaModal(); })()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p class="help">Select one or more workers to apply the selected leave range to.</p>
|
||||
<form id="apply-multi-form" method="post" action="{% url 'rota:apply_leave_multi' %}" hx-post="{% url 'rota:apply_leave_multi' %}" hx-swap="none">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="start_date" value="{{ start_date }}" />
|
||||
<input type="hidden" name="end_date" value="{{ end_date }}" />
|
||||
<div style="max-height:300px; overflow:auto; margin-top:0.5rem;">
|
||||
{% for w in workers %}
|
||||
<div class="field">
|
||||
<label class="checkbox"><input type="checkbox" name="worker_id" value="{{ w.id }}"> {{ w.name }}{% if w.site %} — {{ w.site }}{% endif %}</label>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="help">No workers available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button id="apply-multi-submit" class="button is-success" type="submit" disabled>Apply leave</button>
|
||||
<button class="button" type="button" onclick="(function(){ if(window.closeProcRotaModal) window.closeProcRotaModal(); })()">Cancel</button>
|
||||
</footer>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('apply-multi-form');
|
||||
const submitBtn = document.getElementById('apply-multi-submit');
|
||||
if(!form) return;
|
||||
|
||||
function updateBtn(){
|
||||
const any = Array.from(form.querySelectorAll('input[type=checkbox][name="worker_id"]')).some(cb=>cb.checked);
|
||||
submitBtn.disabled = !any;
|
||||
}
|
||||
|
||||
// Attach change handlers to checkboxes
|
||||
Array.from(form.querySelectorAll('input[type=checkbox][name="worker_id"]')).forEach(cb=> cb.addEventListener('change', updateBtn));
|
||||
updateBtn();
|
||||
|
||||
// Make the external Apply button trigger the form submit (button is outside the <form>)
|
||||
submitBtn.addEventListener('click', function(ev){
|
||||
ev.preventDefault();
|
||||
if(typeof form.requestSubmit === 'function'){
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
// older browsers
|
||||
const submitEvt = new Event('submit', {bubbles: true, cancelable: true});
|
||||
form.dispatchEvent(submitEvt);
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback submit handler using fetch so the modal doesn't navigate away
|
||||
form.addEventListener('submit', function(ev){
|
||||
ev.preventDefault();
|
||||
// collect form data
|
||||
const fd = new FormData(form);
|
||||
const url = form.action;
|
||||
// fetch with CSRF from cookie if present
|
||||
function getCookie(name){
|
||||
const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
|
||||
return v ? v.pop() : '';
|
||||
}
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'HX-Request': 'true'
|
||||
},
|
||||
body: fd
|
||||
}).then(resp=>{
|
||||
// Respect HX-Trigger header if present
|
||||
const trig = resp.headers.get('HX-Trigger') || resp.headers.get('Hx-Trigger');
|
||||
if(trig){
|
||||
const parts = trig.split(',').map(s=>s.trim()).filter(Boolean);
|
||||
if(parts.includes('closeModal')){
|
||||
if(window.closeProcRotaModal) window.closeProcRotaModal();
|
||||
}
|
||||
if(parts.includes('leaveUpdated')){
|
||||
// dispatch a custom event that other code listens to
|
||||
try{ window.dispatchEvent(new Event('leaveUpdated')); }catch(e){}
|
||||
}
|
||||
}
|
||||
if(!resp.ok){
|
||||
resp.text().then(t=>{ alert('Failed to apply leave: ' + (t||resp.statusText)); });
|
||||
}
|
||||
}).catch(err=>{ console.error('apply-multi submit failed', err); alert('Failed to apply leave'); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="modal is-active" id="shift-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Confirm delete</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p>Are you sure you want to delete shift <strong>{{ shift.name }}</strong>?</p>
|
||||
<form method="post" hx-post="{% url 'rota:shift_delete' rota.id idx %}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field mt-4">
|
||||
<div class="control">
|
||||
<button class="button is-danger" type="submit">Delete</button>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<form method="post" hx-post="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-list" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Add shift</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Mark required fields with a visual asterisk and set ARIA attributes
|
||||
(function(){
|
||||
try{
|
||||
const form = document.currentScript.previousElementSibling;
|
||||
if(!form) return;
|
||||
const requiredEls = form.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
requiredEls.forEach(el=>{
|
||||
el.setAttribute('aria-required','true');
|
||||
// try to find a label: either <label for="id_x"> or a sibling .label
|
||||
const id = el.id;
|
||||
let lbl = id ? form.querySelector('label[for="'+id+'"]') : null;
|
||||
if(!lbl){
|
||||
// look for nearest label element in the same .field
|
||||
const field = el.closest('.field');
|
||||
if(field) lbl = field.querySelector('label');
|
||||
}
|
||||
if(lbl){
|
||||
const already = lbl.querySelector('.required-indicator') || lbl.querySelector('.has-text-danger') || /\*\s*$/.test(lbl.textContent);
|
||||
if(!already){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'required-indicator has-text-danger';
|
||||
span.textContent = ' *';
|
||||
lbl.appendChild(span);
|
||||
}
|
||||
}
|
||||
});
|
||||
}catch(e){ /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,352 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<script>
|
||||
function _close_shift_modal_targets(){
|
||||
try{ var m=document.getElementById('modal'); if(m) m.innerHTML=''; }catch(e){}
|
||||
try{ var s=document.getElementById('shift-form'); if(s) s.innerHTML=''; }catch(e){}
|
||||
}
|
||||
</script>
|
||||
<div class="modal is-active" id="shift-modal">
|
||||
<div class="modal-background" onclick="_close_shift_modal_targets()"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Shift</p>
|
||||
<button class="delete" aria-label="close" onclick="_close_shift_modal_targets()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
||||
<!-- Structured constraint editor: renders a friendly UI and keeps the
|
||||
hidden `constraints` textarea (form field) in sync as JSON. -->
|
||||
<div class="box" id="constraints-editor">
|
||||
<h4 class="title is-6">Shift constraints</h4>
|
||||
<p class="help">Add constraint entries. The form stores a JSON list; this editor helps build that list.</p>
|
||||
<div id="constraint-errors" class="notification is-danger" style="display:none; margin-bottom:0.5em;"></div>
|
||||
<div id="constraint-list"></div>
|
||||
<div style="margin-top:0.5em;">
|
||||
<button type="button" class="button is-small" id="add-constraint">Add constraint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if focus %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
const focusName = '{{ focus }}';
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
if(listEl){
|
||||
// Ensure at least one row exists
|
||||
if(listEl.children.length === 0){ if(addBtn) addBtn.click(); }
|
||||
setTimeout(()=>{
|
||||
// Try to find a select matching the constraint name
|
||||
const selects = listEl.querySelectorAll('select');
|
||||
let matched = null;
|
||||
selects.forEach(s=>{ if(s.value === focusName) matched = s; });
|
||||
if(matched){ try{ matched.scrollIntoView({behavior:'smooth', block:'center'}); matched.focus(); matched.dispatchEvent(new Event('change',{bubbles:true})); return; }catch(e){} }
|
||||
// otherwise, focus first row and set its type to the requested focus
|
||||
const firstSel = selects[0];
|
||||
if(firstSel){ try{ firstSel.value = focusName; firstSel.dispatchEvent(new Event('change',{bubbles:true})); firstSel.focus(); firstSel.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){} }
|
||||
}, 120);
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
// Constraint type schemas: name -> fields metadata
|
||||
const SCHEMAS = {
|
||||
"night": {label: "Night constraint", fields: [ {name: "options", label: "Options (free JSON)", type: "json"} ]},
|
||||
// PreShiftConstraint fields from rota_generator/shifts.py:
|
||||
// weeks: Optional[List[int]]
|
||||
// start_date: Optional[date]
|
||||
// end_date: Optional[date]
|
||||
// days: Optional[int]
|
||||
// ignore_shifts: List[str]
|
||||
// exclude_days: List[str]
|
||||
// allow_self: bool
|
||||
"pre": {label: "Pre-shift", fields: [
|
||||
{name: "weeks", label: "Weeks (comma-separated integers)", type: "list_int"},
|
||||
{name: "start_date", label: "Start date", type: "date"},
|
||||
{name: "end_date", label: "End date", type: "date"},
|
||||
{name: "days", label: "Duration (days)", type: "int", required: true},
|
||||
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
|
||||
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
|
||||
{name: "allow_self", label: "Allow self", type: "bool"}
|
||||
]},
|
||||
// PostShiftConstraint same fields
|
||||
"post": {label: "Post-shift", fields: [
|
||||
{name: "weeks", label: "Weeks (comma-separated integers)", type: "list_int"},
|
||||
{name: "start_date", label: "Start date", type: "date"},
|
||||
{name: "end_date", label: "End date", type: "date"},
|
||||
{name: "days", label: "Duration (days)", type: "int", required: true},
|
||||
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
|
||||
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
|
||||
{name: "allow_self", label: "Allow self", type: "bool"}
|
||||
]},
|
||||
"min_summed_grade_by_shifts_per_day": {label: "Min summed grade by shifts per day", fields: [ {name: "shifts", label: "Shifts (comma-separated)", type: "list", required: true}, {name: "min_grade_sum", label: "Minimum grade sum", type: "int", required: true}, {name: "days", label: "Days (comma-separated)", type: "list", required: true} ]},
|
||||
"limit_grade_number": {label: "Limit grade number", fields: [ {name: "grade", label: "Grade", type: "int", required: true}, {name: "max_number", label: "Max number", type: "int", required: true} ]},
|
||||
"minimum_grade_number": {label: "Minimum grade number", fields: [ {name: "grade", label: "Grade", type: "int", required: true}, {name: "min_number", label: "Min number", type: "int", required: true} ]},
|
||||
"require_remote_site_presence": {label: "Require remote site presence", fields: [ {name: "site", label: "Site", type: "text", required: true}, {name: "required_number", label: "Required number", type: "int", required: true} ]},
|
||||
"max_shifts_per_week": {label: "Max shifts per week", fields: [ {name: "max_shifts", label: "Max shifts", type: "int", required: true} ]},
|
||||
"max_shifts_per_week_block": {label: "Max shifts per week block", fields: [ {name: "week_block", label: "Week block", type: "int", required: true}, {name: "max_shifts", label: "Max shifts", type: "int", required: true} ]}
|
||||
};
|
||||
|
||||
const textarea = document.getElementById('id_constraints');
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const errorsEl = document.getElementById('constraint-errors');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
|
||||
function validateConstraintsArray(data){
|
||||
const msgs = [];
|
||||
if(!Array.isArray(data)) return ["Constraints must be a JSON list"];
|
||||
data.forEach((c, idx)=>{
|
||||
if(!c || typeof c !== 'object'){
|
||||
msgs.push(`Constraint #${idx+1} must be an object`);
|
||||
return;
|
||||
}
|
||||
const name = c.name || c.type || null;
|
||||
if(!name){ msgs.push(`Constraint #${idx+1} missing 'name'`); return; }
|
||||
if(name==='pre' || name==='post'){
|
||||
const opts = c.options || {};
|
||||
// days (duration) is required
|
||||
const daysVal = (c.days!==undefined && c.days!==null) ? c.days : (opts.days!==undefined && opts.days!==null ? opts.days : null);
|
||||
if(daysVal===null){
|
||||
msgs.push(`Constraint #${idx+1} (pre/post) requires 'Duration (days)' to be set`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function parseInitial(){
|
||||
let val = textarea.value || '';
|
||||
if(!val.trim()) return [];
|
||||
try{ return JSON.parse(val); }catch(e){ return []; }
|
||||
}
|
||||
|
||||
function render(){
|
||||
listEl.innerHTML = '';
|
||||
const data = parseInitial();
|
||||
data.forEach((c, idx)=>{
|
||||
listEl.appendChild(renderConstraintRow(c, idx));
|
||||
});
|
||||
// Ensure textarea is synchronized with the rendered rows. Some
|
||||
// inputs update the textarea via their handlers; this function
|
||||
// triggers those handlers or falls back to reconstructing the
|
||||
// JSON from DOM if needed.
|
||||
function syncTextarea(){
|
||||
try{
|
||||
const children = listEl.children;
|
||||
for(let i=0;i<children.length;i++){
|
||||
const ch = children[i];
|
||||
// dispatch change/input events on controls so their handlers
|
||||
// update the textarea via syncToData
|
||||
const controls = ch.querySelectorAll('select, input, textarea');
|
||||
controls.forEach(ctrl=>{
|
||||
try{
|
||||
if(ctrl.tagName==='SELECT') ctrl.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
else ctrl.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
syncTextarea();
|
||||
// validate and show errors
|
||||
try{
|
||||
const v = validateConstraintsArray(data);
|
||||
if(v && v.length){ errorsEl.style.display='block'; errorsEl.innerHTML = v.map(m=>`<div>${m}</div>`).join(''); }
|
||||
else { errorsEl.style.display='none'; errorsEl.innerHTML=''; }
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function renderConstraintRow(c, idx){
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'box';
|
||||
const header = document.createElement('div');
|
||||
header.style.display = 'flex';
|
||||
header.style.gap = '0.5em';
|
||||
// Type select
|
||||
const selDiv = document.createElement('div'); selDiv.className='select';
|
||||
const sel = document.createElement('select');
|
||||
sel.name = `constraint_type_${idx}`;
|
||||
const optEmpty = document.createElement('option'); optEmpty.value=''; optEmpty.text='-- type --'; sel.appendChild(optEmpty);
|
||||
Object.keys(SCHEMAS).forEach(k=>{ const o=document.createElement('option'); o.value=k; o.text=SCHEMAS[k].label; sel.appendChild(o); });
|
||||
sel.value = c.name || '';
|
||||
sel.onchange = ()=>{ renderFields(); syncToData(); };
|
||||
selDiv.appendChild(sel);
|
||||
header.appendChild(selDiv);
|
||||
|
||||
// Remove button
|
||||
const rm = document.createElement('button'); rm.type='button'; rm.className='button is-small is-danger'; rm.textContent='Remove';
|
||||
rm.onclick = ()=>{ removeAt(idx); };
|
||||
header.appendChild(rm);
|
||||
wrapper.appendChild(header);
|
||||
|
||||
const fieldsContainer = document.createElement('div'); fieldsContainer.style.marginTop='0.5em'; wrapper.appendChild(fieldsContainer);
|
||||
|
||||
function renderFields(){
|
||||
fieldsContainer.innerHTML='';
|
||||
const t = sel.value;
|
||||
if(!t || !(t in SCHEMAS)){
|
||||
// free-form options
|
||||
const ta = document.createElement('textarea'); ta.className='textarea'; ta.rows=3;
|
||||
ta.placeholder='Free JSON options for this constraint (optional)';
|
||||
ta.value = c.options ? JSON.stringify(c.options) : (c.options===undefined?'':JSON.stringify(c.options||{}));
|
||||
ta.oninput = syncToData;
|
||||
fieldsContainer.appendChild(ta);
|
||||
return;
|
||||
}
|
||||
SCHEMAS[t].fields.forEach(f=>{
|
||||
const fieldWrap = document.createElement('div'); fieldWrap.className='field';
|
||||
const label = document.createElement('label'); label.className='label'; label.textContent = f.label;
|
||||
// append required indicator if schema marks this field required
|
||||
if(f.required){
|
||||
if(!label.querySelector('.required-indicator')){
|
||||
const span = document.createElement('span'); span.className='required-indicator has-text-danger'; span.textContent=' *';
|
||||
label.appendChild(span);
|
||||
}
|
||||
}
|
||||
fieldWrap.appendChild(label);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
let input;
|
||||
if(f.type==='text' || f.type==='int' || f.type==='float' || f.type==='list' || f.type==='list_int'){
|
||||
input = document.createElement('input'); input.className='input'; input.type='text';
|
||||
const cur = (c.options && c.options[f.name]!==undefined) ? c.options[f.name] : (c[f.name]!==undefined?c[f.name]:'');
|
||||
if(Array.isArray(cur) && (f.type==='list' || f.type==='list_int')) input.value = cur.join(','); else input.value = cur===null? '': String(cur);
|
||||
input.oninput = syncToData;
|
||||
} else if(f.type==='json'){
|
||||
input = document.createElement('textarea'); input.className='textarea'; input.rows=3;
|
||||
const cur = (c.options && c.options[f.name]!==undefined) ? c.options[f.name] : (c[f.name]!==undefined?c[f.name]:'');
|
||||
try{ input.value = JSON.stringify(cur); }catch(e){ input.value = String(cur); }
|
||||
input.oninput = syncToData;
|
||||
} else if(f.type==='bool'){
|
||||
// checkbox for boolean
|
||||
const cb = document.createElement('input'); cb.type='checkbox'; cb.className='checkbox';
|
||||
const curb = (c.options && c.options[f.name]!==undefined) ? c.options[f.name] : (c[f.name]!==undefined?c[f.name]:false);
|
||||
cb.checked = !!curb;
|
||||
cb.setAttribute('data-field-name', f.name);
|
||||
cb.onchange = syncToData;
|
||||
if(f.required){ cb.required = true; cb.setAttribute('aria-required','true'); }
|
||||
control.appendChild(cb);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
return; // already appended
|
||||
} else if(f.type==='date'){
|
||||
input = document.createElement('input'); input.className='input'; input.type='date';
|
||||
const curd = (c.options && c.options[f.name]!==undefined) ? c.options[f.name] : (c[f.name]!==undefined?c[f.name]:'');
|
||||
input.value = curd ? String(curd) : '';
|
||||
input.oninput = syncToData;
|
||||
} else {
|
||||
input = document.createElement('input'); input.className='input'; input.type='text'; input.oninput = syncToData;
|
||||
}
|
||||
input.setAttribute('data-field-name', f.name);
|
||||
if(f.required){ input.required = true; input.setAttribute('aria-required','true'); }
|
||||
control.appendChild(input);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
});
|
||||
}
|
||||
|
||||
function syncToData(){
|
||||
// read values from fields and update underlying data array in textarea
|
||||
const data = parseInitial();
|
||||
const obj = {name: sel.value, options: {}};
|
||||
const t = sel.value;
|
||||
if(t in SCHEMAS){
|
||||
SCHEMAS[t].fields.forEach(f=>{
|
||||
const input = fieldsContainer.querySelector('[data-field-name="'+f.name+'"]');
|
||||
if(!input) return;
|
||||
let v = input.value;
|
||||
if(f.type==='int'){
|
||||
v = v===''?null:parseInt(v,10);
|
||||
} else if(f.type==='float'){
|
||||
v = v===''?null:parseFloat(v);
|
||||
} else if(f.type==='list'){
|
||||
v = v===''?[]:v.split(',').map(x=>x.trim()).filter(Boolean);
|
||||
} else if(f.type==='list_int'){
|
||||
v = v===''?[]:v.split(',').map(x=>parseInt(x.trim(),10)).filter(n=>!Number.isNaN(n));
|
||||
} else if(f.type==='bool'){
|
||||
const cb = fieldsContainer.querySelector('[data-field-name="'+f.name+'"]'); if(cb) v = !!cb.checked; else v = !!v;
|
||||
} else if(f.type==='date'){
|
||||
v = v===''?null:v; // keep as ISO date string
|
||||
} else if(f.type==='json'){
|
||||
try{ v = v===''?{}:JSON.parse(v); }catch(e){ v = input.value; }
|
||||
}
|
||||
obj.options[f.name] = v;
|
||||
});
|
||||
} else {
|
||||
const ta = fieldsContainer.querySelector('textarea');
|
||||
if(ta){ try{ obj.options = ta.value?JSON.parse(ta.value):{} }catch(e){ obj.options = ta.value } }
|
||||
}
|
||||
data[idx] = obj;
|
||||
textarea.value = JSON.stringify(data);
|
||||
}
|
||||
|
||||
function removeAt(i){
|
||||
const data = parseInitial(); data.splice(i,1); textarea.value = JSON.stringify(data); render();
|
||||
}
|
||||
|
||||
// initialize fields
|
||||
renderFields();
|
||||
// listen for updates to synchronize
|
||||
// expose removeAt to outer scope via element
|
||||
wrapper._removeAt = removeAt;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function removeAt(i){ const data = parseInitial(); data.splice(i,1); textarea.value = JSON.stringify(data); render(); }
|
||||
|
||||
addBtn.addEventListener('click', ()=>{
|
||||
const data = parseInitial(); data.push({name:'', options:{}}); textarea.value = JSON.stringify(data); render();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
try{ render(); }catch(e){ /* fail silently */ }
|
||||
|
||||
// Also sync before form submit (in case some inputs haven't fired)
|
||||
const form = textarea.closest('form'); if(form){ form.addEventListener('submit', ()=>{ /* already synced via inputs */ }); }
|
||||
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Mark required fields in the modal shift form with a visible asterisk and add ARIA attributes
|
||||
(function(){
|
||||
try{
|
||||
const modalForm = document.querySelector('#shift-modal form');
|
||||
if(!modalForm) return;
|
||||
const requiredEls = modalForm.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
requiredEls.forEach(el=>{
|
||||
el.setAttribute('aria-required','true');
|
||||
const id = el.id;
|
||||
let lbl = id ? modalForm.querySelector('label[for="'+id+'"]') : null;
|
||||
if(!lbl){
|
||||
const field = el.closest('.field');
|
||||
if(field) lbl = field.querySelector('label');
|
||||
}
|
||||
if(lbl){
|
||||
const already = lbl.querySelector('.required-indicator') || lbl.querySelector('.has-text-danger') || /\*\s*$/.test(lbl.textContent);
|
||||
if(!already){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'required-indicator has-text-danger';
|
||||
span.textContent = ' *';
|
||||
lbl.appendChild(span);
|
||||
}
|
||||
}
|
||||
});
|
||||
}catch(e){ /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="_close_shift_modal_targets()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<div id="shift-list">
|
||||
<h3 class="subtitle">Shifts</h3>
|
||||
<div class="box">
|
||||
<ul>
|
||||
{% for s in rota.shifts %}
|
||||
<li>
|
||||
<strong>{{ s.name }}</strong> — sites: {{ s.sites|join:", " }} — days: {{ s.days|join:", " }} — workers: {{ s.workers_required }}
|
||||
{% if s.constraints %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<span class="tags">
|
||||
{% for c in s.constraints %}
|
||||
{# Build a simple tooltip showing key: value pairs for the constraint on hover; make clickable to open constraint editor #}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{% for k,v in c.items %}{{ k }}: {{ v }}{% if not forloop.last %}; {% endif %}{% endfor %}" hx-get="{% url 'rota:shift_edit' rota.id forloop.parentloop.counter0 %}?focus={{ c.name }}" hx-target="#modal" hx-swap="innerHTML">{{ c.name }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if s.balance_offset is defined and s.balance_offset %}
|
||||
— balance offset: {{ s.balance_offset }}
|
||||
{% endif %}
|
||||
<span class="buttons is-right">
|
||||
<button class="button is-small is-info" hx-get="{% url 'rota:shift_edit' rota.id forloop.counter0 %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<button class="button is-small is-danger" hx-get="{% url 'rota:shift_delete' rota.id forloop.counter0 %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No shifts defined yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="modal is-active" id="worker-delete-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Confirm delete</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p>Are you sure you want to delete worker <strong>{{ worker.name }}</strong>?</p>
|
||||
<form method="post" hx-post="{% url 'rota:worker_delete' rota.id worker.id %}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field mt-4">
|
||||
<div class="control">
|
||||
<button class="button is-danger" type="submit">Delete</button>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,494 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="worker-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Add Worker</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{# Preserve rota_id if provided so POST handlers can attach the worker to the rota #}
|
||||
{% if rota_id %}
|
||||
<input type="hidden" name="rota_id" value="{{ rota_id }}" />
|
||||
{% endif %}
|
||||
{{ form|crispy }}
|
||||
|
||||
{# Token area: shows a copyable self-service link and a regenerate button when editing a worker #}
|
||||
{% if worker %}
|
||||
{% include 'rota/partials/worker_token_area.html' with worker=worker token_link=token_link %}
|
||||
{% endif %}
|
||||
|
||||
{% if worker %}
|
||||
<div class="box" id="worker-leaves">
|
||||
<h4 class="title is-6">Leaves</h4>
|
||||
<div>
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all token=None %}
|
||||
</div>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.5rem;">
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' worker.id %}">Open full page</a>
|
||||
<button class="button is-small" hx-get="{% url 'rota:worker_leave_add' worker.id %}" hx-target="#modal" hx-swap="innerHTML">Add leave</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
|
||||
<div class="box" id="nwds-editor">
|
||||
<h4 class="title is-6">Non-working days</h4>
|
||||
<p class="help">Add non-working day rules. These will be serialized to the JSON field.</p>
|
||||
<div id="nwds-list"></div>
|
||||
<div style="margin-top:0.5em;">
|
||||
<button type="button" class="button is-small" id="add-nwd">Add non-working day</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if focus %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
const f = '{{ focus }}';
|
||||
const listId = f + '-list';
|
||||
const addBtnId = 'add-' + f;
|
||||
const listEl = document.getElementById(listId);
|
||||
if(listEl){
|
||||
// if no rows exist, trigger the Add button to create one
|
||||
if(listEl.children.length === 0){
|
||||
const btn = document.getElementById(addBtnId);
|
||||
if(btn) btn.click();
|
||||
}
|
||||
// after render, scroll into view and focus the first input
|
||||
setTimeout(()=>{
|
||||
const el = document.getElementById(listId);
|
||||
if(el){
|
||||
try{ el.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){}
|
||||
const firstRow = el.querySelector('.box') || el.firstElementChild;
|
||||
if(firstRow){
|
||||
const input = firstRow.querySelector('input, select, textarea, [data-field]');
|
||||
if(input){ try{ input.focus(); }catch(e){} }
|
||||
}
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
const textarea = document.getElementById('id_nwds');
|
||||
// hide the raw JSON textarea to avoid confusing users; editor will keep it in sync
|
||||
if(textarea){ textarea.style.display = 'none'; }
|
||||
const listEl = document.getElementById('nwds-list');
|
||||
const addBtn = document.getElementById('add-nwd');
|
||||
|
||||
const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||||
|
||||
function parseInitial(){
|
||||
let val = textarea ? textarea.value : '';
|
||||
if(!val) return [];
|
||||
let raw;
|
||||
try{ raw = JSON.parse(val); }catch(e){ return []; }
|
||||
if(!Array.isArray(raw)) return [];
|
||||
|
||||
// Normalize entries: the stored shape may be a list of ints (0=Mon),
|
||||
// strings, or objects {day: 'Mon', start_date, end_date}.
|
||||
return raw.map(item=>{
|
||||
if(item === null || item === undefined) return {};
|
||||
if(typeof item === 'number'){
|
||||
return { day: DAYS[item] || '' };
|
||||
}
|
||||
if(typeof item === 'string'){
|
||||
// Accept 'monday' / 'Mon' / 'mon' / '0' etc.
|
||||
const n = parseInt(item,10);
|
||||
if(!Number.isNaN(n)) return { day: DAYS[n] || '' };
|
||||
const s = item.trim();
|
||||
// try matching first 3 letters ignoring case
|
||||
const low = s.toLowerCase();
|
||||
for(let d of DAYS){ if(d.toLowerCase().startsWith(low.slice(0,3))) return { day: d }; }
|
||||
// fallback: return as-day string
|
||||
return { day: s };
|
||||
}
|
||||
if(typeof item === 'object'){
|
||||
// If object has numeric day, convert
|
||||
const copy = Object.assign({}, item);
|
||||
if(copy.day !== undefined && typeof copy.day === 'number'){
|
||||
copy.day = DAYS[copy.day] || '';
|
||||
}
|
||||
// If day is a short name, normalise to 3-letter capitalised
|
||||
if(copy.day && typeof copy.day === 'string'){
|
||||
const s = copy.day.trim();
|
||||
const low = s.toLowerCase();
|
||||
for(let d of DAYS){ if(d.toLowerCase().startsWith(low.slice(0,3))) { copy.day = d; break; } }
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
function syncTextarea(){
|
||||
// Ensure each row's inputs trigger their onchange handlers so the
|
||||
// hidden textarea is kept in sync. This is resilient whether rows
|
||||
// expose a `_sync` helper or rely on input events.
|
||||
try{
|
||||
const children = listEl.children;
|
||||
for(let i=0;i<children.length;i++){
|
||||
const ch = children[i];
|
||||
if(ch && typeof ch._sync === 'function'){
|
||||
try{ ch._sync(); }catch(e){}
|
||||
} else {
|
||||
// trigger change/input events on contained controls
|
||||
const controls = ch.querySelectorAll('select, input, textarea');
|
||||
controls.forEach(ctrl=>{
|
||||
try{
|
||||
if(ctrl.tagName==='SELECT') ctrl.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
else ctrl.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function render(){
|
||||
listEl.innerHTML = '';
|
||||
const data = parseInitial();
|
||||
data.forEach((item, idx)=>{
|
||||
listEl.appendChild(renderRow(item, idx));
|
||||
});
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
function renderRow(item, idx){
|
||||
const wrap = document.createElement('div'); wrap.className='box';
|
||||
const row = document.createElement('div'); row.style.display='flex'; row.style.gap='0.5em'; row.style.alignItems='flex-end';
|
||||
|
||||
// day select
|
||||
const dayDiv = document.createElement('div'); dayDiv.className='field';
|
||||
const dayLabel = document.createElement('label'); dayLabel.className='label'; dayLabel.textContent='Day'; dayDiv.appendChild(dayLabel);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
// Bulma expects a wrapper div.select containing the <select>
|
||||
const selWrap = document.createElement('div'); selWrap.className='select'; selWrap.style.minWidth='120px';
|
||||
const inner = document.createElement('select');
|
||||
const empty = document.createElement('option'); empty.value=''; empty.text='(any)'; inner.appendChild(empty);
|
||||
DAYS.forEach(d=>{ const o=document.createElement('option'); o.value=d; o.text=d; inner.appendChild(o); });
|
||||
inner.value = item.day || '';
|
||||
inner.onchange = syncAll;
|
||||
selWrap.appendChild(inner);
|
||||
control.appendChild(selWrap);
|
||||
dayDiv.appendChild(control);
|
||||
row.appendChild(dayDiv);
|
||||
|
||||
// start date
|
||||
const sdWrap = document.createElement('div'); sdWrap.className='field';
|
||||
const sdLabel = document.createElement('label'); sdLabel.className='label'; sdLabel.textContent='Start date'; sdWrap.appendChild(sdLabel);
|
||||
const sdControl = document.createElement('div'); sdControl.className='control';
|
||||
const sdInput = document.createElement('input'); sdInput.type='date'; sdInput.className='datepicker input'; sdInput.setAttribute('autocomplete','off'); sdInput.value = item.start_date || '';
|
||||
sdInput.onchange = syncAll;
|
||||
sdControl.appendChild(sdInput); sdWrap.appendChild(sdControl); row.appendChild(sdWrap);
|
||||
|
||||
// end date
|
||||
const edWrap = document.createElement('div'); edWrap.className='field';
|
||||
const edLabel = document.createElement('label'); edLabel.className='label'; edLabel.textContent='End date'; edWrap.appendChild(edLabel);
|
||||
const edControl = document.createElement('div'); edControl.className='control';
|
||||
const edInput = document.createElement('input'); edInput.type='date'; edInput.className='datepicker input'; edInput.setAttribute('autocomplete','off'); edInput.value = item.end_date || '';
|
||||
edInput.onchange = syncAll;
|
||||
edControl.appendChild(edInput); edWrap.appendChild(edControl); row.appendChild(edWrap);
|
||||
|
||||
// remove button
|
||||
const rmDiv = document.createElement('div'); rmDiv.className='field';
|
||||
const rmLabel = document.createElement('label'); rmLabel.className='label'; rmLabel.textContent=''; rmDiv.appendChild(rmLabel);
|
||||
const rmControl = document.createElement('div'); rmControl.className='control';
|
||||
const rmBtn = document.createElement('button'); rmBtn.type='button'; rmBtn.className='button is-danger is-light is-small'; rmBtn.textContent='Remove';
|
||||
rmBtn.onclick = ()=>{ removeAt(idx); };
|
||||
rmControl.appendChild(rmBtn); rmDiv.appendChild(rmControl); row.appendChild(rmDiv);
|
||||
|
||||
wrap.appendChild(row);
|
||||
|
||||
// initialize flatpickr for these newly added inputs if available
|
||||
try{ if(window.initFlatpickr) initFlatpickr(wrap); }catch(e){}
|
||||
|
||||
function syncAll(){
|
||||
const data = parseInitial();
|
||||
const dayVal = inner.value || null;
|
||||
const sd = sdInput.value || null;
|
||||
const ed = edInput.value || null;
|
||||
const obj = {};
|
||||
if(dayVal) obj.day = dayVal;
|
||||
if(sd) obj.start_date = sd;
|
||||
if(ed) obj.end_date = ed;
|
||||
data[idx] = obj;
|
||||
textarea.value = JSON.stringify(data);
|
||||
}
|
||||
|
||||
// expose helper
|
||||
wrap._sync = syncAll;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function removeAt(i){ const data = parseInitial(); data.splice(i,1); textarea.value = JSON.stringify(data); render(); }
|
||||
|
||||
addBtn.addEventListener('click', ()=>{ const data = parseInitial(); data.push({}); textarea.value = JSON.stringify(data); render(); });
|
||||
|
||||
// initial render
|
||||
try{ render(); }catch(e){ console && console.error && console.error(e); }
|
||||
|
||||
})();
|
||||
</script>
|
||||
<!-- Additional structured editors for other worker fields -->
|
||||
<div class="box" id="other-editors">
|
||||
<h4 class="title is-6">Other structured fields</h4>
|
||||
<p class="help">Quick editors for common lists so you don't need to edit JSON manually.</p>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Out of programme</h5>
|
||||
<button type="button" class="button is-small" id="add-oop">Add</button>
|
||||
</div>
|
||||
<div id="oop-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Not available to work</h5>
|
||||
<button type="button" class="button is-small" id="add-nav">Add</button>
|
||||
</div>
|
||||
<div id="nav-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Preferences not to work</h5>
|
||||
<button type="button" class="button is-small" id="add-pref">Add</button>
|
||||
</div>
|
||||
<div id="pref-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Work requests</h5>
|
||||
<button type="button" class="button is-small" id="add-wr">Add</button>
|
||||
</div>
|
||||
<div id="wr-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Locum availability</h5>
|
||||
<button type="button" class="button is-small" id="add-loc">Add</button>
|
||||
</div>
|
||||
<div id="loc-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Forced assignments (week,day,shift)</h5>
|
||||
<button type="button" class="button is-small" id="add-fa">Add</button>
|
||||
</div>
|
||||
<div id="fa-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Forced assignments by date</h5>
|
||||
<button type="button" class="button is-small" id="add-fabd">Add</button>
|
||||
</div>
|
||||
<div id="fabd-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Hard day dependencies</h5>
|
||||
<button type="button" class="button is-small" id="add-hdd">Add</button>
|
||||
</div>
|
||||
<div id="hdd-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Hard day exclusions</h5>
|
||||
<button type="button" class="button is-small" id="add-hde">Add</button>
|
||||
</div>
|
||||
<div id="hde-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Allowed multi-shift sets</h5>
|
||||
<button type="button" class="button is-small" id="add-ams">Add</button>
|
||||
</div>
|
||||
<div id="ams-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// Helper to create a simple list editor bound to a textarea id
|
||||
function makeSimpleListEditor(cfg){
|
||||
const textarea = document.getElementById(cfg.textarea);
|
||||
if(textarea) textarea.style.display='none';
|
||||
const listEl = document.getElementById(cfg.container);
|
||||
const addBtn = document.getElementById(cfg.addButton);
|
||||
const WEEKDAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||||
|
||||
function parse(){
|
||||
let v = textarea ? textarea.value : ''; if(!v) return [];
|
||||
try{ const p = JSON.parse(v); return Array.isArray(p)?p:[];}catch(e){return [];}
|
||||
}
|
||||
|
||||
function render(){ listEl.innerHTML=''; const data = parse(); data.forEach((it,i)=> listEl.appendChild(rowFor(it,i))); }
|
||||
|
||||
function rowFor(item, idx){
|
||||
const wrap = document.createElement('div'); wrap.className='box';
|
||||
const row = document.createElement('div'); row.className='columns';
|
||||
cfg.fields.forEach(f=>{
|
||||
const col = document.createElement('div'); col.className='column';
|
||||
const field = document.createElement('div'); field.className='field';
|
||||
const label = document.createElement('label'); label.className='label'; label.textContent=f.label; field.appendChild(label);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
|
||||
// Special-case: render weekday checkboxes when the field name is 'days'
|
||||
if(f.name === 'days'){
|
||||
const box = document.createElement('div'); box.className='buttons'; box.setAttribute('data-field', f.name);
|
||||
WEEKDAYS.forEach(d => {
|
||||
const btn = document.createElement('label'); btn.className='button is-small is-light'; btn.style.marginRight='0.25rem';
|
||||
const chk = document.createElement('input'); chk.type='checkbox'; chk.value = d; chk.style.marginRight='0.35rem';
|
||||
// mark checked if item[f.name] contains this day (accept set/list/string)
|
||||
try{
|
||||
const cur = item[f.name];
|
||||
if(cur){
|
||||
if(Array.isArray(cur)){
|
||||
if(cur.indexOf(d)!==-1) chk.checked = true;
|
||||
} else if(typeof cur === 'string'){
|
||||
const parts = cur.split(',').map(s=>s.trim()); if(parts.indexOf(d)!==-1) chk.checked = true;
|
||||
} else if(typeof cur === 'object'){
|
||||
// set-like
|
||||
if(cur.hasOwnProperty(d) || (Array.from(Object.values(cur)).indexOf(d)!==-1)) chk.checked = true;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
chk.onchange = ()=>{ sync(idx); };
|
||||
btn.appendChild(chk);
|
||||
const span = document.createElement('span'); span.textContent = d; btn.appendChild(span);
|
||||
box.appendChild(btn);
|
||||
});
|
||||
control.appendChild(box);
|
||||
field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
} else {
|
||||
let input;
|
||||
if(f.type==='date'){
|
||||
input = document.createElement('input'); input.type='date'; input.className='datepicker input'; input.setAttribute('autocomplete','off'); input.value = item[f.name] || '';
|
||||
} else if(f.type==='int'){
|
||||
input = document.createElement('input'); input.type='number'; input.className='input'; input.value = item[f.name] || '';
|
||||
} else if(f.type==='shift'){
|
||||
// render a select populated with shift options passed via cfg
|
||||
input = document.createElement('select'); input.className='select';
|
||||
// wrap select in a div.select to match Bulma styling
|
||||
const selWrap = document.createElement('div'); selWrap.className = 'select';
|
||||
const sel = document.createElement('select');
|
||||
const emptyOpt = document.createElement('option'); emptyOpt.value = ''; emptyOpt.text = '(any)'; sel.appendChild(emptyOpt);
|
||||
const opts = (cfg && cfg.shiftOptions) ? cfg.shiftOptions : (typeof _shiftOptions !== 'undefined' ? _shiftOptions : []);
|
||||
if(Array.isArray(opts)){
|
||||
opts.forEach(o=>{ const op = document.createElement('option'); op.value = o; op.text = o; if(item[f.name] === o) op.selected = true; sel.appendChild(op); });
|
||||
}
|
||||
sel.value = item[f.name] || '';
|
||||
sel.onchange = ()=>{ sync(idx); };
|
||||
sel.setAttribute('data-field', f.name);
|
||||
selWrap.appendChild(sel);
|
||||
// set 'input' variable to the wrapper so downstream code appends it
|
||||
input = selWrap;
|
||||
} else {
|
||||
input = document.createElement('input'); input.type='text'; input.className='input'; input.value = item[f.name] || '';
|
||||
}
|
||||
// tag this control with the field name so sync() can find it reliably
|
||||
if(input.tagName === 'DIV' && input.querySelector('select')){
|
||||
// wrapper created for select; the inner select already has data-field
|
||||
// nothing to do here
|
||||
} else {
|
||||
input.setAttribute('data-field', f.name);
|
||||
input.onchange = ()=>{ sync(idx); };
|
||||
}
|
||||
control.appendChild(input); field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
}
|
||||
});
|
||||
// remove
|
||||
const colRm = document.createElement('div'); colRm.className='column is-narrow'; const fr = document.createElement('div'); fr.className='field'; const ctr = document.createElement('div'); ctr.className='control'; const rm = document.createElement('button'); rm.type='button'; rm.className='button is-danger is-light is-small'; rm.textContent='Remove'; rm.onclick = ()=>{ removeAt(idx); }; ctr.appendChild(rm); fr.appendChild(ctr); colRm.appendChild(fr); row.appendChild(colRm);
|
||||
wrap.appendChild(row);
|
||||
try{ if(window.initFlatpickr) initFlatpickr(wrap); }catch(e){}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function sync(i){
|
||||
const data = parse();
|
||||
const el = listEl.children[i]; if(!el) return;
|
||||
const obj = {};
|
||||
cfg.fields.forEach((f)=>{
|
||||
const fld = el.querySelector('[data-field="'+f.name+'"]');
|
||||
if(!fld){
|
||||
// fallback: try to find an input by label text
|
||||
const inp = el.querySelector('input');
|
||||
if(inp && inp.value!=='') obj[f.name] = f.type==='int'?parseInt(inp.value,10):inp.value;
|
||||
return;
|
||||
}
|
||||
// if this is a checkbox group (for days), fld will be a container
|
||||
if(f.name === 'days'){
|
||||
const checks = fld.querySelectorAll('input[type=checkbox]');
|
||||
const vals = Array.from(checks).filter(c=>c.checked).map(c=>c.value);
|
||||
if(vals.length) obj[f.name] = vals;
|
||||
} else {
|
||||
// single input control
|
||||
if(fld.tagName === 'INPUT' || fld.tagName === 'SELECT'){
|
||||
const v = fld.value;
|
||||
if(v !== '') obj[f.name] = f.type==='int'?parseInt(v,10):v;
|
||||
} else {
|
||||
// container wrapping an input (e.g. div.select wrapper or custom control)
|
||||
const inp = fld.querySelector('input') || fld.querySelector('select');
|
||||
if(inp && inp.value !== '') obj[f.name] = f.type==='int'?parseInt(inp.value,10):inp.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
data[i]=obj;
|
||||
if(textarea) textarea.value = JSON.stringify(data);
|
||||
}
|
||||
|
||||
function removeAt(i){ const data = parse(); data.splice(i,1); if(textarea) textarea.value = JSON.stringify(data); render(); }
|
||||
|
||||
addBtn.addEventListener('click', ()=>{ const data = parse(); data.push({}); if(textarea) textarea.value=JSON.stringify(data); render(); });
|
||||
try{ render(); }catch(e){ console && console.error && console.error(e); }
|
||||
}
|
||||
|
||||
// Instantiate editors for many worker fields
|
||||
makeSimpleListEditor({textarea:'id_oop', container:'oop-list', addButton:'add-oop', fields:[{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'},{name:'reason',label:'Reason',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_not_available_to_work', container:'nav-list', addButton:'add-nav', fields:[{name:'date',label:'Date',type:'date'},{name:'reason',label:'Reason',type:'text'}]});
|
||||
makeSimpleListEditor({textarea:'id_pref_not_to_work', container:'pref-list', addButton:'add-pref', fields:[{name:'date',label:'Date',type:'date'},{name:'reason',label:'Reason',type:'text'}]});
|
||||
// If the server provided shift names for the current rota, use them
|
||||
var _shiftOptions = [];
|
||||
try{
|
||||
_shiftOptions = {{ shift_names_json|default:'[]'|safe }};
|
||||
}catch(e){ _shiftOptions = []; }
|
||||
|
||||
makeSimpleListEditor({textarea:'id_work_requests', container:'wr-list', addButton:'add-wr', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_locum_availability', container:'loc-list', addButton:'add-loc', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments', container:'fa-list', addButton:'add-fa', fields:[{name:'week',label:'Week',type:'int'},{name:'day',label:'Day',type:'text'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments_by_date', container:'fabd-list', addButton:'add-fabd', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_allowed_multi_shift_sets', container:'ams-list', addButton:'add-ams', fields:[{name:'shifts',label:'Shifts (comma-separated)',type:'text'}]});
|
||||
|
||||
// Hard day deps/exclusions need days/weeks/start/end
|
||||
makeSimpleListEditor({textarea:'id_hard_day_dependencies', container:'hdd-list', addButton:'add-hdd', fields:[{name:'days',label:'Days (weekdays)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
makeSimpleListEditor({textarea:'id_hard_day_exclusions', container:'hde-list', addButton:'add-hde', fields:[{name:'days',label:'Days (weekdays)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
|
||||
})();
|
||||
</script>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Import workers</p>
|
||||
<button class="delete" aria-label="close" hx-trigger="closeModal"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form id="worker-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label">CSV file</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" accept="text/csv,application/vnd.ms-excel,text/plain" />
|
||||
</div>
|
||||
<p class="help">Upload a CSV file with columns name,email,site,grade,fte (headers optional).</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Or paste CSV / lines</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="workers_text" rows="6" placeholder="name,email,site,grade,fte\nAlice,alice@example.com,exeter,2,1.0"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="assign_to_rota" value="1" checked /> Assign created workers to this rota
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control"><button class="button is-link" type="submit">Import</button></div>
|
||||
<div class="control"><button class="button is-light" type="button" hx-trigger="closeModal">Cancel</button></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="content" style="margin-top:1rem;">
|
||||
<p class="help">Example CSV lines:</p>
|
||||
<pre>name,email,site,grade,fte
|
||||
Alice,alice@example.com,exeter,2,1.0
|
||||
Bob,bob@example.com,plymouth,1,0.8</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
<div id="worker-list">
|
||||
<h2 class="subtitle">Workers</h2>
|
||||
<div class="content">
|
||||
<ul>
|
||||
{% for w in rota.workers.all %}
|
||||
<li>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||
<a href="javascript:void(0)" hx-get="{% url 'rota:worker_overview' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">{{ w.name }}</a>
|
||||
<small style="color:#666">— {{ w.site }}{% if w.grade %} · grade {{ w.grade }}{% endif %} · FTE {{ w.fte }}</small>
|
||||
{# show non-working days stored in options.nwds if present; render JSON safely and let JS format it #}
|
||||
{% if w.options.nwds %}
|
||||
<span id="nwds-summary-{{ w.id }}" class="nwds-summary" data-nwds-id="nwds-data-{{ w.id }}" style="margin-left:0.5rem;color:#666"></span>
|
||||
{% with script_id='nwds-data-'|add:w.id %}
|
||||
{{ w.options.nwds|default:"[]"|json_script:script_id }}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{# Show worker-specific constraint snippets (compact tags with hover details) #}
|
||||
{% if w.options.hard_day_exclusions or w.options.hard_day_dependencies or w.options.forced_assignments or w.options.forced_assignments_by_date or w.options.allowed_multi_shift_sets %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<span class="tags">
|
||||
{% for it in w.options.hard_day_exclusions %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=hde" hx-target="#modal" hx-swap="innerHTML">HDE</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.hard_day_dependencies %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=hdd" hx-target="#modal" hx-swap="innerHTML">HDD</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.forced_assignments %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=fa" hx-target="#modal" hx-swap="innerHTML">FA</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.forced_assignments_by_date %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=fad" hx-target="#modal" hx-swap="innerHTML">FAD</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.allowed_multi_shift_sets %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=ams" hx-target="#modal" hx-swap="innerHTML">AMS</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="flex:0 0 auto; display:flex; gap:0.5rem;">
|
||||
<button class="button is-small is-light" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' w.id %}">Leave</a>
|
||||
<button class="button is-small is-danger" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No workers assigned</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Render NWDS summaries: find script tags with id starting 'nwds-data-' and populate the
|
||||
// corresponding summary span. Clicking a day label toggles display of date details.
|
||||
(function(){
|
||||
const DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||||
function dayLabelFromItem(item){
|
||||
if(item === null || item === undefined) return '(any)';
|
||||
if(typeof item === 'number') return DAYS[item] || String(item);
|
||||
if(typeof item === 'string'){
|
||||
const n = parseInt(item,10);
|
||||
if(!Number.isNaN(n)) return DAYS[n] || item;
|
||||
// normalise to first 3 letters
|
||||
return item.trim().slice(0,3);
|
||||
}
|
||||
if(typeof item === 'object'){
|
||||
if(item.day !== undefined){
|
||||
if(typeof item.day === 'number') return DAYS[item.day] || String(item.day);
|
||||
return String(item.day).slice(0,3);
|
||||
}
|
||||
}
|
||||
return String(item);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nwds-summary').forEach(function(target){
|
||||
try{
|
||||
let arr = [];
|
||||
const dataAttr = target.getAttribute('data-nwds');
|
||||
if(dataAttr){
|
||||
try{ arr = JSON.parse(dataAttr); }catch(e){ arr = []; }
|
||||
} else {
|
||||
const scriptId = target.getAttribute('data-nwds-id');
|
||||
if(scriptId){
|
||||
let script = document.getElementById(scriptId);
|
||||
// Some template/tooling may emit the JSON <script> without an id (observed in output).
|
||||
// Fallback: look for the next sibling <script type="application/json"> after the span.
|
||||
if(!script){
|
||||
let sib = target.nextElementSibling;
|
||||
while(sib){
|
||||
if(sib.tagName === 'SCRIPT' && (sib.type === 'application/json' || sib.getAttribute('type') === 'application/json')){ script = sib; break; }
|
||||
sib = sib.nextElementSibling;
|
||||
}
|
||||
}
|
||||
if(script){
|
||||
try{ arr = JSON.parse(script.textContent || script.innerText || '[]'); }catch(e){ arr = []; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!Array.isArray(arr) || arr.length===0){ target.textContent = ''; return; }
|
||||
arr.forEach(function(item, idx){
|
||||
const a = document.createElement('a');
|
||||
a.href = '#'; a.className = 'nwds-day'; a.style.marginLeft = (idx? '0.4rem':'0'); a.style.color = '#666'; a.textContent = dayLabelFromItem(item);
|
||||
const details = document.createElement('span'); details.className='nwds-dates'; details.style.display='none'; details.style.marginLeft='0.25rem'; details.style.color='#444';
|
||||
if(item && typeof item === 'object'){
|
||||
const sd = item.start_date || item.from || '';
|
||||
const ed = item.end_date || item.to || '';
|
||||
if(sd || ed){ details.textContent = (sd||'') + (sd && ed ? ' → ' : '') + (ed||''); }
|
||||
}
|
||||
a.addEventListener('click', function(ev){ ev.preventDefault(); if(details.textContent){ details.style.display = details.style.display === 'none' ? 'inline' : 'none'; } });
|
||||
target.appendChild(a);
|
||||
target.appendChild(details);
|
||||
});
|
||||
}catch(e){ console.error('nwds render failed', e); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="worker-overview-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Worker: {{ worker.name }}</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="content">
|
||||
<h4 class="title is-6">Basic info</h4>
|
||||
<p><strong>Site:</strong> {{ worker.site }}</p>
|
||||
<p><strong>Grade:</strong> {{ worker.grade }}</p>
|
||||
<p><strong>FTE:</strong> {{ worker.fte }}</p>
|
||||
<p><strong>Email:</strong> {{ worker.email }}</p>
|
||||
|
||||
<h4 class="title is-6">Leaves</h4>
|
||||
<div>
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all token=None %}
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6">Configured constraints & options</h4>
|
||||
<p class="help">Below are the worker's configured options. Values are shown read-only.</p>
|
||||
|
||||
<div class="box" style="white-space:pre-wrap; font-family:monospace; font-size:0.9rem;">
|
||||
{{ pretty_options }}
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6">Quick summary</h4>
|
||||
<div class="tags">
|
||||
{% for it in worker.options.hard_day_exclusions %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">HDE</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.hard_day_dependencies %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">HDD</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.forced_assignments %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">FA</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.forced_assignments_by_date %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">FAD</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.allowed_multi_shift_sets %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">AMS</span>
|
||||
{% empty %}{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:0.5rem;">
|
||||
<button class="button is-small is-light" hx-get="{% url 'rota:worker_edit' rota.id worker.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' worker.id %}">Open full page</a>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Close</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="worker-settings-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Update your settings</p>
|
||||
<button class="delete" aria-label="close" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-swap="none">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="(window.closeProcRotaModal || (()=>{document.getElementById('modal').innerHTML=''}))()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="notification is-success">Settings saved.</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<div id="worker-token-area">
|
||||
{% if worker.request_token %}
|
||||
<div class="field is-grouped is-align-items-center">
|
||||
<div class="control is-expanded">
|
||||
<label class="label">Self-service link</label>
|
||||
<input class="input" type="text" readonly id="worker-token-link" value="{{ token_link }}" />
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button" id="copy-token">Copy</button>
|
||||
</div>
|
||||
<div class="control" id="regenerate-controls">
|
||||
<button type="button" class="button is-danger" id="regenerate-token-trigger">Regenerate</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const btn = document.getElementById('copy-token');
|
||||
if(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const inp = document.getElementById('worker-token-link');
|
||||
if(!inp) return;
|
||||
inp.select();
|
||||
try{ document.execCommand('copy'); }catch(e){ navigator.clipboard && navigator.clipboard.writeText(inp.value); }
|
||||
});
|
||||
}
|
||||
|
||||
// Confirmation workflow for regenerating token: replace the regenerate
|
||||
// button with a small confirm UI (Yes / Cancel). The Yes button uses
|
||||
// HTMX to POST to the regenerate endpoint and swap the token area.
|
||||
const trigger = document.getElementById('regenerate-token-trigger');
|
||||
if(trigger){
|
||||
const controls = document.getElementById('regenerate-controls');
|
||||
const original = controls.innerHTML;
|
||||
trigger.addEventListener('click', function(){
|
||||
const yes = document.createElement('button');
|
||||
yes.className = 'button is-danger';
|
||||
yes.setAttribute('hx-post', '{% url "rota:regenerate_worker_token" worker.id %}');
|
||||
yes.setAttribute('hx-target', '#worker-token-area');
|
||||
yes.setAttribute('hx-swap', 'innerHTML');
|
||||
yes.textContent = 'Yes, regenerate';
|
||||
|
||||
const cancel = document.createElement('button');
|
||||
cancel.className = 'button';
|
||||
cancel.style.marginLeft = '0.5rem';
|
||||
cancel.textContent = 'Cancel';
|
||||
cancel.addEventListener('click', function(){ controls.innerHTML = original; });
|
||||
|
||||
controls.innerHTML = '';
|
||||
controls.appendChild(yes);
|
||||
controls.appendChild(cancel);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="notification is-warning">No token generated yet. Save the worker to create one or contact admin.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,211 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block head %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<div>
|
||||
<h1 class="title is-3" style="margin-bottom:0.2rem;">{{ rota.name }}</h1>
|
||||
<p class="subtitle is-6" style="margin-top:0;">{{ rota.description }}</p>
|
||||
<p class="is-size-7 has-text-grey">Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="buttons">
|
||||
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
|
||||
<a class="button is-small is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Import</a>
|
||||
<a class="button is-small" href="{% url 'rota:rota_detail' rota.id %}">Refresh</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-variable is-6" style="margin-top:0.75rem;">
|
||||
<div class="column is-half">
|
||||
<div class="box">
|
||||
<h2 class="subtitle is-6">Solver settings</h2>
|
||||
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
|
||||
<div class="column is-narrow"><strong>Solver</strong></div>
|
||||
<div class="column">{% if solver %}{{ solver }}{% else %}<span class="has-text-grey">(default)</span>{% endif %}</div>
|
||||
<div class="column is-narrow"><strong>Ratio</strong></div>
|
||||
<div class="column">{% if solver_ratio is not None %}{{ solver_ratio }}{% else %}<span class="has-text-grey">—</span>{% endif %}</div>
|
||||
</div>
|
||||
{% if solver_options %}
|
||||
<div style="margin-top:0.5rem;">
|
||||
<h3 class="is-size-7">Solver options</h3>
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ solver_options }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div class="box">
|
||||
<h2 class="subtitle is-6">RotaBuilder summary</h2>
|
||||
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
|
||||
<div class="column is-narrow"><strong>Weeks</strong></div>
|
||||
<div class="column">{{ weeks_to_rota|default:'—' }}</div>
|
||||
<div class="column is-narrow"><strong>Shifts</strong></div>
|
||||
<div class="column">{{ shift_count|default:'—' }}</div>
|
||||
<div class="column is-narrow"><strong>Workers</strong></div>
|
||||
<div class="column">{{ worker_count|default:'—' }}</div>
|
||||
</div>
|
||||
<p class="help is-size-7">This summary reflects the current rota options and configured shifts/workers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box mb-4">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="buttons">
|
||||
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
|
||||
<button class="button is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Import workers</button>
|
||||
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}">All leave requests</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Constraint options</h2>
|
||||
<div class="box">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<details class="constraints-details" style="margin-top:0.5rem;">
|
||||
<summary class="subtitle is-6" style="cursor:pointer; outline:none;">Configured constraint values{% if configured_constraints %} ({{ configured_constraints|length }}){% endif %}</summary>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<div class="field has-addons" style="margin-bottom:0.5rem;">
|
||||
<div class="control is-expanded"><input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" /></div>
|
||||
<div class="control"><button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button></div>
|
||||
</div>
|
||||
<div class="constraints-scroll" style="max-height:600px; overflow:auto; margin-bottom:0.75em;">
|
||||
{% if configured_constraints %}
|
||||
<table class="table is-fullwidth is-striped is-narrow" id="constraint-table-{{ rota.id }}">
|
||||
<thead><tr><th>Key</th><th>Value</th><th class="has-text-right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key, info in configured_constraints.items %}
|
||||
<tr id="constraint-row-{{ key }}">
|
||||
<td style="vertical-align:middle; min-width:180px;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
|
||||
<td style="vertical-align:middle;">
|
||||
{% if info.typed and info.default is not None %}
|
||||
{% if info.value == info.default %}
|
||||
<pre class="has-text-grey" style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
<span class="tag is-light is-small" title="Using typed default">default</span>
|
||||
{% else %}
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
<div class="help is-size-7" style="margin-top:0.25rem;color:#666;">Default: <code style="white-space:pre-wrap">{{ info.default }}</code></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="vertical-align:middle; text-align:right;">
|
||||
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
|
||||
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="key" value="{{ key }}" />
|
||||
<input type="hidden" name="inline" value="1" />
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="help">No typed constraint options have been configured for this rota.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const input = document.getElementById('constraint-filter-{{ rota.id }}');
|
||||
const clearBtn = document.getElementById('constraint-clear-{{ rota.id }}');
|
||||
const table = document.getElementById('constraint-table-{{ rota.id }}');
|
||||
if(!input || !table) return;
|
||||
const rows = Array.from(table.tBodies[0].rows);
|
||||
function normalize(s){ return (s||'').toString().toLowerCase(); }
|
||||
function applyFilter(){
|
||||
const q = normalize(input.value.trim());
|
||||
if(!q){ rows.forEach(r => r.style.display=''); return; }
|
||||
rows.forEach(r => {
|
||||
const key = normalize(r.cells[0].innerText);
|
||||
const val = normalize(r.cells[1].innerText);
|
||||
r.style.display = (key.indexOf(q) !== -1 || val.indexOf(q) !== -1) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
let debounce;
|
||||
input.addEventListener('input', ()=>{ clearTimeout(debounce); debounce = setTimeout(applyFilter, 150); });
|
||||
clearBtn.addEventListener('click', ()=>{ input.value=''; applyFilter(); input.focus(); });
|
||||
})();
|
||||
</script>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Run controls and historic runs moved to the bottom of the page. #}
|
||||
|
||||
<div>
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
<p><button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button></p>
|
||||
<div id="shift-form">{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="generate_builder" value="1" checked> Generate HTML export</label></div>
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="builder_solve" value="1" checked> Solve when exporting (may be slow)</label></div>
|
||||
<div class="control"><button class="button is-primary" type="submit">Run rota</button></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-4" style="margin-top:0.75rem;">
|
||||
<details class="box">
|
||||
<summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
|
||||
<div style="margin-bottom:0.5em; margin-top:0.5em;">
|
||||
<form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
|
||||
</form>
|
||||
</div>
|
||||
<ul>
|
||||
{% for run in rota.runs.all %}
|
||||
<li>
|
||||
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> — {{ run.get_status_display }}
|
||||
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
|
||||
<span class="ml-2"><a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a></span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No previous runs</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<html>
|
||||
<head>
|
||||
<title>Create Rota</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
</head>
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">{% if editing %}Edit Rota{% else %}Create Rota{% endif %}</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<script>
|
||||
(function(){
|
||||
function parseISO(s){
|
||||
if(!s) return null;
|
||||
// YYYY-MM-DD
|
||||
const parts = s.split('-');
|
||||
if(parts.length!==3) return null;
|
||||
return new Date(parts[0], parts[1]-1, parts[2]);
|
||||
}
|
||||
|
||||
function formatISO(d){
|
||||
if(!d) return '';
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth()+1).padStart(2,'0');
|
||||
const dd = String(d.getDate()).padStart(2,'0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function computeEnd(){
|
||||
const startEl = document.querySelector('[name="start_date"]');
|
||||
const weeksEl = document.querySelector('[name="weeks"]');
|
||||
const endEl = document.querySelector('[name="end_date"]');
|
||||
if(!startEl||!weeksEl||!endEl) return;
|
||||
const startVal = startEl.value;
|
||||
const weeksVal = parseInt(weeksEl.value || '0', 10);
|
||||
const startDate = parseISO(startVal);
|
||||
if(startDate && weeksVal>0){
|
||||
const endDate = new Date(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + weeksVal*7);
|
||||
const iso = formatISO(endDate);
|
||||
// set flatpickr-aware if present
|
||||
if(endEl._flatpickr){
|
||||
endEl._flatpickr.setDate(iso, true);
|
||||
} else {
|
||||
endEl.value = iso;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const startEl = document.querySelector('[name="start_date"]');
|
||||
const weeksEl = document.querySelector('[name="weeks"]');
|
||||
if(startEl) startEl.addEventListener('change', computeEnd);
|
||||
if(weeksEl) weeksEl.addEventListener('input', computeEnd);
|
||||
// initial compute
|
||||
setTimeout(computeEnd, 50);
|
||||
});
|
||||
// listen on document so this works when inserted dynamically
|
||||
document.addEventListener('htmx:afterSwap', function(evt){
|
||||
// rebind when form is replaced or loaded via HTMX
|
||||
const startEl = document.querySelector('[name="start_date"]');
|
||||
const weeksEl = document.querySelector('[name="weeks"]');
|
||||
if(startEl) startEl.addEventListener('change', computeEnd);
|
||||
if(weeksEl) weeksEl.addEventListener('input', computeEnd);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">{% if editing %}Save{% else %}Create{% endif %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
{% load tz %}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Rota run {{ run.id }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
<style>pre { white-space: pre-wrap; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">Rota run for {{ run.rota.name }}</h1>
|
||||
<p class="subtitle">Status: {{ run.get_status_display }} ({{ run.status }})</p>
|
||||
<div style="margin-bottom:0.5rem;" id="run-status-container">
|
||||
<div id="run-status" hx-get="{% url 'rota:rota_run_status' run.id %}" hx-trigger="every 3s" hx-swap="outerHTML">
|
||||
<span class="tag">Status: {{ run.get_status_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>Created: {{ run.created_at|localtime }} started: {{ run.started_at|localtime }} finished: {{ run.finished_at|localtime }}</p>
|
||||
<p class="mt-2">
|
||||
<a class="button" href="{% url 'rota:rota_detail' run.rota.id %}">Back to rota</a>
|
||||
<a class="button is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Open export</a>
|
||||
<span id="view-final-output" style="margin-left:0.5rem;"></span>
|
||||
</p>
|
||||
|
||||
<div class="box">
|
||||
<details>
|
||||
<summary><strong>Log</strong></summary>
|
||||
<div style="margin-top:0.5rem; max-height:40vh; overflow:auto; border-top:1px solid #eee; padding-top:0.5rem;">
|
||||
<div id="run-log"><pre id="run-log-pre">{{ run.log }}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<details>
|
||||
<summary><strong>Result (raw)</strong></summary>
|
||||
<div style="margin-top:0.5rem; max-height:40vh; overflow:auto; border-top:1px solid #eee; padding-top:0.5rem;">
|
||||
<div id="run-result"><pre id="run-result-pre">{{ result_pretty|default:run.result }}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{% if builder_html %}
|
||||
<div class="box">
|
||||
<details>
|
||||
<summary><strong>Builder HTML</strong></summary>
|
||||
<div style="margin-top:0.5rem; border-top:1px solid #eee; padding-top:0.5rem; max-height:50vh; overflow:auto">{{ builder_html|safe }}</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="mt-4">
|
||||
{% if run.export_html %}
|
||||
<a class="button is-link" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">View Stored Export</a>
|
||||
<a class="button" href="{% url 'rota:rota_run_export_download' run.id %}">Download Stored Export</a>
|
||||
{% else %}
|
||||
<span class="tag is-light">No stored export</span>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'rota:rota_run_export_regenerate' run.id %}" style="display:inline; margin-left:0.5em;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="solve" value="0">
|
||||
<button class="button is-small is-light" type="submit">Regenerate export (no solve)</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'rota:rota_run_export_regenerate' run.id %}" style="display:inline; margin-left:0.5em;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="solve" value="1">
|
||||
<button class="button is-small is-primary" type="submit">Regenerate + Solve (may be slow)</button>
|
||||
</form>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Rota Run Export - {{ run.rota.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">Run export for {{ run.rota.name }}</h1>
|
||||
<p class="subtitle">Status: {{ run.status }}{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}</p>
|
||||
|
||||
<h2 class="subtitle">Raw result</h2>
|
||||
<pre style="white-space:pre-wrap;">{{ run.result|safe }}</pre>
|
||||
|
||||
<p class="mt-4">
|
||||
<a class="button is-link" href="{% url 'rota:rota_run_export_download' run.id %}">Download HTML</a>
|
||||
<a class="button" href="{% url 'rota:rota_run_detail' run.id %}">Back</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags static %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2 class="title">Request leave for {{ worker.name }}</h2>
|
||||
|
||||
<div class="box">
|
||||
<h3 class="subtitle">Request leave on calendar</h3>
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
|
||||
<div id="calendar" class="calendar" data-worker-id="{{ worker.id }}" data-mode="inline"></div>
|
||||
<p class="help">Click a start date then a second date to select a range and prefill the leave form.</p>
|
||||
</div>
|
||||
|
||||
{% with back_rota=worker.rotas.all.0 %}
|
||||
{% if back_rota %}
|
||||
<p style="margin-top:0.5rem"><a class="button" href="{% url 'rota:rota_detail' back_rota.id %}">← Back to rota: {{ back_rota.name }}</a></p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Request</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2 class="subtitle">Existing leave for {{ worker.name }}</h2>
|
||||
<div class="content">
|
||||
{% if request.user.is_staff or request.user.is_authenticated and request.user.email and request.user.email == worker.email %}
|
||||
<form method="post" action="{% url 'rota:leave_delete_all' worker.id %}" hx-post="{% url 'rota:leave_delete_all' worker.id %}" hx-swap="none" style="display:inline;margin-bottom:0.5rem">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small is-danger" type="submit" onclick="return confirm('Delete ALL leave requests for this worker?');">Delete all leave</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:0.5rem;">
|
||||
<strong>Legend:</strong>
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="worker_leaves_json" type="application/json">{{ leaves_json|safe }}</script>
|
||||
<script src="{% static 'js/calendar.js' %}"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const container = document.getElementById('calendar');
|
||||
if(!container) return;
|
||||
let leaves = [];
|
||||
try {
|
||||
const raw = document.getElementById('worker_leaves_json');
|
||||
if (raw) {
|
||||
leaves = JSON.parse(raw.textContent || raw.innerText || '[]');
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Unable to parse embedded leaves JSON', e);
|
||||
}
|
||||
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% url "rota:worker_leaves_json" worker.id %}', mode: container.dataset.mode || 'inline' });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<html>
|
||||
<head>
|
||||
<title>Add Worker</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
{% include 'rota/partials/flatpickr_includes.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">Add Worker</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2 class="title">Worker settings for {{ worker.name }}</h2>
|
||||
<div class="box">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="{% url 'rota:request_leave_token' token %}">Back to leave request</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
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
|
||||
from django.conf import settings
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include(("rota.urls", "rota"), namespace="rota")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
# Include django-debug-toolbar when running in DEBUG mode. Use a string
|
||||
# include so the project doesn't fail to import if the package isn't
|
||||
# installed; developers should install it in their dev environment.
|
||||
urlpatterns = [path("__debug__/", include("debug_toolbar.urls"))] + urlpatterns
|
||||
@@ -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,885 @@
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
|
||||
from .models import Worker, Leave, RotaSchedule
|
||||
import importlib
|
||||
import json
|
||||
|
||||
# Try to import typed helper models from rota_generator to validate complex
|
||||
# worker option structures. If unavailable, fall back to permissive behavior.
|
||||
try:
|
||||
from rota_generator.workers import (
|
||||
NonWorkingDays,
|
||||
OutOfProgramme,
|
||||
NotAvailableToWork,
|
||||
PreferenceNotToWork,
|
||||
WorkRequests,
|
||||
HardDayDependency,
|
||||
HardDayExclusion,
|
||||
ShiftStartDate,
|
||||
ShiftEndDate,
|
||||
)
|
||||
except Exception:
|
||||
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
|
||||
WorkRequests = HardDayDependency = HardDayExclusion = None
|
||||
ShiftStartDate = ShiftEndDate = None
|
||||
|
||||
|
||||
class WorkerForm(forms.ModelForm):
|
||||
# Expose core model fields plus a set of richer worker options. Complex
|
||||
# structures (lists/dicts) are edited as JSON in textareas; common simple
|
||||
# options are provided as dedicated form controls for convenience.
|
||||
class Meta:
|
||||
model = Worker
|
||||
fields = ["name", "email", "site", "grade", "fte", "active"]
|
||||
|
||||
# Simple option fields
|
||||
locum = forms.BooleanField(required=False, label="Locum")
|
||||
locum_max_shifts = forms.IntegerField(required=False, label="Locum max shifts")
|
||||
locum_max_shifts_per_week = forms.IntegerField(required=False, label="Locum max shifts per week")
|
||||
locum_on_nwds = forms.BooleanField(required=False, label="Locum on non-working days")
|
||||
remote_site = forms.CharField(required=False, label="Remote site")
|
||||
pair = forms.CharField(required=False, label="Pair")
|
||||
bank_holiday_extra = forms.IntegerField(required=False, label="Bank holiday extra")
|
||||
weekend_shift_target_number = forms.IntegerField(required=False, label="Weekend shift target number")
|
||||
prefer_multi_shift_together = forms.IntegerField(required=False, label="Prefer multi-shift together weight")
|
||||
max_days_worked_per_week = forms.IntegerField(required=False, label="Max days worked per week")
|
||||
|
||||
# Complex structured options edited as JSON
|
||||
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
|
||||
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
|
||||
exact_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->exact_count", label="Exact shifts (JSON)")
|
||||
shift_start_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->start_date (YYYY-MM-DD)", label="Shift start dates (JSON)")
|
||||
shift_end_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->end_date (YYYY-MM-DD)", label="Shift end dates (JSON)")
|
||||
groups = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of group names (e.g. [\"group1\", \"group2\"])", label="Groups (JSON)")
|
||||
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
|
||||
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
|
||||
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
|
||||
hard_day_dependencies = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of HardDayDependency objects", label="Hard day dependencies (JSON)")
|
||||
hard_day_exclusions = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of HardDayExclusion objects", label="Hard day exclusions (JSON)")
|
||||
force_assign_with = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping of shift->list", label="Force assign with (JSON)")
|
||||
max_shifts_per_week_by_shift_name = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->max", label="Max shifts per week by shift (JSON)")
|
||||
forced_assignments = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of tuples (week, day, shift)", label="Forced assignments (JSON)")
|
||||
forced_assignments_by_date = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of tuples (date, shift)", label="Forced assignments by date (JSON)")
|
||||
|
||||
# Availability / request lists
|
||||
nwds = forms.CharField(required=False, widget=forms.Textarea, help_text="Non-working days JSON list", label="Non-working days (JSON)")
|
||||
oop = forms.CharField(required=False, widget=forms.Textarea, help_text="Out of programme JSON list", label="Out of programme (JSON)")
|
||||
not_available_to_work = forms.CharField(required=False, widget=forms.Textarea, help_text="Not-available JSON list", label="Not available to work (JSON)")
|
||||
pref_not_to_work = forms.CharField(required=False, widget=forms.Textarea, help_text="Preference not to work JSON list", label="Preferred not to work (JSON)")
|
||||
work_requests = forms.CharField(required=False, widget=forms.Textarea, help_text="Work requests JSON list", label="Work requests (JSON)")
|
||||
locum_availability = forms.CharField(required=False, widget=forms.Textarea, help_text="Locum availability JSON list", label="Locum availability (JSON)")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get("instance")
|
||||
super().__init__(*args, **kwargs)
|
||||
# Populate initial values for the extra fields from instance.options
|
||||
initial_opts = {}
|
||||
if instance is not None:
|
||||
try:
|
||||
initial_opts = instance.options or {}
|
||||
except Exception:
|
||||
initial_opts = {}
|
||||
|
||||
# Simple field initialisation
|
||||
for fld in [
|
||||
"locum",
|
||||
"locum_max_shifts",
|
||||
"locum_max_shifts_per_week",
|
||||
"locum_on_nwds",
|
||||
"remote_site",
|
||||
"pair",
|
||||
"bank_holiday_extra",
|
||||
"weekend_shift_target_number",
|
||||
"prefer_multi_shift_together",
|
||||
"max_days_worked_per_week",
|
||||
]:
|
||||
if fld in self.fields:
|
||||
self.fields[fld].initial = initial_opts.get(fld, self.fields[fld].initial)
|
||||
|
||||
# JSON fields: pretty-print if present
|
||||
for json_fld in [
|
||||
"assign_as_block_preferences",
|
||||
"shift_fte_overrides",
|
||||
"exact_shifts",
|
||||
"shift_start_dates",
|
||||
"shift_end_dates",
|
||||
"groups",
|
||||
"previous_shifts",
|
||||
"shift_balance_extra",
|
||||
"allowed_multi_shift_sets",
|
||||
"hard_day_dependencies",
|
||||
"hard_day_exclusions",
|
||||
"force_assign_with",
|
||||
"max_shifts_per_week_by_shift_name",
|
||||
"forced_assignments",
|
||||
"forced_assignments_by_date",
|
||||
"nwds",
|
||||
"oop",
|
||||
"not_available_to_work",
|
||||
"pref_not_to_work",
|
||||
"work_requests",
|
||||
"locum_availability",
|
||||
]:
|
||||
if json_fld in self.fields:
|
||||
val = initial_opts.get(json_fld, None)
|
||||
if val is None:
|
||||
self.fields[json_fld].initial = ""
|
||||
else:
|
||||
try:
|
||||
self.fields[json_fld].initial = json.dumps(val, indent=2, default=str)
|
||||
except Exception:
|
||||
self.fields[json_fld].initial = str(val)
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save Worker model and persist extra options into `instance.options`."""
|
||||
instance = super().save(commit=False)
|
||||
opts = instance.options or {}
|
||||
|
||||
# Simple options
|
||||
simple_opts = [
|
||||
"locum",
|
||||
"locum_max_shifts",
|
||||
"locum_max_shifts_per_week",
|
||||
"locum_on_nwds",
|
||||
"remote_site",
|
||||
"pair",
|
||||
"bank_holiday_extra",
|
||||
"weekend_shift_target_number",
|
||||
"prefer_multi_shift_together",
|
||||
"max_days_worked_per_week",
|
||||
]
|
||||
for k in simple_opts:
|
||||
if k in self.cleaned_data:
|
||||
opts[k] = self.cleaned_data.get(k)
|
||||
|
||||
# JSON fields: parse where possible
|
||||
json_fields = [
|
||||
"assign_as_block_preferences",
|
||||
"shift_fte_overrides",
|
||||
"exact_shifts",
|
||||
"shift_start_dates",
|
||||
"shift_end_dates",
|
||||
"groups",
|
||||
"previous_shifts",
|
||||
"shift_balance_extra",
|
||||
"allowed_multi_shift_sets",
|
||||
"hard_day_dependencies",
|
||||
"hard_day_exclusions",
|
||||
"force_assign_with",
|
||||
"max_shifts_per_week_by_shift_name",
|
||||
"forced_assignments",
|
||||
"forced_assignments_by_date",
|
||||
"nwds",
|
||||
"oop",
|
||||
"not_available_to_work",
|
||||
"pref_not_to_work",
|
||||
"work_requests",
|
||||
"locum_availability",
|
||||
]
|
||||
for k in json_fields:
|
||||
if k in self.cleaned_data:
|
||||
val = self.cleaned_data.get(k)
|
||||
if val is None or val == "":
|
||||
opts[k] = []
|
||||
else:
|
||||
# If the cleaned value is a JSON string, parse it. If
|
||||
# it's already a Python structure (possibly containing
|
||||
# pydantic models from `clean()`), keep it but normalize
|
||||
# any model instances into serializable dicts.
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
except Exception:
|
||||
parsed = val
|
||||
else:
|
||||
parsed = val
|
||||
|
||||
def _normalize(obj):
|
||||
# Normalize pydantic BaseModel instances (v2:v1) and recurse
|
||||
if hasattr(obj, "model_dump"):
|
||||
try:
|
||||
res = obj.model_dump()
|
||||
return _normalize(res)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(obj, "dict") and not isinstance(obj, dict):
|
||||
try:
|
||||
res = obj.dict()
|
||||
return _normalize(res)
|
||||
except Exception:
|
||||
pass
|
||||
# Recurse lists/tuples/sets
|
||||
if isinstance(obj, list):
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, set):
|
||||
# JSON can't represent sets; convert to list
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _normalize(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
opts[k] = _normalize(parsed)
|
||||
|
||||
instance.options = opts
|
||||
if commit:
|
||||
instance.save()
|
||||
try:
|
||||
self.save_m2m()
|
||||
except Exception:
|
||||
pass
|
||||
return instance
|
||||
|
||||
def clean(self):
|
||||
"""Validate and parse typed JSON worker option fields when possible.
|
||||
|
||||
This will parse JSON strings into Python structures and, where we can
|
||||
import the typed helper models from `rota_generator.workers`, will
|
||||
validate each list item against the corresponding Pydantic model.
|
||||
"""
|
||||
cleaned = super().clean()
|
||||
|
||||
def _parse_and_validate(field_name, model_cls=None):
|
||||
val = cleaned.get(field_name)
|
||||
if val is None or val == "":
|
||||
return []
|
||||
# If already parsed (not a str), accept it
|
||||
if not isinstance(val, str):
|
||||
parsed = val
|
||||
else:
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
except Exception:
|
||||
# leave as raw string to preserve old behaviour
|
||||
parsed = val
|
||||
|
||||
# If a model class is provided, validate each element
|
||||
if model_cls and parsed and isinstance(parsed, (list, tuple)):
|
||||
validated = []
|
||||
for i, item in enumerate(parsed):
|
||||
try:
|
||||
# If item is already an instance, accept it
|
||||
if hasattr(model_cls, "model_dump") or hasattr(model_cls, "__fields__"):
|
||||
# pydantic v2 or v1 compatible
|
||||
validated.append(model_cls(**item) if isinstance(item, dict) else model_cls.parse_obj(item) if hasattr(model_cls, 'parse_obj') else model_cls(**item))
|
||||
else:
|
||||
validated.append(item)
|
||||
except Exception as e:
|
||||
self.add_error(field_name, forms.ValidationError(f"Invalid item in {field_name}[{i}]: {e}"))
|
||||
validated.append(item)
|
||||
return validated
|
||||
|
||||
return parsed
|
||||
|
||||
# Typed list fields
|
||||
cleaned["nwds"] = _parse_and_validate("nwds", NonWorkingDays)
|
||||
cleaned["oop"] = _parse_and_validate("oop", OutOfProgramme)
|
||||
cleaned["not_available_to_work"] = _parse_and_validate("not_available_to_work", NotAvailableToWork)
|
||||
cleaned["pref_not_to_work"] = _parse_and_validate("pref_not_to_work", PreferenceNotToWork)
|
||||
cleaned["work_requests"] = _parse_and_validate("work_requests", WorkRequests)
|
||||
cleaned["locum_availability"] = _parse_and_validate("locum_availability", WorkRequests)
|
||||
|
||||
cleaned["hard_day_dependencies"] = _parse_and_validate("hard_day_dependencies", HardDayDependency)
|
||||
cleaned["hard_day_exclusions"] = _parse_and_validate("hard_day_exclusions", HardDayExclusion)
|
||||
cleaned["shift_start_dates"] = _parse_and_validate("shift_start_dates", ShiftStartDate)
|
||||
cleaned["shift_end_dates"] = _parse_and_validate("shift_end_dates", ShiftEndDate)
|
||||
|
||||
# forced assignments: expect list of tuples [week, day, shift]
|
||||
fa = cleaned.get("forced_assignments")
|
||||
if isinstance(fa, str):
|
||||
try:
|
||||
fa_parsed = json.loads(fa)
|
||||
except Exception:
|
||||
fa_parsed = fa
|
||||
else:
|
||||
fa_parsed = fa
|
||||
if isinstance(fa_parsed, list):
|
||||
# basic validation
|
||||
for i, t in enumerate(fa_parsed):
|
||||
if not (isinstance(t, (list, tuple)) and len(t) == 3):
|
||||
self.add_error("forced_assignments", forms.ValidationError(f"forced_assignments[{i}] must be a tuple/list of (week, day, shift)"))
|
||||
cleaned["forced_assignments"] = fa_parsed
|
||||
|
||||
# forced_assignments_by_date: expect list of tuples [date, shift]
|
||||
fabd = cleaned.get("forced_assignments_by_date")
|
||||
if isinstance(fabd, str):
|
||||
try:
|
||||
fabd_parsed = json.loads(fabd)
|
||||
except Exception:
|
||||
fabd_parsed = fabd
|
||||
else:
|
||||
fabd_parsed = fabd
|
||||
if isinstance(fabd_parsed, list):
|
||||
for i, t in enumerate(fabd_parsed):
|
||||
if not (isinstance(t, (list, tuple)) and len(t) == 2):
|
||||
self.add_error("forced_assignments_by_date", forms.ValidationError(f"forced_assignments_by_date[{i}] must be a tuple/list of (date, shift)"))
|
||||
cleaned["forced_assignments_by_date"] = fabd_parsed
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
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()` as
|
||||
# legacy builder args (the remaining keys in `options` are treated as
|
||||
# constraint configuration for `RotaConstraintOptions`).
|
||||
builder_args = {
|
||||
"balance_offset_modifier": {"type": "float", "default": 1.0, "help": "Balance offset modifier used by the builder"},
|
||||
"ltft_balance_offset": {"type": "float", "default": 1.0, "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"))
|
||||
elif meta["type"] == "float":
|
||||
self.fields[field_name] = forms.FloatField(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"))
|
||||
|
||||
# Expose solver selection and solver options (JSON) so users can tune
|
||||
# solver behaviour per-rota. Persisted into `RotaSchedule.options` as
|
||||
# keys `solver` (string) and `solver_options` (dict).
|
||||
solver_initial = existing_opts.get("solver", "appsi_highs")
|
||||
solver_opts_initial = existing_opts.get("solver_options", {})
|
||||
try:
|
||||
solver_opts_str = json.dumps(solver_opts_initial, indent=2)
|
||||
except Exception:
|
||||
solver_opts_str = ""
|
||||
|
||||
self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
|
||||
# Common solver ratio is surfaced as a dedicated field for convenience
|
||||
# (many workflows tune this). It's stored inside the solver_options
|
||||
# dict under the key 'ratio'. The free-form JSON textarea remains for
|
||||
# advanced options.
|
||||
solver_ratio_initial = None
|
||||
try:
|
||||
if isinstance(solver_opts_initial, dict) and "ratio" in solver_opts_initial:
|
||||
solver_ratio_initial = solver_opts_initial.get("ratio")
|
||||
else:
|
||||
# also accept legacy top-level 'ratio' key in existing_opts
|
||||
solver_ratio_initial = existing_opts.get("ratio")
|
||||
except Exception:
|
||||
solver_ratio_initial = None
|
||||
|
||||
self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
|
||||
self.fields["solver_ratio"] = forms.FloatField(required=False, initial=solver_ratio_initial, label="Solver ratio (relative gap)", help_text="Relative MIP gap / ratio (e.g. 0.01 for 1%)")
|
||||
self.fields["solver_options"] = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 6}), initial=solver_opts_str, label="Solver options (JSON)", help_text="JSON object of solver-specific options; e.g. {\"seconds\": 60, \"threads\": 4}")
|
||||
|
||||
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):
|
||||
"""Save the RotaSchedule and persist dynamic `opt__*` fields into
|
||||
`instance.options`.
|
||||
|
||||
This persists both typed constraint option fields (created from
|
||||
RotaConstraintOptions) and the small set of builder-argument fields
|
||||
(created in __init__). For structured fields we attempt to parse JSON
|
||||
textareas into Python objects.
|
||||
"""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
opts = instance.options or {}
|
||||
|
||||
# Persist typed constraint option fields using inferred defaults map
|
||||
try:
|
||||
defaults = getattr(self, "_constraint_defaults", {}) or {}
|
||||
except Exception:
|
||||
defaults = {}
|
||||
|
||||
for key, default in defaults.items():
|
||||
field_name = f"opt__{key}"
|
||||
if field_name in self.cleaned_data:
|
||||
val = self.cleaned_data[field_name]
|
||||
if isinstance(default, (list, dict)):
|
||||
# JSON textarea
|
||||
try:
|
||||
parsed = json.loads(val) if val is not None and val != "" else []
|
||||
except Exception:
|
||||
parsed = val
|
||||
opts[key] = parsed
|
||||
else:
|
||||
if val == "" or val is None:
|
||||
opts[key] = None
|
||||
else:
|
||||
opts[key] = val
|
||||
|
||||
# Persist any remaining opt__ fields (builder args and others)
|
||||
for fname, val in self.cleaned_data.items():
|
||||
if not fname.startswith("opt__"):
|
||||
continue
|
||||
key = fname[5:]
|
||||
if key in defaults:
|
||||
# already handled
|
||||
continue
|
||||
v = val
|
||||
# Try to parse JSON-like strings into structures
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith("[") or s.startswith("{"):
|
||||
try:
|
||||
v = json.loads(v)
|
||||
except Exception:
|
||||
pass
|
||||
opts[key] = v
|
||||
|
||||
# Persist the top-level 'weeks' value from the form into options so
|
||||
# the builder can prefer this explicit value instead of deriving it
|
||||
# from end_date. Do not persist end_date itself here.
|
||||
try:
|
||||
weeks_val = self.cleaned_data.get("weeks")
|
||||
if weeks_val is not None:
|
||||
opts["weeks"] = int(weeks_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist solver selection and options (if provided). Solver options are
|
||||
# stored as a dict under opts['solver_options']. If the user supplied
|
||||
# a JSON string, attempt to parse it; otherwise keep raw value.
|
||||
try:
|
||||
solver_name = self.cleaned_data.get("solver")
|
||||
if solver_name:
|
||||
opts["solver"] = solver_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
so = self.cleaned_data.get("solver_options")
|
||||
if so is not None and so != "":
|
||||
if isinstance(so, str):
|
||||
try:
|
||||
parsed = json.loads(so)
|
||||
except Exception:
|
||||
# keep as raw string if parsing fails (non-fatal)
|
||||
parsed = so
|
||||
else:
|
||||
parsed = so
|
||||
opts["solver_options"] = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If a dedicated solver_ratio field was provided, inject/override it
|
||||
# into the solver_options dict so it is honoured at runtime. This
|
||||
# ensures the friendly ratio field wins over any value in the JSON
|
||||
# textarea.
|
||||
try:
|
||||
ratio_val = self.cleaned_data.get("solver_ratio")
|
||||
if ratio_val is not None and ratio_val != "":
|
||||
so = opts.get("solver_options")
|
||||
if so is None or not isinstance(so, dict):
|
||||
so = {}
|
||||
try:
|
||||
so["ratio"] = float(ratio_val)
|
||||
except Exception:
|
||||
so["ratio"] = ratio_val
|
||||
opts["solver_options"] = so
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
instance.options = opts
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class WorkerSelfServiceForm(forms.ModelForm):
|
||||
"""Small form exposed to workers via tokenized links so they can set
|
||||
their FTE and pick non-working days.
|
||||
"""
|
||||
WEEKDAYS = [
|
||||
('0', 'Monday'),
|
||||
('1', 'Tuesday'),
|
||||
('2', 'Wednesday'),
|
||||
('3', 'Thursday'),
|
||||
('4', 'Friday'),
|
||||
('5', 'Saturday'),
|
||||
('6', 'Sunday'),
|
||||
]
|
||||
|
||||
nwds = forms.MultipleChoiceField(choices=WEEKDAYS, required=False, widget=forms.CheckboxSelectMultiple, label="Non-working days")
|
||||
|
||||
class Meta:
|
||||
model = Worker
|
||||
fields = ["fte"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {})
|
||||
if instance is not None:
|
||||
try:
|
||||
opts = instance.options or {}
|
||||
nwds = opts.get('nwds', [])
|
||||
# store as strings for the MultipleChoiceField
|
||||
initial['nwds'] = [str(x) for x in (nwds or [])]
|
||||
initial['fte'] = instance.fte
|
||||
except Exception:
|
||||
pass
|
||||
kwargs['initial'] = initial
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Persist FTE and non-working days (nwds) into the Worker instance.
|
||||
|
||||
The form exposes only `fte` and `nwds`. `fte` is handled by the
|
||||
ModelForm machinery; we need to ensure `nwds` is persisted inside
|
||||
`instance.options['nwds']` as a list of ints.
|
||||
"""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Persist non-working days into instance.options['nwds'] as list of ints
|
||||
opts = instance.options or {}
|
||||
nwds = self.cleaned_data.get('nwds') or []
|
||||
try:
|
||||
opts['nwds'] = [int(x) for x in nwds]
|
||||
except Exception:
|
||||
# fallback: keep as-is
|
||||
opts['nwds'] = nwds
|
||||
|
||||
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 RotaConstraintOptionsForm(forms.Form):
|
||||
"""Dynamically generated form exposing typed `RotaConstraintOptions` fields.
|
||||
|
||||
Fields are created as `opt__{key}` to match existing modal rendering logic
|
||||
and to allow reuse of the `rota_options` endpoint/modal. Saving via
|
||||
`save_for_rota(rota)` will update only the constraint keys inside
|
||||
`RotaSchedule.options`, leaving other keys (e.g. legacy builder args)
|
||||
untouched.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, initial_options=None, **kwargs):
|
||||
"""initial_options: dict of existing rota.options to populate current values."""
|
||||
super().__init__(*args, **kwargs)
|
||||
initial_options = initial_options or {}
|
||||
|
||||
# Try to import the typed model from the rota package. Prefer the
|
||||
# RotaConstraintOptions class (no heavy instantiation) to avoid any
|
||||
# side-effects of creating a RotaBuilder instance during form init.
|
||||
opts_model = None
|
||||
try:
|
||||
mod = importlib.import_module("rota_generator.shifts")
|
||||
RotaConstraintOptions = getattr(mod, "RotaConstraintOptions", None)
|
||||
if RotaConstraintOptions is not None:
|
||||
opts_model = RotaConstraintOptions()
|
||||
else:
|
||||
# Last-resort: try to instantiate RotaBuilder and read its
|
||||
# constraint_options_model attribute (may have side-effects).
|
||||
RotaBuilder = getattr(mod, "RotaBuilder", None)
|
||||
if RotaBuilder is not None:
|
||||
try:
|
||||
rb = RotaBuilder()
|
||||
opts_model = getattr(rb, "constraint_options_model", None)
|
||||
except Exception:
|
||||
opts_model = None
|
||||
except Exception:
|
||||
opts_model = None
|
||||
|
||||
self._constraint_defaults = {}
|
||||
self._constraint_meta = {}
|
||||
|
||||
if opts_model is not None:
|
||||
try:
|
||||
defaults = opts_model.model_dump()
|
||||
except Exception:
|
||||
defaults = {}
|
||||
self._constraint_defaults = defaults
|
||||
# try to read field descriptions
|
||||
try:
|
||||
mf = opts_model.__class__.model_fields
|
||||
except Exception:
|
||||
mf = {}
|
||||
|
||||
for k, v in defaults.items():
|
||||
field_name = f"opt__{k}"
|
||||
help_text = None
|
||||
if isinstance(mf, dict) and k in mf:
|
||||
info = mf[k]
|
||||
try:
|
||||
if hasattr(info, "description") and info.description:
|
||||
help_text = info.description
|
||||
else:
|
||||
help_text = info.get("description")
|
||||
except Exception:
|
||||
help_text = None
|
||||
|
||||
initial = initial_options.get(k, v)
|
||||
|
||||
# Map Python types to Django form fields
|
||||
if isinstance(v, bool):
|
||||
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=help_text)
|
||||
elif isinstance(v, int):
|
||||
# Allow None for Optional[int]
|
||||
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=help_text)
|
||||
elif isinstance(v, float):
|
||||
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=k.replace("_", " "), help_text=help_text)
|
||||
elif isinstance(v, (list, dict)):
|
||||
# JSON textarea for structured values
|
||||
try:
|
||||
init_val = json.dumps(initial) if initial is not None else json.dumps(v)
|
||||
except Exception:
|
||||
init_val = ""
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=init_val, widget=forms.Textarea, label=k.replace("_", " "), help_text=(help_text or "Enter JSON"))
|
||||
else:
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=initial if initial is not None else v, label=k.replace("_", " "), help_text=help_text)
|
||||
|
||||
def save_for_rota(self, rota):
|
||||
"""Persist cleaned constraint fields into `rota.options`.
|
||||
|
||||
Only updates keys that are present in the typed constraint defaults.
|
||||
For structured fields (JSON), attempt to parse into Python objects.
|
||||
"""
|
||||
opts = rota.options or {}
|
||||
for key in 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)
|
||||
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:
|
||||
# For optional int/float fields allow empty -> None
|
||||
if val == "" or val is None:
|
||||
opts[key] = None
|
||||
else:
|
||||
opts[key] = val
|
||||
|
||||
rota.options = opts
|
||||
rota.save(update_fields=["options"])
|
||||
|
||||
|
||||
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)",
|
||||
)
|
||||
|
||||
# Allow entering shift-specific constraints as JSON. This should be a
|
||||
# list of constraint dicts compatible with the scheduler's SingleShift
|
||||
# `constraints` field. Example: `[{"name": "night", "options": {...}}]`
|
||||
constraints = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
|
||||
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
|
||||
)
|
||||
include_groups = forms.CharField(
|
||||
required=False,
|
||||
help_text="Comma separated list of groups that can work this shift (leave empty for all)",
|
||||
label="Include groups",
|
||||
)
|
||||
exclude_groups = forms.CharField(
|
||||
required=False,
|
||||
help_text="Comma separated list of groups that cannot work this shift",
|
||||
label="Exclude groups",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
def clean_constraints(self):
|
||||
val = self.cleaned_data.get("constraints")
|
||||
if not val:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
if not isinstance(parsed, list):
|
||||
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
|
||||
# Validate each constraint object for basic correctness
|
||||
errors = []
|
||||
for i, c in enumerate(parsed):
|
||||
if not isinstance(c, dict):
|
||||
errors.append(f"Constraint #{i+1} must be an object")
|
||||
continue
|
||||
name = c.get('name') or c.get('type')
|
||||
if not name:
|
||||
errors.append(f"Constraint #{i+1} missing 'name'")
|
||||
continue
|
||||
if name in ('pre', 'post'):
|
||||
opts = c.get('options', {}) or {}
|
||||
# days is now required: check top-level or under options
|
||||
days_val = c.get('days') if c.get('days') is not None else opts.get('days')
|
||||
if days_val is None:
|
||||
errors.append(
|
||||
f"Constraint #{i+1} ('{name}') requires a 'days' duration (number of days the constraint applies for)."
|
||||
)
|
||||
if errors:
|
||||
raise forms.ValidationError(errors)
|
||||
return parsed
|
||||
except forms.ValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
|
||||
|
||||
def clean_include_groups(self):
|
||||
val = self.cleaned_data.get("include_groups") or ""
|
||||
return [g.strip() for g in val.split(",") if g.strip()]
|
||||
|
||||
def clean_exclude_groups(self):
|
||||
val = self.cleaned_data.get("exclude_groups") or ""
|
||||
return [g.strip() for g in val.split(",") if g.strip()]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
include = cleaned_data.get("include_groups") or []
|
||||
exclude = cleaned_data.get("exclude_groups") or []
|
||||
overlap = set(include).intersection(set(exclude))
|
||||
if overlap:
|
||||
raise forms.ValidationError(
|
||||
f"Include groups and Exclude groups cannot overlap. Overlapping groups: {', '.join(overlap)}"
|
||||
)
|
||||
return cleaned_data
|
||||
@@ -0,0 +1 @@
|
||||
# package
|
||||
@@ -0,0 +1 @@
|
||||
# package
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
|
||||
from rota.models import Worker
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "List workers assigned to more than one rota (useful before migrating to OneToOne Assignment)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
qs = Worker.objects.annotate(num_rotas=Count('rotas')).filter(num_rotas__gt=1)
|
||||
if not qs.exists():
|
||||
self.stdout.write(self.style.SUCCESS('No workers assigned to more than one rota.'))
|
||||
return
|
||||
for w in qs:
|
||||
self.stdout.write(f"Worker id={w.id} name={w.name!r} assigned_rotas={w.num_rotas}")
|
||||
@@ -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,18 @@
|
||||
# Generated by Django 6.0 on 2025-12-13 21:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rota", "0003_rotarun_export_html"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="worker",
|
||||
name="options",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by assistant for adding nullable request_token
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rota", "0004_worker_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="worker",
|
||||
name="request_token",
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations
|
||||
import uuid
|
||||
|
||||
|
||||
def generate_tokens(apps, schema_editor):
|
||||
Worker = apps.get_model('rota', 'Worker')
|
||||
db_alias = schema_editor.connection.alias
|
||||
for w in Worker.objects.using(db_alias).all():
|
||||
if not getattr(w, 'request_token', None):
|
||||
new_token = uuid.uuid4()
|
||||
# extremely unlikely collision, but guard anyway
|
||||
while Worker.objects.using(db_alias).filter(request_token=new_token).exists():
|
||||
new_token = uuid.uuid4()
|
||||
w.request_token = new_token
|
||||
w.save(update_fields=['request_token'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rota', '0005_add_request_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(generate_tokens, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rota', '0006_populate_request_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='worker',
|
||||
name='request_token',
|
||||
field=models.UUIDField(editable=False, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0 on 2025-12-19 21:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rota", "0007_make_request_token_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="assignment",
|
||||
options={"ordering": ("-created_at",)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="assignment",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="assignment",
|
||||
name="worker",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="rota.worker"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="worker",
|
||||
name="request_token",
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,397 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
|
||||
|
||||
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)
|
||||
# Prefer an explicit value stored in `self.options['weeks']` (set by
|
||||
# the rota edit form). If absent, fall back to deriving from the
|
||||
# stored end_date (backwards compatible).
|
||||
opts_preview = dict(self.options or {})
|
||||
weeks_opt = None
|
||||
if "weeks" in opts_preview:
|
||||
try:
|
||||
weeks_opt = int(opts_preview.get("weeks"))
|
||||
except Exception:
|
||||
weeks_opt = None
|
||||
if weeks_opt is not None:
|
||||
weeks_to_rota = max(1, weeks_opt)
|
||||
else:
|
||||
delta = self.end_date - self.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
|
||||
# Build kwargs for RotaBuilder from any explicit builder args stored in
|
||||
# `self.options` (kept for backward compatibility), and treat the
|
||||
# remaining keys as constraint options which should map to
|
||||
# `RotaConstraintOptions`.
|
||||
opts = dict(self.options or {})
|
||||
# Remove 'weeks' from opts before handing to constraint options so
|
||||
# it doesn't cause validation failures in RotaConstraintOptions.
|
||||
if "weeks" in opts:
|
||||
try:
|
||||
opts.pop("weeks")
|
||||
except Exception:
|
||||
pass
|
||||
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",
|
||||
]
|
||||
rb_kwargs = {k: opts.pop(k) for k in builder_keys if k in opts}
|
||||
rb = RotaBuilder(start_date=self.start_date, weeks_to_rota=weeks_to_rota, name=self.name, **rb_kwargs)
|
||||
|
||||
# If the rota package exposes a typed RotaConstraintOptions model, use it
|
||||
# to validate/coerce the remaining `opts` and attach it to the builder.
|
||||
try:
|
||||
RotaConstraintOptions = getattr(mod, "RotaConstraintOptions")
|
||||
if opts:
|
||||
# validate raw JSON into the typed model
|
||||
opts_model = RotaConstraintOptions.model_validate(opts)
|
||||
else:
|
||||
opts_model = RotaConstraintOptions()
|
||||
# attach the typed model to the builder (overrides defaults)
|
||||
setattr(rb, "constraint_options_model", opts_model)
|
||||
except Exception:
|
||||
# If the model isn't available or validation fails, keep builder
|
||||
# defaults and proceed (backwards compatible behaviour).
|
||||
pass
|
||||
|
||||
# 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)
|
||||
|
||||
# Store additional worker settings / metadata that map to the
|
||||
# pydantic `Worker` model used by the rota generator. This allows the
|
||||
# web UI to expose richer worker options without changing the core
|
||||
# Django schema for frequently used fields.
|
||||
options = models.JSONField(default=dict, blank=True)
|
||||
|
||||
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
|
||||
|
||||
# Token used to allow workers (without Django accounts) to access
|
||||
# self-service links (request leave, view their calendar, etc.). This
|
||||
# is a UUID4 and should be treated as a secret. Regenerate to revoke.
|
||||
# During migration we add this field as nullable and non-unique first,
|
||||
# then populate unique values in a data migration and finally make it
|
||||
# non-null and unique. See migrations/ for the migration sequence.
|
||||
request_token = models.UUIDField(default=uuid.uuid4, editable=False, null=True, unique=False)
|
||||
|
||||
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 `fte` as a float (fractional e.g. 1.0 == 100%),
|
||||
# while the pydantic Worker expects an integer percentage (100).
|
||||
# Convert conservatively:
|
||||
# - if stored value looks like a fraction (<= 10) treat it as fraction
|
||||
# and multiply by 100 (e.g. 1.0 -> 100).
|
||||
# - otherwise treat it as already a percentage and coerce to int.
|
||||
raw_fte = getattr(self, "fte", None)
|
||||
try:
|
||||
if raw_fte is None:
|
||||
fte_pct = 100
|
||||
else:
|
||||
# numeric values
|
||||
f = float(raw_fte)
|
||||
if f <= 10:
|
||||
fte_pct = int(round(f * 100))
|
||||
else:
|
||||
fte_pct = int(round(f))
|
||||
except Exception:
|
||||
fte_pct = 100
|
||||
|
||||
pyd_kwargs = {
|
||||
"name": self.name,
|
||||
"site": self.site or "",
|
||||
"grade": self.grade or 1,
|
||||
"id": self.pk,
|
||||
"fte": fte_pct,
|
||||
}
|
||||
|
||||
# Merge any additional options stored on the Django model into the
|
||||
# kwargs passed to the pydantic Worker model. The pydantic model will
|
||||
# honour its own defaults and types; storing these as a dict keeps the
|
||||
# Django DB schema simple while exposing full worker settings in the UI.
|
||||
try:
|
||||
opts = getattr(self, "options", None) or {}
|
||||
if isinstance(opts, dict):
|
||||
# shallow merge; keys in `options` override defaults above when present
|
||||
pyd_kwargs.update(opts)
|
||||
|
||||
# Defensive normalization: the DB may contain `null`/`[]` for fields
|
||||
# that the pydantic model expects to be ints/dicts/lists. Coerce a
|
||||
# few common keys to safe defaults so Worker(**kwargs) doesn't raise
|
||||
# on older or malformed records.
|
||||
int_keys = [
|
||||
"locum_max_shifts",
|
||||
"locum_max_shifts_per_week",
|
||||
"bank_holiday_extra",
|
||||
"prefer_multi_shift_together",
|
||||
"weekend_shift_target_number",
|
||||
]
|
||||
dict_keys = [
|
||||
"previous_shifts",
|
||||
"shift_balance_extra",
|
||||
"force_assign_with",
|
||||
"max_shifts_per_week_by_shift_name",
|
||||
"assign_as_block_preferences",
|
||||
"shift_fte_overrides",
|
||||
"exact_shifts",
|
||||
]
|
||||
list_keys = [
|
||||
"oop",
|
||||
"not_available_to_work",
|
||||
"pref_not_to_work",
|
||||
"work_requests",
|
||||
"locum_availability",
|
||||
"allowed_multi_shift_sets",
|
||||
"hard_day_dependencies",
|
||||
"hard_day_exclusions",
|
||||
"forced_assignments",
|
||||
"forced_assignments_by_date",
|
||||
"shift_start_dates",
|
||||
"shift_end_dates",
|
||||
]
|
||||
|
||||
for k in int_keys:
|
||||
v = pyd_kwargs.get(k, None)
|
||||
try:
|
||||
if v is None:
|
||||
pyd_kwargs[k] = 0
|
||||
else:
|
||||
pyd_kwargs[k] = int(v)
|
||||
except Exception:
|
||||
pyd_kwargs[k] = 0
|
||||
|
||||
for k in dict_keys:
|
||||
v = pyd_kwargs.get(k, None)
|
||||
if not isinstance(v, dict):
|
||||
# If a list was stored accidentally (common migration/serialization
|
||||
# mistakes) coerce to an empty dict rather than letting pydantic
|
||||
# fail.
|
||||
pyd_kwargs[k] = {}
|
||||
|
||||
for k in list_keys:
|
||||
v = pyd_kwargs.get(k, None)
|
||||
if v is None:
|
||||
pyd_kwargs[k] = []
|
||||
elif isinstance(v, list):
|
||||
# leave as-is
|
||||
pass
|
||||
elif isinstance(v, (set, tuple)):
|
||||
pyd_kwargs[k] = list(v)
|
||||
else:
|
||||
pyd_kwargs[k] = []
|
||||
|
||||
set_keys = ["groups"]
|
||||
for k in set_keys:
|
||||
v = pyd_kwargs.get(k, None)
|
||||
try:
|
||||
if v is None:
|
||||
pyd_kwargs[k] = set()
|
||||
else:
|
||||
pyd_kwargs[k] = set(v)
|
||||
except Exception:
|
||||
pyd_kwargs[k] = set()
|
||||
|
||||
# allowed_multi_shift_sets is expected to be a list of sets; if the
|
||||
# DB contains lists-of-lists convert inner lists to sets.
|
||||
ams = pyd_kwargs.get("allowed_multi_shift_sets")
|
||||
if isinstance(ams, list):
|
||||
new = []
|
||||
for item in ams:
|
||||
if isinstance(item, list):
|
||||
new.append(set(item))
|
||||
elif isinstance(item, (set, tuple)):
|
||||
new.append(set(item))
|
||||
else:
|
||||
# leave unknown types to pydantic to either accept or coerce
|
||||
new.append(item)
|
||||
pyd_kwargs["allowed_multi_shift_sets"] = new
|
||||
except Exception:
|
||||
# ignore any issues reading options and proceed with core fields
|
||||
pass
|
||||
|
||||
return PydWorker(**pyd_kwargs)
|
||||
|
||||
|
||||
class Assignment(models.Model):
|
||||
"""Join model assigning a worker to a rota (can hold role / metadata)."""
|
||||
|
||||
# Enforce that a Worker may only be assigned to a single RotaSchedule by
|
||||
# using a OneToOneField here. This ensures at the DB level that each
|
||||
# Worker has at most one Assignment (and therefore at most one rota).
|
||||
worker = models.OneToOneField(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:
|
||||
# With OneToOneField on worker, uniqueness for (worker, rota) is
|
||||
# implied by the worker uniqueness. Keep ordering for convenience.
|
||||
ordering = ("-created_at",)
|
||||
|
||||
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,190 @@
|
||||
import threading
|
||||
import traceback
|
||||
import io
|
||||
import contextlib
|
||||
|
||||
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
|
||||
|
||||
# Capture any stdout/stderr produced by the external generator
|
||||
out_buf = io.StringIO()
|
||||
err_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
|
||||
result = generate_rota_for_schedule(rota)
|
||||
# Attach captured output to the result for debugging
|
||||
try:
|
||||
result = result or {}
|
||||
result["generator_stdout"] = out_buf.getvalue()
|
||||
result["generator_stderr"] = err_buf.getvalue()
|
||||
except Exception:
|
||||
pass
|
||||
# Also persist captured generator output into the run.log so
|
||||
# it's visible from the run detail page when executed in the
|
||||
# background thread.
|
||||
try:
|
||||
captured = "\n[GENERATOR STDOUT]\n" + (result.get("generator_stdout") or "") + "\n[GENERATOR STDERR]\n" + (result.get("generator_stderr") or "")
|
||||
run.log = (run.log or "") + captured
|
||||
run.save(update_fields=["log"])
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
# Defensive: if no workers are assigned to the rota, skip the
|
||||
# builder step and return a clear message rather than raising an
|
||||
# exception from deep inside the builder.
|
||||
try:
|
||||
worker_count = rota.workers.count()
|
||||
except Exception:
|
||||
worker_count = 0
|
||||
|
||||
if worker_count == 0:
|
||||
result["builder_error"] = "No workers assigned to rota; add workers before running the builder."
|
||||
else:
|
||||
try:
|
||||
# Capture stdout/stderr from the builder so debugging info
|
||||
# is available in the RotaRun result and log.
|
||||
out_buf = io.StringIO()
|
||||
err_buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
|
||||
rb = rota.to_rota_builder()
|
||||
# If the rota has solver options saved in rota.options, prefer
|
||||
# those when invoking the builder. `solver_options` is expected
|
||||
# to be a dict of solver-specific options; `solver` may be a
|
||||
# string naming the solver plugin.
|
||||
opts = rota.options or {}
|
||||
solver_options = opts.get("solver_options") or {}
|
||||
solver_name = opts.get("solver") or None
|
||||
|
||||
# build the model and optionally solve. If solver_name is
|
||||
# provided, pass it through; otherwise let builder default.
|
||||
if solver_name:
|
||||
rb.build_and_solve(options=solver_options, solve=builder_solve, solver=solver_name)
|
||||
else:
|
||||
rb.build_and_solve(options=solver_options, 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
|
||||
result["builder_stdout"] = out_buf.getvalue()
|
||||
result["builder_stderr"] = err_buf.getvalue()
|
||||
|
||||
# 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"
|
||||
# Append captured output to run.log for convenient viewing
|
||||
try:
|
||||
captured = "\n[BUILDER STDOUT]\n" + result.get("builder_stdout", "") + "\n[BUILDER STDERR]\n" + result.get("builder_stderr", "")
|
||||
run.log = (run.log or "") + captured
|
||||
# persist the appended log so it is visible to users
|
||||
# when the run finishes
|
||||
run.save(update_fields=["log"])
|
||||
except Exception:
|
||||
pass
|
||||
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)
|
||||
# Persist the log immediately so UI can show it even if
|
||||
# the outer flow later marks the run failed.
|
||||
try:
|
||||
run.save(update_fields=["log"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
run.mark_finished(result=result)
|
||||
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
# Ensure the traceback is persisted into the run log and result (if
|
||||
# available) before marking the run failed so the UI can display the
|
||||
# diagnostic information when viewing the run details.
|
||||
try:
|
||||
run.log = (run.log or "") + "\n" + tb
|
||||
# If we have a partially constructed `result`, attach it so the
|
||||
# failure page can show any captured stdout/stderr
|
||||
try:
|
||||
if 'result' in locals() and isinstance(result, dict):
|
||||
run.result = result
|
||||
run.save(update_fields=["log", "result"])
|
||||
else:
|
||||
run.save(update_fields=["log"])
|
||||
except Exception:
|
||||
# best-effort: ignore persistence errors here
|
||||
pass
|
||||
except Exception:
|
||||
# ignore log assignment errors and continue to mark failed
|
||||
pass
|
||||
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,50 @@
|
||||
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("rota/<int:rota_id>/workers/import/", views.worker_import, name="worker_import"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/overview/", views.worker_overview, name="worker_overview"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||
path("worker/<int:worker_id>/leave/add/", views.worker_leave_add, name="worker_leave_add"),
|
||||
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
||||
path("worker/<int:worker_id>/leaves.json", views.leaves_json, name="worker_leaves_json"),
|
||||
path("leave/request/", views.request_leave, name="request_leave"),
|
||||
path("leave/request/token/<uuid:token>/", views.request_leave, name="request_leave_token"),
|
||||
path("worker/settings/token/<uuid:token>/", views.worker_settings_token, name="worker_settings_token"),
|
||||
path("leaves/all/", views.leaves_global, name="leaves_global"),
|
||||
path("leaves/all.json", views.leaves_global_json, name="leaves_global_json"),
|
||||
# Rota-scoped variants which render the same views but with rota_id supplied
|
||||
path("rota/<int:rota_id>/leaves/all/", views.leaves_global, name="leaves_global_rota"),
|
||||
path("rota/<int:rota_id>/leaves/all.json", views.leaves_global_json, name="leaves_global_json_rota"),
|
||||
path("rota/<int:rota_id>/leave/select_workers/", views.select_workers_modal, name="select_workers_modal_rota"),
|
||||
path("leave/select_workers/", views.select_workers_modal, name="select_workers_modal"),
|
||||
path("leave/apply_multi/", views.apply_leave_multi, name="apply_leave_multi"),
|
||||
path("leave/<int:leave_id>/delete/", views.leave_delete, name="leave_delete"),
|
||||
path("worker/<int:worker_id>/leaves/delete_all/", views.leave_delete_all, name="leave_delete_all"),
|
||||
path("worker/<int:worker_id>/regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"),
|
||||
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
|
||||
path("run/<int:run_id>/status/", views.rota_run_status, name="rota_run_status"),
|
||||
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/inline/", views.rota_option_inline, name="rota_option_inline"),
|
||||
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"),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
+620
-88
@@ -1,34 +1,542 @@
|
||||
from pyomo.opt.results.container import ignore
|
||||
from collections import defaultdict
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from rota.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days, RotaConstraintOptions, MaxShiftsPerWeekBlockConstraint
|
||||
import typer
|
||||
import subprocess
|
||||
import pandas as pd
|
||||
|
||||
from rota.workers import (
|
||||
from rota_generator.workers import (
|
||||
Worker,
|
||||
NotAvailableToWork,
|
||||
NonWorkingDays,
|
||||
WorkRequests,
|
||||
PreferenceNotToWork,
|
||||
OutOfProgramme,
|
||||
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
|
||||
)
|
||||
|
||||
from loguru import logger
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
ROTA_REQUESTS = "/home/ross/Sync/cons/requests.ods"
|
||||
ROTA_B_PATH = "/home/ross/Sync/cons/rota_b.xlsx"
|
||||
ROTA_START_DATE = "2026-08-03"
|
||||
|
||||
|
||||
sites = ("rota a", "rota b", "rota c")
|
||||
|
||||
|
||||
def _normalize_worker_key(value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", text).lower()
|
||||
|
||||
|
||||
def _validate_worker_alignment(base_workers, other_workers, base_label, other_label):
|
||||
"""Validate that two worker collections contain the same members.
|
||||
|
||||
Comparison is case-insensitive and whitespace-normalized.
|
||||
Raises ValueError with a clear diff when the sets do not match.
|
||||
"""
|
||||
base_clean = [str(w).strip() for w in base_workers if str(w).strip()]
|
||||
other_clean = [str(w).strip() for w in other_workers if str(w).strip()]
|
||||
|
||||
base_norm_to_raw = {_normalize_worker_key(w): w for w in base_clean}
|
||||
other_norm_to_raw = {_normalize_worker_key(w): w for w in other_clean}
|
||||
|
||||
base_norm = set(base_norm_to_raw.keys())
|
||||
other_norm = set(other_norm_to_raw.keys())
|
||||
|
||||
only_in_base_norm = sorted(base_norm - other_norm)
|
||||
only_in_other_norm = sorted(other_norm - base_norm)
|
||||
|
||||
if not only_in_base_norm and not only_in_other_norm:
|
||||
return
|
||||
|
||||
only_in_base = [base_norm_to_raw[k] for k in only_in_base_norm]
|
||||
only_in_other = [other_norm_to_raw[k] for k in only_in_other_norm]
|
||||
|
||||
raise ValueError(
|
||||
"Worker mismatch detected between sources. "
|
||||
f"{base_label} only: {only_in_base}. "
|
||||
f"{other_label} only: {only_in_other}."
|
||||
)
|
||||
|
||||
|
||||
def _parse_sheet_date(value):
|
||||
if pd.isna(value):
|
||||
return None
|
||||
parsed = pd.to_datetime(value, errors="coerce")
|
||||
if pd.isna(parsed):
|
||||
return None
|
||||
return parsed.date()
|
||||
|
||||
|
||||
def _parse_worked_target_cell(value):
|
||||
"""Parse values like '5 (6.88)' into (worked, target)."""
|
||||
if pd.isna(value):
|
||||
return None
|
||||
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in {"nan", "-"}:
|
||||
return None
|
||||
|
||||
match = re.match(r"^\s*(-?\d+(?:\.\d+)?)\s*\(([^)]+)\)\s*$", text)
|
||||
if match:
|
||||
try:
|
||||
worked = float(match.group(1).strip())
|
||||
target = float(match.group(2).strip())
|
||||
return worked, target
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Fallback for cells that contain only one number.
|
||||
try:
|
||||
target = float(text)
|
||||
return 0.0, target
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_priors_from_running_totals(running_totals_df):
|
||||
"""Extract previous shifts map from Running totals tab.
|
||||
|
||||
Reads only oncall nights, twilights and weekend A values.
|
||||
"""
|
||||
priors_map = {}
|
||||
if running_totals_df is None or running_totals_df.empty:
|
||||
return priors_map
|
||||
|
||||
# Find the header row that starts with 'Worker'.
|
||||
header_row_index = None
|
||||
for i in range(len(running_totals_df)):
|
||||
first_cell = str(running_totals_df.iloc[i, 0]).strip().lower()
|
||||
if first_cell == "worker":
|
||||
header_row_index = i
|
||||
break
|
||||
|
||||
if header_row_index is None:
|
||||
return priors_map
|
||||
|
||||
header_values = [
|
||||
str(c).strip().lower() if not pd.isna(c) else ""
|
||||
for c in running_totals_df.iloc[header_row_index].tolist()
|
||||
]
|
||||
|
||||
def _find_col(header_name):
|
||||
for idx, col in enumerate(header_values):
|
||||
if col == header_name:
|
||||
return idx
|
||||
return None
|
||||
|
||||
worker_col = _find_col("worker")
|
||||
oncall_col = _find_col("oncall night")
|
||||
twilight_col = _find_col("twilight")
|
||||
weekend_a_col = _find_col("weekend a")
|
||||
|
||||
if worker_col is None:
|
||||
return priors_map
|
||||
|
||||
for i in range(header_row_index + 1, len(running_totals_df)):
|
||||
row = running_totals_df.iloc[i]
|
||||
worker_raw = row.iloc[worker_col] if worker_col < len(row) else None
|
||||
worker = str(worker_raw).strip() if not pd.isna(worker_raw) else ""
|
||||
if not worker or worker.lower() in {"nan", "total"}:
|
||||
continue
|
||||
|
||||
prev = {}
|
||||
if oncall_col is not None and oncall_col < len(row):
|
||||
parsed = _parse_worked_target_cell(row.iloc[oncall_col])
|
||||
if parsed is not None:
|
||||
prev["oncall"] = parsed
|
||||
|
||||
if twilight_col is not None and twilight_col < len(row):
|
||||
parsed = _parse_worked_target_cell(row.iloc[twilight_col])
|
||||
if parsed is not None:
|
||||
prev["twilight"] = parsed
|
||||
|
||||
if weekend_a_col is not None and weekend_a_col < len(row):
|
||||
parsed = _parse_worked_target_cell(row.iloc[weekend_a_col])
|
||||
if parsed is not None:
|
||||
prev["weekend"] = parsed
|
||||
|
||||
if prev:
|
||||
priors_map[worker] = prev
|
||||
|
||||
return priors_map
|
||||
|
||||
def extract_leave_and_rota_from_calender(calender_df):
|
||||
"""
|
||||
Extract leave and rota assignments from calender_df.
|
||||
- The first row contains worker names, starting from the 5th column (index 4).
|
||||
- The second column (index 1) contains dates in dd/mm/yyyy format.
|
||||
- The second column (index 1) also contains the rota/leave code for that date.
|
||||
Returns a list of dicts: {"date": ..., "/worker": ..., "rota": ...}
|
||||
"""
|
||||
# Get worker names from the first row, starting from column 5 (index 4)
|
||||
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
|
||||
# Iterate over each row (skip the header row)
|
||||
|
||||
# Get rota data from the second row (index 1), starting from column 5 (index 4)
|
||||
# Extract the rota data for each worker from the first data row (index 0), columns 5 onwards
|
||||
#rota_row = calender_df.iloc[0]
|
||||
#rota_data = {}
|
||||
#for i, worker in enumerate(worker_names):
|
||||
# assignment = str(rota_row.iloc[5 + i]).strip() if len(rota_row) > 5 + i else ""
|
||||
# #rota_data[worker] = f"rota {assignment.lower()}"
|
||||
|
||||
leave_requests = []
|
||||
work_requests = []
|
||||
# Process leave data from row 3 onwards (index 2 and up)
|
||||
for idx, row in calender_df.iloc[3:].iterrows():
|
||||
date = _parse_sheet_date(row.iloc[1])
|
||||
if date is None:
|
||||
continue
|
||||
|
||||
if date < datetime.date.fromisoformat(ROTA_START_DATE):
|
||||
continue # skip rows before rota start date
|
||||
for i, worker in enumerate(worker_names):
|
||||
if not worker or worker.lower() == "nan":
|
||||
continue
|
||||
leave_val = str(row.iloc[5 + i]).strip().lower() if len(row) > 5 + i else ""
|
||||
if "no" in leave_val:
|
||||
leave_requests.append({
|
||||
"date": date,
|
||||
"worker": worker,
|
||||
})
|
||||
elif "yes" in leave_val:
|
||||
work_requests.append({
|
||||
"date": date,
|
||||
"worker": worker,
|
||||
})
|
||||
elif leave_val != "" and leave_val != "nan":
|
||||
leave_requests.append({
|
||||
"date": date,
|
||||
"worker": worker,
|
||||
})
|
||||
|
||||
|
||||
#print(leave_requests)
|
||||
return leave_requests, work_requests, worker_names
|
||||
|
||||
def extract_shift_assignments_from_rota(rota_df):
|
||||
"""
|
||||
Extract shift assignments from rota_df.
|
||||
- Data starts from the 3rd row (index 2).
|
||||
- The first column is the week start date.
|
||||
- For each day (Mon-Sun), two columns: twilight initials, night initials.
|
||||
Returns a list of dicts: {"week_start": ..., "day": ..., "shift": ..., "initial": ...}
|
||||
"""
|
||||
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
shifts = ["twilight", "oncall"]
|
||||
assignments = []
|
||||
|
||||
skipped_rows = 0
|
||||
rota_start = datetime.date.fromisoformat(ROTA_START_DATE)
|
||||
for idx, row in rota_df.iloc[2:].iterrows():
|
||||
week_start = _parse_sheet_date(row.iloc[0])
|
||||
if week_start is None:
|
||||
print(f"Invalid date format in row {idx}: {row.iloc[0]}")
|
||||
continue # skip rows with invalid date
|
||||
|
||||
if week_start < rota_start:
|
||||
skipped_rows += 1
|
||||
continue # skip rows before rota start date
|
||||
|
||||
week = ((week_start - rota_start).days // 7) + 1
|
||||
|
||||
for i, day in enumerate(days):
|
||||
for j, shift in enumerate(shifts):
|
||||
if i < 5:
|
||||
# Mon-Fri: two columns per day (twilight, oncall)
|
||||
col_idx = 2 + i * 2 + j # 2 for Mon twilight, 3 for Mon oncall, etc.
|
||||
weekend = False
|
||||
else:
|
||||
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
|
||||
weekend = True
|
||||
if j == 0:
|
||||
col_idx = 12 + (i - 5) # 12 for Sat, 13 for Sun
|
||||
else:
|
||||
continue # No night shift column for Sat/Sun
|
||||
if col_idx >= len(row):
|
||||
continue
|
||||
initial = str(row.iloc[col_idx]).strip()
|
||||
if weekend:
|
||||
initial = initial.split("/")[0].strip() # In case of "initial1/initial2", take the first one
|
||||
if initial and initial.lower() != "nan" and initial.lower() != "nat":
|
||||
if weekend:
|
||||
shift = "weekend"
|
||||
assignments.append({
|
||||
"week_start": week_start,
|
||||
"week": week,
|
||||
"day": day,
|
||||
"date": week_start + datetime.timedelta(days=i),
|
||||
"shift": shift,
|
||||
"initial": initial,
|
||||
})
|
||||
# Build a mapping of assignments by worker initial
|
||||
assignments_by_worker = {}
|
||||
for assignment in assignments:
|
||||
initial = assignment["initial"]
|
||||
if initial not in assignments_by_worker:
|
||||
assignments_by_worker[initial] = []
|
||||
assignments_by_worker[initial].append(assignment)
|
||||
|
||||
return assignments, assignments_by_worker
|
||||
|
||||
def load_workers():
|
||||
# Path to the ODS file
|
||||
ods_path = ROTA_REQUESTS
|
||||
|
||||
# Read all sheets
|
||||
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
|
||||
|
||||
# Extract sheets
|
||||
days_df = xls["Days"]
|
||||
calender_df = xls["Calendar"]
|
||||
rota_df = xls["Rota"]
|
||||
running_totals_df = xls.get("Running totals")
|
||||
|
||||
|
||||
rota_b_path = ROTA_B_PATH
|
||||
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
|
||||
# Extract rota_b assignments: date in column A, worker in column B
|
||||
rota_b_assignments = defaultdict(list)
|
||||
for idx, row in rota_b_df.iterrows():
|
||||
logger.debug(f"{idx}: {row}")
|
||||
date_val = row.iloc[0]
|
||||
try:
|
||||
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
||||
except AttributeError:
|
||||
# end of file
|
||||
break
|
||||
|
||||
if pd.isna(date_val) or pd.isna(worker_val):
|
||||
continue
|
||||
try:
|
||||
date = pd.to_datetime(date_val).date()
|
||||
except Exception:
|
||||
continue
|
||||
worker = str(worker_val).strip()
|
||||
if not worker or worker.lower() == "nan":
|
||||
continue
|
||||
rota_b_assignments[worker].append(date)
|
||||
print(rota_b_assignments)
|
||||
# You can now use rota_b_assignments as needed
|
||||
|
||||
# --- Load prior allocations from requests.ods -> Running totals tab ---
|
||||
priors_map = _load_priors_from_running_totals(running_totals_df)
|
||||
logger.debug("priors_map from Running totals: {}", priors_map)
|
||||
|
||||
# --- Example: Print the first few rows of each sheet ---
|
||||
#print("Days sheet:")
|
||||
#print(days_df.head())
|
||||
weekday_cols = ["Mon", "Tue", "Wed", "Thu", "Fri"]
|
||||
workers_days = []
|
||||
rota_data = {}
|
||||
for idx, row in days_df.iloc[7:].iterrows():
|
||||
# Extract initials from the name column (assumed to be in brackets at the end)
|
||||
name = str(row.iloc[1]).strip()
|
||||
initial = str(row.iloc[2]).strip()
|
||||
if not name or name.lower() == "nan":
|
||||
continue
|
||||
availability = {}
|
||||
requests = {}
|
||||
rota_data[name] = f"rota {str(row.iloc[3]).strip().lower()}"
|
||||
for i, day in enumerate(weekday_cols):
|
||||
val = row.iloc[4 + i]
|
||||
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
|
||||
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
|
||||
availability[day] = available
|
||||
workers_days.append({"initial": initial, "name": name, "availability": availability, "requests": requests})
|
||||
|
||||
|
||||
leave_requests, work_requests, calendar_workers = extract_leave_and_rota_from_calender(calender_df)
|
||||
|
||||
assignments, assignments_by_worker = extract_shift_assignments_from_rota(rota_df)
|
||||
print(assignments)
|
||||
|
||||
# Validate cross-source worker matching before constructing Worker objects.
|
||||
days_workers = [w["name"] for w in workers_days]
|
||||
days_initials = [w["initial"] for w in workers_days]
|
||||
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_workers,
|
||||
other_workers=calendar_workers,
|
||||
base_label="Days tab worker names",
|
||||
other_label="Calendar tab worker names",
|
||||
)
|
||||
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_initials,
|
||||
other_workers=list(assignments_by_worker.keys()),
|
||||
base_label="Days tab worker initials",
|
||||
other_label="Rota tab assigned initials",
|
||||
)
|
||||
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_initials,
|
||||
other_workers=list(rota_b_assignments.keys()),
|
||||
base_label="Days tab worker initials",
|
||||
other_label="Rota B assignments initials",
|
||||
)
|
||||
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_initials,
|
||||
other_workers=list(priors_map.keys()),
|
||||
base_label="Days tab worker initials",
|
||||
other_label="Running totals worker initials",
|
||||
)
|
||||
|
||||
logger.debug("Using Running totals priors for oncall/twilight/weekend A only")
|
||||
|
||||
workers = []
|
||||
for worker_day in workers_days:
|
||||
worker = worker_day["name"]
|
||||
initial = worker_day["initial"]
|
||||
|
||||
start_date = ROTA_START_DATE
|
||||
|
||||
|
||||
#if initial == "ND":
|
||||
# start_date = "2025-09-29" # Non-working day worker starts later
|
||||
#if initial == "MF":
|
||||
# start_date = "2026-01-15" # Medical leave worker starts earlier
|
||||
#if initial == "AA":
|
||||
# start_date = "2025-11-17" # Absent worker starts later
|
||||
|
||||
end_date = None
|
||||
#if initial == "L1":
|
||||
# end_date = "2025-11-17" # L1 worker has a leave request
|
||||
#elif initial == "L2":
|
||||
# end_date = "2025-09-29" # L2 worker has a leave request
|
||||
|
||||
non_working_days = []
|
||||
for day, available in worker_day["availability"].items():
|
||||
if not available:
|
||||
non_working_days.append(NonWorkingDays(day=day, start_date=datetime.datetime(2025, 11, 17)))
|
||||
|
||||
w = Worker(
|
||||
name=worker_day["initial"],
|
||||
site=rota_data[worker],
|
||||
grade=1,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
# Add leave requests for this worker
|
||||
not_available_to_work=[
|
||||
NotAvailableToWork(
|
||||
date=req["date"],
|
||||
reason="leave",
|
||||
) for req in leave_requests if req["worker"] == worker
|
||||
],
|
||||
work_requests=[
|
||||
WorkRequests(
|
||||
date=req["date"],
|
||||
shift="*"
|
||||
) for req in work_requests if req["worker"] == worker
|
||||
],
|
||||
nwds=non_working_days,
|
||||
previous_shifts=priors_map.get(initial, {}),
|
||||
max_days_worked_per_week=2
|
||||
)
|
||||
|
||||
|
||||
#if initial == "L1":
|
||||
# w.oop = [OutOfProgramme(
|
||||
# start_date="2025-09-15",
|
||||
# end_date="2025-10-14",
|
||||
# reason="even out shifts",
|
||||
# )]
|
||||
|
||||
|
||||
if initial in assignments_by_worker:
|
||||
# Add shift assignments for this worker
|
||||
#if initial in ("RK", "HB", "DS", "L1", "L2", "L3", "GFM", "TS", "AB", "IR", "MF", "ND", "RG", "AA", "BR", "MS", "HA", "COD", "JL", "TB", "NHO"):
|
||||
for assignment in assignments_by_worker[initial]:
|
||||
#if initial == "NHO":
|
||||
# pass
|
||||
# #if assignment["shift"] == "oncall":
|
||||
# # continue
|
||||
# #if assignment["shift"] == "twilight":
|
||||
# # continue
|
||||
# #1if assignment["shift"] == "weekend":
|
||||
# #1 continue
|
||||
#print(assignment)
|
||||
w.force_assign_shift(
|
||||
week=assignment["week"],
|
||||
day=assignment["day"],
|
||||
shift_name=assignment["shift"],
|
||||
)
|
||||
if initial in rota_b_assignments:
|
||||
for date in rota_b_assignments.get(initial, []):
|
||||
w.force_assign_shift_by_date(
|
||||
date=date,
|
||||
shift_name="weekend b",
|
||||
)
|
||||
|
||||
w.add_hard_day_exclusion({"Sun", "Mon"})
|
||||
if w.site == "rota a":
|
||||
|
||||
w.prefer_multi_shift_together = 10
|
||||
w.allow_shifts_together("oncall", "twilight")
|
||||
#w.add_force_assign_with("twilight", "oncall")
|
||||
w.add_force_assign_with("weekend", "oncall")
|
||||
|
||||
if w.name in ("DS","RG","MOB","TB"):
|
||||
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
|
||||
w.max_days_worked_per_week = 3
|
||||
else:
|
||||
w.max_days_worked_per_week = 2
|
||||
w.add_max_days_per_week_block_constraint(max_days=2, week_block=2)
|
||||
w.add_hard_day_dependency({"Fri", "Sun"})
|
||||
w.add_hard_day_exclusion({"Sat", "Sun"})
|
||||
else:
|
||||
w.max_unique_shifts_per_week_block = [MaxUniqueShiftsPerWeekBlockConstraint(week_block=1, max_unique_shifts=1)]
|
||||
|
||||
|
||||
|
||||
workers.append(w)
|
||||
|
||||
return workers
|
||||
|
||||
|
||||
|
||||
def parse_time_limit(t) -> int:
|
||||
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
|
||||
if isinstance(t, int):
|
||||
return t
|
||||
if isinstance(t, float):
|
||||
return int(t)
|
||||
t = str(t).strip().lower()
|
||||
if not t:
|
||||
return 0
|
||||
if t.isdigit():
|
||||
return int(t)
|
||||
if t.endswith("h"):
|
||||
return int(float(t[:-1]) * 3600)
|
||||
if t.endswith("m"):
|
||||
return int(float(t[:-1]) * 60)
|
||||
if t.endswith("s"):
|
||||
return int(float(t[:-1]))
|
||||
try:
|
||||
return int(float(t))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
suspend: bool = False,
|
||||
solve: bool = True,
|
||||
time_to_run: int = 60 * 60,
|
||||
ratio: float = 0.001,
|
||||
start_date: datetime.datetime = "2025-09-01",
|
||||
time_to_run: str = "1h",
|
||||
ratio: float = 0.1,
|
||||
start_date: datetime.datetime = ROTA_START_DATE,
|
||||
weeks: int = 22,
|
||||
bom: int = 1,
|
||||
):
|
||||
@@ -39,13 +547,24 @@ def main(
|
||||
rota_start_date,
|
||||
weeks_to_rota=weeks,
|
||||
balance_offset_modifier=bom,
|
||||
name="cons rota",
|
||||
use_previous_shifts=True,
|
||||
name="cons rota test",
|
||||
allow_force_assignment_with_leave_conflict=True,
|
||||
constraint_options=RotaConstraintOptions(
|
||||
balance_weekends=True,
|
||||
max_shifts_per_week=6,
|
||||
max_weekend_frequency=3,
|
||||
max_days_per_week_block=[(3, 2), (4, 4)],
|
||||
#balance_weekend_days=True,
|
||||
balance_weekend_days_quadratic=True,
|
||||
weekend_day_hard_max_deviation=2,
|
||||
balance_weekend_days_use_previous_shifts=True,
|
||||
balance_weekend_days_weight=500000
|
||||
|
||||
),
|
||||
)
|
||||
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
||||
Rota.constraint_options["balance_weekends"] = True
|
||||
Rota.constraint_options["max_weekend_frequency"] = 4
|
||||
Rota.constraint_options["max_shifts_per_week"] = 20
|
||||
# Rota.constraint_options["avoid_st2_first_month"] = True
|
||||
|
||||
#Rota.enable_unavailabilities = False
|
||||
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
@@ -55,11 +574,10 @@ def main(
|
||||
days=days,
|
||||
balance_offset=2,
|
||||
assign_as_block=False,
|
||||
constraint=[
|
||||
#{
|
||||
# "name": "max_shifts_per_week",
|
||||
# "options": 3,
|
||||
#}
|
||||
constraints=[
|
||||
#PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
||||
#PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
|
||||
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
|
||||
],
|
||||
),
|
||||
SingleShift(
|
||||
@@ -68,41 +586,45 @@ def main(
|
||||
workers_required=1,
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
balance_offset=2,
|
||||
constraint=[
|
||||
#{"name": "pre", "options": 2},
|
||||
#{"name": "post", "options": 1},
|
||||
balance_offset=1,
|
||||
constraints=[
|
||||
PostShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"]),
|
||||
#PreShiftConstraint(days=1),
|
||||
#PostShiftConstraint(days=3),
|
||||
],
|
||||
display_char="a",
|
||||
#force_assign_with=["oncall"]
|
||||
),
|
||||
SingleShift(
|
||||
sites=("rota b",),
|
||||
sites=("rota b", "rota c"),
|
||||
name="weekend b",
|
||||
#end_date="2026-07-20",
|
||||
workers_required=1,
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
balance_offset=2,
|
||||
constraint=[
|
||||
{"name": "pre", "options": 1},
|
||||
{"name": "post", "options": 1},
|
||||
],
|
||||
display_char="b",
|
||||
constraints=[
|
||||
PreShiftConstraint(days=2),
|
||||
PostShiftConstraint(days=2),
|
||||
],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
"rota a",
|
||||
"rota b",
|
||||
"rota d"
|
||||
),
|
||||
name="twilight",
|
||||
workers_required=1,
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=5,
|
||||
#constraint=[
|
||||
# {"name": "pre", "options": 1},
|
||||
# {"name": "post", "options": 1},
|
||||
#],
|
||||
balance_offset=1,
|
||||
constraints=[
|
||||
PreShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"], exclude_days=("Sat", "Sun")),
|
||||
MaxShiftsPerWeekConstraint(max_shifts=1),
|
||||
MaxShiftsPerWeekBlockConstraint(week_block=3,max_shifts=1)
|
||||
],
|
||||
),
|
||||
)
|
||||
#Rota.allow_shifts_together_for_all_workers(
|
||||
@@ -120,83 +642,90 @@ def main(
|
||||
Rota.build_shifts()
|
||||
# Rota.add_grade_constraint_by_week([2], [1], Rota.get_shift_names())
|
||||
|
||||
for n in range(1, 12):
|
||||
w = Worker(name=f"A{n:02d}", site="rota a", grade=1)
|
||||
#w.prefer_multi_shift_together = 10
|
||||
#w.allow_shifts_together("oncall", "twilight")
|
||||
#w.allow_shifts_together("oncall", "weekend")
|
||||
|
||||
#w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
# Build workers
|
||||
|
||||
w.add_force_assign_with("twilight", "oncall")
|
||||
w.add_force_assign_with("weekend", "oncall")
|
||||
w.set_max_shifts_per_week("twilight", 1)
|
||||
workers = load_workers()
|
||||
Rota.add_workers(workers)
|
||||
|
||||
if n <= 2:
|
||||
pass
|
||||
w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
else:
|
||||
w.add_hard_day_dependency("Fri", "Sun")
|
||||
#w.set_max_shifts_per_week("weekend", 1)
|
||||
# #w.add_hard_day_dependency("Sat", "Sun")
|
||||
|
||||
print(w)
|
||||
#for n in range(1, 12):
|
||||
# w = Worker(name=f"A{n:02d}", site="rota a", grade=1)
|
||||
# #w.prefer_multi_shift_together = 10
|
||||
# #w.allow_shifts_together("oncall", "twilight")
|
||||
# #w.allow_shifts_together("oncall", "weekend")
|
||||
|
||||
Rota.add_worker(w)
|
||||
# #w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
|
||||
w13 = Worker(name=f"A13", site="rota a", grade=1, start_date="2025-10-01")
|
||||
#w.prefer_multi_shift_together = 10
|
||||
#w.allow_shifts_together("oncall", "twilight")
|
||||
#w.allow_shifts_together("oncall", "weekend")
|
||||
|
||||
#w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
|
||||
w13.add_force_assign_with("twilight", "oncall")
|
||||
w13.add_force_assign_with("weekend", "oncall")
|
||||
# w.add_force_assign_with("twilight", "oncall")
|
||||
# w.add_force_assign_with("weekend", "oncall")
|
||||
# w.set_max_shifts_per_week("twilight", 1)
|
||||
|
||||
Rota.add_worker(w13)
|
||||
|
||||
w13 = Worker(name=f"A14", site="rota a", grade=1, start_date="2025-12-01")
|
||||
#w.prefer_multi_shift_together = 10
|
||||
#w.allow_shifts_together("oncall", "twilight")
|
||||
#w.allow_shifts_together("oncall", "weekend")
|
||||
|
||||
# if n <= 2:
|
||||
# pass
|
||||
# w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
# else:
|
||||
# w.add_hard_day_dependency("Fri", "Sun")
|
||||
# #w.set_max_shifts_per_week("weekend", 1)
|
||||
# # #w.add_hard_day_dependency("Sat", "Sun")
|
||||
|
||||
w13.add_force_assign_with("twilight", "oncall")
|
||||
w13.add_force_assign_with("weekend", "oncall")
|
||||
#w.set_max_shifts_per_week("twilight", 1)
|
||||
# print(w)
|
||||
|
||||
Rota.add_worker(w13)
|
||||
# Rota.add_worker(w)
|
||||
|
||||
for w in range(14, 24):
|
||||
w = Worker(
|
||||
name=f"B{w}",
|
||||
site="rota b",
|
||||
grade=1,
|
||||
nwds=[
|
||||
NonWorkingDays(
|
||||
day="Fri",
|
||||
)
|
||||
],
|
||||
)
|
||||
#w.add_hard_day_exclusion("Sat", "Sun")
|
||||
w.set_max_shifts_per_week("twilight", 2)
|
||||
w.set_max_shifts_per_week("weekend b", 1)
|
||||
#w13 = Worker(name=f"A13", site="rota a", grade=1, start_date="2025-10-01")
|
||||
##w.prefer_multi_shift_together = 10
|
||||
##w.allow_shifts_together("oncall", "twilight")
|
||||
##w.allow_shifts_together("oncall", "weekend")
|
||||
|
||||
print(w)
|
||||
##w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
|
||||
Rota.add_worker(w)
|
||||
#w13.add_force_assign_with("twilight", "oncall")
|
||||
#w13.add_force_assign_with("weekend", "oncall")
|
||||
##w.set_max_shifts_per_week("twilight", 1)
|
||||
|
||||
#Rota.add_worker(w13)
|
||||
|
||||
#w13 = Worker(name=f"A14", site="rota a", grade=1, start_date="2025-12-01")
|
||||
##w.prefer_multi_shift_together = 10
|
||||
##w.allow_shifts_together("oncall", "twilight")
|
||||
##w.allow_shifts_together("oncall", "weekend")
|
||||
|
||||
##w.add_hard_day_dependency("Fri", "Sat", "Sun")
|
||||
|
||||
#w13.add_force_assign_with("twilight", "oncall")
|
||||
#w13.add_force_assign_with("weekend", "oncall")
|
||||
##w.set_max_shifts_per_week("twilight", 1)
|
||||
|
||||
#Rota.add_worker(w13)
|
||||
|
||||
#for w in range(14, 24):
|
||||
# w = Worker(
|
||||
# name=f"B{w}",
|
||||
# site="rota b",
|
||||
# grade=1,
|
||||
# nwds=[
|
||||
# NonWorkingDays(
|
||||
# day="Fri",
|
||||
# )
|
||||
# ],
|
||||
# )
|
||||
# #w.add_hard_day_exclusion("Sat", "Sun")
|
||||
# w.set_max_shifts_per_week("twilight", 2)
|
||||
# w.set_max_shifts_per_week("weekend b", 1)
|
||||
|
||||
# print(w)
|
||||
|
||||
# Rota.add_worker(w)
|
||||
|
||||
# Rota.build_workers()
|
||||
# Rota.build_model()
|
||||
|
||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
||||
seconds_to_run = parse_time_limit(time_to_run)
|
||||
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
|
||||
|
||||
# start_time = time.time()
|
||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
|
||||
Rota.build_and_solve(solver_options, export=True, export_with_timestamp=False, solve=solve, solver="appsi_highs")
|
||||
|
||||
# Rota.solve_shifts_by_block(solver_options, block_length=13)
|
||||
# Rota.solve_shifts_individually(solver_options)
|
||||
@@ -225,6 +754,9 @@ def main(
|
||||
subprocess.run(
|
||||
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||
)
|
||||
subprocess.run(
|
||||
["scp", "output/timetable.js", "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||
)
|
||||
|
||||
if suspend_on_finish:
|
||||
os.system("systemctl suspend")
|
||||
|
||||
+230
-107
@@ -2,7 +2,20 @@ 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,
|
||||
PreShiftConstraint,
|
||||
PostShiftConstraint,
|
||||
NightConstraint,
|
||||
RequireRemoteSitePresenceConstraint,
|
||||
LimitGradeNumberConstraint,
|
||||
MinimumGradeNumberConstraint,
|
||||
MaxShiftsPerWeekConstraint,
|
||||
)
|
||||
import typer
|
||||
import subprocess
|
||||
|
||||
@@ -17,9 +30,14 @@ sites = (
|
||||
"plymouth",
|
||||
"taunton",
|
||||
"proc",
|
||||
"plymouth ir",
|
||||
"truro ir",
|
||||
"exeter ir",
|
||||
"torbay ir",
|
||||
"ir nights",
|
||||
)
|
||||
|
||||
from rota.workers import (
|
||||
from rota_generator.workers import (
|
||||
Worker,
|
||||
NotAvailableToWork,
|
||||
NonWorkingDays,
|
||||
@@ -28,16 +46,38 @@ from rota.workers import (
|
||||
OutOfProgramme,
|
||||
)
|
||||
|
||||
def parse_time_limit(t) -> int:
|
||||
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
|
||||
if isinstance(t, int):
|
||||
return t
|
||||
if isinstance(t, float):
|
||||
return int(t)
|
||||
t = str(t).strip().lower()
|
||||
if not t:
|
||||
return 0
|
||||
if t.isdigit():
|
||||
return int(t)
|
||||
if t.endswith("h"):
|
||||
return int(float(t[:-1]) * 3600)
|
||||
if t.endswith("m"):
|
||||
return int(float(t[:-1]) * 60)
|
||||
if t.endswith("s"):
|
||||
return int(float(t[:-1]))
|
||||
try:
|
||||
return int(float(t))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
suspend: bool = False,
|
||||
solve: bool = True,
|
||||
time_to_run: int = 60 * 60 * 4,
|
||||
ratio: float = 0.001,
|
||||
start_date: datetime.datetime = "2025-09-01",
|
||||
weeks: int = 16,
|
||||
bom: int = 2,
|
||||
time_to_run: str = "10h",
|
||||
ratio: float = 0.1,
|
||||
start_date: datetime.datetime = "2026-09-07",
|
||||
weeks: int = 26,
|
||||
bom: float = 1,
|
||||
):
|
||||
rota_start_date = start_date.date()
|
||||
suspend_on_finish = suspend
|
||||
@@ -46,17 +86,18 @@ def main(
|
||||
rota_start_date,
|
||||
weeks_to_rota=weeks,
|
||||
balance_offset_modifier=bom,
|
||||
name="proc_rota",
|
||||
name="proc_rota_sep_2026",
|
||||
)
|
||||
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
||||
Rota.constraint_options["balance_shifts"] = False
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
Rota.constraint_options["balance_weekends"] = True
|
||||
Rota.constraint_options["max_night_frequency"] = 3 # 3
|
||||
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
||||
Rota.constraint_options["max_weekend_frequency"] = 2
|
||||
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
||||
# Rota.constraint_options["avoid_st2_first_month"] = True
|
||||
#Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
||||
Rota.constraint_options["max_weekend_frequency"] = 3
|
||||
|
||||
Rota.constraint_options["max_days_per_week_block"] = [(8,3)]
|
||||
Rota.constraint_options["maximum_allowed_shift_diff"] = 1
|
||||
|
||||
# wr = [
|
||||
# WorkerRequirement(
|
||||
@@ -76,17 +117,13 @@ def main(
|
||||
"exeter twilights and weekends",
|
||||
"exeter no nights",
|
||||
"exeter twilights",
|
||||
"exeter ir",
|
||||
),
|
||||
name="exeter_twilight",
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=4,
|
||||
constraint=[
|
||||
{
|
||||
"name": "max_shifts_per_week",
|
||||
"options": 2,
|
||||
}
|
||||
],
|
||||
balance_offset=2,
|
||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -96,18 +133,25 @@ def main(
|
||||
"truro twilights and weekends",
|
||||
"truro twilights and weekend nights",
|
||||
"truro twilights, weekends and weekend nights",
|
||||
"truro ir",
|
||||
),
|
||||
name="truro_twilight",
|
||||
length=12.5,
|
||||
days=days[:4],
|
||||
balance_offset=4,
|
||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("torbay", "torbay twilights", "torbay twilights and weekends"),
|
||||
sites=(
|
||||
"torbay",
|
||||
"torbay twilights",
|
||||
"torbay twilights and weekends",
|
||||
"torbay ir",
|
||||
),
|
||||
name="torbay_twilight",
|
||||
length=12.5,
|
||||
days=days[:4],
|
||||
balance_offset=4,
|
||||
balance_offset=2,
|
||||
assign_as_block=True,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -116,13 +160,14 @@ def main(
|
||||
"plymouth twilights",
|
||||
"plymouth twilights and weekends",
|
||||
"plymouth twilights, weekends and weekend nights",
|
||||
"plymouth ir",
|
||||
),
|
||||
name="plymouth_twilight",
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=4,
|
||||
balance_offset=2,
|
||||
workers_required=1,
|
||||
constraint=[{"name": "max_shifts_per_week", "options": 2}],
|
||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -130,14 +175,15 @@ def main(
|
||||
"exeter twilights and weekends",
|
||||
"exeter no nights",
|
||||
"exeter weekends",
|
||||
# "exeter twilights",
|
||||
),
|
||||
name="weekend_exeter",
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
balance_offset=3,
|
||||
# balance_offset=2,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 2}],
|
||||
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
|
||||
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
|
||||
),
|
||||
SingleShift(
|
||||
@@ -151,22 +197,22 @@ def main(
|
||||
name="weekend_truro",
|
||||
length=12.5,
|
||||
days=days[4:],
|
||||
balance_offset=3,
|
||||
# balance_offset=3,
|
||||
#rota_on_nwds=True,
|
||||
#force_as_block=True,
|
||||
assign_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
# force_as_block_unless_nwd=True
|
||||
# assign_as_block=True,
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("torbay", "torbay twilights and weekends"),
|
||||
name="weekend_torbay",
|
||||
length=12.5,
|
||||
days=days[4:],
|
||||
balance_offset=3,
|
||||
# balance_offset=2,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
# force_as_block_unless_nwd=True
|
||||
),
|
||||
SingleShift(
|
||||
@@ -179,11 +225,11 @@ def main(
|
||||
name="weekend_plymouth1",
|
||||
length=8,
|
||||
days=days[5:],
|
||||
balance_offset=2.9,
|
||||
# balance_offset=2.9,
|
||||
workers_required=1,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -194,11 +240,11 @@ def main(
|
||||
name="weekend_plymouth2",
|
||||
length=8,
|
||||
days=days[5:],
|
||||
balance_offset=2.9,
|
||||
# balance_offset=2.9,
|
||||
workers_required=1,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -206,6 +252,7 @@ def main(
|
||||
"plymouth twilights",
|
||||
"plymouth twilights and weekends",
|
||||
"plymouth twilights, weekends and weekend nights",
|
||||
"plymouth ir",
|
||||
),
|
||||
name="plymouth_bank_holidays",
|
||||
length=8,
|
||||
@@ -217,26 +264,25 @@ def main(
|
||||
bank_holidays_only=True,
|
||||
),
|
||||
SingleShift(
|
||||
sites=sites,
|
||||
sites=[
|
||||
*sites,
|
||||
],
|
||||
name="night_weekday",
|
||||
length=12.25,
|
||||
days=days[:4],
|
||||
balance_offset=3.9,
|
||||
# balance_offset=3.9,
|
||||
balance_weighting=1,
|
||||
# hard_constrain_shift=False,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
rota_on_nwds=True,
|
||||
constraint=[
|
||||
{"name": "night"},
|
||||
{"name": "pre", "options": 2},
|
||||
{"name": "post", "options": 2},
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("plymouth", 1),
|
||||
},
|
||||
{"name": "limit_grade_number", "options": {2: 1}},
|
||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
||||
constraints=[
|
||||
NightConstraint(),
|
||||
PreShiftConstraint(days=2),
|
||||
PostShiftConstraint(days=2),
|
||||
RequireRemoteSitePresenceConstraint(site="plymouth", required_number=1),
|
||||
LimitGradeNumberConstraint(grade=2, max_number=2),
|
||||
MinimumGradeNumberConstraint(grade=4, min_number=1),
|
||||
],
|
||||
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||
),
|
||||
@@ -251,32 +297,36 @@ def main(
|
||||
name="night_weekend",
|
||||
length=12.25,
|
||||
days=days[4:],
|
||||
balance_offset=2.9,
|
||||
# balance_offset=2.9,
|
||||
balance_weighting=1,
|
||||
# hard_constrain_shift=False,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
rota_on_nwds=True,
|
||||
constraint=[
|
||||
{"name": "night"},
|
||||
{"name": "pre", "options": 2},
|
||||
{"name": "post", "options": 3},
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("plymouth", 1),
|
||||
},
|
||||
{"name": "limit_grade_number", "options": {2: 2}},
|
||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
||||
constraints=[
|
||||
NightConstraint(),
|
||||
PreShiftConstraint(days=2),
|
||||
PostShiftConstraint(days=3),
|
||||
RequireRemoteSitePresenceConstraint(site="plymouth", required_number=1),
|
||||
LimitGradeNumberConstraint(grade=2, max_number=2),
|
||||
MinimumGradeNumberConstraint(grade=4, min_number=1),
|
||||
],
|
||||
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||
),
|
||||
)
|
||||
|
||||
# Prevent shifts for ST2s for 2 weeks
|
||||
Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
||||
# Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
||||
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
|
||||
["weekend_plymouth1", "weekend_plymouth2"], 6
|
||||
)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
|
||||
["plymouth_twilight", "plymouth_bank_holidays"], 6
|
||||
)
|
||||
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
|
||||
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
|
||||
@@ -287,8 +337,43 @@ def main(
|
||||
if load_leave:
|
||||
import leave
|
||||
|
||||
# Internal-only option: remove leave requests between two dates.
|
||||
# Configure by editing the two variables below (not exposed via CLI).
|
||||
# Use strings in 'YYYY-MM-DD' or datetime.date objects. Set either to None to disable.
|
||||
REMOVE_LEAVE_FROM = ""
|
||||
REMOVE_LEAVE_TO = ""
|
||||
|
||||
workers = leave.load_leave(Rota)
|
||||
|
||||
# If the internal remove range is set, filter out NotAvailableToWork entries
|
||||
if REMOVE_LEAVE_FROM and REMOVE_LEAVE_TO:
|
||||
# Coerce to date objects if strings provided
|
||||
def _to_date(v):
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
if isinstance(v, datetime.datetime):
|
||||
return v.date()
|
||||
return v
|
||||
|
||||
_from = _to_date(REMOVE_LEAVE_FROM)
|
||||
_to = _to_date(REMOVE_LEAVE_TO)
|
||||
|
||||
for name, w in list(workers.items()):
|
||||
original = w.get("leave", [])
|
||||
before_count = len(original)
|
||||
filtered = [lr for lr in original if not (_from <= lr.date <= _to)]
|
||||
after_count = len(filtered)
|
||||
if after_count != before_count:
|
||||
print(
|
||||
f"Removed {before_count - after_count} leave entries for '{name}' between {_from} and {_to}"
|
||||
)
|
||||
w["leave"] = filtered
|
||||
|
||||
n = 0
|
||||
for worker in workers:
|
||||
worker_name = worker
|
||||
@@ -301,13 +386,18 @@ def main(
|
||||
site = w["site"]
|
||||
except KeyError:
|
||||
print(f"Worker {worker} has no site")
|
||||
raise KeyError
|
||||
print(w)
|
||||
sys.exit(1)
|
||||
grade = w["grade"]
|
||||
try:
|
||||
fte = float(w["fte"]) * 100
|
||||
except ValueError:
|
||||
print(f"{worker} has invalid fte: {fte}")
|
||||
raise ValueError
|
||||
except KeyError:
|
||||
print(f"Worker {worker} has no FTE")
|
||||
print(w)
|
||||
sys.exit(1)
|
||||
|
||||
nwd = w["nwd"]
|
||||
end_date = w["end_date"]
|
||||
@@ -328,9 +418,9 @@ def main(
|
||||
nwd_end_date = Rota.rota_end_date
|
||||
|
||||
if "[" in i:
|
||||
print("Split nwd", worker_name, i)
|
||||
# print("Split nwd", worker_name, i)
|
||||
a, b = i.split("[")[1][:-1].split("-")
|
||||
print(f"{a=} {b=}")
|
||||
# print(f"{a=} {b=}")
|
||||
nwd_start_date = datetime.datetime.strptime(
|
||||
a, "%d/%m/%Y"
|
||||
).date()
|
||||
@@ -349,47 +439,48 @@ def main(
|
||||
if oop:
|
||||
formatted_oops = []
|
||||
for dates in oop.split(","):
|
||||
print(dates)
|
||||
if "-" in dates:
|
||||
s, e = dates.split("-")
|
||||
elif "to" in dates:
|
||||
s, e = dates.split(" to ")
|
||||
raw = dates.strip()
|
||||
# strip surrounding brackets/parentheses/quotes
|
||||
raw = raw.strip("()[]\"' ")
|
||||
if "-" in raw:
|
||||
s, e = raw.split("-", 1)
|
||||
elif " to " in raw:
|
||||
s, e = raw.split(" to ", 1)
|
||||
elif "to" in raw:
|
||||
s, e = raw.split("to", 1)
|
||||
else:
|
||||
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
|
||||
try:
|
||||
formatted_oops.append(
|
||||
{
|
||||
"start_date": datetime.datetime.strptime(
|
||||
s.strip(), "%d/%m/%Y"
|
||||
).date(),
|
||||
"end_date": datetime.datetime.strptime(
|
||||
e.strip(), "%d/%m/%Y"
|
||||
).date(),
|
||||
}
|
||||
raise ValueError(
|
||||
f"Cannot parse OOP dates: '{oop}' for {worker}"
|
||||
)
|
||||
|
||||
s = s.strip().strip("()[]\"' ")
|
||||
e = e.strip().strip("()[]\"' ")
|
||||
|
||||
parsed = False
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
start_dt = datetime.datetime.strptime(s, fmt).date()
|
||||
end_dt = datetime.datetime.strptime(e, fmt).date()
|
||||
formatted_oops.append(
|
||||
{"start_date": start_dt, "end_date": end_dt}
|
||||
)
|
||||
parsed = True
|
||||
break
|
||||
except ValueError:
|
||||
try:
|
||||
formatted_oops.append(
|
||||
{
|
||||
"start_date": datetime.datetime.strptime(
|
||||
s.strip(), "%d/%m/%y"
|
||||
).date(),
|
||||
"end_date": datetime.datetime.strptime(
|
||||
e.strip(), "%d/%m/%y"
|
||||
).date(),
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
print(e)
|
||||
continue
|
||||
|
||||
if not parsed:
|
||||
print("WORKER", worker)
|
||||
print("DATES", s, e)
|
||||
raise
|
||||
raise ValueError(
|
||||
f"Cannot parse OOP dates: '{oop}' for {worker}"
|
||||
)
|
||||
|
||||
oop = formatted_oops
|
||||
else:
|
||||
oop = []
|
||||
|
||||
print(f"{worker} OOP {oop}")
|
||||
# print(f"{worker} OOP {oop}")
|
||||
|
||||
previous_shifts = {}
|
||||
# if twi:
|
||||
@@ -423,21 +514,50 @@ def main(
|
||||
|
||||
shift_fte_overrides = {}
|
||||
|
||||
#if worker_name == "Ben Kemp":
|
||||
exact_shifts = {}
|
||||
|
||||
if worker in ["hannah.lewis29@nhs.net", "shruti.bodapati@nhs.net", "a.abouelatta@nnhs.net"]:
|
||||
exact_shifts = {
|
||||
"night_weekday": 4,
|
||||
"night_weekend": 3,
|
||||
}
|
||||
elif worker in ["g.abdelhalim@nhs.net", "nang.thiriphoo@nhs.net"]:
|
||||
exact_shifts = {
|
||||
"night_weekend": 3,
|
||||
"night_weekday": 0,
|
||||
}
|
||||
|
||||
override_shift_start_dates = []
|
||||
if worker in ["Anushka Kulkarni"]:
|
||||
override_shift_start_dates = [
|
||||
{
|
||||
"shift": "night_weekday",
|
||||
"start_date": datetime.datetime.strptime(
|
||||
"2026-11-01", "%Y-%m-%d"
|
||||
).date(),
|
||||
},
|
||||
{
|
||||
"shift": "night_weekend",
|
||||
"start_date": datetime.datetime.strptime(
|
||||
"2026-11-01", "%Y-%m-%d"
|
||||
).date(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# if worker_name == "Hadi Mohamed":
|
||||
# shift_fte_overrides = {
|
||||
# "plymouth_twilight": 100,
|
||||
# "weekend_plymouth1": 50,
|
||||
# "weekend_plymouth2": 50,
|
||||
# "weekend_exeter": 50,
|
||||
# }
|
||||
# elif worker_name == "Joel Lim":
|
||||
# shift_fte_overrides = {
|
||||
# "plymouth_twilight": 100,
|
||||
# "weekend_exeter": 50,
|
||||
# }
|
||||
if worker_name == "Nang Thiriphoo":
|
||||
shift_fte_overrides = {
|
||||
"weekend_exeter": 40,
|
||||
}
|
||||
# if worker_name == "Nang Thiriphoo":
|
||||
# shift_fte_overrides = {
|
||||
# "weekend_exeter": 40,
|
||||
# }
|
||||
|
||||
w = Worker(
|
||||
name=worker_name,
|
||||
@@ -457,20 +577,23 @@ def main(
|
||||
shift_balance_extra=w["shift_balance_extra"],
|
||||
bank_holiday_extra=w["bank_holiday_extra"],
|
||||
shift_fte_overrides=shift_fte_overrides,
|
||||
exact_shifts=exact_shifts,
|
||||
shift_start_dates=override_shift_start_dates,
|
||||
)
|
||||
|
||||
print(w)
|
||||
# print(w)
|
||||
|
||||
Rota.add_worker(w)
|
||||
leave.load_academy(Rota)
|
||||
|
||||
# Rota.build_workers()
|
||||
# Rota.build_model()
|
||||
|
||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
||||
seconds_to_run = parse_time_limit(time_to_run)
|
||||
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
|
||||
|
||||
# start_time = time.time()
|
||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
|
||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs", export_with_timestamp=True)
|
||||
|
||||
# Rota.solve_shifts_by_block(solver_options, block_length=13)
|
||||
# Rota.solve_shifts_individually(solver_options)
|
||||
@@ -1,387 +0,0 @@
|
||||
Rota 2021/2022,,, (POSSIBLE IDT),,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,80% until dec 1st,,,,,,,,,,,,,,,,
|
||||
Name,,Stephanie Bailey,Luciana Calovi Motschenbacher,Layla Bell,Seren Peters,Alex wood,Salma Aslam,Max Ireland,Charles Finan,Paul Ward,Tom Welsh,Delilah Trimmer,Max Finzel,Zainab Sharaf,Maththew O'Brien,Kyaw Tint,Nicolas Dziadulewicz,Jean Skumar,Michael Tomek,Vilim Kalamar,Dhuvresh Patel,Khalil Madbak,Georgina Edwards,Jenn Haw fong,Paul Jenkins,Jenna Millington,Amoolya Mannava,Ross Kruger,Sophie McGlade,Rebecca Murphy,Fiona Lyall,Joel Lim,Wijhdan Abusrewil,Mark Haley,Andrew MacCormick,Alexander Sanchez - cabello,Sayed Hashim Alqarooni,Chong Yew Ng,Matthew Thorley,Harriet Conley,Jon Skinner,Cameron Bullock,Alexander Wijnberg,Christian Greer,Alan Eccles,Hannah Lewis,Sarath Vennam,Amina Odeh,Mo Babsail,Richard Chaytor,Sherafghan PROC,Madalina Drumea,Mark Fellows,Clement Leung
|
||||
Rotation,,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford no proc,Derriford,Derriford,Derriford,Derriford,Derriford Twilights,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay Twilights,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro no PROC,Barnstaple,Taunton,Taunton
|
||||
Grade,,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST2,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST3,ST4,ST4,ST4,ST4,ST4,ST5,ST5,ST5,ST5,ST6,ST2,ST2,ST2,ST2,ST3,ST4,ST4,ST4,ST5,ST6,ST2,ST2,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST5 (until dec),ST3,ST2,ST4
|
||||
PROC site,,Derriford,,exeter,Torbay,Exeter,,Derriford,Derriford,Exeter,Exeter,Derriford,Derriford,torbay,Torbay,derriford,,derriford,exeter,,Exeter,,,derriford,Derriford,,derriford,Derriford,Derriford,Exeter,NA,derriford,Torbay,Derriford,Derriford,,torbay,Derriford,Derriford,Torbay,NA,Exeter,Derriford,Truro,Truro,Derriford,Truro,Truro,Derriford,derriford,Derriford,Barnstaple,NA,NA
|
||||
%FTE,,0,0,100,100,100,0,100,100,100,100,80,100,100,100,100,100,60,80,100,100,0,80,100,100,0,100,100,0,100,60,100,100,100,100,100,100,100,100,60,100,100,100,100,80,100,100,100,100,100,100,80,0,0
|
||||
NWD,,,,,,,"Thursday,Friday",,"Tuesday,Thursday",,,wednesday,,,,,,"Wednesday,thursday",Wednesday,,,,Friday,,,"thursday, friday",,,Friday,,"monday,friday",,"thursday, friday",,,,,,,"monday,thursday",,,,,"wednesday, thursday,Friday",,,,,,,,,
|
||||
Flexible NWD,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
End Date,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
OOP,,,,,,,6/9/21-31/3/22,,,,,,,,,,,,,,,,,,,6/9/21-2/5/22,,,1/1/22-5/9/22,6/9/21-28/2/22,,,1/3/22-31/3/22,,,,,,,6/9/21-20/9/21,,,,,,,,,1/8/22-4/9/22,23/7/22-4/9/22,,,,
|
||||
Group,,Y1G1,Y1G1,Y1G2,Y3G1,Y3G1,Y3G2,Y3G2,,,,,Y2G1,Y2G2,Y2G2,Y2G2,Y3G1,Y3G1,Y3G2,Y3G2,Y3G2,,,,,,,,,,,Y2G1,Y2G1,Y2G2,Y2G2,Y3G2,,,,,,Y2G1,Y2G2,Y3G1,Y3G1,Y3G2,,,,,,,Y2G1,
|
||||
?PROC,,,,,,,,,,,,,,,,,,,,,,NO,,,,WEEKEND ONLY,,,,,No,,,,,,,,,,No,,,,,,,,,,Yes - until ST6 in dec,???,NO,NO
|
||||
Pair,,,,,,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,,,,1,,,,,,,,,,,,,,2,,,
|
||||
Extra_night_weekday,,3.72,,-0.28,-0.28,-0.28,,-0.28,-0.28,-0.28,-0.28,0.63,-0.28,-0.28,-0.28,-0.28,-0.28,4.63,2.17,-0.28,3.72,,2.17,-0.28,-0.28,,-0.28,-0.28,,0.3,0,-0.28,-0.28,-0.28,3.72,-0.28,-0.28,-1.06,-0.28,0.27,,-0.28,-0.28,-0.28,-1.83,-0.28,-0.28,-0.28,-0.28,-0.28,0,-0.28,,
|
||||
Extra_night_weekend,,-0.21,,-0.21,-0.21,-0.21,,-0.21,-0.21,-0.21,-0.21,0.47,-0.21,2.79,2.79,2.79,-0.21,-2.53,-1.37,-0.21,-0.21,,-1.37,-0.21,-0.21,,-0.21,-0.21,,0.22,0,-0.21,-0.21,-0.21,-0.21,-0.21,-0.21,2.21,-0.21,0.21,,-0.21,-0.21,-0.21,1.63,-0.21,-0.21,-0.21,-0.21,-0.21,0,2.79,,
|
||||
Extra_twilight,,-1.97,,-0.97,1.03,1.03,,-0.97,-1.97,1.03,1.03,-0.38,0.47,0.47,-1.53,0.47,-0.53,-3.92,-2.23,0.47,-2.53,,-2.23,0.47,0.47,,0.47,0.47,,0.33,0.08,,,,,,,,,,,-1.39,-1.39,0.61,0.49,-1.39,-0.39,1.61,2.61,-1.39,0.61,0,,
|
||||
Extra_weekend,,-0.73,,2.27,-0.73,-0.73,,2.27,2.27,-0.73,-0.73,-1.04,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1.96,-1.04,-1.04,-0.63,1.96,0.96,-1.04,-2.04,1.96,-1.04,0,,
|
||||
Extra_weekend_plymouth1,,,,,,,,,,,,,-2.22,-0.22,-2.22,-0.22,-0.22,2.27,-0.98,-2.22,-0.22,,1.02,1.78,-0.22,,1.78,1.78,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
|
||||
Extra_weekend_plymouth2,,,,,,,,,,,,,1.78,-2.22,1.78,-2.22,-0.22,0.27,3.02,1.78,-0.22,,1.02,-2.22,-0.22,,-2.22,-0.22,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
|
||||
Extra_plymouth_bank_holiday,,,,,,,,,,,,,0.2,0.2,0.2,0.2,0.2,0.12,-0.84,0.2,0.2,,0.16,0.2,0.2,,0.2,-1.8,,0.01,0.12,,,,,,,,,,,,,,,,,,,,,,,
|
||||
bank_holidays_worked,,,,1,,,,1,,2,1,3,,2,1,2,,2,1,,3,,,,,,2,2,,,,2,,2,,1,,1,2,,,,2,2,,,,2,,,1,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
Date,Exams,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
6/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
|
||||
7/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
|
||||
8/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
|
||||
9/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
|
||||
10/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
||||
11/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
||||
12/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
||||
13/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
14/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
15/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
16/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
17/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,A/L,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
18/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
19/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
20/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
21/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
22/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
23/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
24/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
25/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
26/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
27/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
28/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
29/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
30/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
1/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
2/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
3/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
4/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
5/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
6/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
7/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
8/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,A/L,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
9/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
10/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
11/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
12/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
13/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
14/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
15/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
16/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
17/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
18/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
19/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
20/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
21/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
22/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,No nights,,,,,,A/L,,,,,,,,
|
||||
23/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
|
||||
24/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
|
||||
25/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
26/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
27/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
28/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
29/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
30/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
31/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
1/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
2/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
3/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
4/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
5/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
6/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
7/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
8/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
9/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
10/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
11/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
12/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
13/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
14/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
15/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
16/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
17/11/21,2A,,NO ONCALL,,2A,2A,Mat leave,2A,,2A,,,,,,,2A,2A,2A,2A,2A,No Oncall,2A,2A,,Mat leave,,,,OOPT,,A/L,2A,,,2A,,2A,,,,,,2A,,2A,2A,,,,,2A,,2A
|
||||
18/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
19/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
20/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
21/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
22/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
23/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
24/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
25/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
26/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
27/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
28/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
29/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
30/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
1/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
2/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
3/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
4/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
5/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
6/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
7/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
8/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
9/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
10/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
11/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
|
||||
12/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
|
||||
13/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
14/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
15/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
16/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
17/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
18/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
19/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
20/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
21/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
22/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
23/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
24/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
25/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
26/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
27/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
28/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
29/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
30/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
31/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
1/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
2/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
3/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
4/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
5/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
6/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
7/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
8/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
|
||||
9/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
|
||||
10/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
11/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
12/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
13/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
14/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
15/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
16/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
17/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
18/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
19/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
20/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
21/1/22,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
||||
22/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
||||
23/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
||||
24/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
25/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
26/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
27/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
28/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
29/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
||||
30/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
||||
31/1/22,,,NO ONCALL,,,,Mat leave,,,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
||||
1/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
2/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
3/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
4/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
5/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
|
||||
6/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
|
||||
7/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
8/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
9/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
10/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
11/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
12/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
13/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
14/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
15/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
16/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
17/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
18/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,A/L,,A/L,,A/L,,,,,,,,
|
||||
19/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
|
||||
20/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
|
||||
21/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
22/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
23/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
24/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
25/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
||||
26/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
||||
27/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
||||
28/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
1/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,,,,,,,,,,,,,,,A/L,A/L,,,,,
|
||||
2/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
|
||||
3/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,A/L,,,,,,,,A/L,A/L,,,,,
|
||||
4/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
|
||||
5/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
|
||||
6/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
|
||||
7/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
|
||||
8/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
|
||||
9/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,POST ONCALL,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
||||
10/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
||||
11/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
||||
12/3/22,,OFF ONCALL,IDT,POST ONCALL,,,OOPT,,,,OOPT,A/L,,,,,,POST ONCALL,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,POST ONCALL,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
|
||||
13/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
|
||||
14/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
15/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
16/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,POST ONCALL,A/L,,A/L,,pre 2b,A/L,,,,,
|
||||
17/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,A/L,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
18/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
19/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
|
||||
20/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
|
||||
21/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,A/L,,,,pre 2b,,A/L,,,,
|
||||
22/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
||||
23/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
||||
24/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
||||
25/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
26/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
27/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
28/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,,,,,
|
||||
29/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
30/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
31/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
1/4/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,2b,,,Ramadan,A/L,,
|
||||
2/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
|
||||
3/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
|
||||
4/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
5/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
6/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
7/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,S/L,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
8/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
9/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
10/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
11/4/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
|
||||
12/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,A/L,A/L,,,A/L,,,,,,A/L,,,,,Ramadan,,,
|
||||
13/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,A/L,,,,,,,,,,,Ramadan,,,
|
||||
14/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
|
||||
15/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights or twiligts,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
16/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
17/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
18/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,A/L,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
19/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
||||
20/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
21/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
22/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
23/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
||||
24/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
||||
25/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
26/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
27/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,S/L,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
28/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,S/L,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
||||
29/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
||||
30/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
||||
1/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
||||
2/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
||||
3/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
4/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
5/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
6/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
||||
7/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
|
||||
8/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
|
||||
9/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
10/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
11/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
12/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
13/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
14/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
15/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
16/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
17/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
18/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
19/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
20/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
21/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
22/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
23/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
24/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
25/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
26/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
27/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
28/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
|
||||
29/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
|
||||
30/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
31/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
1/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
2/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
3/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
4/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
5/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
6/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
|
||||
7/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
|
||||
8/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
9/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
10/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
11/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
12/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
13/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
14/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
15/6/22,2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,2a,2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,2a,,,,,,2a,,2a
|
||||
16/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
17/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
18/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
19/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
20/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
|
||||
21/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
|
||||
22/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
23/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
24/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
25/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
26/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
27/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
28/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
29/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
30/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
1/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
2/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
3/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
4/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
5/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
6/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
7/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
8/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
9/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
10/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
11/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
12/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
13/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
14/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
15/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
||||
16/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
||||
17/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
||||
18/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
19/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
20/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
21/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
22/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
23/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
24/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
25/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
26/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
27/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
28/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
29/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,,A/L,,,A/L,,OOPT,,,,
|
||||
30/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
|
||||
31/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
|
||||
1/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
2/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
3/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
4/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
5/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
||||
6/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
||||
7/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
||||
8/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
|
||||
9/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
|
||||
10/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
11/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
12/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
13/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
14/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
15/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
16/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
17/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
18/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
19/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
20/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
21/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
22/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
23/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
24/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
25/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
26/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
27/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
28/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
29/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
30/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
31/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
1/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
2/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
3/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,,OOPE,OOPT,,,,
|
||||
4/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,OFF,,,,,,,,,,,No Oncall,,A/L,,,OFF,OFF,OOPT,OFF,,,,,,,A/L,,,,OFF,,,,,,,,OOPE,OOPT,OFF,,,
|
||||
|
@@ -1,41 +1,49 @@
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
import sys
|
||||
from requests import Session
|
||||
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}"
|
||||
|
||||
|
||||
live_rota = True
|
||||
|
||||
def load_leave(Rota):
|
||||
|
||||
def load_leave(Rota):
|
||||
with Session() as s:
|
||||
shifts = Rota.get_shift_names()
|
||||
|
||||
workers = {}
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache"
|
||||
}
|
||||
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||
|
||||
if live_rota:
|
||||
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSfcQi5Qs__A8hE2CVvUA_6ULNJALrtZOBHPUZ3xyrxPif9obtQF2IqhioT_4nebjHV1Ac5iqhNtuq4/pub?gid=2024511103&single=true&output=csv", headers=headers)
|
||||
download = s.get(
|
||||
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv",
|
||||
headers=headers,
|
||||
)
|
||||
# download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
|
||||
decoded_content = download.content.decode('utf-8')
|
||||
decoded_content = download.content.decode("utf-8")
|
||||
|
||||
reader = csv.reader(decoded_content.splitlines(), delimiter=',')
|
||||
reader = csv.reader(decoded_content.splitlines(), delimiter=",")
|
||||
|
||||
else:
|
||||
with open("/home/ross/Downloads/PROC Draft RK version.csv") as f:
|
||||
reader = csv.reader(f.read().splitlines(), delimiter=',')
|
||||
reader = csv.reader(f.read().splitlines(), delimiter=",")
|
||||
|
||||
n = 0
|
||||
leave = {}
|
||||
@@ -47,10 +55,12 @@ def load_leave(Rota):
|
||||
n = 1
|
||||
for row in reader:
|
||||
if n < 10:
|
||||
print(row)
|
||||
row_title = row[1]
|
||||
print(row_title)
|
||||
pass
|
||||
#print(row)
|
||||
row_title = row[0]
|
||||
#print(row_title)
|
||||
row_date = row[0]
|
||||
row_extra = row[1]
|
||||
r = row[2:]
|
||||
# header, extract names
|
||||
if n == 1:
|
||||
@@ -80,44 +90,81 @@ def load_leave(Rota):
|
||||
|
||||
worker = workers[a]
|
||||
|
||||
lower_item = r[i].lower()
|
||||
lower_item = r[i].lower().strip()
|
||||
if lower_item == "derriford":
|
||||
lower_item = "plymouth"
|
||||
|
||||
if lower_item == "derriford twilights":
|
||||
lower_item = "plymouth_twilights"
|
||||
|
||||
if "PROC site" in row_title or row_title == "PROC nights from" or "proc nights" in row_title.lower():
|
||||
if (
|
||||
"PROC site" in row_title
|
||||
or row_title == "PROC nights from"
|
||||
or "proc nights" in row_title.lower()
|
||||
):
|
||||
worker["site_pref"] = lower_item
|
||||
|
||||
elif row_title in ("Rotation", "Site", "Placement", "Placement location"):
|
||||
elif row_title in (
|
||||
"Rotation",
|
||||
"Site",
|
||||
"Placement",
|
||||
"Placement location",
|
||||
"Placement location",
|
||||
):
|
||||
worker["site"] = lower_item
|
||||
|
||||
elif row_title in ("Grade", "Grade (ST)") or "Year of training" in row_title:
|
||||
elif (
|
||||
row_title in ("Grade", "Grade (ST)")
|
||||
or "Year of training" in row_title
|
||||
):
|
||||
worker["grade"] = lower_item.lstrip("ST")
|
||||
|
||||
elif row_title in ("%FTE", "%FTE on-call", "FTE", "FTE on-call") or "on-call commitment" in row_title:
|
||||
elif (
|
||||
row_title
|
||||
in (
|
||||
"%FTE",
|
||||
"%FTE on-call",
|
||||
"FTE",
|
||||
"FTE on-call",
|
||||
"Full-time equivalent (FTE)",
|
||||
)
|
||||
or "on-call commitment" in row_title
|
||||
):
|
||||
if lower_item:
|
||||
if lower_item.endswith("%"):
|
||||
lower_item = float(lower_item.rstrip("%")) / 100
|
||||
worker["fte"] = lower_item
|
||||
|
||||
elif "NWD" in row_title:
|
||||
elif (
|
||||
"NWD" in row_title or row_title == "If <100% FTE, specify days off:"
|
||||
):
|
||||
worker["nwd"] = lower_item
|
||||
|
||||
elif row_title == "Flexible NWD":
|
||||
worker["flexible_nwd"] = lower_item
|
||||
|
||||
elif row_title in ("End Date", "CCT date"):
|
||||
elif row_title in ("End Date", "CCT date", "CCT Date"):
|
||||
if lower_item:
|
||||
try:
|
||||
date = datetime.strptime(lower_item, "%d/%m/%y").date()
|
||||
except ValueError:
|
||||
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
||||
try:
|
||||
date = datetime.strptime(lower_item, "%d-%m-%y").date()
|
||||
except ValueError:
|
||||
try:
|
||||
date = datetime.strptime(
|
||||
lower_item, "%d/%m/%Y"
|
||||
).date()
|
||||
except ValueError:
|
||||
print(
|
||||
f"Cannot parse date: '{lower_item}' (n: {n}, row: {row})"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
worker["end_date"] = date
|
||||
else:
|
||||
worker["end_date"] = None
|
||||
|
||||
|
||||
elif row_title in ("Start date",):
|
||||
if lower_item:
|
||||
try:
|
||||
@@ -125,13 +172,22 @@ def load_leave(Rota):
|
||||
except ValueError:
|
||||
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
||||
except ValueError:
|
||||
logger.warning(f"Cannot parse date: {lower_item} (n: {n}, row: {row})")
|
||||
logger.warning(
|
||||
f"Cannot parse date: {lower_item} (n: {n}, row: {row})"
|
||||
)
|
||||
raise ValueError(f"Cannot parse date: {lower_item}")
|
||||
worker["start_date"] = date
|
||||
else:
|
||||
worker["start_date"] = None
|
||||
|
||||
elif "OOP" in row_title or "out of programme" in row_title.lower():
|
||||
if lower_item in (
|
||||
"OOPT 1 START",
|
||||
"OOPT 1 END",
|
||||
"OOPT 2 START",
|
||||
"OOPT 2 END",
|
||||
):
|
||||
continue
|
||||
worker["oop"] = lower_item
|
||||
|
||||
elif row_title == "Group":
|
||||
@@ -156,8 +212,30 @@ def load_leave(Rota):
|
||||
if lower_item:
|
||||
worker["bank_holiday_extra"] = int(lower_item)
|
||||
|
||||
elif re.match(date_re, row_date) is not None:
|
||||
elif row_title == "Weekends/Twilights":
|
||||
if lower_item:
|
||||
worker["site"] = lower_item
|
||||
|
||||
|
||||
#elif row_title == "Academy":
|
||||
# pass
|
||||
#elif "night specific" in row_title:
|
||||
# if lower_item:
|
||||
# for shift in lower_item.split(","):
|
||||
# shift = shift.strip()
|
||||
# if shift == "nights":
|
||||
# shift = "night_weekday"
|
||||
# elif shift == "nights only":
|
||||
# if date.weekday() < 5:
|
||||
# shift = "night_weekday"
|
||||
# else:
|
||||
# shift = "night_weekend"
|
||||
# worker["single_nights"].append(
|
||||
# WorkRequests(date=date, shift=shift)
|
||||
# )
|
||||
|
||||
|
||||
elif re.match(date_re, row_date) is not None:
|
||||
if lower_item != "":
|
||||
try:
|
||||
try:
|
||||
@@ -167,30 +245,150 @@ def load_leave(Rota):
|
||||
|
||||
# This may be easier to do as a dict
|
||||
if lower_item in shifts:
|
||||
worker["requests"].append(WorkRequests(date=date, shift=lower_item))
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift=lower_item)
|
||||
)
|
||||
elif lower_item in ("nights only"):
|
||||
if date.weekday() < 5:
|
||||
worker["requests"].append(WorkRequests(date=date, shift="night_weekday"))
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift="night_weekday")
|
||||
)
|
||||
else:
|
||||
worker["requests"].append(WorkRequests(date=date, shift="night_weekend"))
|
||||
|
||||
elif lower_item in ("work_request", "happy to work", "offered", "may be", "volunteered"):
|
||||
worker["requests"].append(WorkRequests(date=date, shift="*"))
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift="night_weekend")
|
||||
)
|
||||
|
||||
elif lower_item in (
|
||||
"work_request",
|
||||
"happy to work",
|
||||
"offered",
|
||||
"may be",
|
||||
"volunteered",
|
||||
):
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift="*")
|
||||
)
|
||||
|
||||
else:
|
||||
worker["leave"].append(NotAvailableToWork(date=date, reason=lower_item))
|
||||
worker["leave"].append(
|
||||
NotAvailableToWork(date=date, reason=lower_item)
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f"Error with: {lower_item}")
|
||||
print(f"{row=}")
|
||||
raise e
|
||||
|
||||
|
||||
n = n + 1
|
||||
|
||||
|
||||
|
||||
return workers
|
||||
|
||||
|
||||
# load_leave()
|
||||
def load_academy(Rota):
|
||||
"""Load academy month blocks from the main published CSV and register avoids.
|
||||
|
||||
Spreadsheet layout expected:
|
||||
- Column A: row title (should be 'Academy' for relevant rows)
|
||||
- Column B: start date for the month (e.g. '07/09/2026')
|
||||
- Columns C...: worker columns matching the header row (first CSV row's columns C...)
|
||||
Cells containing the word 'academy' (case-insensitive) mark that worker as being in academy
|
||||
for that month.
|
||||
"""
|
||||
# Use the same published CSV as load_leave (main sheet)
|
||||
url = (
|
||||
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv"
|
||||
)
|
||||
|
||||
with Session() as s:
|
||||
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||
download = s.get(url, headers=headers)
|
||||
decoded = download.content.decode("utf-8")
|
||||
|
||||
reader = csv.reader(decoded.splitlines(), delimiter=",")
|
||||
|
||||
names = []
|
||||
month_starts: list[datetime.date] = []
|
||||
month_cells: list[list[str]] = []
|
||||
|
||||
n = 0
|
||||
for row in reader:
|
||||
n += 1
|
||||
if not row:
|
||||
continue
|
||||
|
||||
# header row: collect worker names from column C onwards
|
||||
if n == 1:
|
||||
# some files may have two leading columns (like 'Email Address' and blank/extra), so take from index 2
|
||||
names = [c.strip() for c in (row[2:] if len(row) > 2 else row[1:])]
|
||||
continue
|
||||
|
||||
row_title = row[0].strip() if len(row) > 0 else ""
|
||||
start_cell = row[1].strip() if len(row) > 1 else ""
|
||||
cells = row[2:] if len(row) > 2 else []
|
||||
|
||||
# Only treat rows where the first column mentions 'Academy' (some sheets may include other rows)
|
||||
if row_title and "academy" in row_title.lower():
|
||||
# parse start date
|
||||
try:
|
||||
try:
|
||||
start_date = datetime.strptime(start_cell, "%d/%m/%Y").date()
|
||||
except ValueError:
|
||||
start_date = datetime.strptime(start_cell, "%d/%m/%y").date()
|
||||
except Exception:
|
||||
# skip rows without a valid date
|
||||
continue
|
||||
|
||||
month_starts.append(start_date)
|
||||
# normalize cell list to names length
|
||||
row_cells = [(cells[i] if i < len(cells) else "").strip() for i in range(len(names))]
|
||||
month_cells.append(row_cells)
|
||||
|
||||
if not month_starts:
|
||||
return
|
||||
|
||||
# determine twilight shifts available
|
||||
twilight_shift_names = [s.name for s in Rota.get_shifts() if "twilight" in s.name]
|
||||
|
||||
# for each month start, compute end date and register avoids
|
||||
for idx, start_date in enumerate(month_starts):
|
||||
if idx + 1 < len(month_starts):
|
||||
end_date = month_starts[idx + 1] - timedelta(days=1)
|
||||
else:
|
||||
end_date = Rota.rota_end_date
|
||||
|
||||
cells = month_cells[idx]
|
||||
for i, cell in enumerate(cells):
|
||||
if i >= len(names):
|
||||
break
|
||||
v = (cell or "").lower()
|
||||
if "academy" in v:
|
||||
worker_name = names[i]
|
||||
matching_workers = [w for w in Rota.workers if w.name.strip() == worker_name.strip()]
|
||||
if not matching_workers:
|
||||
logger.warning(f"Academy data: worker '{worker_name}' not found in rota, skipping")
|
||||
continue
|
||||
worker = matching_workers[0]
|
||||
|
||||
# choose twilight shifts relevant to the worker's site
|
||||
candidate_shifts = []
|
||||
for sh_name in twilight_shift_names:
|
||||
try:
|
||||
sh = Rota.get_shift_by_name(sh_name)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(worker, "site") and worker.site in getattr(sh, "sites", []):
|
||||
candidate_shifts.append(sh_name)
|
||||
elif sh_name.startswith(f"{worker.site}_"):
|
||||
candidate_shifts.append(sh_name)
|
||||
|
||||
if not candidate_shifts:
|
||||
candidate_shifts = twilight_shift_names.copy()
|
||||
|
||||
#print(f"Academy: {worker.name} on academy from {start_date} to {end_date}, avoiding shifts: {candidate_shifts}")
|
||||
|
||||
Rota.add_avoid_shifts_for_workers_on_dates(
|
||||
names=[worker.name],
|
||||
shifts=candidate_shifts,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
reason="academy",
|
||||
)
|
||||
@@ -1,6 +1,26 @@
|
||||
html {
|
||||
--bg-color: white;
|
||||
--text-color: black;
|
||||
--table-bg: #f9f9f9;
|
||||
--border-color: #ddd;
|
||||
--header-bg: #e0e0e0;
|
||||
--highlight-color: #ffff99;
|
||||
--worker-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
/* color: red; */
|
||||
html.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--table-bg: #1e1e1e;
|
||||
--border-color: #333;
|
||||
--header-bg: #2a2a2a;
|
||||
--highlight-color: #333300;
|
||||
--worker-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -14,7 +34,8 @@ table {
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
background-color: var(--table-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#main-table {
|
||||
@@ -72,12 +93,12 @@ th {
|
||||
}
|
||||
|
||||
.table-div tr:hover {
|
||||
background-color: lightblue;
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
.table-div tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
background-color: var(--worker-bg);
|
||||
}
|
||||
|
||||
.Sat {
|
||||
|
||||
@@ -445,9 +445,19 @@ $("#rota-table tr td:first-child").each((n, td) => {
|
||||
|
||||
function viewWorker(worker) {
|
||||
ds = worker.dataset;
|
||||
let exactShiftsText = "(none)";
|
||||
if (ds.exactShifts) {
|
||||
try {
|
||||
let parsed = JSON.parse(ds.exactShifts);
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", ");
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
||||
Site: ${ds.site}</br>
|
||||
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
||||
Exact shifts: ${exactShiftsText}</br>
|
||||
Start date: ${ds.start_date}</br>
|
||||
End date: ${ds.end_date}</br>
|
||||
Non working days: ${ds.nwds}</br>
|
||||
@@ -513,6 +523,311 @@ $("#gen-table").each(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// ICS Export functionality
|
||||
let customShiftTimes = {}; // Will store user-defined times
|
||||
|
||||
function getShiftTimes() {
|
||||
// Merge default with custom
|
||||
const defaultTimes = {
|
||||
'night_weekday': { start: '20:00', end: '08:15', nextDay: true },
|
||||
'night_weekend': { start: '20:00', end: '08:15', nextDay: true },
|
||||
'exeter_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'truro_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'torbay_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'plymouth_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'weekend_exeter': { start: '08:00', end: '20:30', nextDay: false },
|
||||
'weekend_truro': { start: '08:00', end: '20:30', nextDay: false },
|
||||
'weekend_torbay': { start: '08:00', end: '20:30', nextDay: false },
|
||||
'weekend_plymouth1': { start: '08:00', end: '16:00', nextDay: false },
|
||||
'weekend_plymouth2': { start: '16:00', end: '00:00', nextDay: false },
|
||||
'plymouth_bank_holidays': { start: '08:00', end: '16:00', nextDay: false },
|
||||
};
|
||||
return { ...defaultTimes, ...customShiftTimes };
|
||||
}
|
||||
|
||||
function generateICS(shiftType, shiftData) {
|
||||
let icsContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Rota Generator//EN
|
||||
`;
|
||||
|
||||
const shiftTimes = getShiftTimes();
|
||||
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
||||
|
||||
shiftData.forEach(entry => {
|
||||
const date = entry.date;
|
||||
const workerName = entry.worker;
|
||||
|
||||
const startDate = new Date(date + 'T' + time.start + ':00');
|
||||
let endDate = new Date(date + 'T' + time.end + ':00');
|
||||
if (time.nextDay) {
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
|
||||
const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase();
|
||||
const summary = workerName ? `${shiftTitle} - ${workerName}` : shiftTitle;
|
||||
const description = workerName ?
|
||||
`Shift: ${shiftType}\nWorker: ${workerName}` :
|
||||
`Shift: ${shiftType}`;
|
||||
|
||||
icsContent += `BEGIN:VEVENT
|
||||
UID:${workerName ? workerName + '-' : ''}${shiftType}-${date}@rota
|
||||
DTSTAMP:${formatDate(new Date())}
|
||||
DTSTART:${formatDate(startDate)}
|
||||
DTEND:${formatDate(endDate)}
|
||||
SUMMARY:${summary}
|
||||
DESCRIPTION:${description}
|
||||
END:VEVENT
|
||||
`;
|
||||
});
|
||||
|
||||
icsContent += 'END:VCALENDAR';
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
function downloadICS(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Collect shift dates and worker assignments
|
||||
const shiftDates = {};
|
||||
const workerShifts = {};
|
||||
|
||||
$('#main-table tr.worker-row').each(function() {
|
||||
const workerName = $(this).find('td.worker').attr('data-worker');
|
||||
if (workerName) {
|
||||
workerShifts[workerName] = {};
|
||||
|
||||
$(this).find('td.rota-day').each(function() {
|
||||
const shift = $(this).attr('data-shift');
|
||||
const date = $(this).attr('data-date');
|
||||
if (shift && shift !== '' && date) {
|
||||
const shifts = shift.split(',').map(s => s.trim()).filter(s => s);
|
||||
shifts.forEach(s => {
|
||||
// Collect by shift type with worker info
|
||||
if (!shiftDates[s]) {
|
||||
shiftDates[s] = [];
|
||||
}
|
||||
// Check if we already have this date for this shift type
|
||||
const existingEntry = shiftDates[s].find(entry => entry.date === date);
|
||||
if (!existingEntry) {
|
||||
shiftDates[s].push({ date: date, worker: workerName });
|
||||
}
|
||||
|
||||
// Collect by worker
|
||||
if (!workerShifts[workerName][s]) {
|
||||
workerShifts[workerName][s] = [];
|
||||
}
|
||||
if (!workerShifts[workerName][s].includes(date)) {
|
||||
workerShifts[workerName][s].push(date);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add ICS export buttons
|
||||
const icsButtonsDiv = $('<div id="ics-export-buttons" style="margin-top: 20px;"><h3>Export ICS Files</h3></div>');
|
||||
const setTimesButton = $(`<button id="set-shift-times" style="margin: 5px;">Set Shift Times</button>`);
|
||||
icsButtonsDiv.append(setTimesButton);
|
||||
|
||||
// Shift type exports (with worker info)
|
||||
const shiftExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Shift Type</h4></div>');
|
||||
Object.keys(shiftDates).sort().forEach(shiftType => {
|
||||
const button = $(`<button style="margin: 5px;">Download ${shiftType.replace(/_/g, ' ')} ICS</button>`);
|
||||
button.on('click', () => {
|
||||
const ics = generateICS(shiftType, shiftDates[shiftType]);
|
||||
downloadICS(`${shiftType}.ics`, ics);
|
||||
});
|
||||
shiftExportsDiv.append(button);
|
||||
});
|
||||
icsButtonsDiv.append(shiftExportsDiv);
|
||||
|
||||
// Worker exports
|
||||
const workerExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Worker</h4></div>');
|
||||
Object.keys(workerShifts).sort().forEach(workerName => {
|
||||
const workerShiftTypes = Object.keys(workerShifts[workerName]);
|
||||
if (workerShiftTypes.length > 0) {
|
||||
const button = $(`<button style="margin: 5px;">Download ${workerName} ICS</button>`);
|
||||
button.on('click', () => {
|
||||
showWorkerShiftSelectionModal(workerName, workerShifts[workerName]);
|
||||
});
|
||||
workerExportsDiv.append(button);
|
||||
}
|
||||
});
|
||||
icsButtonsDiv.append(workerExportsDiv);
|
||||
|
||||
$('#export-div').append(icsButtonsDiv);
|
||||
|
||||
// Function to show worker shift selection modal
|
||||
function showWorkerShiftSelectionModal(workerName, workerShiftData) {
|
||||
const checkboxesDiv = $('#worker-shift-checkboxes');
|
||||
checkboxesDiv.empty();
|
||||
|
||||
$('#worker-shift-modal-title').text(`Select Shifts for ${workerName}`);
|
||||
|
||||
// Create checkboxes for each shift type
|
||||
Object.keys(workerShiftData).sort().forEach(shiftType => {
|
||||
const dates = workerShiftData[shiftType];
|
||||
const checkboxId = `shift-${shiftType}`;
|
||||
const checkbox = $(`
|
||||
<div style="margin: 8px 0;">
|
||||
<input type="checkbox" id="${checkboxId}" checked>
|
||||
<label for="${checkboxId}" style="margin-left: 8px;">
|
||||
${shiftType.replace(/_/g, ' ').toUpperCase()} (${dates.length} shift${dates.length !== 1 ? 's' : ''})
|
||||
</label>
|
||||
</div>
|
||||
`);
|
||||
checkboxesDiv.append(checkbox);
|
||||
});
|
||||
|
||||
// Store worker data for export
|
||||
$('#worker-shift-export').data('workerName', workerName);
|
||||
$('#worker-shift-export').data('workerShiftData', workerShiftData);
|
||||
|
||||
// Show modal
|
||||
workerShiftModal.show();
|
||||
}
|
||||
|
||||
// Modal for worker shift selection
|
||||
const workerShiftModal = $(`
|
||||
<div id="worker-shift-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||
<h3 style="margin-top:0;" id="worker-shift-modal-title">Select Shifts for Export</h3>
|
||||
<div id="worker-shift-checkboxes" style="margin: 16px 0;"></div>
|
||||
<div style="text-align:right; margin-top:16px;">
|
||||
<button id="worker-shift-cancel" style="margin-right:8px;">Cancel</button>
|
||||
<button id="worker-shift-export">Export Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$('body').append(workerShiftModal);
|
||||
|
||||
// Modal for setting shift times
|
||||
const shiftTimesModal = $(`
|
||||
<div id="shift-times-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||
<h3 style="margin-top:0;">Set Shift Times</h3>
|
||||
<div id="shift-times-controls"></div>
|
||||
<div style="text-align:right; margin-top:16px;">
|
||||
<button id="save-shift-times">Save</button>
|
||||
<button id="close-shift-times">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$("body").append(shiftTimesModal);
|
||||
|
||||
$('#set-shift-times').on('click', function() {
|
||||
$('#shift-times-modal').show();
|
||||
populateShiftTimesModal();
|
||||
});
|
||||
|
||||
$('#close-shift-times').on('click', function() {
|
||||
$('#shift-times-modal').hide();
|
||||
});
|
||||
|
||||
$('#save-shift-times').on('click', function() {
|
||||
saveShiftTimes();
|
||||
$('#shift-times-modal').hide();
|
||||
});
|
||||
|
||||
// Worker shift modal event handlers
|
||||
$('#worker-shift-cancel').on('click', function() {
|
||||
workerShiftModal.hide();
|
||||
});
|
||||
|
||||
$('#worker-shift-export').on('click', function() {
|
||||
const workerName = $(this).data('workerName');
|
||||
const workerShiftData = $(this).data('workerShiftData');
|
||||
|
||||
// Get selected shift types
|
||||
const selectedShifts = [];
|
||||
$('#worker-shift-checkboxes input[type="checkbox"]:checked').each(function() {
|
||||
const shiftType = $(this).attr('id').replace('shift-', '');
|
||||
selectedShifts.push(shiftType);
|
||||
});
|
||||
|
||||
if (selectedShifts.length === 0) {
|
||||
alert('Please select at least one shift type to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate ICS for selected shifts
|
||||
let icsContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Rota Generator//EN
|
||||
`;
|
||||
|
||||
selectedShifts.forEach(shiftType => {
|
||||
const dates = workerShiftData[shiftType];
|
||||
const shiftTimes = getShiftTimes();
|
||||
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
||||
|
||||
dates.forEach(date => {
|
||||
const startDate = new Date(date + 'T' + time.start + ':00');
|
||||
let endDate = new Date(date + 'T' + time.end + ':00');
|
||||
if (time.nextDay) {
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
|
||||
const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase();
|
||||
|
||||
icsContent += `BEGIN:VEVENT
|
||||
UID:${workerName}-${shiftType}-${date}@rota
|
||||
DTSTAMP:${formatDate(new Date())}
|
||||
DTSTART:${formatDate(startDate)}
|
||||
DTEND:${formatDate(endDate)}
|
||||
SUMMARY:${shiftTitle} - ${workerName}
|
||||
DESCRIPTION:Shift: ${shiftType}\nWorker: ${workerName}
|
||||
END:VEVENT
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
icsContent += 'END:VCALENDAR';
|
||||
downloadICS(`${workerName.replace(/[^a-zA-Z0-9]/g, '_')}.ics`, icsContent);
|
||||
|
||||
workerShiftModal.hide();
|
||||
});
|
||||
|
||||
function populateShiftTimesModal() {
|
||||
const controls = $('#shift-times-controls');
|
||||
controls.empty();
|
||||
const shiftTimes = getShiftTimes();
|
||||
Object.keys(shiftDates).sort().forEach(shiftType => {
|
||||
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
||||
const div = $(`
|
||||
<div style="margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
<label><strong>${shiftType.replace(/_/g, ' ')}</strong></label><br>
|
||||
<label>Start: <input type="time" class="shift-start" data-shift="${shiftType}" value="${time.start}"></label>
|
||||
<label>End: <input type="time" class="shift-end" data-shift="${shiftType}" value="${time.end}"></label>
|
||||
<label><input type="checkbox" class="shift-nextday" data-shift="${shiftType}" ${time.nextDay ? 'checked' : ''}> Ends next day</label>
|
||||
</div>
|
||||
`);
|
||||
controls.append(div);
|
||||
});
|
||||
}
|
||||
|
||||
function saveShiftTimes() {
|
||||
customShiftTimes = {};
|
||||
$('#shift-times-controls .shift-start').each(function() {
|
||||
const shift = $(this).data('shift');
|
||||
const start = $(this).val();
|
||||
const end = $(`input.shift-end[data-shift="${shift}"]`).val();
|
||||
const nextDay = $(`input.shift-nextday[data-shift="${shift}"]`).is(':checked');
|
||||
customShiftTimes[shift] = { start, end, nextDay };
|
||||
});
|
||||
}
|
||||
|
||||
function hsv2rgb(h, s, v) {
|
||||
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
||||
var rgb, i, data = [];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../timetable.css
|
||||
@@ -1 +0,0 @@
|
||||
../timetable.js
|
||||
+84
-24
@@ -1,6 +1,26 @@
|
||||
html {
|
||||
--bg-color: white;
|
||||
--text-color: black;
|
||||
--table-bg: #f9f9f9;
|
||||
--border-color: #ddd;
|
||||
--header-bg: #e0e0e0;
|
||||
--highlight-color: #ffff99;
|
||||
--worker-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
/* color: red; */
|
||||
html.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--table-bg: #1e1e1e;
|
||||
--border-color: #333;
|
||||
--header-bg: #2a2a2a;
|
||||
--highlight-color: #333300;
|
||||
--worker-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -14,7 +34,8 @@ table {
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
background-color: var(--table-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#main-table {
|
||||
@@ -31,14 +52,14 @@ table {
|
||||
|
||||
.table-div {
|
||||
/* overflow-x: auto; */
|
||||
width: 80%;
|
||||
/* width: 80%; */
|
||||
overflow-x: scroll;
|
||||
margin-left: 200px;
|
||||
overflow-y: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-div th {
|
||||
#main-table th {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
transform: rotate(90deg) translateX(-100px);
|
||||
@@ -47,12 +68,12 @@ table {
|
||||
|
||||
}
|
||||
|
||||
.table-div th p {
|
||||
#main-table th p {
|
||||
margin: 0 -100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.table-div th p:before {
|
||||
#main-table th p:before {
|
||||
content: '';
|
||||
width: 0;
|
||||
padding-top: 110%;
|
||||
@@ -61,23 +82,23 @@ table {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-div td,
|
||||
#main-table td,
|
||||
th {
|
||||
max-width: 10px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.table-div tr {
|
||||
#main-table tr {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.table-div tr:hover {
|
||||
background-color: lightblue;
|
||||
#main-table tr:hover {
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
.table-div tr:hover .unavailable {
|
||||
#main-table tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
background-color: var(--worker-bg);
|
||||
}
|
||||
|
||||
.Sat {
|
||||
@@ -96,7 +117,7 @@ th {
|
||||
|
||||
}
|
||||
|
||||
th.bank-holiday {
|
||||
#main-table th.bank-holiday {
|
||||
color: darkblue;
|
||||
|
||||
}
|
||||
@@ -118,7 +139,8 @@ th.bank-holiday {
|
||||
color: #0074D9;
|
||||
}
|
||||
|
||||
.torquay, .torbay {
|
||||
.torquay,
|
||||
.torbay {
|
||||
color: #5FA8E8;
|
||||
}
|
||||
|
||||
@@ -186,10 +208,8 @@ th.bank-holiday {
|
||||
color: lightcoral;
|
||||
}
|
||||
|
||||
.th {}
|
||||
|
||||
.table-div th.worker,
|
||||
.table-div td.worker {
|
||||
#main-table th.worker,
|
||||
#main-table td.worker {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
@@ -209,7 +229,7 @@ th.bank-holiday {
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
.header th {
|
||||
#main-table.header th {
|
||||
max-width: 200px;
|
||||
|
||||
}
|
||||
@@ -218,7 +238,7 @@ th.bank-holiday {
|
||||
border: 1px dotted orange;
|
||||
} */
|
||||
|
||||
th.site-title {
|
||||
#main-table th.site-title {
|
||||
transform: unset;
|
||||
}
|
||||
|
||||
@@ -250,6 +270,7 @@ td.large-shift-diff-pos::before {
|
||||
content: "*";
|
||||
color: red;
|
||||
}
|
||||
|
||||
td.large-shift-diff-neg::before {
|
||||
content: "*";
|
||||
color: green;
|
||||
@@ -259,6 +280,7 @@ tr.large-shift-diff-pos td {
|
||||
border-bottom: solid 1px red;
|
||||
border-top: solid 1px red;
|
||||
}
|
||||
|
||||
tr.large-shift-diff-neg td {
|
||||
border-bottom: solid 1px green;
|
||||
border-top: solid 1px green;
|
||||
@@ -279,11 +301,9 @@ table.transposed td {
|
||||
border: 1px solid black;
|
||||
max-width: 100px;
|
||||
} */
|
||||
table.transposed {
|
||||
}
|
||||
table.transposed {}
|
||||
|
||||
table.transposed tr {
|
||||
}
|
||||
table.transposed tr {}
|
||||
|
||||
table.transposed th,
|
||||
table.transposed td {
|
||||
@@ -326,3 +346,43 @@ table.transposed th.bank-holiday {
|
||||
.multi-shift {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button.selected {
|
||||
background: #0074d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#main-table th.col-hover {
|
||||
background: #ffe066 !important;
|
||||
color: #222 !important;
|
||||
}
|
||||
|
||||
.highlight-day-btn.highlighted {
|
||||
background: #ffe066 !important;
|
||||
color: #222 !important;
|
||||
border: 2px solid #bfa600 !important;
|
||||
}
|
||||
|
||||
.highlight-day-btn.highlighted {
|
||||
background: #ffe066 !important;
|
||||
color: #222 !important;
|
||||
border: 2px solid #bfa600 !important;
|
||||
}
|
||||
|
||||
#main-table td.day-highlighted,
|
||||
#main-table th.day-highlighted {
|
||||
/* background is set inline for custom color */
|
||||
}
|
||||
|
||||
#display-settings-modal {
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
#main-table td.force-assigned.force-assigned-highlight {
|
||||
background-color: #ffe066 !important;
|
||||
border: 2px solid #ff8800 !important;
|
||||
}
|
||||
|
||||
#linear-shift-timetable td, #linear-shift-timetable th {
|
||||
max-width: unset;
|
||||
}
|
||||
+1871
-29
File diff suppressed because it is too large
Load Diff
Regular → Executable
+7
@@ -15,3 +15,10 @@ loguru
|
||||
numpy
|
||||
highspy
|
||||
typer
|
||||
django>=6.0,<7
|
||||
django-crispy-forms
|
||||
crispy-bulma
|
||||
django-debug-toolbar
|
||||
pandas
|
||||
odfpy
|
||||
openpyxl
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Iterable, List, Literal, Optional
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
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 = [
|
||||
@@ -31,6 +31,32 @@ class NotAvailableToWork(BaseModel):
|
||||
date: datetime.date
|
||||
reason: str = "unknown"
|
||||
|
||||
class HardDayDependency(BaseModel):
|
||||
days: Set[str] = Field(..., description="List of days for the dependency group")
|
||||
weeks: Optional[List[int]] = None # If None, applies to all weeks
|
||||
start_date: Optional[datetime.date] = None # Optional start date for the dependency
|
||||
end_date: Optional[datetime.date] = None # Optional end date for the dependency
|
||||
ignore_dates: list[datetime.date] = Field(default_factory=list, description="List of specific dates to ignore for this dependency")
|
||||
|
||||
@field_validator("weeks")
|
||||
def weeks_none_if_dates_set(cls, weeks, values):
|
||||
data = values.data
|
||||
if (data.get("start_date") is not None or data.get("end_date") is not None) and weeks is not None:
|
||||
raise ValueError("If start_date or end_date is set, weeks must not be set")
|
||||
return weeks
|
||||
|
||||
class HardDayExclusion(BaseModel):
|
||||
days: set[str] = Field(..., description="List of days to exclude together")
|
||||
weeks: Optional[List[int]] = None # If None, applies to all weeks
|
||||
start_date: Optional[datetime.date] = None # Optional start date for the dependency
|
||||
end_date: Optional[datetime.date] = None # Optional end date for the dependency
|
||||
|
||||
@field_validator("weeks")
|
||||
def weeks_none_if_dates_set(cls, weeks, values):
|
||||
data = values.data
|
||||
if (data.get("start_date") is not None or data.get("end_date") is not None) and weeks is not None:
|
||||
raise ValueError("If start_date or end_date is set, weeks must not be set")
|
||||
return weeks
|
||||
|
||||
def generate_not_available_to_works(
|
||||
start_date: datetime.date,
|
||||
@@ -85,6 +111,100 @@ class OutOfProgramme(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class AvoidShiftOnDates(BaseModel):
|
||||
"""Worker-level constraint: avoid listed shifts on given dates or date range."""
|
||||
shifts: list[str]
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
dates: Optional[list[datetime.date]] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
@field_validator("start_date", "end_date", mode="before")
|
||||
def coerce_dates(cls, v):
|
||||
# allow strings in common formats
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime.date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class MaxDaysPerWeekBlockConstraint(BaseModel):
|
||||
max_days: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Maximum number of worked days allowed inside each sliding week block.",
|
||||
)
|
||||
week_block: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||
)
|
||||
ignore_dates: list[datetime.date] = Field(
|
||||
default_factory=list,
|
||||
description="List of specific dates to exclude from this constraint.",
|
||||
)
|
||||
|
||||
|
||||
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
||||
max_unique_shifts: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Maximum number of distinct shift names allowed inside each sliding week block.",
|
||||
)
|
||||
week_block: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||
)
|
||||
ignore_dates: list[datetime.date] = Field(
|
||||
default_factory=list,
|
||||
description="List of specific dates to exclude from this constraint.",
|
||||
)
|
||||
|
||||
|
||||
class ShiftStartDate(BaseModel):
|
||||
shift: str
|
||||
start_date: datetime.date
|
||||
|
||||
@field_validator("start_date", mode="before")
|
||||
@classmethod
|
||||
def coerce_date(cls, v):
|
||||
if isinstance(v, datetime.date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class ShiftEndDate(BaseModel):
|
||||
shift: str
|
||||
end_date: datetime.date
|
||||
|
||||
@field_validator("end_date", mode="before")
|
||||
@classmethod
|
||||
def coerce_date(cls, v):
|
||||
if isinstance(v, datetime.date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class Worker(BaseModel):
|
||||
name: str
|
||||
site: str
|
||||
@@ -114,19 +234,72 @@ class Worker(BaseModel):
|
||||
allowed_multi_shift_sets: list[set] = [] # or list/set of frozensets
|
||||
prefer_multi_shift_together: int = 0 # Default: no preference
|
||||
# Set to a positive integer to prefer, negative to discourage, 0 for neutral
|
||||
hard_day_dependencies: set[frozenset[str]] = set() # e.g. {frozenset({"Mon", "Tue"}), frozenset({"Fri", "Sun"})}
|
||||
hard_day_exclusions: list[tuple[str, str]] = [] # e.g. [("Fri", "Sun")]
|
||||
hard_day_dependencies: list[HardDayDependency] = []
|
||||
hard_day_exclusions: list[HardDayExclusion] = []
|
||||
force_assign_with: dict[str, list[str]] = {} # e.g. {"oncall": ["weekend"]}
|
||||
max_shifts_per_week_by_shift_name: dict[str, int] = {} # e.g. {"night": 2, "a": 3}
|
||||
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
||||
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
||||
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
||||
max_days_per_week_block: list[MaxDaysPerWeekBlockConstraint] = []
|
||||
max_unique_shifts_per_week_block: list[MaxUniqueShiftsPerWeekBlockConstraint] = []
|
||||
|
||||
|
||||
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
||||
|
||||
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
|
||||
|
||||
weekend_shift_target_number: int = 0
|
||||
exact_shifts: dict[str, int] = {} # Map shift_name to exact number of shifts
|
||||
groups: set[str] = set() # Groups that the worker belongs to
|
||||
shift_start_dates: list[ShiftStartDate] = [] # List of ShiftStartDate models
|
||||
shift_end_dates: list[ShiftEndDate] = [] # List of ShiftEndDate models
|
||||
|
||||
weekend_shift_target_number: float = 0.0
|
||||
avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
|
||||
|
||||
@field_validator("shift_start_dates", mode="before")
|
||||
@classmethod
|
||||
def coerce_shift_start_dates(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, dict):
|
||||
res = []
|
||||
for shift, d in v.items():
|
||||
res.append(ShiftStartDate(shift=shift, start_date=d))
|
||||
return res
|
||||
if isinstance(v, list):
|
||||
res = []
|
||||
for item in v:
|
||||
if isinstance(item, ShiftStartDate):
|
||||
res.append(item)
|
||||
elif isinstance(item, dict):
|
||||
res.append(ShiftStartDate(**item))
|
||||
else:
|
||||
raise ValueError(f"Invalid ShiftStartDate item: {item}")
|
||||
return res
|
||||
raise ValueError("Must be a list or dictionary")
|
||||
|
||||
@field_validator("shift_end_dates", mode="before")
|
||||
@classmethod
|
||||
def coerce_shift_end_dates(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, dict):
|
||||
res = []
|
||||
for shift, d in v.items():
|
||||
res.append(ShiftEndDate(shift=shift, end_date=d))
|
||||
return res
|
||||
if isinstance(v, list):
|
||||
res = []
|
||||
for item in v:
|
||||
if isinstance(item, ShiftEndDate):
|
||||
res.append(item)
|
||||
elif isinstance(item, dict):
|
||||
res.append(ShiftEndDate(**item))
|
||||
else:
|
||||
raise ValueError(f"Invalid ShiftEndDate item: {item}")
|
||||
return res
|
||||
raise ValueError("Must be a list or dictionary")
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
@@ -323,13 +496,88 @@ class Worker(BaseModel):
|
||||
self.proportion_rota_to_work = days_to_work / Rota.rota_days_length
|
||||
self.days_to_work = days_to_work
|
||||
|
||||
# Shift-specific start/end dates and adjusted FTEs
|
||||
self.calculated_shift_start_dates = {}
|
||||
self.calculated_shift_end_dates = {}
|
||||
self.proportion_rota_to_work_shifts = {}
|
||||
|
||||
# Convert list of ShiftStartDate/ShiftEndDate models/dicts to dictionary mapping for quick lookup
|
||||
start_dates_dict = {item.shift: item.start_date for item in getattr(self, "shift_start_dates", []) if hasattr(item, "shift")}
|
||||
end_dates_dict = {item.shift: item.end_date for item in getattr(self, "shift_end_dates", []) if hasattr(item, "shift")}
|
||||
|
||||
for shift in Rota.get_shifts():
|
||||
s_date = start_dates_dict.get(shift.name, self.start_date)
|
||||
if s_date is None:
|
||||
calc_s = self.calculated_start_date
|
||||
else:
|
||||
if isinstance(s_date, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
s_date = datetime.datetime.strptime(s_date, fmt).date()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
calc_s = s_date
|
||||
if calc_s < Rota.start_date:
|
||||
calc_s = Rota.start_date
|
||||
elif calc_s > Rota.rota_end_date:
|
||||
calc_s = Rota.rota_end_date
|
||||
|
||||
e_date = end_dates_dict.get(shift.name, self.end_date)
|
||||
if e_date is None:
|
||||
calc_e = self.calculated_end_date
|
||||
else:
|
||||
if isinstance(e_date, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
e_date = datetime.datetime.strptime(e_date, fmt).date()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
calc_e = e_date
|
||||
if calc_e > Rota.rota_end_date:
|
||||
calc_e = Rota.rota_end_date
|
||||
elif calc_e < Rota.start_date:
|
||||
calc_e = Rota.start_date
|
||||
|
||||
if calc_s >= calc_e:
|
||||
calc_s = Rota.rota_end_date
|
||||
calc_e = Rota.rota_end_date
|
||||
|
||||
self.calculated_shift_start_dates[shift.name] = calc_s
|
||||
self.calculated_shift_end_dates[shift.name] = calc_e
|
||||
|
||||
days_active = (calc_e - calc_s).days
|
||||
|
||||
# Subtract overlapping OOP days from the active period of this shift
|
||||
for item in self.oop:
|
||||
start_oop = item.start_date
|
||||
end_oop = item.end_date
|
||||
if isinstance(start_oop, datetime.date):
|
||||
start_oop_date = start_oop
|
||||
else:
|
||||
start_oop_date = datetime.datetime.strptime(start_oop, "%d/%m/%y").date()
|
||||
|
||||
if isinstance(end_oop, datetime.date):
|
||||
end_oop_date = end_oop
|
||||
else:
|
||||
end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date()
|
||||
|
||||
overlap_start = max(calc_s, start_oop_date)
|
||||
overlap_end = min(calc_e, end_oop_date)
|
||||
if overlap_start < overlap_end:
|
||||
days_active -= (overlap_end - overlap_start).days
|
||||
|
||||
self.proportion_rota_to_work_shifts[shift.name] = max(0.0, days_active / Rota.rota_days_length)
|
||||
|
||||
# We have to adjust the full time equivalent for people who CCT / leave the rota early
|
||||
self.fte_adj = self.fte * self.proportion_rota_to_work
|
||||
|
||||
self.fte_adj_shifts = {}
|
||||
if self.shift_fte_overrides:
|
||||
for shift, fte in self.shift_fte_overrides.items():
|
||||
self.fte_adj_shifts[shift] = fte * self.proportion_rota_to_work
|
||||
for shift in Rota.get_shifts():
|
||||
prop = self.proportion_rota_to_work_shifts.get(shift.name, self.proportion_rota_to_work)
|
||||
base_fte = self.shift_fte_overrides.get(shift.name, self.fte)
|
||||
self.fte_adj_shifts[shift.name] = base_fte * prop
|
||||
|
||||
|
||||
if self.fte_adj > 100:
|
||||
@@ -366,6 +614,23 @@ class Worker(BaseModel):
|
||||
style="red",
|
||||
)
|
||||
|
||||
# Register any per-worker avoid-shift-on-dates rules with the rota builder
|
||||
try:
|
||||
for av in getattr(self, "avoid_shifts_on_dates", []):
|
||||
entry = {
|
||||
"names": [self.name],
|
||||
"shifts": av.shifts,
|
||||
"start_date": av.start_date,
|
||||
"end_date": av.end_date,
|
||||
"dates": av.dates,
|
||||
"reason": getattr(av, "reason", None),
|
||||
}
|
||||
# append to rota-level list; the model builder will enforce these
|
||||
if hasattr(Rota.constraint_options_model, "avoid_shifts_by_worker_dates"):
|
||||
Rota.constraint_options_model.avoid_shifts_by_worker_dates.append(entry)
|
||||
except Exception:
|
||||
Rota.add_warning("Worker/avoid_shifts_on_dates", f"Failed to register avoid_shifts_on_dates for {self.name}")
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return (self.site, self.grade, self.fte_adj, self.name) < (
|
||||
other.site,
|
||||
@@ -409,15 +674,21 @@ class Worker(BaseModel):
|
||||
self.allowed_multi_shift_sets = []
|
||||
self.allowed_multi_shift_sets.append(frozenset(shifts))
|
||||
|
||||
def add_hard_day_dependency(self, *days: str):
|
||||
if not hasattr(self, "hard_day_dependencies"):
|
||||
self.hard_day_dependencies = set()
|
||||
self.hard_day_dependencies.add(frozenset(days))
|
||||
def add_hard_day_dependency(self, days: set[str], weeks: Optional[List[int]] = None, start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None, ignore_dates: Optional[List[datetime.date]] = None):
|
||||
"""
|
||||
Add a hard day dependency for this worker.
|
||||
days: List of days that must be grouped together.
|
||||
weeks: Optional list of weeks this dependency applies to. If None, applies to all weeks.
|
||||
"""
|
||||
self.hard_day_dependencies.append(HardDayDependency(days=days, weeks=weeks, start_date=start_date, end_date=end_date, ignore_dates=ignore_dates or []))
|
||||
|
||||
def add_hard_day_exclusion(self, day1: str, day2: str):
|
||||
if not hasattr(self, "hard_day_exclusions"):
|
||||
self.hard_day_exclusions = []
|
||||
self.hard_day_exclusions.append((day1, day2))
|
||||
def add_hard_day_exclusion(self, days: set[str], weeks: Optional[List[int]] = None, start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None):
|
||||
"""
|
||||
Add a hard day exclusion for this worker.
|
||||
days: List of days that must be excluded together.
|
||||
weeks: Optional list of weeks this exclusion applies to. If None, applies to all weeks.
|
||||
"""
|
||||
self.hard_day_exclusions.append(HardDayExclusion(days=days, weeks=weeks, start_date=start_date, end_date=end_date))
|
||||
|
||||
def add_force_assign_with(self, shift: str, with_shifts: list[str] | str):
|
||||
if not hasattr(self, "force_assign_with"):
|
||||
@@ -431,3 +702,15 @@ class Worker(BaseModel):
|
||||
if not hasattr(self, "max_shifts_per_week_by_shift_name"):
|
||||
self.max_shifts_per_week_by_shift_name = {}
|
||||
self.max_shifts_per_week_by_shift_name[shift_name] = max_per_week
|
||||
|
||||
def force_assign_shift(self, week: int, day: str, shift_name: str):
|
||||
"""Force this worker to be assigned to a shift on a specific week/day."""
|
||||
self.forced_assignments.append((week, day, shift_name))
|
||||
|
||||
def force_assign_shift_by_date(self, date: datetime.date, shift_name: str):
|
||||
"""Force this worker to be assigned to a shift on a specific date."""
|
||||
self.forced_assignments_by_date.append((date, shift_name))
|
||||
|
||||
def add_max_days_per_week_block_constraint(self, max_days: int, week_block: int, ignore_dates: Optional[List[datetime.date]] = None):
|
||||
"""Add a constraint to limit the number of days worked in any sliding block of weeks."""
|
||||
self.max_days_per_week_block.append(MaxDaysPerWeekBlockConstraint(max_days=max_days, week_block=week_block, ignore_dates=ignore_dates or []))
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Shared calendar styles */
|
||||
.calendar { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:1rem; max-width:900px; margin-left:auto; margin-right:auto }
|
||||
.calendar .week { display:flex; gap:0.5rem; }
|
||||
.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; text-align:center }
|
||||
.calendar .day.other-month { background:#f9f9f9; color:#999; }
|
||||
.calendar .day.leave { background:#ffecec; }
|
||||
.calendar .day.selected { color:#2b8fd6; font-weight:700 }
|
||||
.calendar .day.in-range { color:#2b8fd6; font-weight:600 }
|
||||
.calendar .day:hover { box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
|
||||
.calendar .day.day-header { background:transparent; border:0; padding:0.25rem; min-height:unset }
|
||||
#calendar-controls { margin-top:0.5rem }
|
||||
#calendar-controls .button { margin-left:0.5rem }
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Nice, light styling for forms and modals used in request flows */
|
||||
:root{
|
||||
--primary:#2b8fd6;
|
||||
--primary-dark:#226a9e;
|
||||
--muted:#6b7280;
|
||||
--bg:#ffffff;
|
||||
--glass: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
/* Make modal card a pleasant width and add gentle shadow */
|
||||
#modal .modal-card, #modal .modal-content {
|
||||
width: min(820px, 96%);
|
||||
border-radius: 10px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#modal .modal-card-head .modal-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
color: #0b2540;
|
||||
}
|
||||
|
||||
#modal .modal-card-body {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Form layout improvements */
|
||||
form .field {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
form label {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.25rem;
|
||||
display:block;
|
||||
}
|
||||
|
||||
/* Make inputs stretch and more modern */
|
||||
input[type="text"], input[type="date"], input[type="email"], input[type="number"], textarea, select {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dfe6ee;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
|
||||
background: linear-gradient(180deg,#fff,#fcfdff);
|
||||
}
|
||||
|
||||
textarea { min-height: 100px; }
|
||||
|
||||
.form-errors, .help.is-danger { color: #9b1c1c; background: #fff2f2; padding:0.5rem; border-radius:6px; border:1px solid #f1c0c0 }
|
||||
|
||||
/* Primary button styling */
|
||||
.button.is-primary {
|
||||
background: linear-gradient(180deg,var(--primary),var(--primary-dark));
|
||||
color: white;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(43,143,214,0.16);
|
||||
}
|
||||
|
||||
.button.is-primary:hover{ background: linear-gradient(180deg,var(--primary-dark),#1b5a82); }
|
||||
|
||||
.button + .button { margin-left: 0.5rem; }
|
||||
|
||||
/* Small helpers for inline legend */
|
||||
.legend-swatch { display:inline-block; width:12px; height:12px; border-radius:3px; margin-right:0.5rem; vertical-align:middle; border:1px solid #ddd }
|
||||
|
||||
/* Responsive tweaks */
|
||||
@media (max-width: 640px){
|
||||
#modal .modal-card, #modal .modal-content { width: 96%; }
|
||||
.button.is-primary { width: 100%; }
|
||||
}
|
||||
|
||||
/* Leave list styling */
|
||||
#leave-list ul { list-style:none; padding:0; margin:0; }
|
||||
#leave-list li { padding:0.6rem 0; border-bottom:1px solid #f3f6f9; display:flex; align-items:center; justify-content:space-between }
|
||||
#leave-list li .leave-range { font-weight:600; color:#0b2540 }
|
||||
#leave-list li em { color:var(--muted); margin-left:0.5rem }
|
||||
#leave-list li form { margin-left:1rem }
|
||||
@@ -0,0 +1,210 @@
|
||||
/* Shared calendar JS
|
||||
Usage: call initCalendar(containerId, opts)
|
||||
opts:
|
||||
workerId: optional worker id
|
||||
leaves: array of {start:'YYYY-MM-DD', end:'YYYY-MM-DD'}
|
||||
requestUrl: url to use for requesting leave (GET will be used with query params)
|
||||
mode: 'inline' or other
|
||||
*/
|
||||
(function(){
|
||||
// Keep a registry of calendar instances so external code (eg. modal handlers)
|
||||
// can clear selections when needed.
|
||||
window._calendars = window._calendars || {};
|
||||
|
||||
function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` }
|
||||
|
||||
function initCalendar(containerId, opts){
|
||||
// Accept either an element id or a direct element reference
|
||||
const container = typeof containerId === 'string' ? document.getElementById(containerId) : containerId;
|
||||
if(!container) return;
|
||||
// ensure container uses the expected CSS class so shared styles apply
|
||||
try{ container.classList.add && container.classList.add('calendar'); }catch(e){}
|
||||
console.debug('initCalendar', containerId, opts);
|
||||
const workerId = opts.workerId || container.dataset.workerId || '';
|
||||
let leaves = opts.leaves || [];
|
||||
const leavesUrl = opts.leavesUrl || null;
|
||||
const requestUrl = opts.requestUrl || (window.location.origin + '/leave/request/');
|
||||
|
||||
let viewYear = new Date().getFullYear();
|
||||
let viewMonth = new Date().getMonth();
|
||||
let selStart = null; let selEnd = null; let previewEnd = null;
|
||||
|
||||
function inLeaves(isoStr){
|
||||
const matches = [];
|
||||
for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) matches.push(L); }
|
||||
return matches;
|
||||
}
|
||||
|
||||
function buildControls(){
|
||||
let controls = document.getElementById('calendar-controls');
|
||||
if(!controls){
|
||||
controls = document.createElement('div'); controls.id='calendar-controls';
|
||||
controls.innerHTML = `<span id="sel-label">No dates selected</span> <button id="sel-confirm" class="button is-primary">Apply</button> <button id="sel-clear" class="button">Clear</button>`;
|
||||
container.parentNode.insertBefore(controls, container.nextSibling);
|
||||
document.getElementById('sel-clear').onclick = function(){ selStart = null; selEnd = null; previewEnd = null; render(); };
|
||||
document.getElementById('sel-confirm').onclick = function(){
|
||||
if(!selStart) return;
|
||||
if(!selEnd) selEnd = selStart;
|
||||
const startInput = document.querySelector('input[name="start_date"]');
|
||||
const endInput = document.querySelector('input[name="end_date"]');
|
||||
if(startInput) startInput.value = selStart;
|
||||
if(endInput) endInput.value = selEnd;
|
||||
if(typeof initFlatpickr === 'function') initFlatpickr();
|
||||
const form = document.querySelector('form'); if(form) form.scrollIntoView({behavior:'smooth'});
|
||||
};
|
||||
}
|
||||
const label = document.getElementById('sel-label');
|
||||
if(selStart && selEnd) label.textContent = `Selected: ${selStart} -> ${selEnd}`;
|
||||
else if(selStart) label.textContent = `Start: ${selStart}`;
|
||||
else label.textContent = 'No dates selected';
|
||||
document.getElementById('sel-confirm').disabled = !selStart;
|
||||
}
|
||||
|
||||
function render(){
|
||||
container.innerHTML='';
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
const startDay = first.getDay();
|
||||
const days = new Date(viewYear, viewMonth+1, 0).getDate();
|
||||
let cur = 1 - startDay;
|
||||
|
||||
const header = document.createElement('div'); header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.style.marginBottom='0.5rem';
|
||||
const title = document.createElement('div'); title.textContent = first.toLocaleString(undefined,{month:'long', year:'numeric'});
|
||||
const prev = document.createElement('button'); prev.textContent='◀'; prev.className='button'; prev.onclick=()=>{ viewMonth--; if(viewMonth<0){ viewMonth=11; viewYear--; } render(); };
|
||||
const next = document.createElement('button'); next.textContent='▶'; next.className='button'; next.onclick=()=>{ viewMonth++; if(viewMonth>11){ viewMonth=0; viewYear++; } render(); };
|
||||
header.appendChild(prev); header.appendChild(title); header.appendChild(next);
|
||||
container.appendChild(header);
|
||||
|
||||
// Day-of-week header row
|
||||
const dow = document.createElement('div'); dow.className='week'; dow.style.fontWeight='bold';
|
||||
const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
for(const dn of dayNames){ const dh = document.createElement('div'); dh.className='day day-header'; dh.style.textAlign='center'; dh.style.padding='0.25rem'; dh.textContent = dn; dow.appendChild(dh); }
|
||||
container.appendChild(dow);
|
||||
|
||||
while(cur<=days){
|
||||
const week = document.createElement('div'); week.className='week';
|
||||
for(let i=0;i<7;i++){
|
||||
const dayDiv = document.createElement('div'); dayDiv.className='day';
|
||||
if(cur<1 || cur>days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
|
||||
else{
|
||||
const isoStr = iso(viewYear, viewMonth, cur);
|
||||
dayDiv.textContent = cur;
|
||||
const matches = inLeaves(isoStr);
|
||||
if(matches && matches.length){
|
||||
dayDiv.classList.add('leave');
|
||||
try{
|
||||
// collect distinct colors for this day's matches
|
||||
const colors = Array.from(new Set(matches.map(x=>x.color).filter(Boolean)));
|
||||
if(colors.length===1){
|
||||
dayDiv.style.backgroundImage = ''; // clear any previous gradient
|
||||
dayDiv.style.backgroundColor = colors[0];
|
||||
} else if(colors.length>1){
|
||||
// build an equal-split horizontal gradient (cap to 6 colours)
|
||||
const n = Math.min(colors.length, 6);
|
||||
const seg = 100 / n;
|
||||
const stops = colors.slice(0,n).map((c,i)=>{
|
||||
const start = Math.round(i*seg);
|
||||
const end = Math.round((i+1)*seg);
|
||||
return `${c} ${start}% ${end}%`;
|
||||
}).join(', ');
|
||||
dayDiv.style.backgroundColor = '';
|
||||
dayDiv.style.backgroundImage = `linear-gradient(90deg, ${stops})`;
|
||||
}
|
||||
}catch(e){ /* defensive: leave default styling */ }
|
||||
// set tooltip to show reasons / worker names for overlapping leaves
|
||||
try{ dayDiv.title = matches.map(x => x.reason || '').filter(Boolean).join(', '); }catch(e){}
|
||||
}
|
||||
const rangeEnd = selEnd || previewEnd;
|
||||
if(selStart && rangeEnd){ if(selStart<=isoStr && isoStr<=rangeEnd) dayDiv.classList.add('in-range'); }
|
||||
else if(selStart && selStart===isoStr) dayDiv.classList.add('selected');
|
||||
dayDiv.dataset.iso = isoStr;
|
||||
// Pointer handling is done via a delegated listener attached to the
|
||||
// container (see below) so clicks that hit the numeric text node are
|
||||
// still caught. Keep the day hover handlers here for previews.
|
||||
dayDiv.onmouseover = function(){ if(selStart && !selEnd){ previewEnd = this.dataset.iso; render(); } };
|
||||
dayDiv.onmouseout = function(){ if(selStart && !selEnd){ previewEnd = null; render(); } };
|
||||
}
|
||||
week.appendChild(dayDiv);
|
||||
cur++;
|
||||
}
|
||||
container.appendChild(week);
|
||||
}
|
||||
buildControls();
|
||||
}
|
||||
|
||||
// expose a simple API to clear the current selection from outside.
|
||||
function clearSelection(){ selStart = null; selEnd = null; previewEnd = null; render(); }
|
||||
function setLeaves(newLeaves){ leaves = newLeaves || []; render(); }
|
||||
async function refreshLeaves(){
|
||||
if(leavesUrl){
|
||||
try{
|
||||
const res = await fetch(leavesUrl, { credentials: 'same-origin' });
|
||||
if(res.ok){
|
||||
const data = await res.json();
|
||||
if(data && Array.isArray(data.leaves)){
|
||||
setLeaves(data.leaves.map(l=>({start:l.start, end:l.end, reason:l.reason, color:l.color, worker_id:l.worker_id}))); return;
|
||||
}
|
||||
}
|
||||
}catch(e){ console.error('refreshLeaves failed', e); }
|
||||
}
|
||||
// fallback: do nothing
|
||||
}
|
||||
|
||||
window._calendars[containerId] = { clearSelection, setLeaves, refreshLeaves };
|
||||
|
||||
// Delegated pointer handler: attach once to the container so clicks on
|
||||
// numeric text nodes (or any child) are still detected via event
|
||||
// delegation. This is more reliable than per-day handlers when the
|
||||
// DOM changes or when the target is a text node.
|
||||
if(!container._pointerHandlerAttached){
|
||||
container.addEventListener('pointerdown', function(evt){
|
||||
try{
|
||||
const dayEl = evt.target.closest && evt.target.closest('.day');
|
||||
console.debug('calendar click', evt.target, dayEl && dayEl.dataset && dayEl.dataset.iso);
|
||||
if(!dayEl || dayEl.classList.contains('other-month')) return;
|
||||
const clicked = dayEl.dataset.iso;
|
||||
if(!selStart){ selStart = clicked; selEnd = null; }
|
||||
else if(selStart && !selEnd){ if(clicked < selStart){ selEnd = selStart; selStart = clicked } else selEnd = clicked }
|
||||
else { selStart = clicked; selEnd = null }
|
||||
previewEnd = null;
|
||||
render();
|
||||
if(selStart && selEnd){
|
||||
// If a specific workerId is supplied, use existing single-worker modal flow.
|
||||
if(workerId){
|
||||
const url = requestUrl + '?worker_id=' + encodeURIComponent(workerId) + '&start_date=' + selStart + '&end_date=' + selEnd;
|
||||
setTimeout(function(){
|
||||
try{ if(window.htmx && typeof window.htmx.ajax === 'function'){ window.htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } else { htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } }catch(e){ window.location = url; }
|
||||
}, 80);
|
||||
} else {
|
||||
// Global calendar: prompt for workers to apply this leave to.
|
||||
// Use an HTMX GET to render the select-workers modal.
|
||||
let url = '/leave/select_workers/?start_date=' + encodeURIComponent(selStart) + '&end_date=' + encodeURIComponent(selEnd);
|
||||
// Include rota_id if present in the page URL
|
||||
try{ const rotaParam = new URLSearchParams(window.location.search).get('rota_id'); if(rotaParam){ url += '&rota_id=' + encodeURIComponent(rotaParam); } }catch(e){}
|
||||
setTimeout(function(){
|
||||
try{ if(window.htmx && typeof window.htmx.ajax === 'function'){ window.htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } else { htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); } }catch(e){ window.location = url; }
|
||||
}, 80);
|
||||
}
|
||||
}
|
||||
}catch(e){ console.error('calendar pointer handler', e); }
|
||||
});
|
||||
container._pointerHandlerAttached = true;
|
||||
}
|
||||
|
||||
render();
|
||||
// If a server leavesUrl is provided, refresh from server to ensure authoritative data
|
||||
if(leavesUrl){
|
||||
// don't await here — fire-and-forget refresh
|
||||
refreshLeaves();
|
||||
}
|
||||
}
|
||||
|
||||
// provide a global helper to clear selections across all calendars
|
||||
window.clearAllCalendarSelections = function(){
|
||||
try{
|
||||
if(window._calendars){ Object.values(window._calendars).forEach(c => c.clearSelection && c.clearSelection()); }
|
||||
}catch(e){ console.error('clearAllCalendarSelections', e); }
|
||||
};
|
||||
|
||||
// expose
|
||||
window.initCalendar = initCalendar;
|
||||
})();
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Project-level static copy of timetable.css to increase chance of being served by Django staticfinders */
|
||||
html {}
|
||||
|
||||
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
|
||||
|
||||
/* minimal rules copied from original timetable.css */
|
||||
.table-div { overflow-x: auto; }
|
||||
#main-table th { text-align: center; }
|
||||
|
||||
/* keep the rest in app static */
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import datetime
|
||||
from rota_generator.shifts import RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker, AvoidShiftOnDates
|
||||
|
||||
|
||||
def build_simple_rota():
|
||||
start = datetime.date(2026, 4, 6) # Monday
|
||||
Rota = RotaBuilder(start, weeks_to_rota=4, name="test_rota")
|
||||
Rota.add_shifts(
|
||||
SingleShift(sites=("exeter",), name="night_weekday", length=12.25, days=days[:4], workers_required=1),
|
||||
SingleShift(sites=("exeter",), name="plymouth_twilight", length=8, days=days[5:], workers_required=1),
|
||||
)
|
||||
Rota.build_shifts()
|
||||
return Rota
|
||||
|
||||
|
||||
def test_avoid_single_date_for_worker():
|
||||
Rota = build_simple_rota()
|
||||
# create a worker who should avoid night_weekday on 2026-04-07
|
||||
avoid = AvoidShiftOnDates(shifts=["night_weekday"], dates=[datetime.date(2026, 4, 7)])
|
||||
w = Worker(name="Test Worker", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||
Rota.add_worker(w)
|
||||
Rota.build_workers()
|
||||
|
||||
# find week/day corresponding to 2026-04-07
|
||||
weeks_days = [(wk, d) for wk, d in Rota.weeks_days_product if Rota.week_day_date_map[(wk, d)] == datetime.date(2026, 4, 7)]
|
||||
assert len(weeks_days) == 1
|
||||
wk, day = weeks_days[0]
|
||||
|
||||
# The model should mark works[worker.id, wk, day, 'night_weekday'] impossible -> check unavailable_to_work
|
||||
assert (w.id, wk, day) not in Rota.unavailable_to_work
|
||||
|
||||
# Build and solve minimal model (but we can inspect constraints). Instead, check that the avoid rule was registered
|
||||
found = False
|
||||
for c in Rota.constraint_options_model.avoid_shifts_by_worker_dates:
|
||||
if "names" in c and w.name in c["names"]:
|
||||
found = True
|
||||
# ensure the date is represented
|
||||
assert c.get("dates") is not None and datetime.date(2026,4,7) in c.get("dates")
|
||||
assert found
|
||||
|
||||
|
||||
def test_avoid_date_range_for_worker_applies_to_multiple_days():
|
||||
Rota = build_simple_rota()
|
||||
# Avoid night_weekday for 2026-04-06 through 2026-04-09
|
||||
avoid = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="09/04/2026")
|
||||
w = Worker(name="Range Worker", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||
Rota.add_worker(w)
|
||||
Rota.build_workers()
|
||||
|
||||
# Collect dates between start and end
|
||||
sd = datetime.date(2026,4,6)
|
||||
ed = datetime.date(2026,4,9)
|
||||
dates_in_range = [d for d in Rota.get_date_range(sd, ed)]
|
||||
assert len(dates_in_range) == 4
|
||||
|
||||
# Ensure the rota-level constraint was added
|
||||
found = False
|
||||
for c in Rota.constraint_options_model.avoid_shifts_by_worker_dates:
|
||||
if w.name in c.get("names", []):
|
||||
found = True
|
||||
assert c.get("start_date") == sd
|
||||
assert c.get("end_date") == ed
|
||||
assert found
|
||||
|
||||
|
||||
def test_export_rota_for_visual_inspection():
|
||||
Rota = build_simple_rota()
|
||||
# add two workers
|
||||
avoid = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="26/04/2026")
|
||||
w1 = Worker(name="Export A", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||
w2 = Worker(name="Export B", site="exeter", grade=3, fte=100)
|
||||
|
||||
|
||||
|
||||
Rota.add_workers((w1, w2))
|
||||
|
||||
# Build model but do not solve, then export
|
||||
Rota.name = "export_test_rota"
|
||||
Rota.build_and_solve(solve=True)
|
||||
|
||||
assert Rota.results.solver.status == "ok", "Rota should be feasible"
|
||||
|
||||
# Next we check that worker w1 has no night_weekday shifts assigned between 06/04/2026 and 26/04/2026
|
||||
sd = datetime.date(2026,4,6)
|
||||
ed = datetime.date(2026,4,26)
|
||||
for single_date in Rota.get_date_range(sd, ed):
|
||||
wk_day = Rota.get_week_day_by_date(single_date)
|
||||
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w1.id, wk_day[0], wk_day[1])
|
||||
for sh in assigned_shifts:
|
||||
assert sh.name != "night_weekday", f"Worker {w1.name} was assigned forbidden shift on {single_date}"
|
||||
|
||||
# We also check that w1 has 4 night shifts in total (the max possible in 4 weeks)
|
||||
night_shift_count = 0
|
||||
for wk, day in Rota.weeks_days_product:
|
||||
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w1.id, wk, day)
|
||||
for sh in assigned_shifts:
|
||||
if sh.name == "night_weekday":
|
||||
night_shift_count += 1
|
||||
assert night_shift_count == 4, f"Worker {w1.name} should have 4 night shifts, has {night_shift_count}"
|
||||
|
||||
# And that w2 has 12 night shifts (no restrictions)
|
||||
night_shift_count_w2 = 0
|
||||
for wk, day in Rota.weeks_days_product:
|
||||
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w2.id, wk, day)
|
||||
for sh in assigned_shifts:
|
||||
if sh.name == "night_weekday":
|
||||
night_shift_count_w2 += 1
|
||||
assert night_shift_count_w2 == 12, f"Worker {w2.name} should have 12 night shifts, has {night_shift_count_w2}"
|
||||
|
||||
# Next check that adding a short avoid to w2 results in an infeasible model
|
||||
avoid2 = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="07/04/2026")
|
||||
w2.avoid_shifts_on_dates.append(avoid2)
|
||||
Rota.build_and_solve(solve=True)
|
||||
|
||||
assert Rota.results.solver.termination_condition == "infeasible", "Rota should be infeasible due to avoid shifts on dates constraints"
|
||||
|
||||
|
||||
Rota.export_rota_to_html("avoid_shifts_on_dates_test.html", folder="tests")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from rota.shifts import 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():
|
||||
@@ -31,7 +31,7 @@ def test_balance_blocks_across_2_groups(demo_rota_balance_sites):
|
||||
balance_offset=40,
|
||||
workers_required=2,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "balance_across_groups"}],
|
||||
constraints=[BalanceAcrossGroupsConstraint()],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_nights_across_sites"] = False
|
||||
@@ -76,7 +76,7 @@ def test_balance_blocks_across_2_groups_unbalanced(demo_rota_balance_sites):
|
||||
balance_offset=40,
|
||||
workers_required=3,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "balance_across_groups"}],
|
||||
constraints=[BalanceAcrossGroupsConstraint()],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_nights_across_sites"] = False
|
||||
@@ -116,7 +116,7 @@ def test_balance_blocks_across_3_groups(demo_rota_balance_sites):
|
||||
balance_offset=40,
|
||||
workers_required=3,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "balance_across_groups"}],
|
||||
constraints=[BalanceAcrossGroupsConstraint()],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_nights_across_sites"] = False
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from pytest import approx
|
||||
from rota.shifts import 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)
|
||||
@@ -61,7 +61,7 @@ def test_weighted_shift_balancing():
|
||||
balance_weighting=10,
|
||||
workers_required=1,
|
||||
force_as_block=False,
|
||||
constraint=[{"name": "pre","options": "2"}, {"name": "post","options": "2"}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)]
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group1", "group2"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from rota.shifts import RotaBuilder, SingleShift, days
|
||||
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():
|
||||
@@ -33,7 +33,7 @@ def test_constraint_limit_grades(limit_constraint_rota):
|
||||
balance_offset=60,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "limit_grade_number", "options": {2: 1}}],
|
||||
constraints=[LimitGradeNumberConstraint(grade=2, max_number=1)]
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.01})
|
||||
@@ -60,7 +60,7 @@ def test_constraint_limit_grades2(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "limit_grade_number", "options": {3: 1}}],
|
||||
constraints=[LimitGradeNumberConstraint(grade=3, max_number=1)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -96,7 +96,7 @@ def test_constraint_limit_grades3(limit_constraint_rota):
|
||||
balance_offset=60,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "limit_grade_number", "options": {2: 3, 3: 1}}],
|
||||
constraints=[LimitGradeNumberConstraint(grade=2, max_number=3), LimitGradeNumberConstraint(grade=3, max_number=1)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -124,7 +124,7 @@ def test_constraint_limit_grades4(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "limit_grade_number", "options": {2: 4, 3: 0}}],
|
||||
constraints=[LimitGradeNumberConstraint(grade=2, max_number=4), LimitGradeNumberConstraint(grade=3, max_number=0)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -143,7 +143,7 @@ def test_constraint_minimum_grades(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=1,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "minimum_grade_number", "options": (3, 1)}],
|
||||
constraints=[MinimumGradeNumberConstraint(grade=3, min_number=1)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -170,7 +170,7 @@ def test_constraint_minimum_grades2(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "minimum_grade_number", "options": (3, 3)}],
|
||||
constraints=[MinimumGradeNumberConstraint(grade=3, min_number=3)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -197,7 +197,7 @@ def test_constraint_minimum_grades3(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "minimum_grade_number", "options": (3, 0)}],
|
||||
constraints=[MinimumGradeNumberConstraint(grade=3, min_number=0)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -217,7 +217,7 @@ def test_constraint_minimum_grades_no_valid_worker(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "minimum_grade_number", "options": (4, 1)}],
|
||||
constraints=[MinimumGradeNumberConstraint(grade=4, min_number=1)],
|
||||
),
|
||||
)
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
@@ -236,11 +236,8 @@ def test_constraint_require_remote_site_presence_week(limit_constraint_rota):
|
||||
balance_offset=40,
|
||||
workers_required=2,
|
||||
force_as_block=True,
|
||||
constraint=[
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("group1", 2),
|
||||
}
|
||||
constraints=[
|
||||
RequireRemoteSitePresenceConstraint(site="group1", required_number=2)
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -260,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(
|
||||
@@ -272,11 +269,8 @@ def test_constraint_require_remote_site_presence_week2(limit_constraint_rota):
|
||||
balance_offset=20,
|
||||
workers_required=3,
|
||||
force_as_block=True,
|
||||
constraint=[
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("group1", 2),
|
||||
}
|
||||
constraints=[
|
||||
RequireRemoteSitePresenceConstraint(site="group1", required_number=2)
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -304,11 +298,8 @@ def test_constraint_require_remote_site_presence_week3(limit_constraint_rota):
|
||||
balance_offset=20,
|
||||
workers_required=3,
|
||||
force_as_block=True,
|
||||
constraint=[
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("group2", 1),
|
||||
}
|
||||
constraints=[
|
||||
RequireRemoteSitePresenceConstraint(site="group2", required_number=1)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 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():
|
||||
@@ -45,7 +45,7 @@ def test_basic_assignment(demo_rota_night_unavailable):
|
||||
name="night_weekend",
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.00})
|
||||
@@ -66,7 +66,7 @@ def test_assign_night_prior_to_unavailablity(demo_rota_night_unavailable):
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
workers_required=2,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.00})
|
||||
@@ -101,7 +101,7 @@ def test_assign_prior_to_unavailablity_non_night(demo_rota_night_unavailable):
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
workers_required=2,
|
||||
constraint=[],
|
||||
constraints=[],
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.00})
|
||||
@@ -135,7 +135,7 @@ def test_assign_split(demo_rota_night_unavailable):
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
workers_required=3,
|
||||
constraint=[],
|
||||
constraints=[],
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.00})
|
||||
|
||||
+21
-20
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from rota.shifts import InvalidShift, NoWorkers, 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)
|
||||
@@ -35,7 +35,7 @@ def test_nights():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}, {"name": "pre", "options": 2}],
|
||||
constraints=[NightConstraint(), PreShiftConstraint(days=2)],
|
||||
workers_required=2,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -60,7 +60,7 @@ def test_nights_pre3():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
|
||||
constraints=[NightConstraint(), PreShiftConstraint(days=3)],
|
||||
workers_required=2,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -84,7 +84,7 @@ def test_nights_pre3_2():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
|
||||
constraints=[NightConstraint(), PreShiftConstraint(days=3)],
|
||||
workers_required=1,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -108,7 +108,7 @@ def test_nights_fail():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}, {"name": "pre", "options": 3}],
|
||||
constraints=[NightConstraint(), PreShiftConstraint(days=8), PostShiftConstraint(days=2)],
|
||||
workers_required=2,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -120,6 +120,7 @@ def test_nights_fail():
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
Rota.export_rota_to_html("night fail", folder="tests")
|
||||
assert Rota.results.solver.status in ("warning", "error")
|
||||
|
||||
def test_nights2():
|
||||
@@ -132,7 +133,7 @@ def test_nights2():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=2,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -158,10 +159,10 @@ def test_nights_pre_wrap_around():
|
||||
length=12.5,
|
||||
days=days[:2],
|
||||
force_as_block=True,
|
||||
constraint=[
|
||||
{"name": "night"},
|
||||
{"name": "pre", "options": 5},
|
||||
{"name": "post", "options": 5},
|
||||
constraints=[
|
||||
NightConstraint(),
|
||||
PreShiftConstraint(days=5),
|
||||
PostShiftConstraint(days=5),
|
||||
],
|
||||
workers_required=3,
|
||||
),
|
||||
@@ -195,7 +196,7 @@ def test_nights_max_frequency():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=2,
|
||||
),
|
||||
)
|
||||
@@ -213,7 +214,7 @@ def test_nights_max_frequency_fail():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=2,
|
||||
),
|
||||
)
|
||||
@@ -230,7 +231,7 @@ def test_nights_max_frequency3():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=1,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -239,7 +240,7 @@ def test_nights_max_frequency3():
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
@@ -257,7 +258,7 @@ def test_nights_max_frequency4():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=1,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -266,7 +267,7 @@ def test_nights_max_frequency4():
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
@@ -291,7 +292,7 @@ def test_nights_max_frequency_exclusions():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=2,
|
||||
),
|
||||
)
|
||||
@@ -310,7 +311,7 @@ def test_nights_max_frequency_exclusions2():
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=2,
|
||||
),
|
||||
)
|
||||
@@ -346,7 +347,7 @@ def test_nights_max_frequency_exclusions3():
|
||||
days=days[5:],
|
||||
force_as_block=True,
|
||||
balance_offset=10,
|
||||
constraint=[{"name": "night"}],
|
||||
constraints=[NightConstraint()],
|
||||
workers_required=2,
|
||||
),
|
||||
)
|
||||
|
||||
+60
-3
@@ -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."""
|
||||
@@ -163,7 +163,7 @@ def test_nwd_force_as_block_force_split():
|
||||
for worker in Rota.workers:
|
||||
shifts = Rota.get_worker_shift_list(worker)
|
||||
shifts_string = "".join([i if i != "" else "-" for i in shifts])
|
||||
for week in weeks_from_list(shifts_string[7 * 5 :]):
|
||||
for week in weeks_from_list(shifts_string[7 * 6 :]):
|
||||
assert week in ("-----ww", "dddddww")
|
||||
if worker.name == "worker1":
|
||||
assert shifts_string[: 7 * 5].count("ww-") == 4
|
||||
@@ -214,3 +214,60 @@ def test_nwd_testing():
|
||||
assert week == "dddddww"
|
||||
else:
|
||||
assert week == "-----ww"
|
||||
|
||||
|
||||
def test_force_as_block_unless_nwd_with_subblocks():
|
||||
# Test that a Fri/Sat/Sun shift can be split into Fri and Sat/Sun blocks
|
||||
# if a worker does not work on Fridays.
|
||||
Rota = setup_rota(weeks_to_rota=2)
|
||||
start_date = Rota.start_date
|
||||
|
||||
# worker1 does not work on Fridays (Fri is NWD)
|
||||
worker1 = Worker(
|
||||
name="worker1", site="group1", grade=1,
|
||||
nwds=[{"day": "Fri", "start_date": start_date, "end_date": start_date + datetime.timedelta(weeks=2)}],
|
||||
)
|
||||
# worker2 works normally
|
||||
worker2 = Worker(name="worker2", site="group1", grade=1)
|
||||
|
||||
Rota.add_workers((worker1, worker2))
|
||||
|
||||
# Fri/Sat/Sun shift, requiring 1 worker, forced as block unless NWD
|
||||
# We specify sub-blocks: [["Fri"], ["Sat", "Sun"]]
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",), name="weekend", length=12.5,
|
||||
days=["Fri", "Sat", "Sun"],
|
||||
workers_required=1,
|
||||
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
|
||||
),
|
||||
)
|
||||
|
||||
# We remove no valid shifts warning
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
|
||||
Rota.build_and_solve()
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
# Check assignments:
|
||||
# Since worker1 has NWD on Friday:
|
||||
# - worker1 cannot work Friday.
|
||||
# - But worker1 can work Sat and Sun as a block!
|
||||
# So on Saturday and Sunday, worker1 should be assigned.
|
||||
# On Friday, worker2 should be assigned.
|
||||
for week in Rota.weeks:
|
||||
w1_fri = Rota.model.works[worker1.id, week, "Fri", "weekend"].value
|
||||
w1_sat = Rota.model.works[worker1.id, week, "Sat", "weekend"].value
|
||||
w1_sun = Rota.model.works[worker1.id, week, "Sun", "weekend"].value
|
||||
|
||||
w2_fri = Rota.model.works[worker2.id, week, "Fri", "weekend"].value
|
||||
w2_sat = Rota.model.works[worker2.id, week, "Sat", "weekend"].value
|
||||
w2_sun = Rota.model.works[worker2.id, week, "Sun", "weekend"].value
|
||||
|
||||
assert w1_fri == 0
|
||||
|
||||
# Either worker1 works Sat/Sun and worker2 works Fri (split block)
|
||||
# OR worker2 works Fri/Sat/Sun (full block) and worker1 works nothing
|
||||
is_split = (w1_sat == 1 and w1_sun == 1 and w2_fri == 1 and w2_sat == 0 and w2_sun == 0)
|
||||
is_full_w2 = (w1_sat == 0 and w1_sun == 0 and w2_fri == 1 and w2_sat == 1 and w2_sun == 1)
|
||||
assert is_split or is_full_w2
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
|
||||
import pytest
|
||||
from rota.shifts import NoWorkers, 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
|
||||
@@ -34,7 +34,7 @@ class TestRemoteRotas:
|
||||
workers_required=1,
|
||||
balance_offset=100,
|
||||
force_as_block=False,
|
||||
constraint=[{"name":"require_remote_site_presence_week", "options":("group2", 2)}],
|
||||
constraints=[RequireRemoteSitePresenceConstraint(site="group2", required_number=2)],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
@@ -70,7 +70,7 @@ class TestRemoteRotas:
|
||||
workers_required=1,
|
||||
balance_offset=10,
|
||||
force_as_block=True,
|
||||
constraint=[{"name":"require_remote_site_presence_week", "options":("group2", 1)}],
|
||||
constraints=[RequireRemoteSitePresenceConstraint(site="group2", required_number=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group1", "group2"), name="b", length= 12.5, days=days[3:],
|
||||
@@ -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(
|
||||
@@ -130,7 +130,7 @@ class TestRemoteRotas:
|
||||
workers_required=1,
|
||||
balance_offset=10,
|
||||
force_as_block=False,
|
||||
constraint=[{"name":"require_remote_site_presence_week", "options":("group2", 1)}],
|
||||
constraints=[RequireRemoteSitePresenceConstraint(site="group2", required_number=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group1", "group2"), name="b", length= 12.5, days=days[3:],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user