Compare commits

..
18 Commits
Author SHA1 Message Date
Ross f019d2f565 . 2025-12-15 22:25:17 +00:00
Ross d2e128006a . 2025-12-15 22:24:11 +00:00
Ross 761f52eafa . 2025-12-15 22:16:35 +00:00
Ross 2482f6a04a . 2025-12-15 22:07:50 +00:00
Ross e99ab2c1fa . 2025-12-15 21:58:11 +00:00
Ross cf966573de . 2025-12-15 21:54:55 +00:00
Ross 6674306306 . 2025-12-15 21:51:40 +00:00
Ross a7e898f0a7 . 2025-12-15 21:48:06 +00:00
Ross bc7036859e . 2025-12-15 21:46:00 +00:00
Ross 5b4838e67d . 2025-12-15 21:45:33 +00:00
Ross b48382e990 . 2025-12-15 21:44:57 +00:00
Ross 9fc2ba3210 Enhance worker list display with site, grade, and FTE information; add non-working days summary rendering 2025-12-15 21:41:10 +00:00
Ross c5850eb1b2 Add functionality to delete all leave requests for a worker with permission checks 2025-12-15 21:25:21 +00:00
Ross 638fe17c60 . 2025-12-15 21:10:33 +00:00
Ross a783ce1f3e Refactor fte mapping in Worker model to convert fractional values to percentage for pydantic compatibility 2025-12-15 20:54:05 +00:00
Ross 00c737becf Add guard against division by zero in target_shifts calculation with warning logging 2025-12-15 20:51:19 +00:00
Ross b0af2daca4 Improve error handling in _run_rota_job to persist logs and results for better UI diagnostics 2025-12-15 20:45:06 +00:00
Ross 9b5f52035d Enhance JSON parsing and normalization in WorkerForm to handle various input types 2025-12-15 20:41:26 +00:00
17 changed files with 717 additions and 88 deletions
+11
View File
@@ -43,10 +43,14 @@ 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",
@@ -133,6 +137,13 @@ STATICFILES_DIRS = [
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"
@@ -14,7 +14,14 @@
<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 %}">&larr; Back to rota: {{ back_rota.name }}</a></p>
{% endif %}
{% endwith %}
{% endif %}
<form method="post">
{% csrf_token %}
{% if token %}
@@ -0,0 +1,73 @@
{% 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 id="calendar" class="calendar" data-mode="inline"></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 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 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; }
window.initCalendar('calendar', { leaves: leaves, leavesUrl: leavesUrlBase, rotaId: initialRota, requestUrl: '{% url "rota:request_leave" %}', mode: container.dataset.mode || 'inline' });
function refreshLegendFor(id){
const legend = document.getElementById('rota-worker-legend');
if(!legend) return;
if(!id){ legend.innerText = 'Showing leave for all rotas.'; return; }
legend.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){ legend.innerText = 'No workers with leaves in this rota.'; return; }
legend.innerHTML = 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('');
}).catch(e=>{ legend.innerText = 'Unable to load legend'; console.debug(e); });
}
// Refresh legend for the initially selected rota (or show all rotas)
refreshLegendFor(initialRota);
})();
</script>
<div class="box" style="margin-top:0.75rem;">
<h4 class="subtitle is-6">Workers on this rota</h4>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
<div id="rota-worker-legend">Select a rota to show its workers.</div>
</div>
</div>
{% endblock %}
@@ -7,11 +7,14 @@
<!-- 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. -->
<form method="post" hx-post="{% url 'rota:leave_delete' l.id %}" hx-swap="none" style="display:inline;margin-left:0.5rem">
{% 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>
@@ -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>
@@ -4,9 +4,23 @@
<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="{% url 'rota:worker_detail' w.id %}">{{ w.name }}</a>
<small style="color:#666">&mdash; {{ 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 %}
</div>
<div style="flex:0 0 auto;">
<button class="button is-small is-light ml-2" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
<button class="button is-small is-danger ml-2" 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>
@@ -14,3 +28,69 @@
</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>
@@ -12,14 +12,17 @@
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
<div class="mb-4">
<p><button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button></p>
<p>
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}" style="margin-left:0.5rem;">All leave requests</a>
</p>
{% include 'rota/partials/worker_list.html' %}
</div>
<div class="mb-4">
<h2 class="subtitle">Historic runs</h2>
<div class="box">
<div style="margin-bottom:0.5em;">
<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>
@@ -36,7 +39,7 @@
<li>No previous runs</li>
{% endfor %}
</ul>
</div>
</details>
</div>
<div class="mb-4">
@@ -45,10 +48,16 @@
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
<p><button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button></p>
<hr />
<h3 class="subtitle is-6">Configured constraint values</h3>
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;">
<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 style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
<input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" />
<button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button>
</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">
<table class="table is-fullwidth is-striped is-narrow" id="constraint-table-{{ rota.id }}">
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
<tbody>
{% for key, info in configured_constraints.items %}
@@ -73,6 +82,30 @@
{% 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>
<form method="post">
@@ -1,54 +1,67 @@
{% extends 'base.html' %}
{% load crispy_forms_tags static %}
<html>
<head>
<title>{{ worker.name }}</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>
{% include 'rota/partials/flatpickr_includes.html' %}
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
</head>
<body>
<section class="section">
{% block content %}
<div class="container">
<div class="box">
<h2 class="subtitle">Request leave on calendar</h2>
<div id="calendar" data-worker-id="{{ worker.id }}"></div>
<p class="help">Select a date or drag to select a range to request leave.</p>
</div>
<h1 class="title">{{ worker.name }}</h1>
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
<h2 class="title">Request leave for {{ worker.name }}</h2>
<div class="box">
<h2 class="subtitle">Request leave</h2>
<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 %}">&larr; 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">Add leave</button>
<button class="button is-primary" type="submit">Request</button>
</div>
</div>
</form>
</div>
<h2 class="subtitle">Existing leave</h2>
<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>
</section>
</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;
const leaves = [
{% for l in worker.leaves.all %}
{start:'{{ l.start_date|date:"Y-m-d" }}', end:'{{ l.end_date|date:"Y-m-d" }}'},
{% endfor %}
];
window.initCalendar('calendar', { workerId: container.dataset.workerId, leaves: leaves, requestUrl: '{% url "rota:request_leave" %}', leavesUrl: '{% url "rota:worker_leaves_json" worker.id %}' });
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>
</body>
</html>
{% endblock %}
+7
View File
@@ -17,8 +17,15 @@ Including another URLconf
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
+30 -1
View File
@@ -168,11 +168,40 @@ class WorkerForm(forms.ModelForm):
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
opts[k] = parsed
else:
parsed = val
def _normalize(obj):
# Normalize pydantic BaseModel instances (v2:v1)
if hasattr(obj, "model_dump"):
try:
return obj.model_dump()
except Exception:
pass
if hasattr(obj, "dict") and not isinstance(obj, dict):
try:
return obj.dict()
except Exception:
pass
# Recurse lists/tuples
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, dict):
return {k: _normalize(v) for k, v in obj.items()}
return obj
opts[k] = _normalize(parsed)
instance.options = opts
if commit:
+21 -2
View File
@@ -153,13 +153,32 @@ class Worker(models.Model):
except Exception:
raise RuntimeError("Unable to import rota.workers.Worker; ensure project package is importable")
# Map fte: Django stores percentage (100) while pydantic Worker expects int
# 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": int(self.fte),
"fte": fte_pct,
}
# Merge any additional options stored on the Django model into the
+25
View File
@@ -120,11 +120,36 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
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:
+9
View File
@@ -20,7 +20,16 @@ urlpatterns = [
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>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
+169 -1
View File
@@ -320,7 +320,29 @@ def worker_detail(request, worker_id):
else:
form = LeaveForm()
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form})
# Build a JSON-serializable list of leaves for the calendar widget to consume
leaves = []
try:
for leave in worker.leaves.all():
try:
s = leave.start_date.isoformat()
except Exception:
s = str(leave.start_date)
try:
e = leave.end_date.isoformat()
except Exception:
e = str(leave.end_date)
leaves.append({"start": s, "end": e})
except Exception:
leaves = []
# Provide a JSON-encoded string for safe embedding in the template
try:
leaves_json = json.dumps(leaves)
except Exception:
leaves_json = '[]'
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form, "leaves": leaves, "leaves_json": leaves_json})
@require_http_methods(["GET", "POST"])
@@ -1017,6 +1039,106 @@ def leaves_json(request, worker_id):
return JsonResponse({'leaves': data})
def leaves_global_json(request, rota_id=None):
"""Return JSON list of all leave ranges across all workers."""
from .models import Leave
# Optionally filter by rota_id (provided as query param) so the global
# calendar can be scoped to a particular rota's workers.
rota_id = request.GET.get('rota_id') if rota_id is None else rota_id
qs = Leave.objects.select_related('worker')
if rota_id:
try:
rota_id_i = int(rota_id)
qs = qs.filter(worker__rotas=rota_id_i)
except Exception:
pass
# Build a worker->color map for workers present in the query so the
# client can color-code leaves by worker.
worker_colors = {}
def color_for_worker(pk):
# deterministic hue based on id
h = (int(pk) * 37) % 360
return f'hsl({h} 65% 85%)'
data = []
for leave in qs.all().order_by('start_date'):
worker = getattr(leave, 'worker', None)
wid = getattr(worker, 'id', None)
wname = getattr(worker, 'name', '') if worker else ''
if wid not in worker_colors and wid is not None:
worker_colors[wid] = color_for_worker(wid)
data.append({
'start': leave.start_date.isoformat(),
'end': leave.end_date.isoformat(),
'reason': wname or (leave.reason or ''),
'worker_id': wid,
'color': worker_colors.get(wid),
})
return JsonResponse({'leaves': data})
def leaves_global(request, rota_id=None):
"""Page showing a global calendar with all leave requests."""
# Provide an empty JSON payload initially; the calendar will fetch the authoritative list
leaves_json = '[]'
selected = rota_id if rota_id is not None else request.GET.get('rota_id')
return render(request, 'rota/leaves_global.html', {'leaves_json': leaves_json, 'rotas': RotaSchedule.objects.all().order_by('name'), 'selected_rota_id': selected})
@require_http_methods(["GET"])
def select_workers_modal(request, rota_id=None):
"""Return an HTMX modal partial that lists workers to apply a selected leave range to.
Query params: start_date, end_date, rota_id (optional)
"""
start_date = request.GET.get('start_date')
end_date = request.GET.get('end_date')
rota_id = request.GET.get('rota_id') if rota_id is None else rota_id
workers = Worker.objects.all()
if rota_id:
try:
workers = workers.filter(rotas__id=int(rota_id))
except Exception:
pass
workers = workers.order_by('name')
modal_html = render_to_string(
'rota/partials/select_workers_modal.html',
{'workers': workers, 'start_date': start_date, 'end_date': end_date},
request=request,
)
return HttpResponse(modal_html)
@require_http_methods(["POST"])
def apply_leave_multi(request):
"""Apply a leave range to multiple workers. Expects POST with worker_ids (list), start_date and end_date."""
from .models import Leave
worker_ids = request.POST.getlist('worker_id')
start_date = request.POST.get('start_date')
end_date = request.POST.get('end_date')
if not worker_ids or not start_date:
return HttpResponseBadRequest('Missing parameters')
created = []
for wid in worker_ids:
try:
w = Worker.objects.get(pk=int(wid))
except Exception:
continue
try:
leave = Leave(worker=w, start_date=start_date, end_date=end_date)
leave.save()
created.append(leave)
except Exception:
continue
# Return simple HTMX response: close modal and trigger calendar refresh
response = HttpResponse('')
response['HX-Trigger'] = 'closeModal,leaveUpdated'
return response
@require_POST
def leave_delete(request, leave_id):
"""Delete a Leave record. Allowed if the requester is staff, or if a valid token matches the worker, or the authenticated user's email matches the worker email."""
@@ -1062,6 +1184,52 @@ def leave_delete(request, leave_id):
return response
@require_POST
def leave_delete_all(request, worker_id):
"""Delete all Leave records for a worker. Permissions same as `leave_delete`.
Returns an OOB swap updating `#leave-list` and triggers `leaveUpdated`.
"""
worker = get_object_or_404(Worker, pk=worker_id)
# permission checks (same as leave_delete)
allowed = False
token = request.POST.get('token') or request.GET.get('token')
try:
if request.user and request.user.is_authenticated and request.user.is_staff:
allowed = True
elif request.user and request.user.is_authenticated and getattr(request.user, 'email', None) and request.user.email == (worker.email or ''):
allowed = True
elif token:
try:
import uuid
token_uuid = uuid.UUID(token)
if worker.request_token == token_uuid:
allowed = True
except Exception:
allowed = False
except Exception:
allowed = False
if not allowed:
return HttpResponseBadRequest('Permission denied')
# delete all leaves for this worker
worker.leaves.all().delete()
# render updated leave list for the worker
leave_list_html = render_to_string(
"rota/partials/leave_list.html",
{"leaves": worker.leaves.all(), "token": token},
request=request,
)
resp = f'<div id="leave-list" hx-swap-oob="true">{leave_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "leaveUpdated"
return response
@require_POST
def rota_run_export_regenerate(request, run_id):
"""Regenerate the HTML export for a completed run and persist it on the RotaRun.
+1
View File
@@ -18,3 +18,4 @@ typer
django>=6.0,<7
django-crispy-forms
crispy-bulma
django-debug-toolbar
+13
View File
@@ -2007,6 +2007,19 @@ class RotaBuilder(object):
for i in shift.sites
)
# Guard against division by zero: if no full-time
# equivalent is available for the shift's sites the
# denominator may be zero (for example when all site
# FTEs are 0). In that case emit a warning and set the
# target to 0 rather than raising an exception.
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
self.add_warning(
"Zero FTE for sites",
f"full_time_equivalent sum is zero for shift {shift.name}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
else:
target_shifts = (
total_shifts
/ full_time_equivalent_joined
+50 -5
View File
@@ -14,8 +14,12 @@
function iso(y,m,d){ return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` }
function initCalendar(containerId, opts){
const container = document.getElementById(containerId);
// 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;
@@ -25,7 +29,11 @@
let viewMonth = new Date().getMonth();
let selStart = null; let selEnd = null; let previewEnd = null;
function inLeaves(isoStr){ for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) return true } return false }
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');
@@ -46,7 +54,7 @@
};
}
const label = document.getElementById('sel-label');
if(selStart && selEnd) label.textContent = `Selected: ${selStart} ${selEnd}`;
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;
@@ -80,7 +88,31 @@
else{
const isoStr = iso(viewYear, viewMonth, cur);
dayDiv.textContent = cur;
if(inLeaves(isoStr)) dayDiv.classList.add('leave');
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');
@@ -109,7 +141,7 @@
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}))); return;
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); }
@@ -127,6 +159,7 @@
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; }
@@ -135,10 +168,22 @@
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); }
});