Compare commits

...
25 Commits
Author SHA1 Message Date
Ross 0db4881d6f Enhance textarea synchronization and improve WorkerForm save logic 2025-12-14 22:28:17 +00:00
Ross 008c28f68e . 2025-12-14 22:18:56 +00:00
Ross 6d4e335a90 . 2025-12-14 22:16:35 +00:00
Ross e3cbde0986 . 2025-12-14 22:10:46 +00:00
Ross 792349b2ea . 2025-12-14 22:08:30 +00:00
Ross 55df9da8cb . 2025-12-14 21:57:16 +00:00
Ross f0ebc0d01a . 2025-12-14 21:52:31 +00:00
Ross ff2b2ebb95 . 2025-12-14 21:37:08 +00:00
Ross 046f3a5a4a . 2025-12-14 21:27:51 +00:00
Ross de54e03fcf Refactor leave deletion form to prevent default HTMX swap behavior 2025-12-14 21:20:54 +00:00
Ross 90b957fb78 . 2025-12-14 21:17:42 +00:00
Ross d6ede37e0b . 2025-12-14 21:07:16 +00:00
Ross 26d3ff7a2d . 2025-12-14 21:00:30 +00:00
Ross 5165fe78bd . 2025-12-14 17:16:59 +00:00
Ross c43ff7fc73 . 2025-12-14 17:13:19 +00:00
Ross 2889825734 . 2025-12-14 17:08:23 +00:00
Ross 1dd7c8d19e . 2025-12-14 09:30:23 +00:00
Ross e4eaca378b . 2025-12-14 09:22:47 +00:00
Ross 249e09766d . 2025-12-13 22:41:32 +00:00
Ross d509922450 . 2025-12-13 22:18:42 +00:00
Ross a89aa0a35b . 2025-12-13 22:07:07 +00:00
Ross bd5ca8f64c . 2025-12-13 21:33:39 +00:00
Ross 18a45ff87e refactor: change days type to Optional[int] in PreShiftConstraint and PostShiftConstraint 2025-12-12 22:35:06 +00:00
Ross f6dd9285a5 . 2025-12-12 22:31:07 +00:00
Ross 00f09d5e5b . 2025-12-11 21:59:35 +00:00
29 changed files with 2463 additions and 200 deletions
+6 -2
View File
@@ -121,12 +121,16 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "/static/"
# Project-level output directory (app-level) plus repo-level `output` for legacy layout # 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 = [ STATICFILES_DIRS = [
BASE_DIR / "static",
BASE_DIR / "output", BASE_DIR / "output",
# also look for output/ at the repository root (BASE_DIR.parent) # also look for output/ at the repository root (BASE_DIR.parent)
BASE_DIR.parent / "output", BASE_DIR.parent / "output",
# repository-level static folder (workspace root)
BASE_DIR.parent / "static",
] ]
# Default primary key field type # Default primary key field type
@@ -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;
}
+170
View File
@@ -0,0 +1,170 @@
{% 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){}
}
}catch(e){ console.error('htmx:afterRequest robustness handler', e); }
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,61 @@
{% 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>
<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,20 @@
{% 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. -->
<form method="post" 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>
</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>
@@ -16,6 +16,238 @@ function _close_shift_modal_targets(){
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML"> <form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ 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-list"></div>
<div style="margin-top:0.5em;">
<button type="button" class="button is-small" id="add-constraint">Add constraint</button>
</div>
</div>
<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: "Days (integer)", type: "int"},
{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: "Days (integer)", type: "int"},
{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"}, {name: "min_grade_sum", label: "Minimum grade sum", type: "int"}, {name: "days", label: "Days (comma-separated)", type: "list"} ]},
"limit_grade_number": {label: "Limit grade number", fields: [ {name: "grade", label: "Grade", type: "int"}, {name: "max_number", label: "Max number", type: "int"} ]},
"minimum_grade_number": {label: "Minimum grade number", fields: [ {name: "grade", label: "Grade", type: "int"}, {name: "min_number", label: "Min number", type: "int"} ]},
"require_remote_site_presence": {label: "Require remote site presence", fields: [ {name: "site", label: "Site", type: "text"}, {name: "required_number", label: "Required number", type: "int"} ]},
"max_shifts_per_week": {label: "Max shifts per week", fields: [ {name: "max_shifts", label: "Max shifts", type: "int"} ]},
"max_shifts_per_week_block": {label: "Max shifts per week block", fields: [ {name: "week_block", label: "Week block", type: "int"}, {name: "max_shifts", label: "Max shifts", type: "int"} ]}
};
const textarea = document.getElementById('id_constraints');
const listEl = document.getElementById('constraint-list');
const addBtn = document.getElementById('add-constraint');
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();
}
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;
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;
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);
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>
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<button class="button is-primary" type="submit">Save</button> <button class="button is-primary" type="submit">Save</button>
@@ -14,6 +14,318 @@
<input type="hidden" name="rota_id" value="{{ rota_id }}" /> <input type="hidden" name="rota_id" value="{{ rota_id }}" />
{% endif %} {% endif %}
{{ form|crispy }} {{ 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 %}
<!-- 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>
<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-half">
<h5 class="subtitle is-6">Out of programme</h5>
<div id="oop-list"></div>
<button type="button" class="button is-small" id="add-oop">Add OOP</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Not available to work</h5>
<div id="nav-list"></div>
<button type="button" class="button is-small" id="add-nav">Add unavailability</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Preferences not to work</h5>
<div id="pref-list"></div>
<button type="button" class="button is-small" id="add-pref">Add preference</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Work requests</h5>
<div id="wr-list"></div>
<button type="button" class="button is-small" id="add-wr">Add request</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Locum availability</h5>
<div id="loc-list"></div>
<button type="button" class="button is-small" id="add-loc">Add locum availability</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Forced assignments (week,day,shift)</h5>
<div id="fa-list"></div>
<button type="button" class="button is-small" id="add-fa">Add forced assignment</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Forced assignments by date</h5>
<div id="fabd-list"></div>
<button type="button" class="button is-small" id="add-fabd">Add forced assignment by date</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Hard day dependencies</h5>
<div id="hdd-list"></div>
<button type="button" class="button is-small" id="add-hdd">Add dependency</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Hard day exclusions</h5>
<div id="hde-list"></div>
<button type="button" class="button is-small" id="add-hde">Add exclusion</button>
</div>
<div class="column is-half">
<h5 class="subtitle is-6">Allowed multi-shift sets</h5>
<div id="ams-list"></div>
<button type="button" class="button is-small" id="add-ams">Add set</button>
</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);
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';
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 {
input = document.createElement('input'); input.type='text'; input.className='input'; input.value = item[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 inputs = el.querySelectorAll('input'); const obj = {}; cfg.fields.forEach((f,j)=>{ const val = inputs[j].value; if(val!=='') obj[f.name]= f.type==='int'?parseInt(val,10):val; }); 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'}]});
makeSimpleListEditor({textarea:'id_work_requests', container:'wr-list', addButton:'add-wr', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'text'}]});
makeSimpleListEditor({textarea:'id_locum_availability', container:'loc-list', addButton:'add-loc', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'text'}]});
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:'text'}]});
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:'text'}]});
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 (comma-separated)',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 (comma-separated)',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="field">
<div class="control"> <div class="control">
<button class="button is-primary" type="submit">Save</button> <button class="button is-primary" type="submit">Save</button>
@@ -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>
@@ -1,24 +1,18 @@
<html> {% extends 'base.html' %}
<head> {% block head %}
<title>Rota: {{ rota.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet"> <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' %} {% include 'rota/partials/flatpickr_includes.html' %}
</head> {% endblock %}
<body> {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title">{{ rota.name }}</h1> <h1 class="title">{{ rota.name }}</h1>
<p> <p><a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a></p>
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
</p>
<p class="subtitle">{{ rota.description }}</p> <p class="subtitle">{{ rota.description }}</p>
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p> <p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
<div class="mb-4"> <div class="mb-4">
<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></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>
{% include 'rota/partials/worker_list.html' %} {% include 'rota/partials/worker_list.html' %}
</div> </div>
@@ -34,12 +28,9 @@
<ul> <ul>
{% for run in rota.runs.all %} {% for run in rota.runs.all %}
<li> <li>
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> <a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> {{ run.get_status_display }}
{{ run.get_status_display }}
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %} {% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
<span class="ml-2"> <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>
<a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a>
</span>
</li> </li>
{% empty %} {% empty %}
<li>No previous runs</li> <li>No previous runs</li>
@@ -48,29 +39,17 @@
</div> </div>
</div> </div>
<div class="mb-4">
<h2 class="subtitle">Builder export</h2>
<p>
<a class="button is-link" href="{% url 'rota:rota_export_builder' rota.id %}" target="_blank">View generated HTML (no solver)</a>
<a class="button" href="{% url 'rota:rota_export_builder' rota.id %}?solve=1">Generate + Solve (may be slow)</a>
</p>
</div>
<div class="mb-4"> <div class="mb-4">
<h2 class="subtitle">Constraint options</h2> <h2 class="subtitle">Constraint options</h2>
<div class="box"> <div class="box">
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p> <p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
<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>
<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 /> <hr />
<h3 class="subtitle is-6">Configured constraint values</h3> <h3 class="subtitle is-6">Configured constraint values</h3>
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;"> <div style="max-height:200px; overflow:auto; margin-bottom:0.75em;">
{% if configured_constraints %} {% if configured_constraints %}
<table class="table is-fullwidth is-striped is-narrow"> <table class="table is-fullwidth is-striped is-narrow">
<thead> <thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
<tr><th>Key</th><th>Value</th><th></th></tr>
</thead>
<tbody> <tbody>
{% for key, info in configured_constraints.items %} {% for key, info in configured_constraints.items %}
<tr id="constraint-row-{{ key }}"> <tr id="constraint-row-{{ key }}">
@@ -93,74 +72,27 @@
<p class="help">No typed constraint options have been configured for this rota.</p> <p class="help">No typed constraint options have been configured for this rota.</p>
{% endif %} {% endif %}
</div> </div>
<hr />
<h3 class="subtitle is-6">Available constraint keys</h3>
<div style="max-height:300px; overflow:auto;">
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr>
<th>Key</th>
<th>Default</th>
<th>Current</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for key, info in available_constraints.items %}
<tr>
<td><code>{{ key }}</code></td>
<td><pre style="white-space:pre-wrap;">{{ info.default|default:"" }}</pre></td>
<td><pre style="white-space:pre-wrap;">{{ info.current|default:"" }}</pre></td>
<td>{{ info.description|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No constraint metadata available</td></tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="field is-grouped is-grouped-multiline"> <div class="field is-grouped is-grouped-multiline">
<div class="control"> <div class="control"><label class="checkbox"><input type="checkbox" name="generate_builder" value="1"> Generate HTML export</label></div>
<label class="checkbox"> <div class="control"><label class="checkbox"><input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow)</label></div>
<input type="checkbox" name="generate_builder" value="1"> Generate HTML export <div class="control"><button class="button is-primary" type="submit">Run scheduler (background)</button></div>
</label>
</div>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="builder_solve" value="1"> Solve when exporting (may be slow)
</label>
</div>
<div class="control">
<button class="button is-primary" type="submit">Run scheduler (background)</button>
</div>
</div> </div>
</form> </form>
<hr> <hr />
<div> <div>
<h2 class="subtitle">Edit shifts</h2> <h2 class="subtitle">Edit shifts</h2>
<p> <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>
<button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button> <div id="shift-form">{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}</div>
</p>
<div id="shift-form">
{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}
</div>
<div id="modal" hx-on="closeModal: this.innerHTML='' "></div>
{% include 'rota/partials/shift_list.html' %} {% include 'rota/partials/shift_list.html' %}
</div> </div>
</div> </div>
</section> </section>
</body> {% endblock %}
</html>
@@ -13,15 +13,37 @@
<h1 class="title">Rota run for {{ run.rota.name }}</h1> <h1 class="title">Rota run for {{ run.rota.name }}</h1>
<p class="subtitle">Status: {{ run.get_status_display }} ({{ run.status }})</p> <p class="subtitle">Status: {{ run.get_status_display }} ({{ run.status }})</p>
<p>Created: {{ run.created_at|localtime }} started: {{ run.started_at|localtime }} finished: {{ run.finished_at|localtime }}</p> <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>
</p>
<div class="box"> <div class="box">
<h2 class="subtitle">Log</h2> <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;">
<pre>{{ run.log }}</pre> <pre>{{ run.log }}</pre>
</div> </div>
</details>
</div>
<div class="box"> <div class="box">
<h2 class="subtitle">Result</h2> <details>
<pre>{{ run.result }}</pre> <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;">
<pre>{{ result_pretty|default:run.result }}</pre>
</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"> <p class="mt-4">
{% if run.export_html %} {% 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 is-link" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">View Stored Export</a>
@@ -1,13 +1,20 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags static %}
<html> <html>
<head> <head>
<title>{{ worker.name }}</title> <title>{{ worker.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet"> <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' %} {% include 'rota/partials/flatpickr_includes.html' %}
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
</head> </head>
<body> <body>
<section class="section"> <section class="section">
<div class="container"> <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> <h1 class="title">{{ worker.name }}</h1>
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p> <p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
@@ -26,15 +33,22 @@
<h2 class="subtitle">Existing leave</h2> <h2 class="subtitle">Existing leave</h2>
<div class="content"> <div class="content">
<ul> {% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
{% for l in worker.leaves.all %}
<li>{{ l.start_date }} → {{ l.end_date }} {% if l.reason %}({{ l.reason }}){% endif %}</li>
{% empty %}
<li>No leave recorded</li>
{% endfor %}
</ul>
</div> </div>
</div> </div>
</section> </section>
<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 %}' });
})();
</script>
</body> </body>
</html> </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 %}
+333 -34
View File
@@ -5,12 +5,270 @@ from .models import Worker, Leave, RotaSchedule
import importlib import importlib
import json 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,
)
except Exception:
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
WorkRequests = HardDayDependency = HardDayExclusion = None
class WorkerForm(forms.ModelForm): 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: class Meta:
model = Worker model = Worker
fields = ["name", "email", "site", "grade", "fte", "active"] 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)")
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",
"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",
"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:
try:
parsed = json.loads(val)
except Exception:
parsed = val
opts[k] = 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)
# Hard day dependency/exclusion structures
cleaned["hard_day_dependencies"] = _parse_and_validate("hard_day_dependencies", HardDayDependency)
cleaned["hard_day_exclusions"] = _parse_and_validate("hard_day_exclusions", HardDayExclusion)
# 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 LeaveForm(forms.ModelForm):
class Meta: class Meta:
@@ -149,44 +407,62 @@ class RotaScheduleForm(forms.ModelForm):
self.fields["end_date"].initial = end_date self.fields["end_date"].initial = end_date
return cleaned return cleaned
def save(self, commit=True):
instance = super().save(commit=False)
end_date = self.cleaned_data.get("end_date")
if end_date:
instance.end_date = end_date
# Collect option fields and persist into instance.options
opts = instance.options or {}
# constraint-based option fields
for key in getattr(self, "_constraint_defaults", {}).keys():
field_name = f"opt__{key}"
if field_name in self.cleaned_data:
val = self.cleaned_data[field_name]
default = self._constraint_defaults.get(key)
# Parse JSON for list/dict fields
if isinstance(default, (list, dict)):
try:
parsed = json.loads(val) if val is not None and val != "" else []
except Exception:
parsed = val
opts[key] = parsed
else:
opts[key] = val
# builder args (prefixed with opt__) class WorkerSelfServiceForm(forms.ModelForm):
builder_keys = [ """Small form exposed to workers via tokenized links so they can set
"balance_offset_modifier", their FTE and pick non-working days.
"ltft_balance_offset", """
"use_previous_shifts", WEEKDAYS = [
"use_shift_balance_extra", ('0', 'Monday'),
"use_bank_holiday_extra", ('1', 'Tuesday'),
"allow_force_assignment_with_leave_conflict", ('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
] ]
for k in builder_keys:
field_name = f"opt__{k}" nwds = forms.MultipleChoiceField(choices=WEEKDAYS, required=False, widget=forms.CheckboxSelectMultiple, label="Non-working days")
if field_name in self.cleaned_data:
opts[k] = self.cleaned_data[field_name] 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 instance.options = opts
if commit: if commit:
instance.save() instance.save()
try: try:
@@ -334,9 +610,32 @@ class ShiftForm(forms.Form):
help_text="Maximum allowed difference in allocated shifts for this shift (leave blank for default)", 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':{}}])",
)
def clean_sites(self): def clean_sites(self):
val = self.cleaned_data["sites"] val = self.cleaned_data["sites"]
sites = [s.strip() for s in val.split(",") if s.strip()] sites = [s.strip() for s in val.split(",") if s.strip()]
if not sites: if not sites:
raise forms.ValidationError("At least one site is required") raise forms.ValidationError("At least one site is required")
return sites 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")
return parsed
except forms.ValidationError:
raise
except Exception as exc:
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
@@ -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),
),
]
+105
View File
@@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
import uuid
class RotaSchedule(models.Model): class RotaSchedule(models.Model):
@@ -122,8 +123,22 @@ class Worker(models.Model):
fte = models.FloatField(default=1.0) fte = models.FloatField(default=1.0)
active = models.BooleanField(default=True) 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") 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): def __str__(self):
return self.name return self.name
@@ -147,6 +162,96 @@ class Worker(models.Model):
"fte": int(self.fte), "fte": int(self.fte),
} }
# 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",
]
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",
]
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] = []
# 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) return PydWorker(**pyd_kwargs)
+51 -1
View File
@@ -1,6 +1,7 @@
import threading import threading
import traceback import traceback
from typing import Optional import io
import contextlib
from django.utils import timezone from django.utils import timezone
@@ -34,7 +35,27 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
try: try:
from project_rota_hook import generate_rota_for_schedule # type: ignore 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) 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: except Exception:
tb = traceback.format_exc() tb = traceback.format_exc()
run.mark_failed(message=tb) run.mark_failed(message=tb)
@@ -51,13 +72,33 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
# result. This allows the web UI to present the generated HTML for a # result. This allows the web UI to present the generated HTML for a
# run without requiring a separate on-demand build. # run without requiring a separate on-demand build.
if generate_builder: 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: 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() rb = rota.to_rota_builder()
# build the model and optionally solve # build the model and optionally solve
rb.build_and_solve(solve=builder_solve) rb.build_and_solve(solve=builder_solve)
html = rb.get_worker_timetable_html(include_html_tag=True) html = rb.get_worker_timetable_html(include_html_tag=True)
# store under result so it's accessible from UI # store under result so it's accessible from UI
result["builder_html"] = html 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 # Persist the HTML on the RotaRun record for easy retrieval
try: try:
run.export_html = html run.export_html = html
@@ -65,6 +106,15 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
except Exception: except Exception:
# best-effort: if saving fails, include a log entry # best-effort: if saving fails, include a log entry
run.log = (run.log or "") + "\nFailed to persist export_html" 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: except Exception as exc:
# include failure information in run.log and result # include failure information in run.log and result
tb = traceback.format_exc() tb = traceback.format_exc()
+6
View File
@@ -16,6 +16,12 @@ urlpatterns = [
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"), path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"), path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"), 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("leave/<int:leave_id>/delete/", views.leave_delete, name="leave_delete"),
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>/", views.rota_run_detail, name="rota_run_detail"),
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"), path("run/<int:run_id>/export/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("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
+289 -20
View File
@@ -3,12 +3,14 @@ from django.urls import reverse
from .models import RotaSchedule, Worker from .models import RotaSchedule, Worker
from .forms import WorkerForm, LeaveForm, RotaScheduleForm from .forms import WorkerForm, LeaveForm, RotaScheduleForm
from .forms import WorkerSelfServiceForm
from .services import run_rota_async from .services import run_rota_async
from .forms import ShiftForm from .forms import ShiftForm
import json import json
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.http import JsonResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
import traceback import traceback
@@ -265,15 +267,13 @@ def worker_create(request):
{"rota": rota}, {"rota": rota},
request=request, request=request,
) )
resp = ( resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp) response = HttpResponse(resp)
# close modal via HX-Trigger to avoid racing OOB swaps that target #modal
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
return response return response
# If we couldn't render the worker list (no rota), just close modal # If we couldn't render the worker list (no rota), just close modal via trigger
response = HttpResponse('<div id="modal" hx-swap-oob="true"></div>') response = HttpResponse('')
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
return response return response
# Non-HTMX POST -> redirect # Non-HTMX POST -> redirect
@@ -302,7 +302,21 @@ def worker_detail(request, worker_id):
leave = form.save(commit=False) leave = form.save(commit=False)
leave.worker = worker leave.worker = worker
leave.save() leave.save()
# For non-HTMX (full-page) submissions just redirect back to the
# worker detail page. Token-aware redirects are handled by the
# tokenized `request_leave` view (the public token page).
return redirect("rota:worker_detail", worker_id=worker.id) return redirect("rota:worker_detail", worker_id=worker.id)
else:
# Pre-fill dates when `date` query param is provided (ISO YYYY-MM-DD)
date_q = request.GET.get("date") or request.POST.get("date")
if date_q:
try:
import datetime
d = datetime.date.fromisoformat(date_q)
form = LeaveForm(initial={"start_date": d, "end_date": d})
except Exception:
form = LeaveForm()
else: else:
form = LeaveForm() form = LeaveForm()
@@ -320,10 +334,7 @@ def worker_edit(request, rota_id, worker_id):
form.save() form.save()
# Return updated worker list OOB and close modal # Return updated worker list OOB and close modal
worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request) worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request)
resp = ( resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp) response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
return response return response
@@ -340,12 +351,40 @@ def worker_edit(request, rota_id, worker_id):
form = WorkerForm(instance=worker) form = WorkerForm(instance=worker)
modal_html = render_to_string( modal_html = render_to_string(
"rota/partials/worker_form_modal.html", "rota/partials/worker_form_modal.html",
{"form": form, "form_action": request.path, "rota": rota}, {"form": form, "form_action": request.path, "rota": rota, "worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if worker and getattr(worker, 'request_token', None) else ''},
request=request, request=request,
) )
return HttpResponse(modal_html) return HttpResponse(modal_html)
@require_POST
def regenerate_worker_token(request, worker_id):
# Only allow staff users to regenerate tokens from the admin modal
if not (request.user and request.user.is_authenticated and request.user.is_staff):
return HttpResponseBadRequest("Permission denied")
worker = get_object_or_404(Worker, pk=worker_id)
# generate unique token
import uuid
new_token = uuid.uuid4()
# guard against collision
while Worker.objects.filter(request_token=new_token).exclude(pk=worker.pk).exists():
new_token = uuid.uuid4()
worker.request_token = new_token
worker.save(update_fields=["request_token"])
# render only the token area so HTMX can swap it
token_html = render_to_string(
"rota/partials/worker_token_area.html",
{"worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if getattr(worker, 'request_token', None) else ''},
request=request,
)
response = HttpResponse(token_html)
response["HX-Trigger"] = "workerTokenRegenerated"
return response
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def worker_delete(request, rota_id, worker_id): def worker_delete(request, rota_id, worker_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id) rota = get_object_or_404(RotaSchedule, pk=rota_id)
@@ -365,10 +404,7 @@ def worker_delete(request, rota_id, worker_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id) rota = get_object_or_404(RotaSchedule, pk=rota_id)
worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request) worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request)
resp = ( resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp) response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
return response return response
@@ -386,7 +422,24 @@ def rota_run_detail(request, run_id):
from .models import RotaRun from .models import RotaRun
run = get_object_or_404(RotaRun, pk=run_id) run = get_object_or_404(RotaRun, pk=run_id)
return render(request, "rota/rota_run_detail.html", {"run": run}) # Prepare a pretty-printed JSON representation of the result for easier
# debugging in templates. Also expose any builder HTML present in the
# persisted `export_html` or embedded in the `result` under
# `builder_html` so it can be viewed inline.
result_pretty = None
builder_html = None
try:
if run.result is not None:
import json as _json
result_pretty = _json.dumps(run.result, indent=2, default=str)
# prefer persisted export_html if present
builder_html = getattr(run, "export_html", None) or (run.result.get("builder_html") if isinstance(run.result, dict) else None)
else:
builder_html = getattr(run, "export_html", None)
except Exception:
result_pretty = str(run.result)
return render(request, "rota/rota_run_detail.html", {"run": run, "result_pretty": result_pretty, "builder_html": builder_html})
def rota_run_export_html(request, run_id): def rota_run_export_html(request, run_id):
@@ -420,6 +473,164 @@ def rota_options_update(request, rota_id):
return redirect("rota:rota_detail", rota_id=rota.id) return redirect("rota:rota_detail", rota_id=rota.id)
@require_http_methods(["GET", "POST"])
def request_leave(request, token=None):
"""Allow a worker to request leave for themselves.
Resolution strategy:
- If the user is authenticated, try to find a `Worker` with matching
email (first match). If found, use that worker.
- Otherwise, fall back to `worker_id` provided via GET/POST.
Supports HTMX modal flow when `HX-Request` header is present.
"""
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
worker = None
# 1) token link (preferred for unauthenticated users)
if token:
try:
worker = get_object_or_404(Worker, request_token=token)
except Exception:
worker = None
# 2) try authenticated user -> worker by email
if worker is None:
try:
if request.user and request.user.is_authenticated and getattr(request.user, "email", None):
worker = Worker.objects.filter(email=request.user.email).first()
except Exception:
worker = None
# 3) fallback to explicit worker_id param
if worker is None:
worker_id = request.GET.get("worker_id") or request.POST.get("worker_id")
if worker_id:
try:
worker = get_object_or_404(Worker, pk=int(worker_id))
except Exception:
worker = None
if request.method == "POST":
form = LeaveForm(request.POST)
if form.is_valid():
leave = form.save(commit=False)
if worker is None:
return HttpResponseBadRequest("Worker not specified or not found")
leave.worker = worker
leave.save()
# On HTMX modal post, return an OOB swap to close modal and optionally
# update worker detail partial if present.
if is_hx:
# Render updated leave list and return OOB swaps: update leave-list and clear modal.
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)
# Trigger modal close and a leaveUpdated event so client can refresh calendar highlights
response["HX-Trigger"] = "closeModal,leaveUpdated"
return response
# For full-page submissions: if this request was made using a tokenized
# URL (public leave request page), keep the user on that token page so
# they can see confirmation and existing requests. Otherwise go to the
# worker detail page.
if token:
try:
return redirect(reverse('rota:request_leave_token', args=[token]))
except Exception:
# fallback to worker detail
pass
return redirect("rota:worker_detail", worker_id=worker.id)
else:
# Prefill dates when provided via query params for both full page and HTMX modal
date_q = request.GET.get("date") or request.POST.get("date")
start_q = request.GET.get("start_date") or request.POST.get("start_date")
end_q = request.GET.get("end_date") or request.POST.get("end_date")
if date_q:
try:
import datetime
d = datetime.date.fromisoformat(date_q)
form = LeaveForm(initial={"start_date": d, "end_date": d})
except Exception:
form = LeaveForm()
elif start_q or end_q:
try:
import datetime
sd = datetime.date.fromisoformat(start_q) if start_q else None
ed = datetime.date.fromisoformat(end_q) if end_q else None
init = {}
if sd:
init["start_date"] = sd
if ed:
init["end_date"] = ed
form = LeaveForm(initial=init)
except Exception:
form = LeaveForm()
else:
form = LeaveForm()
# Render modal or full page
if is_hx:
modal_html = render_to_string(
"rota/partials/leave_request_modal.html",
{"form": form, "form_action": request.get_full_path(), "worker": worker},
request=request,
)
return HttpResponse(modal_html)
return render(request, "rota/leave_request.html", {"form": form, "worker": worker, "token": token})
@require_http_methods(["GET", "POST"])
def worker_settings_token(request, token):
"""Allow a worker identified by a request token to update simple settings
such as FTE and non-working days without requiring authentication.
"""
worker = None
if token:
try:
worker = get_object_or_404(Worker, request_token=token)
except Exception:
worker = None
if worker is None:
return HttpResponseBadRequest("Invalid token")
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
if request.method == "POST":
form = WorkerSelfServiceForm(request.POST, instance=worker)
if form.is_valid():
form.save()
if is_hx:
# Return minimal fragment confirming success and trigger a leaveUpdated
# so any calendars can refresh (if they rely on worker settings).
resp = render_to_string("rota/partials/worker_settings_saved.html", {"worker": worker}, request=request)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal,leaveUpdated"
return response
# Full page: redirect back to tokenized request page
return redirect(reverse('rota:request_leave_token', args=[token]))
else:
form = WorkerSelfServiceForm(instance=worker)
# HTMX modal request: return partial modal
if is_hx:
modal_html = render_to_string(
"rota/partials/worker_selfservice_modal.html",
{"form": form, "form_action": request.get_full_path(), "worker": worker},
request=request,
)
return HttpResponse(modal_html)
return render(request, "rota/worker_settings.html", {"form": form, "worker": worker, "token": token})
def rota_options(request, rota_id): def rota_options(request, rota_id):
"""HTMX-aware modal edit for rota.options JSON. """HTMX-aware modal edit for rota.options JSON.
@@ -487,7 +698,7 @@ def _oob_update_options_display(request, rota):
{"rota": rota, "options_pretty": options_pretty}, {"rota": rota, "options_pretty": options_pretty},
request=request, request=request,
) )
resp = '<div id="rota-options-display" hx-swap-oob="true">' + options_html + '</div>' + '<div id="modal" hx-swap-oob="true"></div>' resp = '<div id="rota-options-display" hx-swap-oob="true">' + options_html + '</div>'
response = HttpResponse(resp) response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
return response return response
@@ -793,6 +1004,64 @@ def rota_run_export_download(request, run_id):
return response return response
def leaves_json(request, worker_id):
"""Return JSON list of leave ranges for a given worker."""
worker = get_object_or_404(Worker, pk=worker_id)
data = []
for leave in worker.leaves.all().order_by('start_date'):
data.append({
'start': leave.start_date.isoformat(),
'end': leave.end_date.isoformat(),
'reason': leave.reason,
})
return JsonResponse({'leaves': data})
@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."""
from .models import Leave
leave = get_object_or_404(Leave, pk=leave_id)
worker = leave.worker
# permission checks
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')
# perform delete
leave.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 @require_POST
def rota_run_export_regenerate(request, run_id): def rota_run_export_regenerate(request, run_id):
"""Regenerate the HTML export for a completed run and persist it on the RotaRun. """Regenerate the HTML export for a completed run and persist it on the RotaRun.
@@ -882,6 +1151,7 @@ def shift_add(request, rota_id):
"workers_required": int(data["workers_required"]), "workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]), "assign_as_block": bool(data["assign_as_block"]),
"balance_offset": data.get("balance_offset"), "balance_offset": data.get("balance_offset"),
"constraints": data.get("constraints", []),
} }
current = rota.shifts or [] current = rota.shifts or []
@@ -895,7 +1165,6 @@ def shift_add(request, rota_id):
resp = ( resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>' f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>' + '<div id="shift-form" hx-swap-oob="true"></div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
) )
response = HttpResponse(resp) response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
@@ -930,6 +1199,7 @@ def shift_edit(request, rota_id, idx):
"days": data["days"], "days": data["days"],
"workers_required": int(data["workers_required"]), "workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]), "assign_as_block": bool(data["assign_as_block"]),
"constraints": data.get("constraints", []),
} }
current = rota.shifts or [] current = rota.shifts or []
current[idx] = shift_dict current[idx] = shift_dict
@@ -940,7 +1210,6 @@ def shift_edit(request, rota_id, idx):
resp = ( resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>' f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>' + '<div id="shift-form" hx-swap-oob="true"></div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
) )
response = HttpResponse(resp) response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
@@ -958,6 +1227,7 @@ def shift_edit(request, rota_id, idx):
"workers_required": shift.get("workers_required"), "workers_required": shift.get("workers_required"),
"assign_as_block": shift.get("assign_as_block", False), "assign_as_block": shift.get("assign_as_block", False),
"balance_offset": shift.get("balance_offset", None), "balance_offset": shift.get("balance_offset", None),
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
} }
form = ShiftForm(initial=initial) form = ShiftForm(initial=initial)
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request) modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
@@ -984,7 +1254,6 @@ def shift_delete(request, rota_id, idx):
resp = ( resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>' f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>' + '<div id="shift-form" hx-swap-oob="true"></div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
) )
response = HttpResponse(resp) response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal" response["HX-Trigger"] = "closeModal"
+2 -2
View File
@@ -90,13 +90,13 @@ class NightConstraint(BaseShiftConstraint):
options: Any = None options: Any = None
class PreShiftConstraint(BaseShiftConstraint): class PreShiftConstraint(BaseShiftConstraint):
days: Any = None days: Optional[int] = None
ignore_shifts: List[str] = [] ignore_shifts: List[str] = []
exclude_days: List[str] = [] exclude_days: List[str] = []
allow_self: bool = True allow_self: bool = True
class PostShiftConstraint(BaseShiftConstraint): class PostShiftConstraint(BaseShiftConstraint):
days: Any = None days: Optional[int] = None
ignore_shifts: List[str] = [] ignore_shifts: List[str] = []
exclude_days: List[str] = [] exclude_days: List[str] = []
allow_self: bool = True allow_self: bool = True
+12
View File
@@ -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 }
+79
View File
@@ -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 }
+165
View File
@@ -0,0 +1,165 @@
/* 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){
const container = document.getElementById(containerId);
if(!container) return;
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){ for(const L of leaves){ if(!L.start) continue; if(L.start<=isoStr && isoStr<=L.end) return true } return false }
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;
if(inLeaves(isoStr)) dayDiv.classList.add('leave');
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}))); 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');
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){
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);
}
}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;
})();
+10
View File
@@ -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 */