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)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
# Project-level output directory (app-level) plus repo-level `output` for legacy layout
STATIC_URL = "/static/"
# Project-level static directories so the dev server serves files from these locations.
# Include the repository `static/` (project-level) and legacy `output/` directories.
STATICFILES_DIRS = [
BASE_DIR / "static",
BASE_DIR / "output",
# also look for output/ at the repository root (BASE_DIR.parent)
BASE_DIR.parent / "output",
# repository-level static folder (workspace root)
BASE_DIR.parent / "static",
]
# 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">
{% csrf_token %}
{{ form|crispy }}
<!-- Structured constraint editor: renders a friendly UI and keeps the
hidden `constraints` textarea (form field) in sync as JSON. -->
<div class="box" id="constraints-editor">
<h4 class="title is-6">Shift constraints</h4>
<p class="help">Add constraint entries. The form stores a JSON list; this editor helps build that list.</p>
<div id="constraint-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="control">
<button class="button is-primary" type="submit">Save</button>
@@ -14,6 +14,318 @@
<input type="hidden" name="rota_id" value="{{ rota_id }}" />
{% endif %}
{{ form|crispy }}
{# Token area: shows a copyable self-service link and a regenerate button when editing a worker #}
{% if worker %}
{% include 'rota/partials/worker_token_area.html' with worker=worker token_link=token_link %}
{% endif %}
<!-- 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="control">
<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>
<head>
<title>Rota: {{ rota.name }}</title>
{% extends 'base.html' %}
{% block head %}
<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' %}
</head>
<body>
{% endblock %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title">{{ rota.name }}</h1>
<p>
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
</p>
<p><a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a></p>
<p class="subtitle">{{ rota.description }}</p>
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
<div class="mb-4">
<p>
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
</p>
<p><button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button></p>
{% include 'rota/partials/worker_list.html' %}
</div>
@@ -34,12 +28,9 @@
<ul>
{% for run in rota.runs.all %}
<li>
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a>
{{ run.get_status_display }}
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> {{ run.get_status_display }}
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
<span class="ml-2">
<a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a>
</span>
<span class="ml-2"><a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a></span>
</li>
{% empty %}
<li>No previous runs</li>
@@ -48,29 +39,17 @@
</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">
<h2 class="subtitle">Constraint options</h2>
<div class="box">
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
<p>
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
</p>
<p><button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button></p>
<hr />
<h3 class="subtitle is-6">Configured constraint values</h3>
<div style="max-height:200px; overflow:auto; margin-bottom:0.75em;">
{% if configured_constraints %}
<table class="table is-fullwidth is-striped is-narrow">
<thead>
<tr><th>Key</th><th>Value</th><th></th></tr>
</thead>
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
<tbody>
{% for key, info in configured_constraints.items %}
<tr id="constraint-row-{{ key }}">
@@ -93,74 +72,27 @@
<p class="help">No typed constraint options have been configured for this rota.</p>
{% endif %}
</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>
<form method="post">
{% csrf_token %}
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<label class="checkbox">
<input type="checkbox" name="generate_builder" value="1"> Generate HTML export
</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 class="control"><label class="checkbox"><input type="checkbox" name="generate_builder" value="1"> Generate HTML export</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>
</form>
<hr>
<hr />
<div>
<h2 class="subtitle">Edit shifts</h2>
<p>
<button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button>
</p>
<div id="shift-form">
{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}
</div>
<div id="modal" hx-on="closeModal: this.innerHTML='' "></div>
<p><button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button></p>
<div id="shift-form">{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}</div>
{% include 'rota/partials/shift_list.html' %}
</div>
</div>
</section>
</body>
</html>
{% endblock %}
@@ -13,15 +13,37 @@
<h1 class="title">Rota run for {{ run.rota.name }}</h1>
<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 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">
<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>
</div>
</details>
</div>
<div class="box">
<h2 class="subtitle">Result</h2>
<pre>{{ run.result }}</pre>
<details>
<summary><strong>Result (raw)</strong></summary>
<div style="margin-top:0.5rem; max-height:40vh; overflow:auto; border-top:1px solid #eee; padding-top:0.5rem;">
<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">
{% if run.export_html %}
<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>
<head>
<title>{{ worker.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
{% include 'rota/partials/flatpickr_includes.html' %}
<link rel="stylesheet" href="{% static 'css/calendar.css' %}">
</head>
<body>
<section class="section">
<div class="container">
<div class="box">
<h2 class="subtitle">Request leave on calendar</h2>
<div id="calendar" data-worker-id="{{ worker.id }}"></div>
<p class="help">Select a date or drag to select a range to request leave.</p>
</div>
<h1 class="title">{{ worker.name }}</h1>
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
@@ -26,15 +33,22 @@
<h2 class="subtitle">Existing leave</h2>
<div class="content">
<ul>
{% 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>
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all %}
</div>
</div>
</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>
</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 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):
# Expose core model fields plus a set of richer worker options. Complex
# structures (lists/dicts) are edited as JSON in textareas; common simple
# options are provided as dedicated form controls for convenience.
class Meta:
model = Worker
fields = ["name", "email", "site", "grade", "fte", "active"]
# Simple option fields
locum = forms.BooleanField(required=False, label="Locum")
locum_max_shifts = forms.IntegerField(required=False, label="Locum max shifts")
locum_max_shifts_per_week = forms.IntegerField(required=False, label="Locum max shifts per week")
locum_on_nwds = forms.BooleanField(required=False, label="Locum on non-working days")
remote_site = forms.CharField(required=False, label="Remote site")
pair = forms.CharField(required=False, label="Pair")
bank_holiday_extra = forms.IntegerField(required=False, label="Bank holiday extra")
weekend_shift_target_number = forms.IntegerField(required=False, label="Weekend shift target number")
prefer_multi_shift_together = forms.IntegerField(required=False, label="Prefer multi-shift together weight")
max_days_worked_per_week = forms.IntegerField(required=False, label="Max days worked per week")
# Complex structured options edited as JSON
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
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 Meta:
@@ -149,44 +407,62 @@ class RotaScheduleForm(forms.ModelForm):
self.fields["end_date"].initial = end_date
return cleaned
def save(self, commit=True):
instance = super().save(commit=False)
end_date = self.cleaned_data.get("end_date")
if end_date:
instance.end_date = end_date
# Collect option fields and persist into instance.options
opts = instance.options or {}
# constraint-based option fields
for key in getattr(self, "_constraint_defaults", {}).keys():
field_name = f"opt__{key}"
if field_name in self.cleaned_data:
val = self.cleaned_data[field_name]
default = self._constraint_defaults.get(key)
# Parse JSON for list/dict fields
if isinstance(default, (list, dict)):
try:
parsed = json.loads(val) if val is not None and val != "" else []
except Exception:
parsed = val
opts[key] = parsed
else:
opts[key] = val
# builder args (prefixed with opt__)
builder_keys = [
"balance_offset_modifier",
"ltft_balance_offset",
"use_previous_shifts",
"use_shift_balance_extra",
"use_bank_holiday_extra",
"allow_force_assignment_with_leave_conflict",
class WorkerSelfServiceForm(forms.ModelForm):
"""Small form exposed to workers via tokenized links so they can set
their FTE and pick non-working days.
"""
WEEKDAYS = [
('0', 'Monday'),
('1', 'Tuesday'),
('2', 'Wednesday'),
('3', 'Thursday'),
('4', 'Friday'),
('5', 'Saturday'),
('6', 'Sunday'),
]
for k in builder_keys:
field_name = f"opt__{k}"
if field_name in self.cleaned_data:
opts[k] = self.cleaned_data[field_name]
nwds = forms.MultipleChoiceField(choices=WEEKDAYS, required=False, widget=forms.CheckboxSelectMultiple, label="Non-working days")
class Meta:
model = Worker
fields = ["fte"]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None:
try:
opts = instance.options or {}
nwds = opts.get('nwds', [])
# store as strings for the MultipleChoiceField
initial['nwds'] = [str(x) for x in (nwds or [])]
initial['fte'] = instance.fte
except Exception:
pass
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
def save(self, commit=True):
"""Persist FTE and non-working days (nwds) into the Worker instance.
The form exposes only `fte` and `nwds`. `fte` is handled by the
ModelForm machinery; we need to ensure `nwds` is persisted inside
`instance.options['nwds']` as a list of ints.
"""
instance = super().save(commit=False)
# Persist non-working days into instance.options['nwds'] as list of ints
opts = instance.options or {}
nwds = self.cleaned_data.get('nwds') or []
try:
opts['nwds'] = [int(x) for x in nwds]
except Exception:
# fallback: keep as-is
opts['nwds'] = nwds
instance.options = opts
if commit:
instance.save()
try:
@@ -334,9 +610,32 @@ class ShiftForm(forms.Form):
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):
val = self.cleaned_data["sites"]
sites = [s.strip() for s in val.split(",") if s.strip()]
if not sites:
raise forms.ValidationError("At least one site is required")
return sites
def clean_constraints(self):
val = self.cleaned_data.get("constraints")
if not val:
return []
try:
parsed = json.loads(val)
if not isinstance(parsed, list):
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
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.utils import timezone
import uuid
class RotaSchedule(models.Model):
@@ -122,8 +123,22 @@ class Worker(models.Model):
fte = models.FloatField(default=1.0)
active = models.BooleanField(default=True)
# Store additional worker settings / metadata that map to the
# pydantic `Worker` model used by the rota generator. This allows the
# web UI to expose richer worker options without changing the core
# Django schema for frequently used fields.
options = models.JSONField(default=dict, blank=True)
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
# Token used to allow workers (without Django accounts) to access
# self-service links (request leave, view their calendar, etc.). This
# is a UUID4 and should be treated as a secret. Regenerate to revoke.
# During migration we add this field as nullable and non-unique first,
# then populate unique values in a data migration and finally make it
# non-null and unique. See migrations/ for the migration sequence.
request_token = models.UUIDField(default=uuid.uuid4, editable=False, null=True, unique=False)
def __str__(self):
return self.name
@@ -147,6 +162,96 @@ class Worker(models.Model):
"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)
+51 -1
View File
@@ -1,6 +1,7 @@
import threading
import traceback
from typing import Optional
import io
import contextlib
from django.utils import timezone
@@ -34,7 +35,27 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
try:
from project_rota_hook import generate_rota_for_schedule # type: ignore
# Capture any stdout/stderr produced by the external generator
out_buf = io.StringIO()
err_buf = io.StringIO()
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
result = generate_rota_for_schedule(rota)
# Attach captured output to the result for debugging
try:
result = result or {}
result["generator_stdout"] = out_buf.getvalue()
result["generator_stderr"] = err_buf.getvalue()
except Exception:
pass
# Also persist captured generator output into the run.log so
# it's visible from the run detail page when executed in the
# background thread.
try:
captured = "\n[GENERATOR STDOUT]\n" + (result.get("generator_stdout") or "") + "\n[GENERATOR STDERR]\n" + (result.get("generator_stderr") or "")
run.log = (run.log or "") + captured
run.save(update_fields=["log"])
except Exception:
pass
except Exception:
tb = traceback.format_exc()
run.mark_failed(message=tb)
@@ -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
# run without requiring a separate on-demand build.
if generate_builder:
# Defensive: if no workers are assigned to the rota, skip the
# builder step and return a clear message rather than raising an
# exception from deep inside the builder.
try:
worker_count = rota.workers.count()
except Exception:
worker_count = 0
if worker_count == 0:
result["builder_error"] = "No workers assigned to rota; add workers before running the builder."
else:
try:
# Capture stdout/stderr from the builder so debugging info
# is available in the RotaRun result and log.
out_buf = io.StringIO()
err_buf = io.StringIO()
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
rb = rota.to_rota_builder()
# build the model and optionally solve
rb.build_and_solve(solve=builder_solve)
html = rb.get_worker_timetable_html(include_html_tag=True)
# store under result so it's accessible from UI
result["builder_html"] = html
result["builder_stdout"] = out_buf.getvalue()
result["builder_stderr"] = err_buf.getvalue()
# Persist the HTML on the RotaRun record for easy retrieval
try:
run.export_html = html
@@ -65,6 +106,15 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
except Exception:
# best-effort: if saving fails, include a log entry
run.log = (run.log or "") + "\nFailed to persist export_html"
# Append captured output to run.log for convenient viewing
try:
captured = "\n[BUILDER STDOUT]\n" + result.get("builder_stdout", "") + "\n[BUILDER STDERR]\n" + result.get("builder_stderr", "")
run.log = (run.log or "") + captured
# persist the appended log so it is visible to users
# when the run finishes
run.save(update_fields=["log"])
except Exception:
pass
except Exception as exc:
# include failure information in run.log and result
tb = traceback.format_exc()
+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>/delete/", views.worker_delete, name="worker_delete"),
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>/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"),
+289 -20
View File
@@ -3,12 +3,14 @@ from django.urls import reverse
from .models import RotaSchedule, Worker
from .forms import WorkerForm, LeaveForm, RotaScheduleForm
from .forms import WorkerSelfServiceForm
from .services import run_rota_async
from .forms import ShiftForm
import json
from django.views.decorators.http import require_http_methods
from django.template.loader import render_to_string
from django.http import HttpResponse, HttpResponseBadRequest
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.shortcuts import HttpResponseRedirect
import traceback
@@ -265,15 +267,13 @@ def worker_create(request):
{"rota": rota},
request=request,
)
resp = (
f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
# close modal via HX-Trigger to avoid racing OOB swaps that target #modal
response["HX-Trigger"] = "closeModal"
return response
# If we couldn't render the worker list (no rota), just close modal
response = HttpResponse('<div id="modal" hx-swap-oob="true"></div>')
# If we couldn't render the worker list (no rota), just close modal via trigger
response = HttpResponse('')
response["HX-Trigger"] = "closeModal"
return response
# Non-HTMX POST -> redirect
@@ -302,7 +302,21 @@ def worker_detail(request, worker_id):
leave = form.save(commit=False)
leave.worker = worker
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)
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:
form = LeaveForm()
@@ -320,10 +334,7 @@ def worker_edit(request, rota_id, worker_id):
form.save()
# Return updated worker list OOB and close modal
worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request)
resp = (
f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
@@ -340,12 +351,40 @@ def worker_edit(request, rota_id, worker_id):
form = WorkerForm(instance=worker)
modal_html = render_to_string(
"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,
)
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"])
def worker_delete(request, rota_id, worker_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)
worker_list_html = render_to_string("rota/partials/worker_list.html", {"rota": rota}, request=request)
resp = (
f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
return response
@@ -386,7 +422,24 @@ def rota_run_detail(request, run_id):
from .models import RotaRun
run = get_object_or_404(RotaRun, pk=run_id)
return render(request, "rota/rota_run_detail.html", {"run": run})
# 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):
@@ -420,6 +473,164 @@ def rota_options_update(request, 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):
"""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},
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["HX-Trigger"] = "closeModal"
return response
@@ -793,6 +1004,64 @@ def rota_run_export_download(request, run_id):
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
def rota_run_export_regenerate(request, run_id):
"""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"]),
"assign_as_block": bool(data["assign_as_block"]),
"balance_offset": data.get("balance_offset"),
"constraints": data.get("constraints", []),
}
current = rota.shifts or []
@@ -895,7 +1165,6 @@ def shift_add(request, rota_id):
resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
@@ -930,6 +1199,7 @@ def shift_edit(request, rota_id, idx):
"days": data["days"],
"workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]),
"constraints": data.get("constraints", []),
}
current = rota.shifts or []
current[idx] = shift_dict
@@ -940,7 +1210,6 @@ def shift_edit(request, rota_id, idx):
resp = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
@@ -958,6 +1227,7 @@ def shift_edit(request, rota_id, idx):
"workers_required": shift.get("workers_required"),
"assign_as_block": shift.get("assign_as_block", False),
"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)
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 = (
f'<div id="shift-list" hx-swap-oob="true">{shift_list_html}</div>'
+ '<div id="shift-form" hx-swap-oob="true"></div>'
+ '<div id="modal" hx-swap-oob="true"></div>'
)
response = HttpResponse(resp)
response["HX-Trigger"] = "closeModal"
+2 -2
View File
@@ -90,13 +90,13 @@ class NightConstraint(BaseShiftConstraint):
options: Any = None
class PreShiftConstraint(BaseShiftConstraint):
days: Any = None
days: Optional[int] = None
ignore_shifts: List[str] = []
exclude_days: List[str] = []
allow_self: bool = True
class PostShiftConstraint(BaseShiftConstraint):
days: Any = None
days: Optional[int] = None
ignore_shifts: List[str] = []
exclude_days: List[str] = []
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 */