Compare commits
71
Commits
9d187dfa75
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d577b2cdf8 | ||
|
|
0874f05126 | ||
|
|
6ca3db86cb | ||
|
|
96e6a0d625 | ||
|
|
fc2413538c | ||
|
|
5ff78faa07 | ||
|
|
d1fbbe2dc9 | ||
|
|
b5525bd3fb | ||
|
|
33f50c7278 | ||
|
|
981cc314f0 | ||
|
|
0861e371e9 | ||
|
|
13c5ae598a | ||
|
|
0f31dfe1a9 | ||
|
|
02598cb665 | ||
|
|
09c8124cdd | ||
|
|
2949327d39 | ||
|
|
ea68885ff3 | ||
|
|
e288585cd3 | ||
|
|
4cad1158c6 | ||
|
|
f90e6895b4 | ||
|
|
827753644f | ||
|
|
935b4baaba | ||
|
|
dee80ed4fa | ||
|
|
a071b1b027 | ||
|
|
ee263aade1 | ||
|
|
68b80ace23 | ||
|
|
4bb4f5b97a | ||
|
|
d741d7e23c | ||
|
|
64c4189d5f | ||
|
|
277f14747a | ||
|
|
e637c5ac60 | ||
|
|
c71ae4285a | ||
|
|
c248e67304 | ||
|
|
628114ad91 | ||
|
|
f8c6e38841 | ||
|
|
ba268f9b48 | ||
|
|
e7f27a8ee6 | ||
|
|
1d7202bf04 | ||
|
|
82868f8c22 | ||
|
|
d66ddf29ad | ||
|
|
e6bdd4a09e | ||
|
|
62e48ef205 | ||
|
|
b98c28401f | ||
|
|
94b921192f | ||
|
|
23529ba441 | ||
|
|
fe1414a7bf | ||
|
|
0f9cd52911 | ||
|
|
cf9e9ee854 | ||
|
|
120dd16845 | ||
|
|
62010dd09f | ||
|
|
0cbe968aa5 | ||
|
|
5cf2480602 | ||
|
|
1bae1246f9 | ||
|
|
f4ab0be347 | ||
|
|
dadf035627 | ||
|
|
023e345591 | ||
|
|
8458279e19 | ||
|
|
4230e2af6a | ||
|
|
e2c3eecc63 | ||
|
|
105b37feae | ||
|
|
86201ec1d7 | ||
|
|
c59a03fa31 | ||
|
|
3fd0e1ed99 | ||
|
|
b6f5288eed | ||
|
|
a2d22c34fd | ||
|
|
ba154f680a | ||
|
|
32886da33d | ||
|
|
ea38a25fa1 | ||
|
|
1e6d1d5f5b | ||
|
|
60bd8073ee | ||
|
|
3ba4467812 |
@@ -162,6 +162,19 @@
|
||||
if(triggers.includes('leaveUpdated')){
|
||||
try{ console.debug('[DEBUG] htmx:afterRequest -> leaveUpdated'); if(window._calendars){ const cs = Object.values(window._calendars).filter(c=>c.refreshLeaves); console.debug('[DEBUG] afterRequest -> refreshing', cs.length, 'calendars'); cs.forEach(c => c.refreshLeaves && c.refreshLeaves()); } }catch(e){}
|
||||
}
|
||||
if(triggers.includes('runCompleted')){
|
||||
try{
|
||||
// Stop further HTMX polling by removing polling attributes from
|
||||
// the run status element and its container. The server will return
|
||||
// out-of-band (OOB) swaps to update the log/result areas in-place
|
||||
// and to insert a "View final output" button, so a full page
|
||||
// reload is no longer required.
|
||||
const el = document.querySelector('#run-status');
|
||||
if(el){ el.removeAttribute('hx-get'); el.removeAttribute('hx-trigger'); el.removeAttribute('hx-swap'); }
|
||||
const container = document.querySelector('#run-status-container');
|
||||
if(container){ container.removeAttribute('hx-get'); container.removeAttribute('hx-trigger'); }
|
||||
}catch(e){ console.error('runCompleted handler failed', e); }
|
||||
}
|
||||
}catch(e){ console.error('htmx:afterRequest robustness handler', e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,14 +11,31 @@
|
||||
|
||||
{# Rota selection is handled by route scoping; no dropdown needed here #}
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;align-items:center;">
|
||||
<div>
|
||||
<button id="view-calendar-btn" class="button is-small is-link">Calendar</button>
|
||||
<button id="view-list-btn" class="button is-small">List</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="calendar" class="calendar" data-mode="inline"></div>
|
||||
<div id="calendar-list-view" style="display:none; margin-top:0.5rem;"></div>
|
||||
<p class="help">View all leave requests across workers. Choose a rota to scope the calendar, or select "All rotas" to see everything. Click and drag to select dates to open the leave form (you'll need to choose workers in the modal).</p>
|
||||
</div>
|
||||
|
||||
<div 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 id="global-legend" class="box" style="margin-top:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;justify-content:space-between;">
|
||||
<div><strong>Legend:</strong></div>
|
||||
<div>
|
||||
{% if selected_rota_id %}
|
||||
<a class="button is-small is-light" href="{% url 'rota:rota_detail' selected_rota_id %}">Back to rota</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-legend-content" style="margin-top:0.5rem;">
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave
|
||||
<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,28 +63,139 @@
|
||||
const sel = document.getElementById('selected_rota_json');
|
||||
if(sel) initialRota = JSON.parse(sel.textContent || sel.innerText || 'null');
|
||||
}catch(e){ initialRota = null; }
|
||||
// expose initial rota id so other scripts (list view) can access it
|
||||
window._initialRota = initialRota;
|
||||
window.initCalendar('calendar', { leaves: leaves, leavesUrl: leavesUrlBase, rotaId: initialRota, requestUrl: '{% url "rota:request_leave" %}', mode: container.dataset.mode || 'inline' });
|
||||
function refreshLegendFor(id){
|
||||
const legend = document.getElementById('rota-worker-legend');
|
||||
if(!legend) return;
|
||||
if(!id){ legend.innerText = 'Showing leave for all rotas.'; return; }
|
||||
legend.innerText = 'Loading legend…';
|
||||
const globalLegendContent = document.getElementById('global-legend-content');
|
||||
if(!globalLegendContent) return;
|
||||
if(!id){
|
||||
globalLegendContent.innerHTML = '<span style="display:inline-block;width:12px;height:12px;background:#ffecec;border:1px solid #ddd;margin-left:0.5rem;vertical-align:middle"></span> Existing leave'
|
||||
+ '<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range';
|
||||
return;
|
||||
}
|
||||
globalLegendContent.innerText = 'Loading legend…';
|
||||
fetch(leavesUrlBase + '?rota_id=' + encodeURIComponent(id), {credentials:'same-origin'}).then(r=>r.json()).then(data=>{
|
||||
const byWorker = {};
|
||||
(data.leaves||[]).forEach(l=>{ if(l.worker_id){ byWorker[l.worker_id] = {name:l.reason, color:l.color}; } });
|
||||
if(Object.keys(byWorker).length===0){ legend.innerText = 'No workers with leaves in this rota.'; return; }
|
||||
legend.innerHTML = Object.entries(byWorker).map(([id,info])=> `<div style="display:inline-flex;align-items:center;gap:0.5rem;margin-right:0.5rem"><span style="width:14px;height:14px;background:${info.color};border:1px solid rgba(0,0,0,0.1);display:inline-block"></span><span>${info.name}</span></div>`).join('');
|
||||
}).catch(e=>{ legend.innerText = 'Unable to load legend'; console.debug(e); });
|
||||
if(Object.keys(byWorker).length===0){
|
||||
globalLegendContent.innerText = 'No workers with leaves in this rota.';
|
||||
return;
|
||||
}
|
||||
const workerHtml = Object.entries(byWorker).map(([id,info])=> `<div style="display:inline-flex;align-items:center;gap:0.5rem;margin-right:0.5rem"><span style="width:14px;height:14px;background:${info.color};border:1px solid rgba(0,0,0,0.1);display:inline-block"></span><span>${info.name}</span></div>`).join('');
|
||||
globalLegendContent.innerHTML = workerHtml + '<span style="display:inline-block;width:12px;height:12px;background:#cdeffd;border:1px solid #2b8fd6;margin-left:0.75rem;vertical-align:middle"></span> Selected range';
|
||||
}).catch(e=>{ globalLegendContent.innerText = 'Unable to load legend'; console.debug(e); });
|
||||
}
|
||||
// Refresh legend for the initially selected rota (or show all rotas)
|
||||
refreshLegendFor(initialRota);
|
||||
// Ensure the legend element exists (script is before the legend markup)
|
||||
if(document.readyState === 'loading'){
|
||||
document.addEventListener('DOMContentLoaded', function(){ refreshLegendFor(initialRota); });
|
||||
} else {
|
||||
refreshLegendFor(initialRota);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="box" style="margin-top:0.75rem;">
|
||||
<h4 class="subtitle is-6">Workers on this rota</h4>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">
|
||||
<div id="rota-worker-legend">Select a rota to show its workers.</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const listEl = document.getElementById('calendar-list-view');
|
||||
const btnCal = document.getElementById('view-calendar-btn');
|
||||
const btnList = document.getElementById('view-list-btn');
|
||||
let isList = false;
|
||||
|
||||
function showCalendar(){
|
||||
isList = false;
|
||||
if(listEl) listEl.style.display = 'none';
|
||||
if(calendarEl) calendarEl.style.display = '';
|
||||
if(btnCal) btnCal.classList.add('is-link');
|
||||
if(btnList) btnList.classList.remove('is-link');
|
||||
}
|
||||
|
||||
async function renderList(){
|
||||
if(!listEl) return;
|
||||
listEl.innerHTML = '<p>Loading…</p>';
|
||||
try{
|
||||
let url = '{% url "rota:leaves_global_json" %}';
|
||||
const rotaForList = (typeof window !== 'undefined' && window._initialRota) ? window._initialRota : null;
|
||||
if(rotaForList) url += (url.includes('?') ? '&' : '?') + 'rota_id=' + encodeURIComponent(rotaForList);
|
||||
const res = await fetch(url, {credentials:'same-origin'});
|
||||
if(!res.ok) throw new Error('Failed to fetch leaves');
|
||||
const data = await res.json();
|
||||
const leavesArr = (data.leaves||[]).map(l => ({ start: l.start, end: l.end, reason: l.reason, worker_id: l.worker_id, color: l.color }));
|
||||
|
||||
// find min/max date across all leaves
|
||||
if(leavesArr.length===0){ listEl.innerHTML = '<p>No leaves found.</p>'; return; }
|
||||
function toDate(s){ const p = s.split('-'); return new Date(parseInt(p[0]), parseInt(p[1],10)-1, parseInt(p[2],10)); }
|
||||
function isoDate(d){ return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0'); }
|
||||
let minD = null, maxD = null;
|
||||
leavesArr.forEach(l=>{
|
||||
try{
|
||||
const s = toDate(l.start);
|
||||
const e = toDate(l.end || l.start);
|
||||
if(!minD || s < minD) minD = s;
|
||||
if(!maxD || e > maxD) maxD = e;
|
||||
}catch(e){}
|
||||
});
|
||||
if(!minD || !maxD){ listEl.innerHTML = '<p>No valid leave dates found.</p>'; return; }
|
||||
|
||||
// build a map of dates -> leaves that cover that date
|
||||
const dateMap = {};
|
||||
for(let d = new Date(minD); d <= maxD; d.setDate(d.getDate()+1)){
|
||||
dateMap[isoDate(new Date(d))] = [];
|
||||
}
|
||||
leavesArr.forEach(l=>{
|
||||
const s = toDate(l.start);
|
||||
const e = toDate(l.end || l.start);
|
||||
for(let d = new Date(s); d <= e; d.setDate(d.getDate()+1)){
|
||||
const key = isoDate(new Date(d));
|
||||
if(key in dateMap) dateMap[key].push(l);
|
||||
else dateMap[key] = [l];
|
||||
}
|
||||
});
|
||||
|
||||
// Render dates in ascending order
|
||||
const parts = [];
|
||||
const keys = Object.keys(dateMap).sort();
|
||||
for(const dateKey of keys){
|
||||
const items = dateMap[dateKey];
|
||||
const header = `<div style="margin-top:0.5rem; font-weight:bold">${dateKey}</div>`;
|
||||
if(!items || items.length===0){
|
||||
parts.push(header + `<div style="margin-left:1.5rem;margin-top:0.25rem;color:#666">No leaves</div>`);
|
||||
continue;
|
||||
}
|
||||
const rows = items.map(l=>{
|
||||
const name = l.reason || 'Unknown';
|
||||
const color = l.color || '#ddd';
|
||||
return `<div style="margin-left:1.5rem;margin-top:0.25rem;display:flex;align-items:center;gap:0.5rem"><span style="width:12px;height:12px;background:${color};display:inline-block;border:1px solid rgba(0,0,0,0.1)"></span><span>${name}</span></div>`;
|
||||
}).join('');
|
||||
parts.push(header + rows);
|
||||
}
|
||||
listEl.innerHTML = parts.join('');
|
||||
}catch(err){ console.error('renderList failed', err); listEl.innerHTML = '<p>Unable to load list view.</p>'; }
|
||||
}
|
||||
|
||||
function showList(){
|
||||
isList = true;
|
||||
if(calendarEl) calendarEl.style.display = 'none';
|
||||
if(listEl) listEl.style.display = '';
|
||||
if(btnList) btnList.classList.add('is-link');
|
||||
if(btnCal) btnCal.classList.remove('is-link');
|
||||
renderList();
|
||||
}
|
||||
|
||||
if(btnCal) btnCal.addEventListener('click', showCalendar);
|
||||
if(btnList) btnList.addEventListener('click', showList);
|
||||
// default view
|
||||
showCalendar();
|
||||
|
||||
// Refresh views when leaves change
|
||||
window.addEventListener('leaveUpdated', function(){
|
||||
if(isList) renderList();
|
||||
else try{ if(window._calendars && window._calendars['calendar'] && window._calendars['calendar'].refreshLeaves) window._calendars['calendar'].refreshLeaves(); }catch(e){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Consolidated legend above replaces the separate 'Workers on this rota' box #}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="leave-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Add leave for {{ worker.name }}</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form method="post" hx-post="{{ form_action }}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="field" style="margin-top:0.75rem;">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,3 +8,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Mark required fields with a visual asterisk and set ARIA attributes
|
||||
(function(){
|
||||
try{
|
||||
const form = document.currentScript.previousElementSibling;
|
||||
if(!form) return;
|
||||
const requiredEls = form.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
requiredEls.forEach(el=>{
|
||||
el.setAttribute('aria-required','true');
|
||||
// try to find a label: either <label for="id_x"> or a sibling .label
|
||||
const id = el.id;
|
||||
let lbl = id ? form.querySelector('label[for="'+id+'"]') : null;
|
||||
if(!lbl){
|
||||
// look for nearest label element in the same .field
|
||||
const field = el.closest('.field');
|
||||
if(field) lbl = field.querySelector('label');
|
||||
}
|
||||
if(lbl){
|
||||
const already = lbl.querySelector('.required-indicator') || lbl.querySelector('.has-text-danger') || /\*\s*$/.test(lbl.textContent);
|
||||
if(!already){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'required-indicator has-text-danger';
|
||||
span.textContent = ' *';
|
||||
lbl.appendChild(span);
|
||||
}
|
||||
}
|
||||
});
|
||||
}catch(e){ /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -22,12 +22,38 @@ function _close_shift_modal_targets(){
|
||||
<div class="box" id="constraints-editor">
|
||||
<h4 class="title is-6">Shift constraints</h4>
|
||||
<p class="help">Add constraint entries. The form stores a JSON list; this editor helps build that list.</p>
|
||||
<div id="constraint-errors" class="notification is-danger" style="display:none; margin-bottom:0.5em;"></div>
|
||||
<div id="constraint-list"></div>
|
||||
<div style="margin-top:0.5em;">
|
||||
<button type="button" class="button is-small" id="add-constraint">Add constraint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if focus %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
const focusName = '{{ focus }}';
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
if(listEl){
|
||||
// Ensure at least one row exists
|
||||
if(listEl.children.length === 0){ if(addBtn) addBtn.click(); }
|
||||
setTimeout(()=>{
|
||||
// Try to find a select matching the constraint name
|
||||
const selects = listEl.querySelectorAll('select');
|
||||
let matched = null;
|
||||
selects.forEach(s=>{ if(s.value === focusName) matched = s; });
|
||||
if(matched){ try{ matched.scrollIntoView({behavior:'smooth', block:'center'}); matched.focus(); matched.dispatchEvent(new Event('change',{bubbles:true})); return; }catch(e){} }
|
||||
// otherwise, focus first row and set its type to the requested focus
|
||||
const firstSel = selects[0];
|
||||
if(firstSel){ try{ firstSel.value = focusName; firstSel.dispatchEvent(new Event('change',{bubbles:true})); firstSel.focus(); firstSel.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){} }
|
||||
}, 120);
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
// Constraint type schemas: name -> fields metadata
|
||||
@@ -45,7 +71,7 @@ function _close_shift_modal_targets(){
|
||||
{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: "days", label: "Duration (days)", type: "int", required: true},
|
||||
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
|
||||
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
|
||||
{name: "allow_self", label: "Allow self", type: "bool"}
|
||||
@@ -55,23 +81,46 @@ function _close_shift_modal_targets(){
|
||||
{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: "days", label: "Duration (days)", type: "int", required: true},
|
||||
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
|
||||
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
|
||||
{name: "allow_self", label: "Allow self", type: "bool"}
|
||||
]},
|
||||
"min_summed_grade_by_shifts_per_day": {label: "Min summed grade by shifts per day", fields: [ {name: "shifts", label: "Shifts (comma-separated)", type: "list"}, {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"} ]}
|
||||
"min_summed_grade_by_shifts_per_day": {label: "Min summed grade by shifts per day", fields: [ {name: "shifts", label: "Shifts (comma-separated)", type: "list", required: true}, {name: "min_grade_sum", label: "Minimum grade sum", type: "int", required: true}, {name: "days", label: "Days (comma-separated)", type: "list", required: true} ]},
|
||||
"limit_grade_number": {label: "Limit grade number", fields: [ {name: "grade", label: "Grade", type: "int", required: true}, {name: "max_number", label: "Max number", type: "int", required: true} ]},
|
||||
"minimum_grade_number": {label: "Minimum grade number", fields: [ {name: "grade", label: "Grade", type: "int", required: true}, {name: "min_number", label: "Min number", type: "int", required: true} ]},
|
||||
"require_remote_site_presence": {label: "Require remote site presence", fields: [ {name: "site", label: "Site", type: "text", required: true}, {name: "required_number", label: "Required number", type: "int", required: true} ]},
|
||||
"max_shifts_per_week": {label: "Max shifts per week", fields: [ {name: "max_shifts", label: "Max shifts", type: "int", required: true} ]},
|
||||
"max_shifts_per_week_block": {label: "Max shifts per week block", fields: [ {name: "week_block", label: "Week block", type: "int", required: true}, {name: "max_shifts", label: "Max shifts", type: "int", required: true} ]}
|
||||
};
|
||||
|
||||
const textarea = document.getElementById('id_constraints');
|
||||
const listEl = document.getElementById('constraint-list');
|
||||
const errorsEl = document.getElementById('constraint-errors');
|
||||
const addBtn = document.getElementById('add-constraint');
|
||||
|
||||
function validateConstraintsArray(data){
|
||||
const msgs = [];
|
||||
if(!Array.isArray(data)) return ["Constraints must be a JSON list"];
|
||||
data.forEach((c, idx)=>{
|
||||
if(!c || typeof c !== 'object'){
|
||||
msgs.push(`Constraint #${idx+1} must be an object`);
|
||||
return;
|
||||
}
|
||||
const name = c.name || c.type || null;
|
||||
if(!name){ msgs.push(`Constraint #${idx+1} missing 'name'`); return; }
|
||||
if(name==='pre' || name==='post'){
|
||||
const opts = c.options || {};
|
||||
// days (duration) is required
|
||||
const daysVal = (c.days!==undefined && c.days!==null) ? c.days : (opts.days!==undefined && opts.days!==null ? opts.days : null);
|
||||
if(daysVal===null){
|
||||
msgs.push(`Constraint #${idx+1} (pre/post) requires 'Duration (days)' to be set`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return msgs;
|
||||
}
|
||||
|
||||
function parseInitial(){
|
||||
let val = textarea.value || '';
|
||||
if(!val.trim()) return [];
|
||||
@@ -106,6 +155,12 @@ function _close_shift_modal_targets(){
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
syncTextarea();
|
||||
// validate and show errors
|
||||
try{
|
||||
const v = validateConstraintsArray(data);
|
||||
if(v && v.length){ errorsEl.style.display='block'; errorsEl.innerHTML = v.map(m=>`<div>${m}</div>`).join(''); }
|
||||
else { errorsEl.style.display='none'; errorsEl.innerHTML=''; }
|
||||
}catch(e){ /* ignore */ }
|
||||
}
|
||||
|
||||
function renderConstraintRow(c, idx){
|
||||
@@ -148,6 +203,13 @@ function _close_shift_modal_targets(){
|
||||
SCHEMAS[t].fields.forEach(f=>{
|
||||
const fieldWrap = document.createElement('div'); fieldWrap.className='field';
|
||||
const label = document.createElement('label'); label.className='label'; label.textContent = f.label;
|
||||
// append required indicator if schema marks this field required
|
||||
if(f.required){
|
||||
if(!label.querySelector('.required-indicator')){
|
||||
const span = document.createElement('span'); span.className='required-indicator has-text-danger'; span.textContent=' *';
|
||||
label.appendChild(span);
|
||||
}
|
||||
}
|
||||
fieldWrap.appendChild(label);
|
||||
const control = document.createElement('div'); control.className='control';
|
||||
let input;
|
||||
@@ -168,6 +230,7 @@ function _close_shift_modal_targets(){
|
||||
cb.checked = !!curb;
|
||||
cb.setAttribute('data-field-name', f.name);
|
||||
cb.onchange = syncToData;
|
||||
if(f.required){ cb.required = true; cb.setAttribute('aria-required','true'); }
|
||||
control.appendChild(cb);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
@@ -181,6 +244,7 @@ function _close_shift_modal_targets(){
|
||||
input = document.createElement('input'); input.className='input'; input.type='text'; input.oninput = syncToData;
|
||||
}
|
||||
input.setAttribute('data-field-name', f.name);
|
||||
if(f.required){ input.required = true; input.setAttribute('aria-required','true'); }
|
||||
control.appendChild(input);
|
||||
fieldWrap.appendChild(control);
|
||||
fieldsContainer.appendChild(fieldWrap);
|
||||
@@ -248,6 +312,34 @@ function _close_shift_modal_targets(){
|
||||
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Mark required fields in the modal shift form with a visible asterisk and add ARIA attributes
|
||||
(function(){
|
||||
try{
|
||||
const modalForm = document.querySelector('#shift-modal form');
|
||||
if(!modalForm) return;
|
||||
const requiredEls = modalForm.querySelectorAll('input[required], select[required], textarea[required]');
|
||||
requiredEls.forEach(el=>{
|
||||
el.setAttribute('aria-required','true');
|
||||
const id = el.id;
|
||||
let lbl = id ? modalForm.querySelector('label[for="'+id+'"]') : null;
|
||||
if(!lbl){
|
||||
const field = el.closest('.field');
|
||||
if(field) lbl = field.querySelector('label');
|
||||
}
|
||||
if(lbl){
|
||||
const already = lbl.querySelector('.required-indicator') || lbl.querySelector('.has-text-danger') || /\*\s*$/.test(lbl.textContent);
|
||||
if(!already){
|
||||
const span = document.createElement('span');
|
||||
span.className = 'required-indicator has-text-danger';
|
||||
span.textContent = ' *';
|
||||
lbl.appendChild(span);
|
||||
}
|
||||
}
|
||||
});
|
||||
}catch(e){ /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit">Save</button>
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
{% for s in rota.shifts %}
|
||||
<li>
|
||||
<strong>{{ s.name }}</strong> — sites: {{ s.sites|join:", " }} — days: {{ s.days|join:", " }} — workers: {{ s.workers_required }}
|
||||
{% if s.constraints %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<span class="tags">
|
||||
{% for c in s.constraints %}
|
||||
{# Build a simple tooltip showing key: value pairs for the constraint on hover; make clickable to open constraint editor #}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{% for k,v in c.items %}{{ k }}: {{ v }}{% if not forloop.last %}; {% endif %}{% endfor %}" hx-get="{% url 'rota:shift_edit' rota.id forloop.parentloop.counter0 %}?focus={{ c.name }}" hx-target="#modal" hx-swap="innerHTML">{{ c.name }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if s.balance_offset is defined and s.balance_offset %}
|
||||
— balance offset: {{ s.balance_offset }}
|
||||
{% endif %}
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
{% include 'rota/partials/worker_token_area.html' with worker=worker token_link=token_link %}
|
||||
{% endif %}
|
||||
|
||||
{% if worker %}
|
||||
<div class="box" id="worker-leaves">
|
||||
<h4 class="title is-6">Leaves</h4>
|
||||
<div>
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all token=None %}
|
||||
</div>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.5rem;">
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' worker.id %}">Open full page</a>
|
||||
<button class="button is-small" hx-get="{% url 'rota:worker_leave_add' worker.id %}" hx-target="#modal" hx-swap="innerHTML">Add leave</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
|
||||
<div class="box" id="nwds-editor">
|
||||
<h4 class="title is-6">Non-working days</h4>
|
||||
@@ -30,6 +43,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if focus %}
|
||||
<script>
|
||||
(function(){
|
||||
try{
|
||||
const f = '{{ focus }}';
|
||||
const listId = f + '-list';
|
||||
const addBtnId = 'add-' + f;
|
||||
const listEl = document.getElementById(listId);
|
||||
if(listEl){
|
||||
// if no rows exist, trigger the Add button to create one
|
||||
if(listEl.children.length === 0){
|
||||
const btn = document.getElementById(addBtnId);
|
||||
if(btn) btn.click();
|
||||
}
|
||||
// after render, scroll into view and focus the first input
|
||||
setTimeout(()=>{
|
||||
const el = document.getElementById(listId);
|
||||
if(el){
|
||||
try{ el.scrollIntoView({behavior:'smooth', block:'center'}); }catch(e){}
|
||||
const firstRow = el.querySelector('.box') || el.firstElementChild;
|
||||
if(firstRow){
|
||||
const input = firstRow.querySelector('input, select, textarea, [data-field]');
|
||||
if(input){ try{ input.focus(); }catch(e){} }
|
||||
}
|
||||
}
|
||||
}, 120);
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function(){
|
||||
const textarea = document.getElementById('id_nwds');
|
||||
@@ -198,64 +242,84 @@
|
||||
<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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Out of programme</h5>
|
||||
<button type="button" class="button is-small" id="add-oop">Add</button>
|
||||
</div>
|
||||
<div id="oop-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Not available to work</h5>
|
||||
<button type="button" class="button is-small" id="add-nav">Add</button>
|
||||
</div>
|
||||
<div id="nav-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Preferences not to work</h5>
|
||||
<button type="button" class="button is-small" id="add-pref">Add</button>
|
||||
</div>
|
||||
<div id="pref-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Work requests</h5>
|
||||
<button type="button" class="button is-small" id="add-wr">Add</button>
|
||||
</div>
|
||||
<div id="wr-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Locum availability</h5>
|
||||
<button type="button" class="button is-small" id="add-loc">Add</button>
|
||||
</div>
|
||||
<div id="loc-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Forced assignments (week,day,shift)</h5>
|
||||
<button type="button" class="button is-small" id="add-fa">Add</button>
|
||||
</div>
|
||||
<div id="fa-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Forced assignments by date</h5>
|
||||
<button type="button" class="button is-small" id="add-fabd">Add</button>
|
||||
</div>
|
||||
<div id="fabd-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Hard day dependencies</h5>
|
||||
<button type="button" class="button is-small" id="add-hdd">Add</button>
|
||||
</div>
|
||||
<div id="hdd-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Hard day exclusions</h5>
|
||||
<button type="button" class="button is-small" id="add-hde">Add</button>
|
||||
</div>
|
||||
<div id="hde-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div class="column is-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 class="column is-full">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h5 class="subtitle is-6" style="margin:0">Allowed multi-shift sets</h5>
|
||||
<button type="button" class="button is-small" id="add-ams">Add</button>
|
||||
</div>
|
||||
<div id="ams-list" style="margin-top:0.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +332,7 @@
|
||||
if(textarea) textarea.style.display='none';
|
||||
const listEl = document.getElementById(cfg.container);
|
||||
const addBtn = document.getElementById(cfg.addButton);
|
||||
const WEEKDAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||||
|
||||
function parse(){
|
||||
let v = textarea ? textarea.value : ''; if(!v) return [];
|
||||
@@ -284,16 +349,70 @@
|
||||
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] || '';
|
||||
|
||||
// Special-case: render weekday checkboxes when the field name is 'days'
|
||||
if(f.name === 'days'){
|
||||
const box = document.createElement('div'); box.className='buttons'; box.setAttribute('data-field', f.name);
|
||||
WEEKDAYS.forEach(d => {
|
||||
const btn = document.createElement('label'); btn.className='button is-small is-light'; btn.style.marginRight='0.25rem';
|
||||
const chk = document.createElement('input'); chk.type='checkbox'; chk.value = d; chk.style.marginRight='0.35rem';
|
||||
// mark checked if item[f.name] contains this day (accept set/list/string)
|
||||
try{
|
||||
const cur = item[f.name];
|
||||
if(cur){
|
||||
if(Array.isArray(cur)){
|
||||
if(cur.indexOf(d)!==-1) chk.checked = true;
|
||||
} else if(typeof cur === 'string'){
|
||||
const parts = cur.split(',').map(s=>s.trim()); if(parts.indexOf(d)!==-1) chk.checked = true;
|
||||
} else if(typeof cur === 'object'){
|
||||
// set-like
|
||||
if(cur.hasOwnProperty(d) || (Array.from(Object.values(cur)).indexOf(d)!==-1)) chk.checked = true;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
chk.onchange = ()=>{ sync(idx); };
|
||||
btn.appendChild(chk);
|
||||
const span = document.createElement('span'); span.textContent = d; btn.appendChild(span);
|
||||
box.appendChild(btn);
|
||||
});
|
||||
control.appendChild(box);
|
||||
field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
} else {
|
||||
let input;
|
||||
if(f.type==='date'){
|
||||
input = document.createElement('input'); input.type='date'; input.className='datepicker input'; input.setAttribute('autocomplete','off'); input.value = item[f.name] || '';
|
||||
} else if(f.type==='int'){
|
||||
input = document.createElement('input'); input.type='number'; input.className='input'; input.value = item[f.name] || '';
|
||||
} else if(f.type==='shift'){
|
||||
// render a select populated with shift options passed via cfg
|
||||
input = document.createElement('select'); input.className='select';
|
||||
// wrap select in a div.select to match Bulma styling
|
||||
const selWrap = document.createElement('div'); selWrap.className = 'select';
|
||||
const sel = document.createElement('select');
|
||||
const emptyOpt = document.createElement('option'); emptyOpt.value = ''; emptyOpt.text = '(any)'; sel.appendChild(emptyOpt);
|
||||
const opts = (cfg && cfg.shiftOptions) ? cfg.shiftOptions : (typeof _shiftOptions !== 'undefined' ? _shiftOptions : []);
|
||||
if(Array.isArray(opts)){
|
||||
opts.forEach(o=>{ const op = document.createElement('option'); op.value = o; op.text = o; if(item[f.name] === o) op.selected = true; sel.appendChild(op); });
|
||||
}
|
||||
sel.value = item[f.name] || '';
|
||||
sel.onchange = ()=>{ sync(idx); };
|
||||
sel.setAttribute('data-field', f.name);
|
||||
selWrap.appendChild(sel);
|
||||
// set 'input' variable to the wrapper so downstream code appends it
|
||||
input = selWrap;
|
||||
} else {
|
||||
input = document.createElement('input'); input.type='text'; input.className='input'; input.value = item[f.name] || '';
|
||||
}
|
||||
// tag this control with the field name so sync() can find it reliably
|
||||
if(input.tagName === 'DIV' && input.querySelector('select')){
|
||||
// wrapper created for select; the inner select already has data-field
|
||||
// nothing to do here
|
||||
} else {
|
||||
input.setAttribute('data-field', f.name);
|
||||
input.onchange = ()=>{ sync(idx); };
|
||||
}
|
||||
control.appendChild(input); field.appendChild(control); col.appendChild(field); row.appendChild(col);
|
||||
}
|
||||
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);
|
||||
@@ -302,7 +421,38 @@
|
||||
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 sync(i){
|
||||
const data = parse();
|
||||
const el = listEl.children[i]; if(!el) return;
|
||||
const obj = {};
|
||||
cfg.fields.forEach((f)=>{
|
||||
const fld = el.querySelector('[data-field="'+f.name+'"]');
|
||||
if(!fld){
|
||||
// fallback: try to find an input by label text
|
||||
const inp = el.querySelector('input');
|
||||
if(inp && inp.value!=='') obj[f.name] = f.type==='int'?parseInt(inp.value,10):inp.value;
|
||||
return;
|
||||
}
|
||||
// if this is a checkbox group (for days), fld will be a container
|
||||
if(f.name === 'days'){
|
||||
const checks = fld.querySelectorAll('input[type=checkbox]');
|
||||
const vals = Array.from(checks).filter(c=>c.checked).map(c=>c.value);
|
||||
if(vals.length) obj[f.name] = vals;
|
||||
} else {
|
||||
// single input control
|
||||
if(fld.tagName === 'INPUT' || fld.tagName === 'SELECT'){
|
||||
const v = fld.value;
|
||||
if(v !== '') obj[f.name] = f.type==='int'?parseInt(v,10):v;
|
||||
} else {
|
||||
// container wrapping an input (e.g. div.select wrapper or custom control)
|
||||
const inp = fld.querySelector('input') || fld.querySelector('select');
|
||||
if(inp && inp.value !== '') obj[f.name] = f.type==='int'?parseInt(inp.value,10):inp.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
data[i]=obj;
|
||||
if(textarea) textarea.value = JSON.stringify(data);
|
||||
}
|
||||
|
||||
function removeAt(i){ const data = parse(); data.splice(i,1); if(textarea) textarea.value = JSON.stringify(data); render(); }
|
||||
|
||||
@@ -314,15 +464,21 @@
|
||||
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'}]});
|
||||
// If the server provided shift names for the current rota, use them
|
||||
var _shiftOptions = [];
|
||||
try{
|
||||
_shiftOptions = {{ shift_names_json|default:'[]'|safe }};
|
||||
}catch(e){ _shiftOptions = []; }
|
||||
|
||||
makeSimpleListEditor({textarea:'id_work_requests', container:'wr-list', addButton:'add-wr', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_locum_availability', container:'loc-list', addButton:'add-loc', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments', container:'fa-list', addButton:'add-fa', fields:[{name:'week',label:'Week',type:'int'},{name:'day',label:'Day',type:'text'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_forced_assignments_by_date', container:'fabd-list', addButton:'add-fabd', fields:[{name:'date',label:'Date',type:'date'},{name:'shift',label:'Shift',type:'shift'}], shiftOptions:_shiftOptions});
|
||||
makeSimpleListEditor({textarea:'id_allowed_multi_shift_sets', container:'ams-list', addButton:'add-ams', fields:[{name:'shifts',label:'Shifts (comma-separated)',type:'text'}]});
|
||||
|
||||
// Hard day deps/exclusions need days/weeks/start/end
|
||||
makeSimpleListEditor({textarea:'id_hard_day_dependencies', container:'hdd-list', addButton:'add-hdd', fields:[{name:'days',label:'Days (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'}]});
|
||||
makeSimpleListEditor({textarea:'id_hard_day_dependencies', container:'hdd-list', addButton:'add-hdd', fields:[{name:'days',label:'Days (weekdays)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
makeSimpleListEditor({textarea:'id_hard_day_exclusions', container:'hde-list', addButton:'add-hde', fields:[{name:'days',label:'Days (weekdays)',type:'text'},{name:'weeks',label:'Weeks (comma-separated ints)',type:'text'},{name:'start_date',label:'Start date',type:'date'},{name:'end_date',label:'End date',type:'date'}]});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Import workers</p>
|
||||
<button class="delete" aria-label="close" hx-trigger="closeModal"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<form id="worker-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label">CSV file</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" accept="text/csv,application/vnd.ms-excel,text/plain" />
|
||||
</div>
|
||||
<p class="help">Upload a CSV file with columns name,email,site,grade,fte (headers optional).</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Or paste CSV / lines</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="workers_text" rows="6" placeholder="name,email,site,grade,fte\nAlice,alice@example.com,exeter,2,1.0"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="assign_to_rota" value="1" checked /> Assign created workers to this rota
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control"><button class="button is-link" type="submit">Import</button></div>
|
||||
<div class="control"><button class="button is-light" type="button" hx-trigger="closeModal">Cancel</button></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="content" style="margin-top:1rem;">
|
||||
<p class="help">Example CSV lines:</p>
|
||||
<pre>name,email,site,grade,fte
|
||||
Alice,alice@example.com,exeter,2,1.0
|
||||
Bob,bob@example.com,plymouth,1,0.8</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@
|
||||
<li>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||
<a href="{% url 'rota:worker_detail' w.id %}">{{ w.name }}</a>
|
||||
<a href="javascript:void(0)" hx-get="{% url 'rota:worker_overview' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">{{ w.name }}</a>
|
||||
<small style="color:#666">— {{ w.site }}{% if w.grade %} · grade {{ w.grade }}{% endif %} · FTE {{ w.fte }}</small>
|
||||
{# show non-working days stored in options.nwds if present; render JSON safely and let JS format it #}
|
||||
{% if w.options.nwds %}
|
||||
@@ -15,10 +15,33 @@
|
||||
{{ w.options.nwds|default:"[]"|json_script:script_id }}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{# Show worker-specific constraint snippets (compact tags with hover details) #}
|
||||
{% if w.options.hard_day_exclusions or w.options.hard_day_dependencies or w.options.forced_assignments or w.options.forced_assignments_by_date or w.options.allowed_multi_shift_sets %}
|
||||
<div style="margin-top:0.25rem;">
|
||||
<span class="tags">
|
||||
{% for it in w.options.hard_day_exclusions %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=hde" hx-target="#modal" hx-swap="innerHTML">HDE</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.hard_day_dependencies %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=hdd" hx-target="#modal" hx-swap="innerHTML">HDD</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.forced_assignments %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=fa" hx-target="#modal" hx-swap="innerHTML">FA</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.forced_assignments_by_date %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=fad" hx-target="#modal" hx-swap="innerHTML">FAD</a>
|
||||
{% endfor %}
|
||||
{% for it in w.options.allowed_multi_shift_sets %}
|
||||
<a href="javascript:void(0)" class="tag is-light" title="{{ it|escape }}" hx-get="{% url 'rota:worker_edit' rota.id w.id %}?focus=ams" hx-target="#modal" hx-swap="innerHTML">AMS</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="flex:0 0 auto;">
|
||||
<button class="button is-small is-light ml-2" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<button class="button is-small is-danger ml-2" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
|
||||
<div style="flex:0 0 auto; display:flex; gap:0.5rem;">
|
||||
<button class="button is-small is-light" hx-get="{% url 'rota:worker_edit' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' w.id %}">Leave</a>
|
||||
<button class="button is-small is-danger" hx-get="{% url 'rota:worker_delete' rota.id w.id %}" hx-target="#modal" hx-swap="innerHTML">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div class="modal is-active" id="worker-overview-modal">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Worker: {{ worker.name }}</p>
|
||||
<button class="delete" aria-label="close" onclick="document.getElementById('modal').innerHTML='' "></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="content">
|
||||
<h4 class="title is-6">Basic info</h4>
|
||||
<p><strong>Site:</strong> {{ worker.site }}</p>
|
||||
<p><strong>Grade:</strong> {{ worker.grade }}</p>
|
||||
<p><strong>FTE:</strong> {{ worker.fte }}</p>
|
||||
<p><strong>Email:</strong> {{ worker.email }}</p>
|
||||
|
||||
<h4 class="title is-6">Leaves</h4>
|
||||
<div>
|
||||
{% include 'rota/partials/leave_list.html' with leaves=worker.leaves.all token=None %}
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6">Configured constraints & options</h4>
|
||||
<p class="help">Below are the worker's configured options. Values are shown read-only.</p>
|
||||
|
||||
<div class="box" style="white-space:pre-wrap; font-family:monospace; font-size:0.9rem;">
|
||||
{{ pretty_options }}
|
||||
</div>
|
||||
|
||||
<h4 class="title is-6">Quick summary</h4>
|
||||
<div class="tags">
|
||||
{% for it in worker.options.hard_day_exclusions %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">HDE</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.hard_day_dependencies %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">HDD</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.forced_assignments %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">FA</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.forced_assignments_by_date %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">FAD</span>
|
||||
{% empty %}{% endfor %}
|
||||
{% for it in worker.options.allowed_multi_shift_sets %}
|
||||
<span class="tag is-light" title="{{ it|escape }}">AMS</span>
|
||||
{% empty %}{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:0.5rem;">
|
||||
<button class="button is-small is-light" hx-get="{% url 'rota:worker_edit' rota.id worker.id %}" hx-target="#modal" hx-swap="innerHTML">Edit</button>
|
||||
<a class="button is-small" href="{% url 'rota:worker_detail' worker.id %}">Open full page</a>
|
||||
<button type="button" class="button" onclick="document.getElementById('modal').innerHTML=''">Close</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,65 +6,121 @@
|
||||
{% 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 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>
|
||||
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}" style="margin-left:0.5rem;">All leave requests</a>
|
||||
</p>
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<div>
|
||||
<h1 class="title is-3" style="margin-bottom:0.2rem;">{{ rota.name }}</h1>
|
||||
<p class="subtitle is-6" style="margin-top:0;">{{ rota.description }}</p>
|
||||
<p class="is-size-7 has-text-grey">Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="buttons">
|
||||
<a class="button is-small" href="{% url 'rota:rota_edit' rota.id %}">Edit rota</a>
|
||||
<a class="button is-small is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Import</a>
|
||||
<a class="button is-small" href="{% url 'rota:rota_detail' rota.id %}">Refresh</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<details class="box">
|
||||
<summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
|
||||
<div style="margin-bottom:0.5em; margin-top:0.5em;">
|
||||
<form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
|
||||
</form>
|
||||
<div class="columns is-variable is-6" style="margin-top:0.75rem;">
|
||||
<div class="column is-half">
|
||||
<div class="box">
|
||||
<h2 class="subtitle is-6">Solver settings</h2>
|
||||
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
|
||||
<div class="column is-narrow"><strong>Solver</strong></div>
|
||||
<div class="column">{% if solver %}{{ solver }}{% else %}<span class="has-text-grey">(default)</span>{% endif %}</div>
|
||||
<div class="column is-narrow"><strong>Ratio</strong></div>
|
||||
<div class="column">{% if solver_ratio is not None %}{{ solver_ratio }}{% else %}<span class="has-text-grey">—</span>{% endif %}</div>
|
||||
</div>
|
||||
{% if solver_options %}
|
||||
<div style="margin-top:0.5rem;">
|
||||
<h3 class="is-size-7">Solver options</h3>
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ solver_options }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul>
|
||||
{% for run in rota.runs.all %}
|
||||
<li>
|
||||
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> — {{ run.get_status_display }}
|
||||
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
|
||||
<span class="ml-2"><a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a></span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No previous runs</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div class="box">
|
||||
<h2 class="subtitle is-6">RotaBuilder summary</h2>
|
||||
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
|
||||
<div class="column is-narrow"><strong>Weeks</strong></div>
|
||||
<div class="column">{{ weeks_to_rota|default:'—' }}</div>
|
||||
<div class="column is-narrow"><strong>Shifts</strong></div>
|
||||
<div class="column">{{ shift_count|default:'—' }}</div>
|
||||
<div class="column is-narrow"><strong>Workers</strong></div>
|
||||
<div class="column">{{ worker_count|default:'—' }}</div>
|
||||
</div>
|
||||
<p class="help is-size-7">This summary reflects the current rota options and configured shifts/workers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box mb-4">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
{% include 'rota/partials/worker_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="buttons">
|
||||
<button class="button is-link" hx-get="{% url 'rota:worker_add' %}?rota_id={{ rota.id }}" hx-target="#modal" hx-swap="innerHTML">Add worker</button>
|
||||
<button class="button is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Import workers</button>
|
||||
<a class="button is-info is-light is-small" href="{% url 'rota:leaves_global_rota' rota.id %}">All leave requests</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="subtitle">Constraint options</h2>
|
||||
<div class="box">
|
||||
<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>
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<p class="help">Configure typed rota constraint options. Click Edit to open the form showing typed fields and descriptions.</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-link" hx-get="{% url 'rota:rota_options' rota.id %}" hx-target="#modal" hx-swap="innerHTML">Edit constraint options</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<details class="constraints-details" style="margin-top:0.5rem;">
|
||||
<summary class="subtitle is-6" style="cursor:pointer; outline:none;">Configured constraint values{% if configured_constraints %} ({{ configured_constraints|length }}){% endif %}</summary>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
|
||||
<input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" />
|
||||
<button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button>
|
||||
<div class="field has-addons" style="margin-bottom:0.5rem;">
|
||||
<div class="control is-expanded"><input id="constraint-filter-{{ rota.id }}" class="input is-small" type="search" placeholder="Filter constraints (key or value)..." aria-label="Filter constraints" /></div>
|
||||
<div class="control"><button id="constraint-clear-{{ rota.id }}" class="button is-small is-light" type="button">Clear</button></div>
|
||||
</div>
|
||||
<div class="constraints-scroll" style="max-height:600px; overflow:auto; margin-bottom:0.75em;">
|
||||
{% if configured_constraints %}
|
||||
<table class="table is-fullwidth is-striped is-narrow" id="constraint-table-{{ rota.id }}">
|
||||
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
|
||||
<thead><tr><th>Key</th><th>Value</th><th class="has-text-right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key, info in configured_constraints.items %}
|
||||
<tr id="constraint-row-{{ key }}">
|
||||
<td style="vertical-align:middle;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
|
||||
<td style="vertical-align:middle;"><pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre></td>
|
||||
<td style="vertical-align:middle; min-width:180px;"><code title="{{ info.description|default:'' }}">{{ key }}</code></td>
|
||||
<td style="vertical-align:middle;">
|
||||
{% if info.typed and info.default is not None %}
|
||||
{% if info.value == info.default %}
|
||||
<pre class="has-text-grey" style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
<span class="tag is-light is-small" title="Using typed default">default</span>
|
||||
{% else %}
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
<div class="help is-size-7" style="margin-top:0.25rem;color:#666;">Default: <code style="white-space:pre-wrap">{{ info.default }}</code></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<pre style="white-space:pre-wrap; margin:0;">{{ info.pretty }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="vertical-align:middle; text-align:right;">
|
||||
<a href="javascript:void(0)" class="button is-small is-light" role="button" hx-get="{% url 'rota:rota_option_edit' rota.id %}?key={{ key }}" hx-target="#modal" hx-swap="innerHTML">Edit</a>
|
||||
<form method="post" hx-post="{% url 'rota:rota_option_delete' rota.id %}" hx-target="#constraint-row-{{ key }}" hx-swap="outerHTML" style="display:inline">
|
||||
{% csrf_token %}
|
||||
@@ -108,22 +164,46 @@
|
||||
</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>
|
||||
</form>
|
||||
{# Run controls and historic runs moved to the bottom of the page. #}
|
||||
|
||||
<div>
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
<p><button class="button is-link" hx-get="{% url 'rota:shift_add' rota.id %}" hx-target="#shift-form" hx-swap="innerHTML">Add shift</button></p>
|
||||
<div id="shift-form">{# Load form via HTMX when user clicks 'Add shift' - initial empty state #}</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<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>
|
||||
{% include 'rota/partials/shift_list.html' %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped is-grouped-multiline">
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="generate_builder" value="1" checked> Generate HTML export</label></div>
|
||||
<div class="control"><label class="checkbox"><input type="checkbox" name="builder_solve" value="1" checked> Solve when exporting (may be slow)</label></div>
|
||||
<div class="control"><button class="button is-primary" type="submit">Run rota</button></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-4" style="margin-top:0.75rem;">
|
||||
<details class="box">
|
||||
<summary class="subtitle" style="cursor:pointer; outline:none;">Historic runs</summary>
|
||||
<div style="margin-bottom:0.5em; margin-top:0.5em;">
|
||||
<form method="post" action="{% url 'rota:rota_runs_clear' rota.id %}" style="display:inline" onsubmit="return confirm('Delete all historic runs for this rota? This cannot be undone.')">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger is-light is-small" type="submit">Clear all runs</button>
|
||||
</form>
|
||||
</div>
|
||||
<ul>
|
||||
{% for run in rota.runs.all %}
|
||||
<li>
|
||||
<a href="{% url 'rota:rota_run_detail' run.id %}">Run {{ run.id }}</a> — {{ run.get_status_display }}
|
||||
{% if run.finished_at %} — finished {{ run.finished_at }}{% endif %}
|
||||
<span class="ml-2"><a class="button is-small is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Export</a></span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No previous runs</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>Rota run {{ run.id }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
|
||||
<style>pre { white-space: pre-wrap; }</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -12,17 +13,23 @@
|
||||
<div class="container">
|
||||
<h1 class="title">Rota run for {{ run.rota.name }}</h1>
|
||||
<p class="subtitle">Status: {{ run.get_status_display }} ({{ run.status }})</p>
|
||||
<div style="margin-bottom:0.5rem;" id="run-status-container">
|
||||
<div id="run-status" hx-get="{% url 'rota:rota_run_status' run.id %}" hx-trigger="every 3s" hx-swap="outerHTML">
|
||||
<span class="tag">Status: {{ run.get_status_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>Created: {{ run.created_at|localtime }} started: {{ run.started_at|localtime }} finished: {{ run.finished_at|localtime }}</p>
|
||||
<p class="mt-2">
|
||||
<a class="button" href="{% url 'rota:rota_detail' run.rota.id %}">Back to rota</a>
|
||||
<a class="button is-light" href="{% url 'rota:rota_run_export_html' run.id %}" target="_blank">Open export</a>
|
||||
<span id="view-final-output" style="margin-left:0.5rem;"></span>
|
||||
</p>
|
||||
|
||||
<div class="box">
|
||||
<details>
|
||||
<summary><strong>Log</strong></summary>
|
||||
<div style="margin-top:0.5rem; max-height:40vh; overflow:auto; border-top:1px solid #eee; padding-top:0.5rem;">
|
||||
<pre>{{ run.log }}</pre>
|
||||
<div id="run-log"><pre id="run-log-pre">{{ run.log }}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -31,7 +38,7 @@
|
||||
<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 id="run-result"><pre id="run-result-pre">{{ result_pretty|default:run.result }}</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
+222
-7
@@ -16,10 +16,13 @@ try:
|
||||
WorkRequests,
|
||||
HardDayDependency,
|
||||
HardDayExclusion,
|
||||
ShiftStartDate,
|
||||
ShiftEndDate,
|
||||
)
|
||||
except Exception:
|
||||
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
|
||||
WorkRequests = HardDayDependency = HardDayExclusion = None
|
||||
ShiftStartDate = ShiftEndDate = None
|
||||
|
||||
|
||||
class WorkerForm(forms.ModelForm):
|
||||
@@ -45,6 +48,10 @@ class WorkerForm(forms.ModelForm):
|
||||
# Complex structured options edited as JSON
|
||||
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
|
||||
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
|
||||
exact_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->exact_count", label="Exact shifts (JSON)")
|
||||
shift_start_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->start_date (YYYY-MM-DD)", label="Shift start dates (JSON)")
|
||||
shift_end_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->end_date (YYYY-MM-DD)", label="Shift end dates (JSON)")
|
||||
groups = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of group names (e.g. [\"group1\", \"group2\"])", label="Groups (JSON)")
|
||||
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
|
||||
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
|
||||
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
|
||||
@@ -94,6 +101,10 @@ class WorkerForm(forms.ModelForm):
|
||||
for json_fld in [
|
||||
"assign_as_block_preferences",
|
||||
"shift_fte_overrides",
|
||||
"exact_shifts",
|
||||
"shift_start_dates",
|
||||
"shift_end_dates",
|
||||
"groups",
|
||||
"previous_shifts",
|
||||
"shift_balance_extra",
|
||||
"allowed_multi_shift_sets",
|
||||
@@ -146,6 +157,10 @@ class WorkerForm(forms.ModelForm):
|
||||
json_fields = [
|
||||
"assign_as_block_preferences",
|
||||
"shift_fte_overrides",
|
||||
"exact_shifts",
|
||||
"shift_start_dates",
|
||||
"shift_end_dates",
|
||||
"groups",
|
||||
"previous_shifts",
|
||||
"shift_balance_extra",
|
||||
"allowed_multi_shift_sets",
|
||||
@@ -181,22 +196,27 @@ class WorkerForm(forms.ModelForm):
|
||||
parsed = val
|
||||
|
||||
def _normalize(obj):
|
||||
# Normalize pydantic BaseModel instances (v2:v1)
|
||||
# Normalize pydantic BaseModel instances (v2:v1) and recurse
|
||||
if hasattr(obj, "model_dump"):
|
||||
try:
|
||||
return obj.model_dump()
|
||||
res = obj.model_dump()
|
||||
return _normalize(res)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(obj, "dict") and not isinstance(obj, dict):
|
||||
try:
|
||||
return obj.dict()
|
||||
res = obj.dict()
|
||||
return _normalize(res)
|
||||
except Exception:
|
||||
pass
|
||||
# Recurse lists/tuples
|
||||
# Recurse lists/tuples/sets
|
||||
if isinstance(obj, list):
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, tuple):
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, set):
|
||||
# JSON can't represent sets; convert to list
|
||||
return [_normalize(i) for i in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _normalize(v) for k, v in obj.items()}
|
||||
return obj
|
||||
@@ -261,9 +281,10 @@ class WorkerForm(forms.ModelForm):
|
||||
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)
|
||||
cleaned["shift_start_dates"] = _parse_and_validate("shift_start_dates", ShiftStartDate)
|
||||
cleaned["shift_end_dates"] = _parse_and_validate("shift_end_dates", ShiftEndDate)
|
||||
|
||||
# forced assignments: expect list of tuples [week, day, shift]
|
||||
fa = cleaned.get("forced_assignments")
|
||||
@@ -392,8 +413,8 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
# legacy builder args (the remaining keys in `options` are treated as
|
||||
# constraint configuration for `RotaConstraintOptions`).
|
||||
builder_args = {
|
||||
"balance_offset_modifier": {"type": "int", "default": 1, "help": "Balance offset modifier used by the builder"},
|
||||
"ltft_balance_offset": {"type": "int", "default": 1, "help": "LTFT balance offset used by the builder"},
|
||||
"balance_offset_modifier": {"type": "float", "default": 1.0, "help": "Balance offset modifier used by the builder"},
|
||||
"ltft_balance_offset": {"type": "float", "default": 1.0, "help": "LTFT balance offset used by the builder"},
|
||||
"use_previous_shifts": {"type": "bool", "default": False, "help": "Use previous shifts when building the rota"},
|
||||
"use_shift_balance_extra": {"type": "bool", "default": False, "help": "Enable extra shift-balance penalties"},
|
||||
"use_bank_holiday_extra": {"type": "bool", "default": False, "help": "Apply bank-holiday balancing extras"},
|
||||
@@ -407,9 +428,40 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
self.fields[field_name] = forms.BooleanField(required=False, initial=bool(initial), label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
elif meta["type"] == "int":
|
||||
self.fields[field_name] = forms.IntegerField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
elif meta["type"] == "float":
|
||||
self.fields[field_name] = forms.FloatField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
else:
|
||||
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
|
||||
|
||||
# Expose solver selection and solver options (JSON) so users can tune
|
||||
# solver behaviour per-rota. Persisted into `RotaSchedule.options` as
|
||||
# keys `solver` (string) and `solver_options` (dict).
|
||||
solver_initial = existing_opts.get("solver", "appsi_highs")
|
||||
solver_opts_initial = existing_opts.get("solver_options", {})
|
||||
try:
|
||||
solver_opts_str = json.dumps(solver_opts_initial, indent=2)
|
||||
except Exception:
|
||||
solver_opts_str = ""
|
||||
|
||||
self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
|
||||
# Common solver ratio is surfaced as a dedicated field for convenience
|
||||
# (many workflows tune this). It's stored inside the solver_options
|
||||
# dict under the key 'ratio'. The free-form JSON textarea remains for
|
||||
# advanced options.
|
||||
solver_ratio_initial = None
|
||||
try:
|
||||
if isinstance(solver_opts_initial, dict) and "ratio" in solver_opts_initial:
|
||||
solver_ratio_initial = solver_opts_initial.get("ratio")
|
||||
else:
|
||||
# also accept legacy top-level 'ratio' key in existing_opts
|
||||
solver_ratio_initial = existing_opts.get("ratio")
|
||||
except Exception:
|
||||
solver_ratio_initial = None
|
||||
|
||||
self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
|
||||
self.fields["solver_ratio"] = forms.FloatField(required=False, initial=solver_ratio_initial, label="Solver ratio (relative gap)", help_text="Relative MIP gap / ratio (e.g. 0.01 for 1%)")
|
||||
self.fields["solver_options"] = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 6}), initial=solver_opts_str, label="Solver options (JSON)", help_text="JSON object of solver-specific options; e.g. {\"seconds\": 60, \"threads\": 4}")
|
||||
|
||||
def clean_start_date(self):
|
||||
start = self.cleaned_data.get("start_date")
|
||||
if start is None:
|
||||
@@ -436,6 +488,120 @@ class RotaScheduleForm(forms.ModelForm):
|
||||
self.fields["end_date"].initial = end_date
|
||||
return cleaned
|
||||
|
||||
def save(self, commit=True):
|
||||
"""Save the RotaSchedule and persist dynamic `opt__*` fields into
|
||||
`instance.options`.
|
||||
|
||||
This persists both typed constraint option fields (created from
|
||||
RotaConstraintOptions) and the small set of builder-argument fields
|
||||
(created in __init__). For structured fields we attempt to parse JSON
|
||||
textareas into Python objects.
|
||||
"""
|
||||
instance = super().save(commit=False)
|
||||
|
||||
opts = instance.options or {}
|
||||
|
||||
# Persist typed constraint option fields using inferred defaults map
|
||||
try:
|
||||
defaults = getattr(self, "_constraint_defaults", {}) or {}
|
||||
except Exception:
|
||||
defaults = {}
|
||||
|
||||
for key, default in defaults.items():
|
||||
field_name = f"opt__{key}"
|
||||
if field_name in self.cleaned_data:
|
||||
val = self.cleaned_data[field_name]
|
||||
if isinstance(default, (list, dict)):
|
||||
# JSON textarea
|
||||
try:
|
||||
parsed = json.loads(val) if val is not None and val != "" else []
|
||||
except Exception:
|
||||
parsed = val
|
||||
opts[key] = parsed
|
||||
else:
|
||||
if val == "" or val is None:
|
||||
opts[key] = None
|
||||
else:
|
||||
opts[key] = val
|
||||
|
||||
# Persist any remaining opt__ fields (builder args and others)
|
||||
for fname, val in self.cleaned_data.items():
|
||||
if not fname.startswith("opt__"):
|
||||
continue
|
||||
key = fname[5:]
|
||||
if key in defaults:
|
||||
# already handled
|
||||
continue
|
||||
v = val
|
||||
# Try to parse JSON-like strings into structures
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith("[") or s.startswith("{"):
|
||||
try:
|
||||
v = json.loads(v)
|
||||
except Exception:
|
||||
pass
|
||||
opts[key] = v
|
||||
|
||||
# Persist the top-level 'weeks' value from the form into options so
|
||||
# the builder can prefer this explicit value instead of deriving it
|
||||
# from end_date. Do not persist end_date itself here.
|
||||
try:
|
||||
weeks_val = self.cleaned_data.get("weeks")
|
||||
if weeks_val is not None:
|
||||
opts["weeks"] = int(weeks_val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist solver selection and options (if provided). Solver options are
|
||||
# stored as a dict under opts['solver_options']. If the user supplied
|
||||
# a JSON string, attempt to parse it; otherwise keep raw value.
|
||||
try:
|
||||
solver_name = self.cleaned_data.get("solver")
|
||||
if solver_name:
|
||||
opts["solver"] = solver_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
so = self.cleaned_data.get("solver_options")
|
||||
if so is not None and so != "":
|
||||
if isinstance(so, str):
|
||||
try:
|
||||
parsed = json.loads(so)
|
||||
except Exception:
|
||||
# keep as raw string if parsing fails (non-fatal)
|
||||
parsed = so
|
||||
else:
|
||||
parsed = so
|
||||
opts["solver_options"] = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If a dedicated solver_ratio field was provided, inject/override it
|
||||
# into the solver_options dict so it is honoured at runtime. This
|
||||
# ensures the friendly ratio field wins over any value in the JSON
|
||||
# textarea.
|
||||
try:
|
||||
ratio_val = self.cleaned_data.get("solver_ratio")
|
||||
if ratio_val is not None and ratio_val != "":
|
||||
so = opts.get("solver_options")
|
||||
if so is None or not isinstance(so, dict):
|
||||
so = {}
|
||||
try:
|
||||
so["ratio"] = float(ratio_val)
|
||||
except Exception:
|
||||
so["ratio"] = ratio_val
|
||||
opts["solver_options"] = so
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
instance.options = opts
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class WorkerSelfServiceForm(forms.ModelForm):
|
||||
"""Small form exposed to workers via tokenized links so they can set
|
||||
@@ -647,6 +813,16 @@ class ShiftForm(forms.Form):
|
||||
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
|
||||
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
|
||||
)
|
||||
include_groups = forms.CharField(
|
||||
required=False,
|
||||
help_text="Comma separated list of groups that can work this shift (leave empty for all)",
|
||||
label="Include groups",
|
||||
)
|
||||
exclude_groups = forms.CharField(
|
||||
required=False,
|
||||
help_text="Comma separated list of groups that cannot work this shift",
|
||||
label="Exclude groups",
|
||||
)
|
||||
|
||||
def clean_sites(self):
|
||||
val = self.cleaned_data["sites"]
|
||||
@@ -663,8 +839,47 @@ class ShiftForm(forms.Form):
|
||||
parsed = json.loads(val)
|
||||
if not isinstance(parsed, list):
|
||||
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
|
||||
# Validate each constraint object for basic correctness
|
||||
errors = []
|
||||
for i, c in enumerate(parsed):
|
||||
if not isinstance(c, dict):
|
||||
errors.append(f"Constraint #{i+1} must be an object")
|
||||
continue
|
||||
name = c.get('name') or c.get('type')
|
||||
if not name:
|
||||
errors.append(f"Constraint #{i+1} missing 'name'")
|
||||
continue
|
||||
if name in ('pre', 'post'):
|
||||
opts = c.get('options', {}) or {}
|
||||
# days is now required: check top-level or under options
|
||||
days_val = c.get('days') if c.get('days') is not None else opts.get('days')
|
||||
if days_val is None:
|
||||
errors.append(
|
||||
f"Constraint #{i+1} ('{name}') requires a 'days' duration (number of days the constraint applies for)."
|
||||
)
|
||||
if errors:
|
||||
raise forms.ValidationError(errors)
|
||||
return parsed
|
||||
except forms.ValidationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
|
||||
|
||||
def clean_include_groups(self):
|
||||
val = self.cleaned_data.get("include_groups") or ""
|
||||
return [g.strip() for g in val.split(",") if g.strip()]
|
||||
|
||||
def clean_exclude_groups(self):
|
||||
val = self.cleaned_data.get("exclude_groups") or ""
|
||||
return [g.strip() for g in val.split(",") if g.strip()]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
include = cleaned_data.get("include_groups") or []
|
||||
exclude = cleaned_data.get("exclude_groups") or []
|
||||
overlap = set(include).intersection(set(exclude))
|
||||
if overlap:
|
||||
raise forms.ValidationError(
|
||||
f"Include groups and Exclude groups cannot overlap. Overlapping groups: {', '.join(overlap)}"
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
|
||||
from rota.models import Worker
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "List workers assigned to more than one rota (useful before migrating to OneToOne Assignment)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
qs = Worker.objects.annotate(num_rotas=Count('rotas')).filter(num_rotas__gt=1)
|
||||
if not qs.exists():
|
||||
self.stdout.write(self.style.SUCCESS('No workers assigned to more than one rota.'))
|
||||
return
|
||||
for w in qs:
|
||||
self.stdout.write(f"Worker id={w.id} name={w.name!r} assigned_rotas={w.num_rotas}")
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0 on 2025-12-19 21:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rota", "0007_make_request_token_unique"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="assignment",
|
||||
options={"ordering": ("-created_at",)},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="assignment",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="assignment",
|
||||
name="worker",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="rota.worker"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="worker",
|
||||
name="request_token",
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
|
||||
),
|
||||
]
|
||||
@@ -55,14 +55,34 @@ class RotaSchedule(models.Model):
|
||||
)
|
||||
|
||||
# compute weeks_to_rota (integer number of weeks)
|
||||
delta = self.end_date - self.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
# Prefer an explicit value stored in `self.options['weeks']` (set by
|
||||
# the rota edit form). If absent, fall back to deriving from the
|
||||
# stored end_date (backwards compatible).
|
||||
opts_preview = dict(self.options or {})
|
||||
weeks_opt = None
|
||||
if "weeks" in opts_preview:
|
||||
try:
|
||||
weeks_opt = int(opts_preview.get("weeks"))
|
||||
except Exception:
|
||||
weeks_opt = None
|
||||
if weeks_opt is not None:
|
||||
weeks_to_rota = max(1, weeks_opt)
|
||||
else:
|
||||
delta = self.end_date - self.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
|
||||
# Build kwargs for RotaBuilder from any explicit builder args stored in
|
||||
# `self.options` (kept for backward compatibility), and treat the
|
||||
# remaining keys as constraint options which should map to
|
||||
# `RotaConstraintOptions`.
|
||||
opts = dict(self.options or {})
|
||||
# Remove 'weeks' from opts before handing to constraint options so
|
||||
# it doesn't cause validation failures in RotaConstraintOptions.
|
||||
if "weeks" in opts:
|
||||
try:
|
||||
opts.pop("weeks")
|
||||
except Exception:
|
||||
pass
|
||||
builder_keys = [
|
||||
"balance_offset_modifier",
|
||||
"ltft_balance_offset",
|
||||
@@ -209,6 +229,7 @@ class Worker(models.Model):
|
||||
"max_shifts_per_week_by_shift_name",
|
||||
"assign_as_block_preferences",
|
||||
"shift_fte_overrides",
|
||||
"exact_shifts",
|
||||
]
|
||||
list_keys = [
|
||||
"oop",
|
||||
@@ -221,6 +242,8 @@ class Worker(models.Model):
|
||||
"hard_day_exclusions",
|
||||
"forced_assignments",
|
||||
"forced_assignments_by_date",
|
||||
"shift_start_dates",
|
||||
"shift_end_dates",
|
||||
]
|
||||
|
||||
for k in int_keys:
|
||||
@@ -253,6 +276,17 @@ class Worker(models.Model):
|
||||
else:
|
||||
pyd_kwargs[k] = []
|
||||
|
||||
set_keys = ["groups"]
|
||||
for k in set_keys:
|
||||
v = pyd_kwargs.get(k, None)
|
||||
try:
|
||||
if v is None:
|
||||
pyd_kwargs[k] = set()
|
||||
else:
|
||||
pyd_kwargs[k] = set(v)
|
||||
except Exception:
|
||||
pyd_kwargs[k] = set()
|
||||
|
||||
# allowed_multi_shift_sets is expected to be a list of sets; if the
|
||||
# DB contains lists-of-lists convert inner lists to sets.
|
||||
ams = pyd_kwargs.get("allowed_multi_shift_sets")
|
||||
@@ -277,13 +311,18 @@ class Worker(models.Model):
|
||||
class Assignment(models.Model):
|
||||
"""Join model assigning a worker to a rota (can hold role / metadata)."""
|
||||
|
||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE)
|
||||
# Enforce that a Worker may only be assigned to a single RotaSchedule by
|
||||
# using a OneToOneField here. This ensures at the DB level that each
|
||||
# Worker has at most one Assignment (and therefore at most one rota).
|
||||
worker = models.OneToOneField(Worker, on_delete=models.CASCADE)
|
||||
rota = models.ForeignKey(RotaSchedule, on_delete=models.CASCADE)
|
||||
role = models.CharField(max_length=100, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("worker", "rota")
|
||||
# With OneToOneField on worker, uniqueness for (worker, rota) is
|
||||
# implied by the worker uniqueness. Keep ordering for convenience.
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.worker} -> {self.rota}"
|
||||
|
||||
@@ -90,8 +90,21 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
|
||||
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)
|
||||
# If the rota has solver options saved in rota.options, prefer
|
||||
# those when invoking the builder. `solver_options` is expected
|
||||
# to be a dict of solver-specific options; `solver` may be a
|
||||
# string naming the solver plugin.
|
||||
opts = rota.options or {}
|
||||
solver_options = opts.get("solver_options") or {}
|
||||
solver_name = opts.get("solver") or None
|
||||
|
||||
# build the model and optionally solve. If solver_name is
|
||||
# provided, pass it through; otherwise let builder default.
|
||||
if solver_name:
|
||||
rb.build_and_solve(options=solver_options, solve=builder_solve, solver=solver_name)
|
||||
else:
|
||||
rb.build_and_solve(options=solver_options, solve=builder_solve)
|
||||
|
||||
html = rb.get_worker_timetable_html(include_html_tag=True)
|
||||
|
||||
# store under result so it's accessible from UI
|
||||
|
||||
@@ -13,8 +13,11 @@ urlpatterns = [
|
||||
path("rota/<int:rota_id>/shift/<int:idx>/delete/", views.shift_delete, name="shift_delete"),
|
||||
path("rota/<int:rota_id>/edit/", views.rota_edit, name="rota_edit"),
|
||||
path("worker/add/", views.worker_create, name="worker_add"),
|
||||
path("rota/<int:rota_id>/workers/import/", views.worker_import, name="worker_import"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/edit/", views.worker_edit, name="worker_edit"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/overview/", views.worker_overview, name="worker_overview"),
|
||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||
path("worker/<int:worker_id>/leave/add/", views.worker_leave_add, name="worker_leave_add"),
|
||||
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
||||
path("worker/<int:worker_id>/leaves.json", views.leaves_json, name="worker_leaves_json"),
|
||||
path("leave/request/", views.request_leave, name="request_leave"),
|
||||
@@ -32,6 +35,7 @@ urlpatterns = [
|
||||
path("worker/<int:worker_id>/leaves/delete_all/", views.leave_delete_all, name="leave_delete_all"),
|
||||
path("worker/<int:worker_id>/regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"),
|
||||
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
|
||||
path("run/<int:run_id>/status/", views.rota_run_status, name="rota_run_status"),
|
||||
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
||||
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
||||
path("rota/<int:rota_id>/export/builder/", views.rota_export_builder, name="rota_export_builder"),
|
||||
|
||||
+388
-10
@@ -1,4 +1,5 @@
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import RotaSchedule, Worker
|
||||
@@ -14,6 +15,7 @@ from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.shortcuts import HttpResponseRedirect
|
||||
import traceback
|
||||
|
||||
|
||||
|
||||
def _get_constraint_description(key: str):
|
||||
@@ -163,6 +165,10 @@ def rota_detail(request, rota_id):
|
||||
try:
|
||||
opts = rota.options or {}
|
||||
for k, v in opts.items():
|
||||
# Only surface keys that correspond to the typed RotaConstraintOptions
|
||||
# (exclude builder/constructor args and other non-constraint keys).
|
||||
if k not in available_constraints_rich:
|
||||
continue
|
||||
# pretty-print complex values where appropriate
|
||||
try:
|
||||
pretty = json.dumps(v, indent=2, default=str)
|
||||
@@ -175,10 +181,76 @@ def rota_detail(request, rota_id):
|
||||
desc = available_constraints_rich.get(k, {}).get("description")
|
||||
except Exception:
|
||||
desc = None
|
||||
configured_constraints[k] = {"value": v, "pretty": pretty, "typed": (k in available_constraints_rich), "description": desc}
|
||||
default_val = available_constraints_rich.get(k, {}).get("default")
|
||||
configured_constraints[k] = {
|
||||
"value": v,
|
||||
"pretty": pretty,
|
||||
"typed": (k in available_constraints_rich),
|
||||
"description": desc,
|
||||
"default": default_val,
|
||||
}
|
||||
except Exception:
|
||||
configured_constraints = {}
|
||||
|
||||
# Compute a small rota-builder summary for display in the template
|
||||
try:
|
||||
# Prefer explicit value stored in rota.options['weeks'] when present.
|
||||
opts = rota.options or {}
|
||||
if "weeks" in opts:
|
||||
try:
|
||||
weeks_to_rota = max(1, int(opts.get("weeks")))
|
||||
except Exception:
|
||||
weeks_to_rota = None
|
||||
else:
|
||||
delta = rota.end_date - rota.start_date
|
||||
weeks_to_rota = max(1, int(delta.days // 7))
|
||||
except Exception:
|
||||
weeks_to_rota = None
|
||||
|
||||
try:
|
||||
shift_count = len(rota.shifts or [])
|
||||
except Exception:
|
||||
shift_count = None
|
||||
|
||||
try:
|
||||
worker_count = rota.workers.count()
|
||||
except Exception:
|
||||
worker_count = None
|
||||
|
||||
# Extract solver settings saved on the rota (if any) for display.
|
||||
try:
|
||||
opts = rota.options or {}
|
||||
solver = opts.get("solver")
|
||||
so = opts.get("solver_options")
|
||||
# Normalize solver_options to a dict when possible and extract ratio
|
||||
solver_options = None
|
||||
solver_ratio = None
|
||||
if isinstance(so, dict):
|
||||
solver_options = so
|
||||
solver_ratio = so.get("ratio")
|
||||
elif isinstance(so, str):
|
||||
try:
|
||||
solver_options = json.loads(so)
|
||||
except Exception:
|
||||
solver_options = so
|
||||
if isinstance(solver_options, dict):
|
||||
solver_ratio = solver_options.get("ratio")
|
||||
# fallback to legacy top-level 'ratio' key
|
||||
if solver_ratio is None:
|
||||
solver_ratio = opts.get("ratio")
|
||||
# Pretty JSON for display when dict
|
||||
try:
|
||||
if isinstance(solver_options, dict):
|
||||
solver_options_pretty = json.dumps(solver_options, indent=2)
|
||||
else:
|
||||
solver_options_pretty = str(solver_options) if solver_options is not None else None
|
||||
except Exception:
|
||||
solver_options_pretty = None
|
||||
except Exception:
|
||||
solver = None
|
||||
solver_options_pretty = None
|
||||
solver_ratio = None
|
||||
|
||||
# legacy RotaOptionsForm not used here; we provide a typed RotaScheduleForm below
|
||||
|
||||
# Provide a typed RotaScheduleForm instance so the template can render
|
||||
@@ -193,7 +265,18 @@ def rota_detail(request, rota_id):
|
||||
return render(
|
||||
request,
|
||||
"rota/rota_detail.html",
|
||||
{"rota": rota, "options_form": typed_form, "available_constraints": available_constraints_rich, "configured_constraints": configured_constraints},
|
||||
{
|
||||
"rota": rota,
|
||||
"options_form": typed_form,
|
||||
"available_constraints": available_constraints_rich,
|
||||
"configured_constraints": configured_constraints,
|
||||
"weeks_to_rota": weeks_to_rota,
|
||||
"shift_count": shift_count,
|
||||
"worker_count": worker_count,
|
||||
"solver": solver,
|
||||
"solver_options": solver_options_pretty,
|
||||
"solver_ratio": solver_ratio,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -252,12 +335,19 @@ def worker_create(request):
|
||||
# If we created a worker and a rota_id was provided, create Assignment
|
||||
if rota is not None:
|
||||
from .models import Assignment
|
||||
# Only assign if the worker is not already assigned to any rota.
|
||||
try:
|
||||
Assignment.objects.create(worker=worker, rota=rota)
|
||||
if not worker.rotas.exists():
|
||||
Assignment.objects.create(worker=worker, rota=rota)
|
||||
else:
|
||||
# skip assigning; worker already assigned elsewhere
|
||||
pass
|
||||
except Exception:
|
||||
# best-effort: try using the M2M add as fallback
|
||||
# best-effort: try using the M2M add as fallback but still
|
||||
# respect single-rota constraint
|
||||
try:
|
||||
rota.workers.add(worker)
|
||||
if not worker.rotas.exists():
|
||||
rota.workers.add(worker)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -283,9 +373,35 @@ def worker_create(request):
|
||||
|
||||
# HTMX GET: return modal partial
|
||||
if is_hx:
|
||||
# If a rota_id query param is provided, include the rota in the
|
||||
# modal context so the template can offer rota-specific choices
|
||||
rota = None
|
||||
rota_id = request.GET.get("rota_id") or request.POST.get("rota_id")
|
||||
if rota_id:
|
||||
try:
|
||||
rota = get_object_or_404(RotaSchedule, pk=int(rota_id))
|
||||
except Exception:
|
||||
rota = None
|
||||
|
||||
# Prepare a JSON-encoded list of shift names for client-side use
|
||||
shift_names_json = '[]'
|
||||
try:
|
||||
import json as _json
|
||||
if rota is not None and getattr(rota, 'shifts', None):
|
||||
# rota.shifts is stored as list of dicts
|
||||
names = []
|
||||
for s in (rota.shifts or []):
|
||||
try:
|
||||
names.append(s.get('name'))
|
||||
except Exception:
|
||||
continue
|
||||
shift_names_json = _json.dumps(names)
|
||||
except Exception:
|
||||
shift_names_json = '[]'
|
||||
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_form_modal.html",
|
||||
{"form": form, "form_action": request.get_full_path()},
|
||||
{"form": form, "form_action": request.get_full_path(), "rota": rota, "shift_names_json": shift_names_json},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -294,6 +410,139 @@ def worker_create(request):
|
||||
return render(request, "rota/worker_form.html", {"form": form})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def worker_import(request, rota_id=None):
|
||||
"""HTMX endpoint: import workers from CSV or pasted text.
|
||||
|
||||
Accepts a file upload named 'file' (CSV) or a textarea 'workers_text'.
|
||||
CSV columns supported: name,email,site,grade,fte (headers optional).
|
||||
When `rota_id` is provided, the created workers will be assigned to
|
||||
that rota.
|
||||
"""
|
||||
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
||||
|
||||
rota = None
|
||||
if rota_id is not None:
|
||||
try:
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
except Exception:
|
||||
rota = None
|
||||
|
||||
if request.method == "GET":
|
||||
# render modal partial
|
||||
return render(request, "rota/partials/worker_import_modal.html", {"rota": rota})
|
||||
|
||||
# POST: parse uploaded file or textarea
|
||||
created = []
|
||||
errors = []
|
||||
assign_to_rota = request.POST.get("assign_to_rota") == "1"
|
||||
|
||||
import csv
|
||||
try:
|
||||
upload = request.FILES.get("file")
|
||||
if upload:
|
||||
# decode bytes to text
|
||||
try:
|
||||
text = upload.read().decode("utf-8")
|
||||
except Exception:
|
||||
text = upload.read().decode("latin-1")
|
||||
else:
|
||||
text = request.POST.get("workers_text", "")
|
||||
|
||||
# Normalize lines: if simple newline-separated names/emails, make a CSV
|
||||
reader = csv.DictReader(text.splitlines())
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
# Try parsing as simple CSV without headers
|
||||
reader2 = csv.reader([line for line in text.splitlines() if line.strip()])
|
||||
for r in reader2:
|
||||
# Map positional columns: name,email,site,grade,fte
|
||||
while len(r) < 5:
|
||||
r.append("")
|
||||
rows.append({"name": r[0].strip(), "email": r[1].strip(), "site": r[2].strip(), "grade": r[3].strip(), "fte": r[4].strip()})
|
||||
|
||||
from .models import Assignment
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
try:
|
||||
# normalize keys lower-case
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
r = {k.strip().lower(): (v if v is not None else "") for k, v in row.items()}
|
||||
name = r.get("name") or r.get("full_name") or r.get("username")
|
||||
if not name or str(name).strip() == "":
|
||||
errors.append(f"Row {i+1}: missing name")
|
||||
continue
|
||||
email = r.get("email") or ""
|
||||
site = r.get("site") or ""
|
||||
try:
|
||||
grade = int(r.get("grade")) if r.get("grade") not in (None, "") else None
|
||||
except Exception:
|
||||
grade = None
|
||||
try:
|
||||
fte_raw = r.get("fte")
|
||||
if fte_raw in (None, ""):
|
||||
fte = 1.0
|
||||
else:
|
||||
fte = float(fte_raw)
|
||||
except Exception:
|
||||
fte = 1.0
|
||||
|
||||
# If an email is provided, try to find an existing worker to
|
||||
# avoid duplicates; otherwise create a new record.
|
||||
existing = None
|
||||
if email:
|
||||
try:
|
||||
existing = Worker.objects.filter(email__iexact=email.strip()).first()
|
||||
except Exception:
|
||||
existing = None
|
||||
|
||||
if existing is not None:
|
||||
w = existing
|
||||
else:
|
||||
w = Worker.objects.create(name=name.strip(), email=email.strip(), site=site.strip(), grade=grade, fte=fte)
|
||||
created.append(w)
|
||||
|
||||
# Assignment: only assign if the worker is not already assigned
|
||||
# to another rota. If already assigned, record an error and
|
||||
# skip assignment.
|
||||
if assign_to_rota and rota is not None:
|
||||
try:
|
||||
if not w.rotas.exists():
|
||||
Assignment.objects.create(worker=w, rota=rota)
|
||||
else:
|
||||
# If already assigned to this rota, nothing to do.
|
||||
assigned_rota = w.rotas.first()
|
||||
if assigned_rota and assigned_rota.pk != rota.pk:
|
||||
errors.append(f"Row {i+1}: worker '{w.name}' already assigned to rota '{assigned_rota.name}'")
|
||||
except Exception:
|
||||
try:
|
||||
if not w.rotas.exists():
|
||||
rota.workers.add(w)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
errors.append(f"Row {i+1}: {exc}")
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
# If HTMX, return updated worker list partial and close modal
|
||||
if is_hx and rota is not None:
|
||||
worker_list_html = render_to_string(
|
||||
"rota/partials/worker_list.html",
|
||||
{"rota": rota},
|
||||
request=request,
|
||||
)
|
||||
# Return both new worker list and a trigger to close modal
|
||||
resp = f'<div id="worker-list" hx-swap-oob="true">{worker_list_html}</div>'
|
||||
response = HttpResponse(resp)
|
||||
response["HX-Trigger"] = "closeModal"
|
||||
return response
|
||||
|
||||
# Non-HTMX: redirect back to rota detail with message (flash messages not added here)
|
||||
return redirect("rota:rota_detail", rota_id=rota.id if rota is not None else None)
|
||||
|
||||
|
||||
def worker_detail(request, worker_id):
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
if request.method == "POST":
|
||||
@@ -345,6 +594,58 @@ def worker_detail(request, worker_id):
|
||||
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form, "leaves": leaves, "leaves_json": leaves_json})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def worker_leave_add(request, worker_id):
|
||||
"""HTMX endpoint: show a leave form modal for a worker, or create the leave on POST."""
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
||||
|
||||
if request.method == "POST":
|
||||
form = LeaveForm(request.POST)
|
||||
if form.is_valid():
|
||||
leave = form.save(commit=False)
|
||||
leave.worker = worker
|
||||
leave.save()
|
||||
if is_hx:
|
||||
# Close modal and optionally trigger an update; leave list can be refreshed separately
|
||||
response = HttpResponse("")
|
||||
response["HX-Trigger"] = "closeModal"
|
||||
return response
|
||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||
|
||||
|
||||
|
||||
# invalid: return modal with errors
|
||||
if is_hx:
|
||||
modal_html = render_to_string("rota/partials/leave_form_modal.html", {"form": form, "worker": worker, "form_action": request.path}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
# non-HTMX fallthrough
|
||||
# GET: render modal partial
|
||||
form = LeaveForm()
|
||||
if is_hx:
|
||||
modal_html = render_to_string("rota/partials/leave_form_modal.html", {"form": form, "worker": worker, "form_action": request.path}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def worker_overview(request, rota_id, worker_id):
|
||||
"""Return a read-only modal overview of the worker (HTMX GET)."""
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
try:
|
||||
pretty_options = json.dumps(worker.options or {}, indent=2, default=str)
|
||||
except Exception:
|
||||
pretty_options = str(worker.options or {})
|
||||
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_overview_modal.html",
|
||||
{"worker": worker, "rota": rota, "pretty_options": pretty_options},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def worker_edit(request, rota_id, worker_id):
|
||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||
@@ -364,7 +665,7 @@ def worker_edit(request, rota_id, worker_id):
|
||||
# invalid: return form modal with errors
|
||||
modal_html = render_to_string(
|
||||
"rota/partials/worker_form_modal.html",
|
||||
{"form": form, "form_action": request.get_full_path(), "rota": rota},
|
||||
{"form": form, "form_action": request.get_full_path(), "rota": rota, "focus": request.GET.get('focus') or request.POST.get('focus')},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -373,7 +674,7 @@ 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, "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 ''},
|
||||
{"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 '', "focus": request.GET.get('focus')},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(modal_html)
|
||||
@@ -464,6 +765,76 @@ def rota_run_detail(request, run_id):
|
||||
return render(request, "rota/rota_run_detail.html", {"run": run, "result_pretty": result_pretty, "builder_html": builder_html})
|
||||
|
||||
|
||||
def rota_run_status(request, run_id):
|
||||
"""Return a small HTML fragment describing the run status.
|
||||
|
||||
This endpoint is intended to be polled by HTMX. While the run is
|
||||
still running we return a fragment that contains the polling HTMX
|
||||
attributes so the client will continue to poll. Once the run is
|
||||
finished we return a final fragment (no polling attributes) and
|
||||
include a small script to reload the page so the UI updates.
|
||||
"""
|
||||
from .models import RotaRun
|
||||
|
||||
run = get_object_or_404(RotaRun, pk=run_id)
|
||||
|
||||
# If the run is still pending/running, return a self-updating fragment
|
||||
if run.status in (RotaRun.STATUS_PENDING, RotaRun.STATUS_RUNNING):
|
||||
html = (
|
||||
f'<div id="run-status" hx-get="{reverse("rota:rota_run_status", args=[run.id])}" '
|
||||
'hx-trigger="every 3s" hx-swap="outerHTML">'
|
||||
f'<span class="tag is-info">Status: {run.get_status_display()}</span>'
|
||||
f' <small>started: {run.started_at}</small>'
|
||||
f' <small style="margin-left:0.5rem; color:#666;">server_time: {timezone.now().isoformat()}</small>'
|
||||
"</div>"
|
||||
)
|
||||
resp = HttpResponse(html)
|
||||
# Prevent aggressive caching so polling always fetches fresh state
|
||||
resp['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
resp['Pragma'] = 'no-cache'
|
||||
return resp
|
||||
|
||||
# Finished/failed: return final fragment and reload the page so the
|
||||
# user sees final logs/export without further polling.
|
||||
# Build out-of-band swaps to update the log and result sections in-place
|
||||
from django.utils.html import escape
|
||||
|
||||
# Prepare pretty result if present
|
||||
try:
|
||||
if run.result is not None:
|
||||
import json as _json
|
||||
|
||||
result_pretty = _json.dumps(run.result, indent=2, default=str)
|
||||
else:
|
||||
result_pretty = ""
|
||||
except Exception:
|
||||
result_pretty = str(run.result)
|
||||
|
||||
status_fragment = (
|
||||
f'<div id="run-status"><span class="tag is-success">Status: {escape(run.get_status_display())}</span>'
|
||||
f' <small>finished: {escape(str(run.finished_at))}</small></div>'
|
||||
)
|
||||
|
||||
# OOB swap for the log block
|
||||
log_fragment = f'<div id="run-log" hx-swap-oob="true"><pre>{escape(run.log or "")}</pre></div>'
|
||||
|
||||
# OOB swap for the result block
|
||||
result_fragment = f'<div id="run-result" hx-swap-oob="true"><pre>{escape(result_pretty or "")}</pre></div>'
|
||||
|
||||
# OOB swap for a 'View final output' button
|
||||
view_button = (
|
||||
f'<div id="view-final-output" hx-swap-oob="true">'
|
||||
f'<a class="button is-link" href="{reverse("rota:rota_run_export_html", args=[run.id])}" target="_blank">View final output</a>'
|
||||
"</div>"
|
||||
)
|
||||
|
||||
body = status_fragment + log_fragment + result_fragment + view_button
|
||||
resp = HttpResponse(body)
|
||||
resp['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
resp['Pragma'] = 'no-cache'
|
||||
return resp
|
||||
|
||||
|
||||
def rota_run_export_html(request, run_id):
|
||||
from .models import RotaRun
|
||||
|
||||
@@ -1319,6 +1690,8 @@ 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"),
|
||||
"include_groups": data.get("include_groups", []),
|
||||
"exclude_groups": data.get("exclude_groups", []),
|
||||
"constraints": data.get("constraints", []),
|
||||
}
|
||||
|
||||
@@ -1339,7 +1712,7 @@ def shift_add(request, rota_id):
|
||||
return response
|
||||
|
||||
# invalid: return modal with form and errors
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path}, request=request)
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path, "focus": request.GET.get('focus') or request.POST.get('focus')}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
else:
|
||||
@@ -1367,6 +1740,9 @@ def shift_edit(request, rota_id, idx):
|
||||
"days": data["days"],
|
||||
"workers_required": int(data["workers_required"]),
|
||||
"assign_as_block": bool(data["assign_as_block"]),
|
||||
"balance_offset": data.get("balance_offset"),
|
||||
"include_groups": data.get("include_groups", []),
|
||||
"exclude_groups": data.get("exclude_groups", []),
|
||||
"constraints": data.get("constraints", []),
|
||||
}
|
||||
current = rota.shifts or []
|
||||
@@ -1395,10 +1771,12 @@ 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),
|
||||
"include_groups": ",".join(shift.get("include_groups") or []),
|
||||
"exclude_groups": ",".join(shift.get("exclude_groups") or []),
|
||||
"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)
|
||||
modal_html = render_to_string("rota/partials/shift_form_modal.html", {"form": form, "rota": rota, "form_action": request.path, "focus": request.GET.get('focus')}, request=request)
|
||||
return HttpResponse(modal_html)
|
||||
|
||||
|
||||
|
||||
+266
-187
@@ -2,9 +2,10 @@ from pyomo.opt.results.container import ignore
|
||||
from collections import defaultdict
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days, RotaConstraintOptions
|
||||
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days, RotaConstraintOptions, MaxShiftsPerWeekBlockConstraint
|
||||
import typer
|
||||
import subprocess
|
||||
import pandas as pd
|
||||
@@ -15,23 +16,168 @@ from rota_generator.workers import (
|
||||
NonWorkingDays,
|
||||
WorkRequests,
|
||||
PreferenceNotToWork,
|
||||
OutOfProgramme,
|
||||
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
|
||||
)
|
||||
|
||||
from loguru import logger
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
ROTA_REQUESTS = "/home/ross/Sync/cons/requests.ods"
|
||||
ROTA_B_PATH = "/home/ross/Sync/cons/rota_b.xlsx"
|
||||
ROTA_START_DATE = "2026-08-03"
|
||||
|
||||
|
||||
sites = ("rota a", "rota b", "rota c")
|
||||
|
||||
|
||||
def _normalize_worker_key(value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", text).lower()
|
||||
|
||||
|
||||
def _validate_worker_alignment(base_workers, other_workers, base_label, other_label):
|
||||
"""Validate that two worker collections contain the same members.
|
||||
|
||||
Comparison is case-insensitive and whitespace-normalized.
|
||||
Raises ValueError with a clear diff when the sets do not match.
|
||||
"""
|
||||
base_clean = [str(w).strip() for w in base_workers if str(w).strip()]
|
||||
other_clean = [str(w).strip() for w in other_workers if str(w).strip()]
|
||||
|
||||
base_norm_to_raw = {_normalize_worker_key(w): w for w in base_clean}
|
||||
other_norm_to_raw = {_normalize_worker_key(w): w for w in other_clean}
|
||||
|
||||
base_norm = set(base_norm_to_raw.keys())
|
||||
other_norm = set(other_norm_to_raw.keys())
|
||||
|
||||
only_in_base_norm = sorted(base_norm - other_norm)
|
||||
only_in_other_norm = sorted(other_norm - base_norm)
|
||||
|
||||
if not only_in_base_norm and not only_in_other_norm:
|
||||
return
|
||||
|
||||
only_in_base = [base_norm_to_raw[k] for k in only_in_base_norm]
|
||||
only_in_other = [other_norm_to_raw[k] for k in only_in_other_norm]
|
||||
|
||||
raise ValueError(
|
||||
"Worker mismatch detected between sources. "
|
||||
f"{base_label} only: {only_in_base}. "
|
||||
f"{other_label} only: {only_in_other}."
|
||||
)
|
||||
|
||||
|
||||
def _parse_sheet_date(value):
|
||||
if pd.isna(value):
|
||||
return None
|
||||
parsed = pd.to_datetime(value, errors="coerce")
|
||||
if pd.isna(parsed):
|
||||
return None
|
||||
return parsed.date()
|
||||
|
||||
|
||||
def _parse_worked_target_cell(value):
|
||||
"""Parse values like '5 (6.88)' into (worked, target)."""
|
||||
if pd.isna(value):
|
||||
return None
|
||||
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in {"nan", "-"}:
|
||||
return None
|
||||
|
||||
match = re.match(r"^\s*(-?\d+(?:\.\d+)?)\s*\(([^)]+)\)\s*$", text)
|
||||
if match:
|
||||
try:
|
||||
worked = float(match.group(1).strip())
|
||||
target = float(match.group(2).strip())
|
||||
return worked, target
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Fallback for cells that contain only one number.
|
||||
try:
|
||||
target = float(text)
|
||||
return 0.0, target
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_priors_from_running_totals(running_totals_df):
|
||||
"""Extract previous shifts map from Running totals tab.
|
||||
|
||||
Reads only oncall nights, twilights and weekend A values.
|
||||
"""
|
||||
priors_map = {}
|
||||
if running_totals_df is None or running_totals_df.empty:
|
||||
return priors_map
|
||||
|
||||
# Find the header row that starts with 'Worker'.
|
||||
header_row_index = None
|
||||
for i in range(len(running_totals_df)):
|
||||
first_cell = str(running_totals_df.iloc[i, 0]).strip().lower()
|
||||
if first_cell == "worker":
|
||||
header_row_index = i
|
||||
break
|
||||
|
||||
if header_row_index is None:
|
||||
return priors_map
|
||||
|
||||
header_values = [
|
||||
str(c).strip().lower() if not pd.isna(c) else ""
|
||||
for c in running_totals_df.iloc[header_row_index].tolist()
|
||||
]
|
||||
|
||||
def _find_col(header_name):
|
||||
for idx, col in enumerate(header_values):
|
||||
if col == header_name:
|
||||
return idx
|
||||
return None
|
||||
|
||||
worker_col = _find_col("worker")
|
||||
oncall_col = _find_col("oncall night")
|
||||
twilight_col = _find_col("twilight")
|
||||
weekend_a_col = _find_col("weekend a")
|
||||
|
||||
if worker_col is None:
|
||||
return priors_map
|
||||
|
||||
for i in range(header_row_index + 1, len(running_totals_df)):
|
||||
row = running_totals_df.iloc[i]
|
||||
worker_raw = row.iloc[worker_col] if worker_col < len(row) else None
|
||||
worker = str(worker_raw).strip() if not pd.isna(worker_raw) else ""
|
||||
if not worker or worker.lower() in {"nan", "total"}:
|
||||
continue
|
||||
|
||||
prev = {}
|
||||
if oncall_col is not None and oncall_col < len(row):
|
||||
parsed = _parse_worked_target_cell(row.iloc[oncall_col])
|
||||
if parsed is not None:
|
||||
prev["oncall"] = parsed
|
||||
|
||||
if twilight_col is not None and twilight_col < len(row):
|
||||
parsed = _parse_worked_target_cell(row.iloc[twilight_col])
|
||||
if parsed is not None:
|
||||
prev["twilight"] = parsed
|
||||
|
||||
if weekend_a_col is not None and weekend_a_col < len(row):
|
||||
parsed = _parse_worked_target_cell(row.iloc[weekend_a_col])
|
||||
if parsed is not None:
|
||||
prev["weekend"] = parsed
|
||||
|
||||
if prev:
|
||||
priors_map[worker] = prev
|
||||
|
||||
return priors_map
|
||||
|
||||
def extract_leave_and_rota_from_calender(calender_df):
|
||||
"""
|
||||
Extract leave and rota assignments from calender_df.
|
||||
- The first row contains worker names, starting from the 5th column (index 4).
|
||||
- The second column (index 1) contains dates in dd/mm/yyyy format.
|
||||
- The second column (index 1) also contains the rota/leave code for that date.
|
||||
Returns a list of dicts: {"date": ..., "worker": ..., "rota": ...}
|
||||
Returns a list of dicts: {"date": ..., "/worker": ..., "rota": ...}
|
||||
"""
|
||||
# Get worker names from the first row, starting from column 5 (index 4)
|
||||
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
|
||||
@@ -49,15 +195,11 @@ def extract_leave_and_rota_from_calender(calender_df):
|
||||
work_requests = []
|
||||
# Process leave data from row 3 onwards (index 2 and up)
|
||||
for idx, row in calender_df.iloc[3:].iterrows():
|
||||
date_str = str(row.iloc[1]).strip().split(" ")[0] # Get the date string from the second column (index 1)
|
||||
if not date_str or date_str.lower() == "nan":
|
||||
date = _parse_sheet_date(row.iloc[1])
|
||||
if date is None:
|
||||
continue
|
||||
try:
|
||||
date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
continue # skip rows with invalid date
|
||||
|
||||
if date < datetime.date(2026, 2, 16):
|
||||
if date < datetime.date.fromisoformat(ROTA_START_DATE):
|
||||
continue # skip rows before rota start date
|
||||
for i, worker in enumerate(worker_names):
|
||||
if not worker or worker.lower() == "nan":
|
||||
@@ -81,7 +223,7 @@ def extract_leave_and_rota_from_calender(calender_df):
|
||||
|
||||
|
||||
#print(leave_requests)
|
||||
return leave_requests, work_requests#, rota_data
|
||||
return leave_requests, work_requests, worker_names
|
||||
|
||||
def extract_shift_assignments_from_rota(rota_df):
|
||||
"""
|
||||
@@ -96,29 +238,30 @@ def extract_shift_assignments_from_rota(rota_df):
|
||||
assignments = []
|
||||
|
||||
skipped_rows = 0
|
||||
rota_start = datetime.date.fromisoformat(ROTA_START_DATE)
|
||||
for idx, row in rota_df.iloc[2:].iterrows():
|
||||
week_start_str = str(row.iloc[0]).strip().split(" ")[0] # Get the week start date from the first column (index 0)
|
||||
try:
|
||||
week_start = datetime.datetime.strptime(week_start_str, "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
print(f"Invalid date format in row {idx}: {week_start_str}")
|
||||
week_start = _parse_sheet_date(row.iloc[0])
|
||||
if week_start is None:
|
||||
print(f"Invalid date format in row {idx}: {row.iloc[0]}")
|
||||
continue # skip rows with invalid date
|
||||
|
||||
if week_start < datetime.date(2026, 2, 16):
|
||||
if week_start < rota_start:
|
||||
skipped_rows += 1
|
||||
continue # skip rows before rota start date
|
||||
|
||||
week = ((week_start - rota_start).days // 7) + 1
|
||||
|
||||
for i, day in enumerate(days):
|
||||
for j, shift in enumerate(shifts):
|
||||
if i < 5:
|
||||
# Mon-Fri: two columns per day (twilight, oncall)
|
||||
col_idx = 1 + i * 2 + j # 1 for Mon twilight, 2 for Mon oncall, etc.
|
||||
col_idx = 2 + i * 2 + j # 2 for Mon twilight, 3 for Mon oncall, etc.
|
||||
weekend = False
|
||||
else:
|
||||
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
|
||||
weekend = True
|
||||
if j == 0:
|
||||
col_idx = 11 + (i - 5) # 11 for Sat, 12 for Sun
|
||||
col_idx = 12 + (i - 5) # 12 for Sat, 13 for Sun
|
||||
else:
|
||||
continue # No night shift column for Sat/Sun
|
||||
if col_idx >= len(row):
|
||||
@@ -131,7 +274,7 @@ def extract_shift_assignments_from_rota(rota_df):
|
||||
shift = "weekend"
|
||||
assignments.append({
|
||||
"week_start": week_start,
|
||||
"week": idx-1-skipped_rows, # 1-indexed week number
|
||||
"week": week,
|
||||
"day": day,
|
||||
"date": week_start + datetime.timedelta(days=i),
|
||||
"shift": shift,
|
||||
@@ -149,7 +292,7 @@ def extract_shift_assignments_from_rota(rota_df):
|
||||
|
||||
def load_workers():
|
||||
# Path to the ODS file
|
||||
ods_path = "cons/CONSULTANT TWILIGHT & ONCALL ROTA.ods"
|
||||
ods_path = ROTA_REQUESTS
|
||||
|
||||
# Read all sheets
|
||||
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
|
||||
@@ -158,15 +301,22 @@ def load_workers():
|
||||
days_df = xls["Days"]
|
||||
calender_df = xls["Calendar"]
|
||||
rota_df = xls["Rota"]
|
||||
running_totals_df = xls.get("Running totals")
|
||||
|
||||
|
||||
rota_b_path = "cons/Rota B Feb -July 2026.xlsx"
|
||||
rota_b_path = ROTA_B_PATH
|
||||
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
|
||||
# Extract rota_b assignments: date in column A, worker in column B
|
||||
rota_b_assignments = defaultdict(list)
|
||||
for idx, row in rota_b_df.iterrows():
|
||||
logger.debug(f"{idx}: {row}")
|
||||
date_val = row.iloc[0]
|
||||
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
||||
try:
|
||||
worker_val = row.iloc[1].upper() if len(row) > 1 else None
|
||||
except AttributeError:
|
||||
# end of file
|
||||
break
|
||||
|
||||
if pd.isna(date_val) or pd.isna(worker_val):
|
||||
continue
|
||||
try:
|
||||
@@ -180,53 +330,9 @@ def load_workers():
|
||||
print(rota_b_assignments)
|
||||
# You can now use rota_b_assignments as needed
|
||||
|
||||
# --- Load prior allocations (targets) from CSV ---
|
||||
priors_path = "cons/sep2025priors.csv"
|
||||
priors_map = {}
|
||||
try:
|
||||
priors_df = pd.read_csv(priors_path)
|
||||
for _, prow in priors_df.iterrows():
|
||||
initial = str(prow.get("Worker", "")).strip()
|
||||
if not initial or initial.lower() == "nan":
|
||||
continue
|
||||
prev = {}
|
||||
# expected columns: oncall, twilight, weekend
|
||||
for col in ("oncall", "twilight", "weekend"):
|
||||
raw = prow.get(col, "")
|
||||
if pd.isna(raw) or raw == "" or str(raw).strip() in ("-", "nan"):
|
||||
continue
|
||||
s = str(raw).strip()
|
||||
# Expect formats like: '4 (2.50)' or '3 (2.86)'
|
||||
worked = 0.0
|
||||
allocated = 0.0
|
||||
if "(" in s:
|
||||
try:
|
||||
worked_part = s.split("(")[0].strip()
|
||||
worked = float(worked_part)
|
||||
except Exception:
|
||||
worked = 0.0
|
||||
try:
|
||||
inner = s.split("(", 1)[1].split(")", 1)[0]
|
||||
allocated = float(inner.strip())
|
||||
except Exception:
|
||||
allocated = worked
|
||||
else:
|
||||
try:
|
||||
allocated = float(s)
|
||||
except Exception:
|
||||
continue
|
||||
worked = 0.0
|
||||
|
||||
# store as tuple (worked, allocated) as expected by RotaBuilder
|
||||
prev[col] = (worked, allocated)
|
||||
|
||||
if prev:
|
||||
priors_map[initial] = prev
|
||||
logger.debug("priors_map: {}", priors_map)
|
||||
except FileNotFoundError:
|
||||
print(f"Prior allocations file not found: {priors_path}")
|
||||
except Exception as e:
|
||||
print(f"Error reading prior allocations: {e}")
|
||||
# --- Load prior allocations from requests.ods -> Running totals tab ---
|
||||
priors_map = _load_priors_from_running_totals(running_totals_df)
|
||||
logger.debug("priors_map from Running totals: {}", priors_map)
|
||||
|
||||
# --- Example: Print the first few rows of each sheet ---
|
||||
#print("Days sheet:")
|
||||
@@ -236,132 +342,67 @@ def load_workers():
|
||||
rota_data = {}
|
||||
for idx, row in days_df.iloc[7:].iterrows():
|
||||
# Extract initials from the name column (assumed to be in brackets at the end)
|
||||
text = str(row.iloc[1]).strip()
|
||||
if "(" in text and ")" in text:
|
||||
initial = text.split("(")[-1].split(")")[0].strip()
|
||||
else:
|
||||
raise ValueError(f"Invalid format in row {idx}: {text}")
|
||||
name = text.split("(")[0].strip()
|
||||
name = str(row.iloc[1]).strip()
|
||||
initial = str(row.iloc[2]).strip()
|
||||
if not name or name.lower() == "nan":
|
||||
continue
|
||||
availability = {}
|
||||
requests = {}
|
||||
rota_data[name] = f"rota {str(row.iloc[2]).strip().lower()}"
|
||||
rota_data[name] = f"rota {str(row.iloc[3]).strip().lower()}"
|
||||
for i, day in enumerate(weekday_cols):
|
||||
val = row.iloc[3 + i]
|
||||
val = row.iloc[4 + i]
|
||||
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
|
||||
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
|
||||
availability[day] = available
|
||||
workers_days.append({"initial": initial, "name": name, "availability": availability, "requests": requests})
|
||||
|
||||
|
||||
leave_requests, work_requests = extract_leave_and_rota_from_calender(calender_df)
|
||||
leave_requests, work_requests, calendar_workers = extract_leave_and_rota_from_calender(calender_df)
|
||||
|
||||
assignments, assignments_by_worker = extract_shift_assignments_from_rota(rota_df)
|
||||
print(assignments)
|
||||
|
||||
# --- Load per-day prior worked data (Sat/Sun) from sep2025priordays.csv for Rota A workers ---
|
||||
# Build set of initials who are on Rota A
|
||||
rota_a_initials = set()
|
||||
for wd in workers_days:
|
||||
name = wd["name"]
|
||||
initial = wd["initial"]
|
||||
if rota_data.get(name, "") == "rota a":
|
||||
rota_a_initials.add(initial)
|
||||
# Validate cross-source worker matching before constructing Worker objects.
|
||||
days_workers = [w["name"] for w in workers_days]
|
||||
days_initials = [w["initial"] for w in workers_days]
|
||||
|
||||
priordays_path = "cons/sep2025priordays.csv"
|
||||
try:
|
||||
import csv
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_workers,
|
||||
other_workers=calendar_workers,
|
||||
base_label="Days tab worker names",
|
||||
other_label="Calendar tab worker names",
|
||||
)
|
||||
|
||||
perday_counts = defaultdict(lambda: defaultdict(int))
|
||||
with open(priordays_path, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
if len(row) < 4:
|
||||
continue
|
||||
day = row[2].strip()
|
||||
if day not in ("Sat", "Sun"):
|
||||
continue
|
||||
# fields from index 3 onwards are assignments; some may be empty
|
||||
for field in row[3:]:
|
||||
if not field:
|
||||
continue
|
||||
val = field.strip()
|
||||
if not val:
|
||||
continue
|
||||
# entries may be like: RK (oncall, weekend) or CK (weekend b)
|
||||
import re
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_initials,
|
||||
other_workers=list(assignments_by_worker.keys()),
|
||||
base_label="Days tab worker initials",
|
||||
other_label="Rota tab assigned initials",
|
||||
)
|
||||
|
||||
m = re.match(r"\s*([A-Za-z0-9]+)\s*\(([^)]*)\)", val)
|
||||
if not m:
|
||||
# sometimes multiple assignments are concatenated in a single cell separated by commas
|
||||
parts = [p.strip() for p in re.split(r",\s*", val) if p.strip()]
|
||||
for p in parts:
|
||||
m2 = re.match(r"\s*([A-Za-z0-9]+)\s*\(([^)]*)\)", p)
|
||||
if m2:
|
||||
initial = m2.group(1).strip()
|
||||
paren = m2.group(2).lower()
|
||||
if initial in rota_a_initials and "weekend" in paren:
|
||||
perday_counts[initial][day] += 1
|
||||
continue
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_initials,
|
||||
other_workers=list(rota_b_assignments.keys()),
|
||||
base_label="Days tab worker initials",
|
||||
other_label="Rota B assignments initials",
|
||||
)
|
||||
|
||||
initial = m.group(1).strip()
|
||||
paren = m.group(2).lower()
|
||||
if initial in rota_a_initials and "weekend" in paren:
|
||||
perday_counts[initial][day] += 1
|
||||
_validate_worker_alignment(
|
||||
base_workers=days_initials,
|
||||
other_workers=list(priors_map.keys()),
|
||||
base_label="Days tab worker initials",
|
||||
other_label="Running totals worker initials",
|
||||
)
|
||||
|
||||
# Merge per-day counts into priors_map under 'weekend' per-day keys
|
||||
for initial, daymap in perday_counts.items():
|
||||
prev = priors_map.get(initial, {})
|
||||
existing = prev.get("weekend")
|
||||
# If existing is a tuple, convert to dict preserving overall tuple under '__all__'
|
||||
if existing and not isinstance(existing, dict):
|
||||
prev["weekend"] = {"__all__": existing}
|
||||
existing = prev["weekend"]
|
||||
|
||||
for day, worked_count in daymap.items():
|
||||
# Determine allocated value for this per-day entry
|
||||
allocated_day = worked_count
|
||||
if existing:
|
||||
# prefer an explicit per-day allocated if present
|
||||
if isinstance(existing, dict) and day in existing:
|
||||
try:
|
||||
allocated_day = float(existing[day][1])
|
||||
except Exception:
|
||||
allocated_day = worked_count
|
||||
elif isinstance(existing, dict) and "__all__" in existing:
|
||||
try:
|
||||
overall_alloc = float(existing["__all__"][1])
|
||||
allocated_day = overall_alloc / 2.0
|
||||
except Exception:
|
||||
allocated_day = worked_count
|
||||
elif isinstance(existing, tuple):
|
||||
try:
|
||||
allocated_day = float(existing[1]) / 2.0
|
||||
except Exception:
|
||||
allocated_day = worked_count
|
||||
|
||||
# ensure structure
|
||||
if "weekend" not in prev or not isinstance(prev["weekend"], dict):
|
||||
prev.setdefault("weekend", {})
|
||||
|
||||
prev["weekend"][day] = (float(worked_count), float(allocated_day))
|
||||
|
||||
if prev:
|
||||
priors_map[initial] = prev
|
||||
|
||||
logger.debug("priors_map after per-day merge: {}", priors_map)
|
||||
except FileNotFoundError:
|
||||
print(f"Per-day prior file not found: {priordays_path}")
|
||||
except Exception as e:
|
||||
print(f"Error reading per-day priors: {e}")
|
||||
logger.debug("Using Running totals priors for oncall/twilight/weekend A only")
|
||||
|
||||
workers = []
|
||||
for worker_day in workers_days:
|
||||
worker = worker_day["name"]
|
||||
initial = worker_day["initial"]
|
||||
|
||||
start_date = "2026-02-16" # Default start date, can be adjusted later
|
||||
start_date = ROTA_START_DATE
|
||||
|
||||
|
||||
#if initial == "ND":
|
||||
# start_date = "2025-09-29" # Non-working day worker starts later
|
||||
@@ -402,8 +443,10 @@ def load_workers():
|
||||
],
|
||||
nwds=non_working_days,
|
||||
previous_shifts=priors_map.get(initial, {}),
|
||||
max_days_worked_per_week=2
|
||||
)
|
||||
|
||||
|
||||
#if initial == "L1":
|
||||
# w.oop = [OutOfProgramme(
|
||||
# start_date="2025-09-15",
|
||||
@@ -437,6 +480,7 @@ def load_workers():
|
||||
shift_name="weekend b",
|
||||
)
|
||||
|
||||
w.add_hard_day_exclusion({"Sun", "Mon"})
|
||||
if w.site == "rota a":
|
||||
|
||||
w.prefer_multi_shift_together = 10
|
||||
@@ -444,19 +488,18 @@ def load_workers():
|
||||
#w.add_force_assign_with("twilight", "oncall")
|
||||
w.add_force_assign_with("weekend", "oncall")
|
||||
|
||||
if w.name in ("DS",):
|
||||
if w.name in ("DS","RG","MOB","TB"):
|
||||
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
|
||||
w.max_days_worked_per_week = 3
|
||||
else:
|
||||
w.max_days_worked_per_week = 2
|
||||
w.add_max_days_per_week_block_constraint(max_days=2, week_block=2)
|
||||
w.add_hard_day_dependency({"Fri", "Sun"})
|
||||
w.add_hard_day_exclusion({"Sat", "Sun"})
|
||||
else:
|
||||
w.max_unique_shifts_per_week_block = [MaxUniqueShiftsPerWeekBlockConstraint(week_block=1, max_unique_shifts=1)]
|
||||
|
||||
|
||||
#if w.name == "TB":
|
||||
# w.add_hard_day_exclusion({"Sat", "Sun"}, start_date="2025-11-17")
|
||||
|
||||
|
||||
#w.set_max_shifts
|
||||
#w.set_max_shifts_per_week("weekend", 1)
|
||||
|
||||
workers.append(w)
|
||||
|
||||
@@ -464,14 +507,37 @@ def load_workers():
|
||||
|
||||
|
||||
|
||||
def parse_time_limit(t) -> int:
|
||||
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
|
||||
if isinstance(t, int):
|
||||
return t
|
||||
if isinstance(t, float):
|
||||
return int(t)
|
||||
t = str(t).strip().lower()
|
||||
if not t:
|
||||
return 0
|
||||
if t.isdigit():
|
||||
return int(t)
|
||||
if t.endswith("h"):
|
||||
return int(float(t[:-1]) * 3600)
|
||||
if t.endswith("m"):
|
||||
return int(float(t[:-1]) * 60)
|
||||
if t.endswith("s"):
|
||||
return int(float(t[:-1]))
|
||||
try:
|
||||
return int(float(t))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
suspend: bool = False,
|
||||
solve: bool = True,
|
||||
time_to_run: int = 60 * 60,
|
||||
ratio: float = 0.001,
|
||||
start_date: datetime.datetime = "2026-02-16",
|
||||
weeks: int = 24,
|
||||
time_to_run: str = "1h",
|
||||
ratio: float = 0.1,
|
||||
start_date: datetime.datetime = ROTA_START_DATE,
|
||||
weeks: int = 22,
|
||||
bom: int = 1,
|
||||
):
|
||||
rota_start_date = start_date.date()
|
||||
@@ -482,12 +548,13 @@ def main(
|
||||
weeks_to_rota=weeks,
|
||||
balance_offset_modifier=bom,
|
||||
use_previous_shifts=True,
|
||||
name="cons rota feb 2026",
|
||||
name="cons rota test",
|
||||
allow_force_assignment_with_leave_conflict=True,
|
||||
constraint_options=RotaConstraintOptions(
|
||||
balance_weekends=True,
|
||||
max_shifts_per_week=6,
|
||||
max_days_per_week_block=[(4, 2), (4, 4)],
|
||||
max_weekend_frequency=3,
|
||||
max_days_per_week_block=[(3, 2), (4, 4)],
|
||||
#balance_weekend_days=True,
|
||||
balance_weekend_days_quadratic=True,
|
||||
weekend_day_hard_max_deviation=2,
|
||||
@@ -508,8 +575,9 @@ def main(
|
||||
balance_offset=2,
|
||||
assign_as_block=False,
|
||||
constraints=[
|
||||
PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
||||
PostShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=True),
|
||||
#PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
|
||||
#PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
|
||||
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
|
||||
],
|
||||
),
|
||||
SingleShift(
|
||||
@@ -521,6 +589,8 @@ def main(
|
||||
balance_offset=1,
|
||||
constraints=[
|
||||
PostShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"]),
|
||||
#PreShiftConstraint(days=1),
|
||||
#PostShiftConstraint(days=3),
|
||||
],
|
||||
display_char="a",
|
||||
#force_assign_with=["oncall"]
|
||||
@@ -528,11 +598,16 @@ def main(
|
||||
SingleShift(
|
||||
sites=("rota b", "rota c"),
|
||||
name="weekend b",
|
||||
#end_date="2026-07-20",
|
||||
workers_required=1,
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
balance_offset=2,
|
||||
display_char="b",
|
||||
constraints=[
|
||||
PreShiftConstraint(days=2),
|
||||
PostShiftConstraint(days=2),
|
||||
],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -548,6 +623,7 @@ def main(
|
||||
constraints=[
|
||||
PreShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"], exclude_days=("Sat", "Sun")),
|
||||
MaxShiftsPerWeekConstraint(max_shifts=1),
|
||||
MaxShiftsPerWeekBlockConstraint(week_block=3,max_shifts=1)
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -645,8 +721,8 @@ def main(
|
||||
# Rota.build_workers()
|
||||
# Rota.build_model()
|
||||
|
||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
||||
seconds_to_run = parse_time_limit(time_to_run)
|
||||
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
|
||||
|
||||
# start_time = time.time()
|
||||
Rota.build_and_solve(solver_options, export=True, export_with_timestamp=False, solve=solve, solver="appsi_highs")
|
||||
@@ -678,6 +754,9 @@ def main(
|
||||
subprocess.run(
|
||||
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||
)
|
||||
subprocess.run(
|
||||
["scp", "output/timetable.js", "ross@46.101.13.46:proc/proc-rota/output/"]
|
||||
)
|
||||
|
||||
if suspend_on_finish:
|
||||
os.system("systemctl suspend")
|
||||
|
||||
+236
-113
@@ -2,7 +2,20 @@ import datetime
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from rota_generator.shifts import NoWorkers, RotaBuilder, SingleShift, WorkerRequirement, days
|
||||
from rota_generator.shifts import (
|
||||
NoWorkers,
|
||||
RotaBuilder,
|
||||
SingleShift,
|
||||
WorkerRequirement,
|
||||
days,
|
||||
PreShiftConstraint,
|
||||
PostShiftConstraint,
|
||||
NightConstraint,
|
||||
RequireRemoteSitePresenceConstraint,
|
||||
LimitGradeNumberConstraint,
|
||||
MinimumGradeNumberConstraint,
|
||||
MaxShiftsPerWeekConstraint,
|
||||
)
|
||||
import typer
|
||||
import subprocess
|
||||
|
||||
@@ -17,6 +30,11 @@ sites = (
|
||||
"plymouth",
|
||||
"taunton",
|
||||
"proc",
|
||||
"plymouth ir",
|
||||
"truro ir",
|
||||
"exeter ir",
|
||||
"torbay ir",
|
||||
"ir nights",
|
||||
)
|
||||
|
||||
from rota_generator.workers import (
|
||||
@@ -28,16 +46,38 @@ from rota_generator.workers import (
|
||||
OutOfProgramme,
|
||||
)
|
||||
|
||||
def parse_time_limit(t) -> int:
|
||||
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
|
||||
if isinstance(t, int):
|
||||
return t
|
||||
if isinstance(t, float):
|
||||
return int(t)
|
||||
t = str(t).strip().lower()
|
||||
if not t:
|
||||
return 0
|
||||
if t.isdigit():
|
||||
return int(t)
|
||||
if t.endswith("h"):
|
||||
return int(float(t[:-1]) * 3600)
|
||||
if t.endswith("m"):
|
||||
return int(float(t[:-1]) * 60)
|
||||
if t.endswith("s"):
|
||||
return int(float(t[:-1]))
|
||||
try:
|
||||
return int(float(t))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
suspend: bool = False,
|
||||
solve: bool = True,
|
||||
time_to_run: int = 60 * 60 * 4,
|
||||
ratio: float = 0.001,
|
||||
start_date: datetime.datetime = "2025-09-01",
|
||||
weeks: int = 16,
|
||||
bom: int = 2,
|
||||
time_to_run: str = "10h",
|
||||
ratio: float = 0.1,
|
||||
start_date: datetime.datetime = "2026-09-07",
|
||||
weeks: int = 26,
|
||||
bom: float = 1,
|
||||
):
|
||||
rota_start_date = start_date.date()
|
||||
suspend_on_finish = suspend
|
||||
@@ -46,19 +86,20 @@ def main(
|
||||
rota_start_date,
|
||||
weeks_to_rota=weeks,
|
||||
balance_offset_modifier=bom,
|
||||
name="proc_rota",
|
||||
name="proc_rota_sep_2026",
|
||||
)
|
||||
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
|
||||
Rota.constraint_options["balance_shifts"] = False
|
||||
Rota.constraint_options["balance_shifts_quadratic"] = True
|
||||
Rota.constraint_options["balance_weekends"] = True
|
||||
Rota.constraint_options["max_night_frequency"] = 3 # 3
|
||||
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
||||
Rota.constraint_options["max_weekend_frequency"] = 2
|
||||
Rota.constraint_options["hard_constrain_pair_separation"] = True
|
||||
# Rota.constraint_options["avoid_st2_first_month"] = True
|
||||
#Rota.constraint_options["max_night_frequency_week_exclusions"] = []
|
||||
Rota.constraint_options["max_weekend_frequency"] = 3
|
||||
|
||||
#wr = [
|
||||
Rota.constraint_options["max_days_per_week_block"] = [(8,3)]
|
||||
Rota.constraint_options["maximum_allowed_shift_diff"] = 1
|
||||
|
||||
# wr = [
|
||||
# WorkerRequirement(
|
||||
# end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||
# number=3,
|
||||
@@ -67,7 +108,7 @@ def main(
|
||||
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
|
||||
# number=4,
|
||||
# ),
|
||||
#]
|
||||
# ]
|
||||
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
@@ -76,17 +117,13 @@ def main(
|
||||
"exeter twilights and weekends",
|
||||
"exeter no nights",
|
||||
"exeter twilights",
|
||||
"exeter ir",
|
||||
),
|
||||
name="exeter_twilight",
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=4,
|
||||
constraint=[
|
||||
{
|
||||
"name": "max_shifts_per_week",
|
||||
"options": 2,
|
||||
}
|
||||
],
|
||||
balance_offset=2,
|
||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -96,18 +133,25 @@ def main(
|
||||
"truro twilights and weekends",
|
||||
"truro twilights and weekend nights",
|
||||
"truro twilights, weekends and weekend nights",
|
||||
"truro ir",
|
||||
),
|
||||
name="truro_twilight",
|
||||
length=12.5,
|
||||
days=days[:4],
|
||||
balance_offset=4,
|
||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("torbay", "torbay twilights", "torbay twilights and weekends"),
|
||||
sites=(
|
||||
"torbay",
|
||||
"torbay twilights",
|
||||
"torbay twilights and weekends",
|
||||
"torbay ir",
|
||||
),
|
||||
name="torbay_twilight",
|
||||
length=12.5,
|
||||
days=days[:4],
|
||||
balance_offset=4,
|
||||
balance_offset=2,
|
||||
assign_as_block=True,
|
||||
),
|
||||
SingleShift(
|
||||
@@ -116,13 +160,14 @@ def main(
|
||||
"plymouth twilights",
|
||||
"plymouth twilights and weekends",
|
||||
"plymouth twilights, weekends and weekend nights",
|
||||
"plymouth ir",
|
||||
),
|
||||
name="plymouth_twilight",
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=4,
|
||||
balance_offset=2,
|
||||
workers_required=1,
|
||||
constraint=[{"name": "max_shifts_per_week", "options": 2}],
|
||||
constraints=[MaxShiftsPerWeekConstraint(max_shifts=2)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -130,14 +175,15 @@ def main(
|
||||
"exeter twilights and weekends",
|
||||
"exeter no nights",
|
||||
"exeter weekends",
|
||||
# "exeter twilights",
|
||||
),
|
||||
name="weekend_exeter",
|
||||
length=12.5,
|
||||
days=days[5:],
|
||||
balance_offset=3,
|
||||
# balance_offset=2,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "post", "options": 2}, {"name": "pre", "options": 2}],
|
||||
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
|
||||
# constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2},],
|
||||
),
|
||||
SingleShift(
|
||||
@@ -151,22 +197,22 @@ def main(
|
||||
name="weekend_truro",
|
||||
length=12.5,
|
||||
days=days[4:],
|
||||
balance_offset=3,
|
||||
# rota_on_nwds=True,
|
||||
# force_as_block=True,
|
||||
assign_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
# force_as_block_unless_nwd=True
|
||||
# balance_offset=3,
|
||||
#rota_on_nwds=True,
|
||||
#force_as_block=True,
|
||||
# assign_as_block=True,
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("torbay", "torbay twilights and weekends"),
|
||||
name="weekend_torbay",
|
||||
length=12.5,
|
||||
days=days[4:],
|
||||
balance_offset=3,
|
||||
# balance_offset=2,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
# force_as_block_unless_nwd=True
|
||||
),
|
||||
SingleShift(
|
||||
@@ -179,11 +225,11 @@ def main(
|
||||
name="weekend_plymouth1",
|
||||
length=8,
|
||||
days=days[5:],
|
||||
balance_offset=2.9,
|
||||
# balance_offset=2.9,
|
||||
workers_required=1,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -194,11 +240,11 @@ def main(
|
||||
name="weekend_plymouth2",
|
||||
length=8,
|
||||
days=days[5:],
|
||||
balance_offset=2.9,
|
||||
# balance_offset=2.9,
|
||||
workers_required=1,
|
||||
rota_on_nwds=True,
|
||||
force_as_block=True,
|
||||
constraint=[{"name": "pre", "options": 2}, {"name": "post", "options": 2}],
|
||||
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=(
|
||||
@@ -206,6 +252,7 @@ def main(
|
||||
"plymouth twilights",
|
||||
"plymouth twilights and weekends",
|
||||
"plymouth twilights, weekends and weekend nights",
|
||||
"plymouth ir",
|
||||
),
|
||||
name="plymouth_bank_holidays",
|
||||
length=8,
|
||||
@@ -217,28 +264,27 @@ def main(
|
||||
bank_holidays_only=True,
|
||||
),
|
||||
SingleShift(
|
||||
sites=sites,
|
||||
sites=[
|
||||
*sites,
|
||||
],
|
||||
name="night_weekday",
|
||||
length=12.25,
|
||||
days=days[:4],
|
||||
balance_offset=3.9,
|
||||
# balance_offset=3.9,
|
||||
balance_weighting=1,
|
||||
# hard_constrain_shift=False,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
rota_on_nwds=True,
|
||||
constraint=[
|
||||
{"name": "night"},
|
||||
{"name": "pre", "options": 2},
|
||||
{"name": "post", "options": 2},
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("plymouth", 1),
|
||||
},
|
||||
{"name": "limit_grade_number", "options": {2: 1}},
|
||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
||||
constraints=[
|
||||
NightConstraint(),
|
||||
PreShiftConstraint(days=2),
|
||||
PostShiftConstraint(days=2),
|
||||
RequireRemoteSitePresenceConstraint(site="plymouth", required_number=1),
|
||||
LimitGradeNumberConstraint(grade=2, max_number=2),
|
||||
MinimumGradeNumberConstraint(grade=4, min_number=1),
|
||||
],
|
||||
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||
),
|
||||
SingleShift(
|
||||
sites=[
|
||||
@@ -251,32 +297,36 @@ def main(
|
||||
name="night_weekend",
|
||||
length=12.25,
|
||||
days=days[4:],
|
||||
balance_offset=2.9,
|
||||
# balance_offset=2.9,
|
||||
balance_weighting=1,
|
||||
# hard_constrain_shift=False,
|
||||
workers_required=4,
|
||||
force_as_block=True,
|
||||
rota_on_nwds=True,
|
||||
constraint=[
|
||||
{"name": "night"},
|
||||
{"name": "pre", "options": 2},
|
||||
{"name": "post", "options": 3},
|
||||
{
|
||||
"name": "require_remote_site_presence_week",
|
||||
"options": ("plymouth", 1),
|
||||
},
|
||||
{"name": "limit_grade_number", "options": {2: 2}},
|
||||
{"name": "minimum_grade_number", "options": (4, 1)},
|
||||
constraints=[
|
||||
NightConstraint(),
|
||||
PreShiftConstraint(days=2),
|
||||
PostShiftConstraint(days=3),
|
||||
RequireRemoteSitePresenceConstraint(site="plymouth", required_number=1),
|
||||
LimitGradeNumberConstraint(grade=2, max_number=2),
|
||||
MinimumGradeNumberConstraint(grade=4, min_number=1),
|
||||
],
|
||||
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
|
||||
),
|
||||
)
|
||||
|
||||
# Prevent shifts for ST2s for 2 weeks
|
||||
Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
||||
# Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
|
||||
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
|
||||
["weekend_plymouth1", "weekend_plymouth2"], 6
|
||||
)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
|
||||
["plymouth_twilight", "plymouth_bank_holidays"], 6
|
||||
)
|
||||
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
|
||||
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
|
||||
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
|
||||
@@ -287,8 +337,43 @@ def main(
|
||||
if load_leave:
|
||||
import leave
|
||||
|
||||
# Internal-only option: remove leave requests between two dates.
|
||||
# Configure by editing the two variables below (not exposed via CLI).
|
||||
# Use strings in 'YYYY-MM-DD' or datetime.date objects. Set either to None to disable.
|
||||
REMOVE_LEAVE_FROM = ""
|
||||
REMOVE_LEAVE_TO = ""
|
||||
|
||||
workers = leave.load_leave(Rota)
|
||||
|
||||
# If the internal remove range is set, filter out NotAvailableToWork entries
|
||||
if REMOVE_LEAVE_FROM and REMOVE_LEAVE_TO:
|
||||
# Coerce to date objects if strings provided
|
||||
def _to_date(v):
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
if isinstance(v, datetime.datetime):
|
||||
return v.date()
|
||||
return v
|
||||
|
||||
_from = _to_date(REMOVE_LEAVE_FROM)
|
||||
_to = _to_date(REMOVE_LEAVE_TO)
|
||||
|
||||
for name, w in list(workers.items()):
|
||||
original = w.get("leave", [])
|
||||
before_count = len(original)
|
||||
filtered = [lr for lr in original if not (_from <= lr.date <= _to)]
|
||||
after_count = len(filtered)
|
||||
if after_count != before_count:
|
||||
print(
|
||||
f"Removed {before_count - after_count} leave entries for '{name}' between {_from} and {_to}"
|
||||
)
|
||||
w["leave"] = filtered
|
||||
|
||||
n = 0
|
||||
for worker in workers:
|
||||
worker_name = worker
|
||||
@@ -301,13 +386,18 @@ def main(
|
||||
site = w["site"]
|
||||
except KeyError:
|
||||
print(f"Worker {worker} has no site")
|
||||
raise KeyError
|
||||
print(w)
|
||||
sys.exit(1)
|
||||
grade = w["grade"]
|
||||
try:
|
||||
fte = float(w["fte"]) * 100
|
||||
except ValueError:
|
||||
print(f"{worker} has invalid fte: {fte}")
|
||||
raise ValueError
|
||||
except KeyError:
|
||||
print(f"Worker {worker} has no FTE")
|
||||
print(w)
|
||||
sys.exit(1)
|
||||
|
||||
nwd = w["nwd"]
|
||||
end_date = w["end_date"]
|
||||
@@ -328,9 +418,9 @@ def main(
|
||||
nwd_end_date = Rota.rota_end_date
|
||||
|
||||
if "[" in i:
|
||||
print("Split nwd", worker_name, i)
|
||||
# print("Split nwd", worker_name, i)
|
||||
a, b = i.split("[")[1][:-1].split("-")
|
||||
print(f"{a=} {b=}")
|
||||
# print(f"{a=} {b=}")
|
||||
nwd_start_date = datetime.datetime.strptime(
|
||||
a, "%d/%m/%Y"
|
||||
).date()
|
||||
@@ -349,47 +439,48 @@ def main(
|
||||
if oop:
|
||||
formatted_oops = []
|
||||
for dates in oop.split(","):
|
||||
print(dates)
|
||||
if "-" in dates:
|
||||
s, e = dates.split("-")
|
||||
elif "to" in dates:
|
||||
s, e = dates.split(" to ")
|
||||
raw = dates.strip()
|
||||
# strip surrounding brackets/parentheses/quotes
|
||||
raw = raw.strip("()[]\"' ")
|
||||
if "-" in raw:
|
||||
s, e = raw.split("-", 1)
|
||||
elif " to " in raw:
|
||||
s, e = raw.split(" to ", 1)
|
||||
elif "to" in raw:
|
||||
s, e = raw.split("to", 1)
|
||||
else:
|
||||
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
|
||||
try:
|
||||
formatted_oops.append(
|
||||
{
|
||||
"start_date": datetime.datetime.strptime(
|
||||
s.strip(), "%d/%m/%Y"
|
||||
).date(),
|
||||
"end_date": datetime.datetime.strptime(
|
||||
e.strip(), "%d/%m/%Y"
|
||||
).date(),
|
||||
}
|
||||
raise ValueError(
|
||||
f"Cannot parse OOP dates: '{oop}' for {worker}"
|
||||
)
|
||||
except ValueError:
|
||||
|
||||
s = s.strip().strip("()[]\"' ")
|
||||
e = e.strip().strip("()[]\"' ")
|
||||
|
||||
parsed = False
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y"):
|
||||
try:
|
||||
start_dt = datetime.datetime.strptime(s, fmt).date()
|
||||
end_dt = datetime.datetime.strptime(e, fmt).date()
|
||||
formatted_oops.append(
|
||||
{
|
||||
"start_date": datetime.datetime.strptime(
|
||||
s.strip(), "%d/%m/%y"
|
||||
).date(),
|
||||
"end_date": datetime.datetime.strptime(
|
||||
e.strip(), "%d/%m/%y"
|
||||
).date(),
|
||||
}
|
||||
{"start_date": start_dt, "end_date": end_dt}
|
||||
)
|
||||
except ValueError as e:
|
||||
print(e)
|
||||
print("WORKER", worker)
|
||||
print("DATES", s, e)
|
||||
raise
|
||||
parsed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not parsed:
|
||||
print("WORKER", worker)
|
||||
print("DATES", s, e)
|
||||
raise ValueError(
|
||||
f"Cannot parse OOP dates: '{oop}' for {worker}"
|
||||
)
|
||||
|
||||
oop = formatted_oops
|
||||
else:
|
||||
oop = []
|
||||
|
||||
print(f"{worker} OOP {oop}")
|
||||
# print(f"{worker} OOP {oop}")
|
||||
|
||||
previous_shifts = {}
|
||||
# if twi:
|
||||
@@ -423,21 +514,50 @@ def main(
|
||||
|
||||
shift_fte_overrides = {}
|
||||
|
||||
#if worker_name == "Ben Kemp":
|
||||
exact_shifts = {}
|
||||
|
||||
if worker in ["hannah.lewis29@nhs.net", "shruti.bodapati@nhs.net", "a.abouelatta@nnhs.net"]:
|
||||
exact_shifts = {
|
||||
"night_weekday": 4,
|
||||
"night_weekend": 3,
|
||||
}
|
||||
elif worker in ["g.abdelhalim@nhs.net", "nang.thiriphoo@nhs.net"]:
|
||||
exact_shifts = {
|
||||
"night_weekend": 3,
|
||||
"night_weekday": 0,
|
||||
}
|
||||
|
||||
override_shift_start_dates = []
|
||||
if worker in ["Anushka Kulkarni"]:
|
||||
override_shift_start_dates = [
|
||||
{
|
||||
"shift": "night_weekday",
|
||||
"start_date": datetime.datetime.strptime(
|
||||
"2026-11-01", "%Y-%m-%d"
|
||||
).date(),
|
||||
},
|
||||
{
|
||||
"shift": "night_weekend",
|
||||
"start_date": datetime.datetime.strptime(
|
||||
"2026-11-01", "%Y-%m-%d"
|
||||
).date(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# if worker_name == "Hadi Mohamed":
|
||||
# shift_fte_overrides = {
|
||||
# "plymouth_twilight": 100,
|
||||
# "weekend_plymouth1": 50,
|
||||
# "weekend_plymouth2": 50,
|
||||
# "weekend_exeter": 50,
|
||||
# }
|
||||
#elif worker_name == "Joel Lim":
|
||||
# elif worker_name == "Joel Lim":
|
||||
# shift_fte_overrides = {
|
||||
# "plymouth_twilight": 100,
|
||||
# "weekend_exeter": 50,
|
||||
# }
|
||||
if worker_name == "Nang Thiriphoo":
|
||||
shift_fte_overrides = {
|
||||
"weekend_exeter": 40,
|
||||
}
|
||||
# if worker_name == "Nang Thiriphoo":
|
||||
# shift_fte_overrides = {
|
||||
# "weekend_exeter": 40,
|
||||
# }
|
||||
|
||||
w = Worker(
|
||||
name=worker_name,
|
||||
@@ -457,20 +577,23 @@ def main(
|
||||
shift_balance_extra=w["shift_balance_extra"],
|
||||
bank_holiday_extra=w["bank_holiday_extra"],
|
||||
shift_fte_overrides=shift_fte_overrides,
|
||||
exact_shifts=exact_shifts,
|
||||
shift_start_dates=override_shift_start_dates,
|
||||
)
|
||||
|
||||
print(w)
|
||||
# print(w)
|
||||
|
||||
Rota.add_worker(w)
|
||||
leave.load_academy(Rota)
|
||||
|
||||
# Rota.build_workers()
|
||||
# Rota.build_model()
|
||||
|
||||
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
|
||||
# solver_options = {"seconds": time_to_run, "threads": 10}
|
||||
seconds_to_run = parse_time_limit(time_to_run)
|
||||
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
|
||||
|
||||
# start_time = time.time()
|
||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
|
||||
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs", export_with_timestamp=True)
|
||||
|
||||
# Rota.solve_shifts_by_block(solver_options, block_length=13)
|
||||
# Rota.solve_shifts_individually(solver_options)
|
||||
@@ -1,387 +0,0 @@
|
||||
Rota 2021/2022,,, (POSSIBLE IDT),,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,80% until dec 1st,,,,,,,,,,,,,,,,
|
||||
Name,,Stephanie Bailey,Luciana Calovi Motschenbacher,Layla Bell,Seren Peters,Alex wood,Salma Aslam,Max Ireland,Charles Finan,Paul Ward,Tom Welsh,Delilah Trimmer,Max Finzel,Zainab Sharaf,Maththew O'Brien,Kyaw Tint,Nicolas Dziadulewicz,Jean Skumar,Michael Tomek,Vilim Kalamar,Dhuvresh Patel,Khalil Madbak,Georgina Edwards,Jenn Haw fong,Paul Jenkins,Jenna Millington,Amoolya Mannava,Ross Kruger,Sophie McGlade,Rebecca Murphy,Fiona Lyall,Joel Lim,Wijhdan Abusrewil,Mark Haley,Andrew MacCormick,Alexander Sanchez - cabello,Sayed Hashim Alqarooni,Chong Yew Ng,Matthew Thorley,Harriet Conley,Jon Skinner,Cameron Bullock,Alexander Wijnberg,Christian Greer,Alan Eccles,Hannah Lewis,Sarath Vennam,Amina Odeh,Mo Babsail,Richard Chaytor,Sherafghan PROC,Madalina Drumea,Mark Fellows,Clement Leung
|
||||
Rotation,,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford no proc,Derriford,Derriford,Derriford,Derriford,Derriford Twilights,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay Twilights,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro no PROC,Barnstaple,Taunton,Taunton
|
||||
Grade,,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST2,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST3,ST4,ST4,ST4,ST4,ST4,ST5,ST5,ST5,ST5,ST6,ST2,ST2,ST2,ST2,ST3,ST4,ST4,ST4,ST5,ST6,ST2,ST2,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST5 (until dec),ST3,ST2,ST4
|
||||
PROC site,,Derriford,,exeter,Torbay,Exeter,,Derriford,Derriford,Exeter,Exeter,Derriford,Derriford,torbay,Torbay,derriford,,derriford,exeter,,Exeter,,,derriford,Derriford,,derriford,Derriford,Derriford,Exeter,NA,derriford,Torbay,Derriford,Derriford,,torbay,Derriford,Derriford,Torbay,NA,Exeter,Derriford,Truro,Truro,Derriford,Truro,Truro,Derriford,derriford,Derriford,Barnstaple,NA,NA
|
||||
%FTE,,0,0,100,100,100,0,100,100,100,100,80,100,100,100,100,100,60,80,100,100,0,80,100,100,0,100,100,0,100,60,100,100,100,100,100,100,100,100,60,100,100,100,100,80,100,100,100,100,100,100,80,0,0
|
||||
NWD,,,,,,,"Thursday,Friday",,"Tuesday,Thursday",,,wednesday,,,,,,"Wednesday,thursday",Wednesday,,,,Friday,,,"thursday, friday",,,Friday,,"monday,friday",,"thursday, friday",,,,,,,"monday,thursday",,,,,"wednesday, thursday,Friday",,,,,,,,,
|
||||
Flexible NWD,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
End Date,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
OOP,,,,,,,6/9/21-31/3/22,,,,,,,,,,,,,,,,,,,6/9/21-2/5/22,,,1/1/22-5/9/22,6/9/21-28/2/22,,,1/3/22-31/3/22,,,,,,,6/9/21-20/9/21,,,,,,,,,1/8/22-4/9/22,23/7/22-4/9/22,,,,
|
||||
Group,,Y1G1,Y1G1,Y1G2,Y3G1,Y3G1,Y3G2,Y3G2,,,,,Y2G1,Y2G2,Y2G2,Y2G2,Y3G1,Y3G1,Y3G2,Y3G2,Y3G2,,,,,,,,,,,Y2G1,Y2G1,Y2G2,Y2G2,Y3G2,,,,,,Y2G1,Y2G2,Y3G1,Y3G1,Y3G2,,,,,,,Y2G1,
|
||||
?PROC,,,,,,,,,,,,,,,,,,,,,,NO,,,,WEEKEND ONLY,,,,,No,,,,,,,,,,No,,,,,,,,,,Yes - until ST6 in dec,???,NO,NO
|
||||
Pair,,,,,,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,,,,1,,,,,,,,,,,,,,2,,,
|
||||
Extra_night_weekday,,3.72,,-0.28,-0.28,-0.28,,-0.28,-0.28,-0.28,-0.28,0.63,-0.28,-0.28,-0.28,-0.28,-0.28,4.63,2.17,-0.28,3.72,,2.17,-0.28,-0.28,,-0.28,-0.28,,0.3,0,-0.28,-0.28,-0.28,3.72,-0.28,-0.28,-1.06,-0.28,0.27,,-0.28,-0.28,-0.28,-1.83,-0.28,-0.28,-0.28,-0.28,-0.28,0,-0.28,,
|
||||
Extra_night_weekend,,-0.21,,-0.21,-0.21,-0.21,,-0.21,-0.21,-0.21,-0.21,0.47,-0.21,2.79,2.79,2.79,-0.21,-2.53,-1.37,-0.21,-0.21,,-1.37,-0.21,-0.21,,-0.21,-0.21,,0.22,0,-0.21,-0.21,-0.21,-0.21,-0.21,-0.21,2.21,-0.21,0.21,,-0.21,-0.21,-0.21,1.63,-0.21,-0.21,-0.21,-0.21,-0.21,0,2.79,,
|
||||
Extra_twilight,,-1.97,,-0.97,1.03,1.03,,-0.97,-1.97,1.03,1.03,-0.38,0.47,0.47,-1.53,0.47,-0.53,-3.92,-2.23,0.47,-2.53,,-2.23,0.47,0.47,,0.47,0.47,,0.33,0.08,,,,,,,,,,,-1.39,-1.39,0.61,0.49,-1.39,-0.39,1.61,2.61,-1.39,0.61,0,,
|
||||
Extra_weekend,,-0.73,,2.27,-0.73,-0.73,,2.27,2.27,-0.73,-0.73,-1.04,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1.96,-1.04,-1.04,-0.63,1.96,0.96,-1.04,-2.04,1.96,-1.04,0,,
|
||||
Extra_weekend_plymouth1,,,,,,,,,,,,,-2.22,-0.22,-2.22,-0.22,-0.22,2.27,-0.98,-2.22,-0.22,,1.02,1.78,-0.22,,1.78,1.78,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
|
||||
Extra_weekend_plymouth2,,,,,,,,,,,,,1.78,-2.22,1.78,-2.22,-0.22,0.27,3.02,1.78,-0.22,,1.02,-2.22,-0.22,,-2.22,-0.22,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
|
||||
Extra_plymouth_bank_holiday,,,,,,,,,,,,,0.2,0.2,0.2,0.2,0.2,0.12,-0.84,0.2,0.2,,0.16,0.2,0.2,,0.2,-1.8,,0.01,0.12,,,,,,,,,,,,,,,,,,,,,,,
|
||||
bank_holidays_worked,,,,1,,,,1,,2,1,3,,2,1,2,,2,1,,3,,,,,,2,2,,,,2,,2,,1,,1,2,,,,2,2,,,,2,,,1,,,
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
Date,Exams,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
6/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
|
||||
7/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
|
||||
8/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
|
||||
9/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
|
||||
10/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
||||
11/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
||||
12/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
|
||||
13/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
14/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
15/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
16/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
|
||||
17/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,A/L,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
18/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
19/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
20/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
21/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
22/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
23/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
24/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
25/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
26/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
27/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
28/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
29/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
30/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
|
||||
1/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
2/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
3/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
4/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
5/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
6/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
7/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
8/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,A/L,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
|
||||
9/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
10/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
11/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
12/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
13/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
14/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
15/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
16/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
17/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
18/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
19/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
20/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
21/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
|
||||
22/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,No nights,,,,,,A/L,,,,,,,,
|
||||
23/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
|
||||
24/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
|
||||
25/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
26/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
27/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
28/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
29/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
30/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
31/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
|
||||
1/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
2/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
3/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
4/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
5/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
6/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
7/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
8/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
9/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
10/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
11/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
12/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
13/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
14/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
|
||||
15/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
16/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
|
||||
17/11/21,2A,,NO ONCALL,,2A,2A,Mat leave,2A,,2A,,,,,,,2A,2A,2A,2A,2A,No Oncall,2A,2A,,Mat leave,,,,OOPT,,A/L,2A,,,2A,,2A,,,,,,2A,,2A,2A,,,,,2A,,2A
|
||||
18/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
19/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
20/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
21/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
22/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
23/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
24/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
25/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
26/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
27/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
28/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
29/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
30/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
1/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
2/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
3/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
4/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
5/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
6/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
7/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
8/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
9/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
10/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
11/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
|
||||
12/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
|
||||
13/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
14/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
15/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
16/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
17/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
18/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
19/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
20/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
21/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
22/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
23/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
24/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
25/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
26/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
27/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
|
||||
28/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
29/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
30/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
31/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
1/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
2/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
3/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
4/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
|
||||
5/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
6/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
7/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
|
||||
8/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
|
||||
9/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
|
||||
10/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
11/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
12/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
13/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
14/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
15/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
16/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
17/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
18/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
19/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
20/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
21/1/22,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
||||
22/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
||||
23/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
|
||||
24/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
25/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
26/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
27/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
28/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
29/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
||||
30/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
||||
31/1/22,,,NO ONCALL,,,,Mat leave,,,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
|
||||
1/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
2/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
3/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
4/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
|
||||
5/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
|
||||
6/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
|
||||
7/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
8/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
9/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
10/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
11/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
12/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
13/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
|
||||
14/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
15/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
16/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
17/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
18/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,A/L,,A/L,,A/L,,,,,,,,
|
||||
19/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
|
||||
20/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
|
||||
21/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
22/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
23/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
24/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
|
||||
25/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
||||
26/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
||||
27/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
|
||||
28/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
1/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,,,,,,,,,,,,,,,A/L,A/L,,,,,
|
||||
2/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
|
||||
3/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,A/L,,,,,,,,A/L,A/L,,,,,
|
||||
4/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
|
||||
5/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
|
||||
6/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
|
||||
7/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
|
||||
8/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
|
||||
9/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,POST ONCALL,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
||||
10/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
||||
11/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
|
||||
12/3/22,,OFF ONCALL,IDT,POST ONCALL,,,OOPT,,,,OOPT,A/L,,,,,,POST ONCALL,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,POST ONCALL,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
|
||||
13/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
|
||||
14/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
15/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
16/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,POST ONCALL,A/L,,A/L,,pre 2b,A/L,,,,,
|
||||
17/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,A/L,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
18/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
|
||||
19/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
|
||||
20/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
|
||||
21/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,A/L,,,,pre 2b,,A/L,,,,
|
||||
22/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
||||
23/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
||||
24/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
|
||||
25/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
26/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
27/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
28/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,,,,,
|
||||
29/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
30/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
31/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
|
||||
1/4/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,2b,,,Ramadan,A/L,,
|
||||
2/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
|
||||
3/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
|
||||
4/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
5/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
6/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
7/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,S/L,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
8/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
9/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
10/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
|
||||
11/4/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
|
||||
12/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,A/L,A/L,,,A/L,,,,,,A/L,,,,,Ramadan,,,
|
||||
13/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,A/L,,,,,,,,,,,Ramadan,,,
|
||||
14/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
|
||||
15/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights or twiligts,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
16/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
17/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
18/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,A/L,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
|
||||
19/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
||||
20/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
21/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
22/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
23/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
||||
24/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
||||
25/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
26/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
27/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,S/L,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
28/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,S/L,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
||||
29/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
|
||||
30/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
||||
1/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
||||
2/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
|
||||
3/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
4/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
5/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
|
||||
6/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
|
||||
7/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
|
||||
8/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
|
||||
9/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
10/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
11/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
12/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
13/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
|
||||
14/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
15/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
16/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
17/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
18/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
19/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
|
||||
20/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
21/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
22/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
23/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
24/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
25/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
26/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
27/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
|
||||
28/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
|
||||
29/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
|
||||
30/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
31/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
1/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
2/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
3/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
4/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
5/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
6/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
|
||||
7/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
|
||||
8/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
9/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
10/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
11/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
12/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
13/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
14/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
|
||||
15/6/22,2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,2a,2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,2a,,,,,,2a,,2a
|
||||
16/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
17/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
18/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
19/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
20/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
|
||||
21/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
|
||||
22/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
23/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
24/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
25/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
26/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
|
||||
27/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
28/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
29/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
30/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
1/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
2/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
3/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
|
||||
4/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
5/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
6/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
|
||||
7/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
8/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
9/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
10/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
11/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
12/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
13/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
14/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
15/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
||||
16/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
||||
17/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
|
||||
18/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
19/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
20/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
21/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
22/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
|
||||
23/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
24/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
25/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
26/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
27/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
28/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
|
||||
29/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,,A/L,,,A/L,,OOPT,,,,
|
||||
30/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
|
||||
31/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
|
||||
1/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
2/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
3/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
4/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
|
||||
5/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
||||
6/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
||||
7/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
|
||||
8/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
|
||||
9/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
|
||||
10/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
11/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
12/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
13/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
14/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
|
||||
15/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
16/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
17/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
18/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
19/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
20/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
21/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
22/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
23/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
24/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
25/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
26/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
27/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
28/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
29/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
30/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
31/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
|
||||
1/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
2/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
|
||||
3/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,,OOPE,OOPT,,,,
|
||||
4/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,OFF,,,,,,,,,,,No Oncall,,A/L,,,OFF,OFF,OOPT,OFF,,,,,,,A/L,,,,OFF,,,,,,,,OOPE,OOPT,OFF,,,
|
||||
|
@@ -1,41 +1,49 @@
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
import sys
|
||||
from requests import Session
|
||||
from loguru import logger
|
||||
|
||||
from rich.pretty import pprint
|
||||
|
||||
|
||||
from rota_generator.workers import Worker, NotAvailableToWork, NonWorkingDays, WorkRequests, PreferenceNotToWork, OutOfProgramme
|
||||
from rota_generator.workers import (
|
||||
Worker,
|
||||
NotAvailableToWork,
|
||||
NonWorkingDays,
|
||||
WorkRequests,
|
||||
PreferenceNotToWork,
|
||||
OutOfProgramme,
|
||||
)
|
||||
|
||||
date_re = r"[\d]{1,2}\/[\d]{1,2}\/[\d]{2}"
|
||||
|
||||
|
||||
live_rota = True
|
||||
|
||||
def load_leave(Rota):
|
||||
|
||||
def load_leave(Rota):
|
||||
with Session() as s:
|
||||
shifts = Rota.get_shift_names()
|
||||
|
||||
workers = {}
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache"
|
||||
}
|
||||
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||
|
||||
if live_rota:
|
||||
download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSfcQi5Qs__A8hE2CVvUA_6ULNJALrtZOBHPUZ3xyrxPif9obtQF2IqhioT_4nebjHV1Ac5iqhNtuq4/pub?gid=2024511103&single=true&output=csv", headers=headers)
|
||||
#download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
|
||||
decoded_content = download.content.decode('utf-8')
|
||||
download = s.get(
|
||||
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv",
|
||||
headers=headers,
|
||||
)
|
||||
# download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
|
||||
decoded_content = download.content.decode("utf-8")
|
||||
|
||||
reader = csv.reader(decoded_content.splitlines(), delimiter=',')
|
||||
reader = csv.reader(decoded_content.splitlines(), delimiter=",")
|
||||
|
||||
else:
|
||||
with open("/home/ross/Downloads/PROC Draft RK version.csv") as f:
|
||||
reader = csv.reader(f.read().splitlines(), delimiter=',')
|
||||
reader = csv.reader(f.read().splitlines(), delimiter=",")
|
||||
|
||||
n = 0
|
||||
leave = {}
|
||||
@@ -47,10 +55,12 @@ def load_leave(Rota):
|
||||
n = 1
|
||||
for row in reader:
|
||||
if n < 10:
|
||||
print(row)
|
||||
row_title = row[1]
|
||||
print(row_title)
|
||||
pass
|
||||
#print(row)
|
||||
row_title = row[0]
|
||||
#print(row_title)
|
||||
row_date = row[0]
|
||||
row_extra = row[1]
|
||||
r = row[2:]
|
||||
# header, extract names
|
||||
if n == 1:
|
||||
@@ -80,44 +90,81 @@ def load_leave(Rota):
|
||||
|
||||
worker = workers[a]
|
||||
|
||||
lower_item = r[i].lower()
|
||||
lower_item = r[i].lower().strip()
|
||||
if lower_item == "derriford":
|
||||
lower_item = "plymouth"
|
||||
|
||||
if lower_item == "derriford twilights":
|
||||
lower_item = "plymouth_twilights"
|
||||
|
||||
if "PROC site" in row_title or row_title == "PROC nights from" or "proc nights" in row_title.lower():
|
||||
if (
|
||||
"PROC site" in row_title
|
||||
or row_title == "PROC nights from"
|
||||
or "proc nights" in row_title.lower()
|
||||
):
|
||||
worker["site_pref"] = lower_item
|
||||
|
||||
elif row_title in ("Rotation", "Site", "Placement", "Placement location"):
|
||||
elif row_title in (
|
||||
"Rotation",
|
||||
"Site",
|
||||
"Placement",
|
||||
"Placement location",
|
||||
"Placement location",
|
||||
):
|
||||
worker["site"] = lower_item
|
||||
|
||||
elif row_title in ("Grade", "Grade (ST)") or "Year of training" in row_title:
|
||||
elif (
|
||||
row_title in ("Grade", "Grade (ST)")
|
||||
or "Year of training" in row_title
|
||||
):
|
||||
worker["grade"] = lower_item.lstrip("ST")
|
||||
|
||||
elif row_title in ("%FTE", "%FTE on-call", "FTE", "FTE on-call") or "on-call commitment" in row_title:
|
||||
if lower_item.endswith("%"):
|
||||
lower_item = float(lower_item.rstrip("%")) / 100
|
||||
worker["fte"] = lower_item
|
||||
elif (
|
||||
row_title
|
||||
in (
|
||||
"%FTE",
|
||||
"%FTE on-call",
|
||||
"FTE",
|
||||
"FTE on-call",
|
||||
"Full-time equivalent (FTE)",
|
||||
)
|
||||
or "on-call commitment" in row_title
|
||||
):
|
||||
if lower_item:
|
||||
if lower_item.endswith("%"):
|
||||
lower_item = float(lower_item.rstrip("%")) / 100
|
||||
worker["fte"] = lower_item
|
||||
|
||||
elif "NWD" in row_title:
|
||||
elif (
|
||||
"NWD" in row_title or row_title == "If <100% FTE, specify days off:"
|
||||
):
|
||||
worker["nwd"] = lower_item
|
||||
|
||||
elif row_title == "Flexible NWD":
|
||||
worker["flexible_nwd"] = lower_item
|
||||
|
||||
elif row_title in ("End Date", "CCT date"):
|
||||
elif row_title in ("End Date", "CCT date", "CCT Date"):
|
||||
if lower_item:
|
||||
try:
|
||||
date = datetime.strptime(lower_item, "%d/%m/%y").date()
|
||||
except ValueError:
|
||||
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
||||
try:
|
||||
date = datetime.strptime(lower_item, "%d-%m-%y").date()
|
||||
except ValueError:
|
||||
try:
|
||||
date = datetime.strptime(
|
||||
lower_item, "%d/%m/%Y"
|
||||
).date()
|
||||
except ValueError:
|
||||
print(
|
||||
f"Cannot parse date: '{lower_item}' (n: {n}, row: {row})"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
worker["end_date"] = date
|
||||
else:
|
||||
worker["end_date"] = None
|
||||
|
||||
|
||||
elif row_title in ("Start date",):
|
||||
if lower_item:
|
||||
try:
|
||||
@@ -125,13 +172,22 @@ def load_leave(Rota):
|
||||
except ValueError:
|
||||
date = datetime.strptime(lower_item, "%d/%m/%Y").date()
|
||||
except ValueError:
|
||||
logger.warning(f"Cannot parse date: {lower_item} (n: {n}, row: {row})")
|
||||
logger.warning(
|
||||
f"Cannot parse date: {lower_item} (n: {n}, row: {row})"
|
||||
)
|
||||
raise ValueError(f"Cannot parse date: {lower_item}")
|
||||
worker["start_date"] = date
|
||||
else:
|
||||
worker["start_date"] = None
|
||||
|
||||
elif "OOP" in row_title or "out of programme" in row_title.lower():
|
||||
if lower_item in (
|
||||
"OOPT 1 START",
|
||||
"OOPT 1 END",
|
||||
"OOPT 2 START",
|
||||
"OOPT 2 END",
|
||||
):
|
||||
continue
|
||||
worker["oop"] = lower_item
|
||||
|
||||
elif row_title == "Group":
|
||||
@@ -156,41 +212,183 @@ def load_leave(Rota):
|
||||
if lower_item:
|
||||
worker["bank_holiday_extra"] = int(lower_item)
|
||||
|
||||
elif re.match(date_re, row_date) is not None:
|
||||
elif row_title == "Weekends/Twilights":
|
||||
if lower_item:
|
||||
worker["site"] = lower_item
|
||||
|
||||
|
||||
#elif row_title == "Academy":
|
||||
# pass
|
||||
#elif "night specific" in row_title:
|
||||
# if lower_item:
|
||||
# for shift in lower_item.split(","):
|
||||
# shift = shift.strip()
|
||||
# if shift == "nights":
|
||||
# shift = "night_weekday"
|
||||
# elif shift == "nights only":
|
||||
# if date.weekday() < 5:
|
||||
# shift = "night_weekday"
|
||||
# else:
|
||||
# shift = "night_weekend"
|
||||
# worker["single_nights"].append(
|
||||
# WorkRequests(date=date, shift=shift)
|
||||
# )
|
||||
|
||||
|
||||
elif re.match(date_re, row_date) is not None:
|
||||
if lower_item != "":
|
||||
try:
|
||||
try:
|
||||
date = datetime.strptime(row_date, "%d/%m/%y").date()
|
||||
except ValueError:
|
||||
date = datetime.strptime(row_date, "%d/%m/%Y").date()
|
||||
|
||||
|
||||
# This may be easier to do as a dict
|
||||
if lower_item in shifts:
|
||||
worker["requests"].append(WorkRequests(date=date, shift=lower_item))
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift=lower_item)
|
||||
)
|
||||
elif lower_item in ("nights only"):
|
||||
if date.weekday() < 5:
|
||||
worker["requests"].append(WorkRequests(date=date, shift="night_weekday"))
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift="night_weekday")
|
||||
)
|
||||
else:
|
||||
worker["requests"].append(WorkRequests(date=date, shift="night_weekend"))
|
||||
|
||||
elif lower_item in ("work_request", "happy to work", "offered", "may be", "volunteered"):
|
||||
worker["requests"].append(WorkRequests(date=date, shift="*"))
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift="night_weekend")
|
||||
)
|
||||
|
||||
elif lower_item in (
|
||||
"work_request",
|
||||
"happy to work",
|
||||
"offered",
|
||||
"may be",
|
||||
"volunteered",
|
||||
):
|
||||
worker["requests"].append(
|
||||
WorkRequests(date=date, shift="*")
|
||||
)
|
||||
|
||||
else:
|
||||
worker["leave"].append(NotAvailableToWork(date=date, reason=lower_item))
|
||||
worker["leave"].append(
|
||||
NotAvailableToWork(date=date, reason=lower_item)
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f"Error with: {lower_item}")
|
||||
print(f"{row=}")
|
||||
raise e
|
||||
|
||||
|
||||
n = n + 1
|
||||
|
||||
|
||||
|
||||
return workers
|
||||
|
||||
|
||||
# load_leave()
|
||||
def load_academy(Rota):
|
||||
"""Load academy month blocks from the main published CSV and register avoids.
|
||||
|
||||
Spreadsheet layout expected:
|
||||
- Column A: row title (should be 'Academy' for relevant rows)
|
||||
- Column B: start date for the month (e.g. '07/09/2026')
|
||||
- Columns C...: worker columns matching the header row (first CSV row's columns C...)
|
||||
Cells containing the word 'academy' (case-insensitive) mark that worker as being in academy
|
||||
for that month.
|
||||
"""
|
||||
# Use the same published CSV as load_leave (main sheet)
|
||||
url = (
|
||||
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv"
|
||||
)
|
||||
|
||||
with Session() as s:
|
||||
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||
download = s.get(url, headers=headers)
|
||||
decoded = download.content.decode("utf-8")
|
||||
|
||||
reader = csv.reader(decoded.splitlines(), delimiter=",")
|
||||
|
||||
names = []
|
||||
month_starts: list[datetime.date] = []
|
||||
month_cells: list[list[str]] = []
|
||||
|
||||
n = 0
|
||||
for row in reader:
|
||||
n += 1
|
||||
if not row:
|
||||
continue
|
||||
|
||||
# header row: collect worker names from column C onwards
|
||||
if n == 1:
|
||||
# some files may have two leading columns (like 'Email Address' and blank/extra), so take from index 2
|
||||
names = [c.strip() for c in (row[2:] if len(row) > 2 else row[1:])]
|
||||
continue
|
||||
|
||||
row_title = row[0].strip() if len(row) > 0 else ""
|
||||
start_cell = row[1].strip() if len(row) > 1 else ""
|
||||
cells = row[2:] if len(row) > 2 else []
|
||||
|
||||
# Only treat rows where the first column mentions 'Academy' (some sheets may include other rows)
|
||||
if row_title and "academy" in row_title.lower():
|
||||
# parse start date
|
||||
try:
|
||||
try:
|
||||
start_date = datetime.strptime(start_cell, "%d/%m/%Y").date()
|
||||
except ValueError:
|
||||
start_date = datetime.strptime(start_cell, "%d/%m/%y").date()
|
||||
except Exception:
|
||||
# skip rows without a valid date
|
||||
continue
|
||||
|
||||
month_starts.append(start_date)
|
||||
# normalize cell list to names length
|
||||
row_cells = [(cells[i] if i < len(cells) else "").strip() for i in range(len(names))]
|
||||
month_cells.append(row_cells)
|
||||
|
||||
if not month_starts:
|
||||
return
|
||||
|
||||
# determine twilight shifts available
|
||||
twilight_shift_names = [s.name for s in Rota.get_shifts() if "twilight" in s.name]
|
||||
|
||||
# for each month start, compute end date and register avoids
|
||||
for idx, start_date in enumerate(month_starts):
|
||||
if idx + 1 < len(month_starts):
|
||||
end_date = month_starts[idx + 1] - timedelta(days=1)
|
||||
else:
|
||||
end_date = Rota.rota_end_date
|
||||
|
||||
cells = month_cells[idx]
|
||||
for i, cell in enumerate(cells):
|
||||
if i >= len(names):
|
||||
break
|
||||
v = (cell or "").lower()
|
||||
if "academy" in v:
|
||||
worker_name = names[i]
|
||||
matching_workers = [w for w in Rota.workers if w.name.strip() == worker_name.strip()]
|
||||
if not matching_workers:
|
||||
logger.warning(f"Academy data: worker '{worker_name}' not found in rota, skipping")
|
||||
continue
|
||||
worker = matching_workers[0]
|
||||
|
||||
# choose twilight shifts relevant to the worker's site
|
||||
candidate_shifts = []
|
||||
for sh_name in twilight_shift_names:
|
||||
try:
|
||||
sh = Rota.get_shift_by_name(sh_name)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(worker, "site") and worker.site in getattr(sh, "sites", []):
|
||||
candidate_shifts.append(sh_name)
|
||||
elif sh_name.startswith(f"{worker.site}_"):
|
||||
candidate_shifts.append(sh_name)
|
||||
|
||||
if not candidate_shifts:
|
||||
candidate_shifts = twilight_shift_names.copy()
|
||||
|
||||
#print(f"Academy: {worker.name} on academy from {start_date} to {end_date}, avoiding shifts: {candidate_shifts}")
|
||||
|
||||
Rota.add_avoid_shifts_for_workers_on_dates(
|
||||
names=[worker.name],
|
||||
shifts=candidate_shifts,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
reason="academy",
|
||||
)
|
||||
@@ -1,6 +1,26 @@
|
||||
html {
|
||||
--bg-color: white;
|
||||
--text-color: black;
|
||||
--table-bg: #f9f9f9;
|
||||
--border-color: #ddd;
|
||||
--header-bg: #e0e0e0;
|
||||
--highlight-color: #ffff99;
|
||||
--worker-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
/* color: red; */
|
||||
html.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--table-bg: #1e1e1e;
|
||||
--border-color: #333;
|
||||
--header-bg: #2a2a2a;
|
||||
--highlight-color: #333300;
|
||||
--worker-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -14,7 +34,8 @@ table {
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
background-color: var(--table-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#main-table {
|
||||
@@ -72,12 +93,12 @@ th {
|
||||
}
|
||||
|
||||
.table-div tr:hover {
|
||||
background-color: lightblue;
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
.table-div tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
background-color: var(--worker-bg);
|
||||
}
|
||||
|
||||
.Sat {
|
||||
|
||||
@@ -445,9 +445,19 @@ $("#rota-table tr td:first-child").each((n, td) => {
|
||||
|
||||
function viewWorker(worker) {
|
||||
ds = worker.dataset;
|
||||
let exactShiftsText = "(none)";
|
||||
if (ds.exactShifts) {
|
||||
try {
|
||||
let parsed = JSON.parse(ds.exactShifts);
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", ");
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
dlg = $(`<div class='dialog' title='${ds.worker}'>
|
||||
Site: ${ds.site}</br>
|
||||
FTE: ${ds.fte} (${ds.fte_adj})</br>
|
||||
Exact shifts: ${exactShiftsText}</br>
|
||||
Start date: ${ds.start_date}</br>
|
||||
End date: ${ds.end_date}</br>
|
||||
Non working days: ${ds.nwds}</br>
|
||||
@@ -513,6 +523,311 @@ $("#gen-table").each(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// ICS Export functionality
|
||||
let customShiftTimes = {}; // Will store user-defined times
|
||||
|
||||
function getShiftTimes() {
|
||||
// Merge default with custom
|
||||
const defaultTimes = {
|
||||
'night_weekday': { start: '20:00', end: '08:15', nextDay: true },
|
||||
'night_weekend': { start: '20:00', end: '08:15', nextDay: true },
|
||||
'exeter_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'truro_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'torbay_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'plymouth_twilight': { start: '14:00', end: '02:30', nextDay: true },
|
||||
'weekend_exeter': { start: '08:00', end: '20:30', nextDay: false },
|
||||
'weekend_truro': { start: '08:00', end: '20:30', nextDay: false },
|
||||
'weekend_torbay': { start: '08:00', end: '20:30', nextDay: false },
|
||||
'weekend_plymouth1': { start: '08:00', end: '16:00', nextDay: false },
|
||||
'weekend_plymouth2': { start: '16:00', end: '00:00', nextDay: false },
|
||||
'plymouth_bank_holidays': { start: '08:00', end: '16:00', nextDay: false },
|
||||
};
|
||||
return { ...defaultTimes, ...customShiftTimes };
|
||||
}
|
||||
|
||||
function generateICS(shiftType, shiftData) {
|
||||
let icsContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Rota Generator//EN
|
||||
`;
|
||||
|
||||
const shiftTimes = getShiftTimes();
|
||||
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
||||
|
||||
shiftData.forEach(entry => {
|
||||
const date = entry.date;
|
||||
const workerName = entry.worker;
|
||||
|
||||
const startDate = new Date(date + 'T' + time.start + ':00');
|
||||
let endDate = new Date(date + 'T' + time.end + ':00');
|
||||
if (time.nextDay) {
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
|
||||
const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase();
|
||||
const summary = workerName ? `${shiftTitle} - ${workerName}` : shiftTitle;
|
||||
const description = workerName ?
|
||||
`Shift: ${shiftType}\nWorker: ${workerName}` :
|
||||
`Shift: ${shiftType}`;
|
||||
|
||||
icsContent += `BEGIN:VEVENT
|
||||
UID:${workerName ? workerName + '-' : ''}${shiftType}-${date}@rota
|
||||
DTSTAMP:${formatDate(new Date())}
|
||||
DTSTART:${formatDate(startDate)}
|
||||
DTEND:${formatDate(endDate)}
|
||||
SUMMARY:${summary}
|
||||
DESCRIPTION:${description}
|
||||
END:VEVENT
|
||||
`;
|
||||
});
|
||||
|
||||
icsContent += 'END:VCALENDAR';
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
function downloadICS(filename, content) {
|
||||
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Collect shift dates and worker assignments
|
||||
const shiftDates = {};
|
||||
const workerShifts = {};
|
||||
|
||||
$('#main-table tr.worker-row').each(function() {
|
||||
const workerName = $(this).find('td.worker').attr('data-worker');
|
||||
if (workerName) {
|
||||
workerShifts[workerName] = {};
|
||||
|
||||
$(this).find('td.rota-day').each(function() {
|
||||
const shift = $(this).attr('data-shift');
|
||||
const date = $(this).attr('data-date');
|
||||
if (shift && shift !== '' && date) {
|
||||
const shifts = shift.split(',').map(s => s.trim()).filter(s => s);
|
||||
shifts.forEach(s => {
|
||||
// Collect by shift type with worker info
|
||||
if (!shiftDates[s]) {
|
||||
shiftDates[s] = [];
|
||||
}
|
||||
// Check if we already have this date for this shift type
|
||||
const existingEntry = shiftDates[s].find(entry => entry.date === date);
|
||||
if (!existingEntry) {
|
||||
shiftDates[s].push({ date: date, worker: workerName });
|
||||
}
|
||||
|
||||
// Collect by worker
|
||||
if (!workerShifts[workerName][s]) {
|
||||
workerShifts[workerName][s] = [];
|
||||
}
|
||||
if (!workerShifts[workerName][s].includes(date)) {
|
||||
workerShifts[workerName][s].push(date);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add ICS export buttons
|
||||
const icsButtonsDiv = $('<div id="ics-export-buttons" style="margin-top: 20px;"><h3>Export ICS Files</h3></div>');
|
||||
const setTimesButton = $(`<button id="set-shift-times" style="margin: 5px;">Set Shift Times</button>`);
|
||||
icsButtonsDiv.append(setTimesButton);
|
||||
|
||||
// Shift type exports (with worker info)
|
||||
const shiftExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Shift Type</h4></div>');
|
||||
Object.keys(shiftDates).sort().forEach(shiftType => {
|
||||
const button = $(`<button style="margin: 5px;">Download ${shiftType.replace(/_/g, ' ')} ICS</button>`);
|
||||
button.on('click', () => {
|
||||
const ics = generateICS(shiftType, shiftDates[shiftType]);
|
||||
downloadICS(`${shiftType}.ics`, ics);
|
||||
});
|
||||
shiftExportsDiv.append(button);
|
||||
});
|
||||
icsButtonsDiv.append(shiftExportsDiv);
|
||||
|
||||
// Worker exports
|
||||
const workerExportsDiv = $('<div style="margin-bottom: 20px;"><h4>Export by Worker</h4></div>');
|
||||
Object.keys(workerShifts).sort().forEach(workerName => {
|
||||
const workerShiftTypes = Object.keys(workerShifts[workerName]);
|
||||
if (workerShiftTypes.length > 0) {
|
||||
const button = $(`<button style="margin: 5px;">Download ${workerName} ICS</button>`);
|
||||
button.on('click', () => {
|
||||
showWorkerShiftSelectionModal(workerName, workerShifts[workerName]);
|
||||
});
|
||||
workerExportsDiv.append(button);
|
||||
}
|
||||
});
|
||||
icsButtonsDiv.append(workerExportsDiv);
|
||||
|
||||
$('#export-div').append(icsButtonsDiv);
|
||||
|
||||
// Function to show worker shift selection modal
|
||||
function showWorkerShiftSelectionModal(workerName, workerShiftData) {
|
||||
const checkboxesDiv = $('#worker-shift-checkboxes');
|
||||
checkboxesDiv.empty();
|
||||
|
||||
$('#worker-shift-modal-title').text(`Select Shifts for ${workerName}`);
|
||||
|
||||
// Create checkboxes for each shift type
|
||||
Object.keys(workerShiftData).sort().forEach(shiftType => {
|
||||
const dates = workerShiftData[shiftType];
|
||||
const checkboxId = `shift-${shiftType}`;
|
||||
const checkbox = $(`
|
||||
<div style="margin: 8px 0;">
|
||||
<input type="checkbox" id="${checkboxId}" checked>
|
||||
<label for="${checkboxId}" style="margin-left: 8px;">
|
||||
${shiftType.replace(/_/g, ' ').toUpperCase()} (${dates.length} shift${dates.length !== 1 ? 's' : ''})
|
||||
</label>
|
||||
</div>
|
||||
`);
|
||||
checkboxesDiv.append(checkbox);
|
||||
});
|
||||
|
||||
// Store worker data for export
|
||||
$('#worker-shift-export').data('workerName', workerName);
|
||||
$('#worker-shift-export').data('workerShiftData', workerShiftData);
|
||||
|
||||
// Show modal
|
||||
workerShiftModal.show();
|
||||
}
|
||||
|
||||
// Modal for worker shift selection
|
||||
const workerShiftModal = $(`
|
||||
<div id="worker-shift-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||
<h3 style="margin-top:0;" id="worker-shift-modal-title">Select Shifts for Export</h3>
|
||||
<div id="worker-shift-checkboxes" style="margin: 16px 0;"></div>
|
||||
<div style="text-align:right; margin-top:16px;">
|
||||
<button id="worker-shift-cancel" style="margin-right:8px;">Cancel</button>
|
||||
<button id="worker-shift-export">Export Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$('body').append(workerShiftModal);
|
||||
|
||||
// Modal for setting shift times
|
||||
const shiftTimesModal = $(`
|
||||
<div id="shift-times-modal" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10000; background:#fff; border:2px solid #888; border-radius:8px; box-shadow:0 4px 24px #0002; padding:24px; min-width:400px; max-height:80vh; overflow-y:auto;">
|
||||
<h3 style="margin-top:0;">Set Shift Times</h3>
|
||||
<div id="shift-times-controls"></div>
|
||||
<div style="text-align:right; margin-top:16px;">
|
||||
<button id="save-shift-times">Save</button>
|
||||
<button id="close-shift-times">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$("body").append(shiftTimesModal);
|
||||
|
||||
$('#set-shift-times').on('click', function() {
|
||||
$('#shift-times-modal').show();
|
||||
populateShiftTimesModal();
|
||||
});
|
||||
|
||||
$('#close-shift-times').on('click', function() {
|
||||
$('#shift-times-modal').hide();
|
||||
});
|
||||
|
||||
$('#save-shift-times').on('click', function() {
|
||||
saveShiftTimes();
|
||||
$('#shift-times-modal').hide();
|
||||
});
|
||||
|
||||
// Worker shift modal event handlers
|
||||
$('#worker-shift-cancel').on('click', function() {
|
||||
workerShiftModal.hide();
|
||||
});
|
||||
|
||||
$('#worker-shift-export').on('click', function() {
|
||||
const workerName = $(this).data('workerName');
|
||||
const workerShiftData = $(this).data('workerShiftData');
|
||||
|
||||
// Get selected shift types
|
||||
const selectedShifts = [];
|
||||
$('#worker-shift-checkboxes input[type="checkbox"]:checked').each(function() {
|
||||
const shiftType = $(this).attr('id').replace('shift-', '');
|
||||
selectedShifts.push(shiftType);
|
||||
});
|
||||
|
||||
if (selectedShifts.length === 0) {
|
||||
alert('Please select at least one shift type to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate ICS for selected shifts
|
||||
let icsContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Rota Generator//EN
|
||||
`;
|
||||
|
||||
selectedShifts.forEach(shiftType => {
|
||||
const dates = workerShiftData[shiftType];
|
||||
const shiftTimes = getShiftTimes();
|
||||
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
||||
|
||||
dates.forEach(date => {
|
||||
const startDate = new Date(date + 'T' + time.start + ':00');
|
||||
let endDate = new Date(date + 'T' + time.end + ':00');
|
||||
if (time.nextDay) {
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const formatDate = (d) => d.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
|
||||
const shiftTitle = shiftType.replace(/_/g, ' ').toUpperCase();
|
||||
|
||||
icsContent += `BEGIN:VEVENT
|
||||
UID:${workerName}-${shiftType}-${date}@rota
|
||||
DTSTAMP:${formatDate(new Date())}
|
||||
DTSTART:${formatDate(startDate)}
|
||||
DTEND:${formatDate(endDate)}
|
||||
SUMMARY:${shiftTitle} - ${workerName}
|
||||
DESCRIPTION:Shift: ${shiftType}\nWorker: ${workerName}
|
||||
END:VEVENT
|
||||
`;
|
||||
});
|
||||
});
|
||||
|
||||
icsContent += 'END:VCALENDAR';
|
||||
downloadICS(`${workerName.replace(/[^a-zA-Z0-9]/g, '_')}.ics`, icsContent);
|
||||
|
||||
workerShiftModal.hide();
|
||||
});
|
||||
|
||||
function populateShiftTimesModal() {
|
||||
const controls = $('#shift-times-controls');
|
||||
controls.empty();
|
||||
const shiftTimes = getShiftTimes();
|
||||
Object.keys(shiftDates).sort().forEach(shiftType => {
|
||||
const time = shiftTimes[shiftType] || { start: '09:00', end: '17:00', nextDay: false };
|
||||
const div = $(`
|
||||
<div style="margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px;">
|
||||
<label><strong>${shiftType.replace(/_/g, ' ')}</strong></label><br>
|
||||
<label>Start: <input type="time" class="shift-start" data-shift="${shiftType}" value="${time.start}"></label>
|
||||
<label>End: <input type="time" class="shift-end" data-shift="${shiftType}" value="${time.end}"></label>
|
||||
<label><input type="checkbox" class="shift-nextday" data-shift="${shiftType}" ${time.nextDay ? 'checked' : ''}> Ends next day</label>
|
||||
</div>
|
||||
`);
|
||||
controls.append(div);
|
||||
});
|
||||
}
|
||||
|
||||
function saveShiftTimes() {
|
||||
customShiftTimes = {};
|
||||
$('#shift-times-controls .shift-start').each(function() {
|
||||
const shift = $(this).data('shift');
|
||||
const start = $(this).val();
|
||||
const end = $(`input.shift-end[data-shift="${shift}"]`).val();
|
||||
const nextDay = $(`input.shift-nextday[data-shift="${shift}"]`).is(':checked');
|
||||
customShiftTimes[shift] = { start, end, nextDay };
|
||||
});
|
||||
}
|
||||
|
||||
function hsv2rgb(h, s, v) {
|
||||
// adapted from http://schinckel.net/2012/01/10/hsv-to-rgb-in-javascript/
|
||||
var rgb, i, data = [];
|
||||
|
||||
@@ -1 +1 @@
|
||||
/home/ross/proc-rota/output/timetable.css
|
||||
/home/ross/proc_rota/output/timetable.css
|
||||
@@ -1 +1 @@
|
||||
/home/ross/proc-rota/output/timetable.js
|
||||
/home/ross/proc_rota/output/timetable.js
|
||||
+25
-4
@@ -1,6 +1,26 @@
|
||||
html {
|
||||
--bg-color: white;
|
||||
--text-color: black;
|
||||
--table-bg: #f9f9f9;
|
||||
--border-color: #ddd;
|
||||
--header-bg: #e0e0e0;
|
||||
--highlight-color: #ffff99;
|
||||
--worker-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
/* color: red; */
|
||||
html.dark-mode {
|
||||
--bg-color: #121212;
|
||||
--text-color: #e0e0e0;
|
||||
--table-bg: #1e1e1e;
|
||||
--border-color: #333;
|
||||
--header-bg: #2a2a2a;
|
||||
--highlight-color: #333300;
|
||||
--worker-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -14,7 +34,8 @@ table {
|
||||
position: relative; */
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
background-color: var(--table-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
#main-table {
|
||||
@@ -72,12 +93,12 @@ th {
|
||||
}
|
||||
|
||||
#main-table tr:hover {
|
||||
background-color: lightblue;
|
||||
background-color: var(--highlight-color);
|
||||
}
|
||||
|
||||
#main-table tr:hover .unavailable {
|
||||
|
||||
background-color: lightgray;
|
||||
background-color: var(--worker-bg);
|
||||
}
|
||||
|
||||
.Sat {
|
||||
|
||||
+1056
-8
File diff suppressed because it is too large
Load Diff
+1032
-238
File diff suppressed because it is too large
Load Diff
+244
-4
@@ -111,6 +111,100 @@ class OutOfProgramme(BaseModel):
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class AvoidShiftOnDates(BaseModel):
|
||||
"""Worker-level constraint: avoid listed shifts on given dates or date range."""
|
||||
shifts: list[str]
|
||||
start_date: Optional[datetime.date] = None
|
||||
end_date: Optional[datetime.date] = None
|
||||
dates: Optional[list[datetime.date]] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
@field_validator("start_date", "end_date", mode="before")
|
||||
def coerce_dates(cls, v):
|
||||
# allow strings in common formats
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, datetime.date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class MaxDaysPerWeekBlockConstraint(BaseModel):
|
||||
max_days: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Maximum number of worked days allowed inside each sliding week block.",
|
||||
)
|
||||
week_block: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||
)
|
||||
ignore_dates: list[datetime.date] = Field(
|
||||
default_factory=list,
|
||||
description="List of specific dates to exclude from this constraint.",
|
||||
)
|
||||
|
||||
|
||||
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
|
||||
max_unique_shifts: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Maximum number of distinct shift names allowed inside each sliding week block.",
|
||||
)
|
||||
week_block: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
|
||||
)
|
||||
ignore_dates: list[datetime.date] = Field(
|
||||
default_factory=list,
|
||||
description="List of specific dates to exclude from this constraint.",
|
||||
)
|
||||
|
||||
|
||||
class ShiftStartDate(BaseModel):
|
||||
shift: str
|
||||
start_date: datetime.date
|
||||
|
||||
@field_validator("start_date", mode="before")
|
||||
@classmethod
|
||||
def coerce_date(cls, v):
|
||||
if isinstance(v, datetime.date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class ShiftEndDate(BaseModel):
|
||||
shift: str
|
||||
end_date: datetime.date
|
||||
|
||||
@field_validator("end_date", mode="before")
|
||||
@classmethod
|
||||
def coerce_date(cls, v):
|
||||
if isinstance(v, datetime.date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(v, fmt).date()
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse date: {v}")
|
||||
|
||||
|
||||
class Worker(BaseModel):
|
||||
name: str
|
||||
site: str
|
||||
@@ -147,15 +241,65 @@ class Worker(BaseModel):
|
||||
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
|
||||
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
|
||||
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
|
||||
max_days_per_week_block: list[MaxDaysPerWeekBlockConstraint] = []
|
||||
max_unique_shifts_per_week_block: list[MaxUniqueShiftsPerWeekBlockConstraint] = []
|
||||
|
||||
|
||||
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
|
||||
|
||||
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
|
||||
|
||||
weekend_shift_target_number: int = 0
|
||||
exact_shifts: dict[str, int] = {} # Map shift_name to exact number of shifts
|
||||
groups: set[str] = set() # Groups that the worker belongs to
|
||||
shift_start_dates: list[ShiftStartDate] = [] # List of ShiftStartDate models
|
||||
shift_end_dates: list[ShiftEndDate] = [] # List of ShiftEndDate models
|
||||
|
||||
weekend_shift_target_number: float = 0.0
|
||||
avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
|
||||
|
||||
@field_validator("shift_start_dates", mode="before")
|
||||
@classmethod
|
||||
def coerce_shift_start_dates(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, dict):
|
||||
res = []
|
||||
for shift, d in v.items():
|
||||
res.append(ShiftStartDate(shift=shift, start_date=d))
|
||||
return res
|
||||
if isinstance(v, list):
|
||||
res = []
|
||||
for item in v:
|
||||
if isinstance(item, ShiftStartDate):
|
||||
res.append(item)
|
||||
elif isinstance(item, dict):
|
||||
res.append(ShiftStartDate(**item))
|
||||
else:
|
||||
raise ValueError(f"Invalid ShiftStartDate item: {item}")
|
||||
return res
|
||||
raise ValueError("Must be a list or dictionary")
|
||||
|
||||
@field_validator("shift_end_dates", mode="before")
|
||||
@classmethod
|
||||
def coerce_shift_end_dates(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, dict):
|
||||
res = []
|
||||
for shift, d in v.items():
|
||||
res.append(ShiftEndDate(shift=shift, end_date=d))
|
||||
return res
|
||||
if isinstance(v, list):
|
||||
res = []
|
||||
for item in v:
|
||||
if isinstance(item, ShiftEndDate):
|
||||
res.append(item)
|
||||
elif isinstance(item, dict):
|
||||
res.append(ShiftEndDate(**item))
|
||||
else:
|
||||
raise ValueError(f"Invalid ShiftEndDate item: {item}")
|
||||
return res
|
||||
raise ValueError("Must be a list or dictionary")
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
@@ -352,13 +496,88 @@ class Worker(BaseModel):
|
||||
self.proportion_rota_to_work = days_to_work / Rota.rota_days_length
|
||||
self.days_to_work = days_to_work
|
||||
|
||||
# Shift-specific start/end dates and adjusted FTEs
|
||||
self.calculated_shift_start_dates = {}
|
||||
self.calculated_shift_end_dates = {}
|
||||
self.proportion_rota_to_work_shifts = {}
|
||||
|
||||
# Convert list of ShiftStartDate/ShiftEndDate models/dicts to dictionary mapping for quick lookup
|
||||
start_dates_dict = {item.shift: item.start_date for item in getattr(self, "shift_start_dates", []) if hasattr(item, "shift")}
|
||||
end_dates_dict = {item.shift: item.end_date for item in getattr(self, "shift_end_dates", []) if hasattr(item, "shift")}
|
||||
|
||||
for shift in Rota.get_shifts():
|
||||
s_date = start_dates_dict.get(shift.name, self.start_date)
|
||||
if s_date is None:
|
||||
calc_s = self.calculated_start_date
|
||||
else:
|
||||
if isinstance(s_date, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
s_date = datetime.datetime.strptime(s_date, fmt).date()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
calc_s = s_date
|
||||
if calc_s < Rota.start_date:
|
||||
calc_s = Rota.start_date
|
||||
elif calc_s > Rota.rota_end_date:
|
||||
calc_s = Rota.rota_end_date
|
||||
|
||||
e_date = end_dates_dict.get(shift.name, self.end_date)
|
||||
if e_date is None:
|
||||
calc_e = self.calculated_end_date
|
||||
else:
|
||||
if isinstance(e_date, str):
|
||||
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
|
||||
try:
|
||||
e_date = datetime.datetime.strptime(e_date, fmt).date()
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
calc_e = e_date
|
||||
if calc_e > Rota.rota_end_date:
|
||||
calc_e = Rota.rota_end_date
|
||||
elif calc_e < Rota.start_date:
|
||||
calc_e = Rota.start_date
|
||||
|
||||
if calc_s >= calc_e:
|
||||
calc_s = Rota.rota_end_date
|
||||
calc_e = Rota.rota_end_date
|
||||
|
||||
self.calculated_shift_start_dates[shift.name] = calc_s
|
||||
self.calculated_shift_end_dates[shift.name] = calc_e
|
||||
|
||||
days_active = (calc_e - calc_s).days
|
||||
|
||||
# Subtract overlapping OOP days from the active period of this shift
|
||||
for item in self.oop:
|
||||
start_oop = item.start_date
|
||||
end_oop = item.end_date
|
||||
if isinstance(start_oop, datetime.date):
|
||||
start_oop_date = start_oop
|
||||
else:
|
||||
start_oop_date = datetime.datetime.strptime(start_oop, "%d/%m/%y").date()
|
||||
|
||||
if isinstance(end_oop, datetime.date):
|
||||
end_oop_date = end_oop
|
||||
else:
|
||||
end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date()
|
||||
|
||||
overlap_start = max(calc_s, start_oop_date)
|
||||
overlap_end = min(calc_e, end_oop_date)
|
||||
if overlap_start < overlap_end:
|
||||
days_active -= (overlap_end - overlap_start).days
|
||||
|
||||
self.proportion_rota_to_work_shifts[shift.name] = max(0.0, days_active / Rota.rota_days_length)
|
||||
|
||||
# We have to adjust the full time equivalent for people who CCT / leave the rota early
|
||||
self.fte_adj = self.fte * self.proportion_rota_to_work
|
||||
|
||||
self.fte_adj_shifts = {}
|
||||
if self.shift_fte_overrides:
|
||||
for shift, fte in self.shift_fte_overrides.items():
|
||||
self.fte_adj_shifts[shift] = fte * self.proportion_rota_to_work
|
||||
for shift in Rota.get_shifts():
|
||||
prop = self.proportion_rota_to_work_shifts.get(shift.name, self.proportion_rota_to_work)
|
||||
base_fte = self.shift_fte_overrides.get(shift.name, self.fte)
|
||||
self.fte_adj_shifts[shift.name] = base_fte * prop
|
||||
|
||||
|
||||
if self.fte_adj > 100:
|
||||
@@ -395,6 +614,23 @@ class Worker(BaseModel):
|
||||
style="red",
|
||||
)
|
||||
|
||||
# Register any per-worker avoid-shift-on-dates rules with the rota builder
|
||||
try:
|
||||
for av in getattr(self, "avoid_shifts_on_dates", []):
|
||||
entry = {
|
||||
"names": [self.name],
|
||||
"shifts": av.shifts,
|
||||
"start_date": av.start_date,
|
||||
"end_date": av.end_date,
|
||||
"dates": av.dates,
|
||||
"reason": getattr(av, "reason", None),
|
||||
}
|
||||
# append to rota-level list; the model builder will enforce these
|
||||
if hasattr(Rota.constraint_options_model, "avoid_shifts_by_worker_dates"):
|
||||
Rota.constraint_options_model.avoid_shifts_by_worker_dates.append(entry)
|
||||
except Exception:
|
||||
Rota.add_warning("Worker/avoid_shifts_on_dates", f"Failed to register avoid_shifts_on_dates for {self.name}")
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return (self.site, self.grade, self.fte_adj, self.name) < (
|
||||
other.site,
|
||||
@@ -474,3 +710,7 @@ class Worker(BaseModel):
|
||||
def force_assign_shift_by_date(self, date: datetime.date, shift_name: str):
|
||||
"""Force this worker to be assigned to a shift on a specific date."""
|
||||
self.forced_assignments_by_date.append((date, shift_name))
|
||||
|
||||
def add_max_days_per_week_block_constraint(self, max_days: int, week_block: int, ignore_dates: Optional[List[datetime.date]] = None):
|
||||
"""Add a constraint to limit the number of days worked in any sliding block of weeks."""
|
||||
self.max_days_per_week_block.append(MaxDaysPerWeekBlockConstraint(max_days=max_days, week_block=week_block, ignore_dates=ignore_dates or []))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import datetime
|
||||
from rota_generator.shifts import RotaBuilder, SingleShift, days
|
||||
from rota_generator.workers import Worker, AvoidShiftOnDates
|
||||
|
||||
|
||||
def build_simple_rota():
|
||||
start = datetime.date(2026, 4, 6) # Monday
|
||||
Rota = RotaBuilder(start, weeks_to_rota=4, name="test_rota")
|
||||
Rota.add_shifts(
|
||||
SingleShift(sites=("exeter",), name="night_weekday", length=12.25, days=days[:4], workers_required=1),
|
||||
SingleShift(sites=("exeter",), name="plymouth_twilight", length=8, days=days[5:], workers_required=1),
|
||||
)
|
||||
Rota.build_shifts()
|
||||
return Rota
|
||||
|
||||
|
||||
def test_avoid_single_date_for_worker():
|
||||
Rota = build_simple_rota()
|
||||
# create a worker who should avoid night_weekday on 2026-04-07
|
||||
avoid = AvoidShiftOnDates(shifts=["night_weekday"], dates=[datetime.date(2026, 4, 7)])
|
||||
w = Worker(name="Test Worker", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||
Rota.add_worker(w)
|
||||
Rota.build_workers()
|
||||
|
||||
# find week/day corresponding to 2026-04-07
|
||||
weeks_days = [(wk, d) for wk, d in Rota.weeks_days_product if Rota.week_day_date_map[(wk, d)] == datetime.date(2026, 4, 7)]
|
||||
assert len(weeks_days) == 1
|
||||
wk, day = weeks_days[0]
|
||||
|
||||
# The model should mark works[worker.id, wk, day, 'night_weekday'] impossible -> check unavailable_to_work
|
||||
assert (w.id, wk, day) not in Rota.unavailable_to_work
|
||||
|
||||
# Build and solve minimal model (but we can inspect constraints). Instead, check that the avoid rule was registered
|
||||
found = False
|
||||
for c in Rota.constraint_options_model.avoid_shifts_by_worker_dates:
|
||||
if "names" in c and w.name in c["names"]:
|
||||
found = True
|
||||
# ensure the date is represented
|
||||
assert c.get("dates") is not None and datetime.date(2026,4,7) in c.get("dates")
|
||||
assert found
|
||||
|
||||
|
||||
def test_avoid_date_range_for_worker_applies_to_multiple_days():
|
||||
Rota = build_simple_rota()
|
||||
# Avoid night_weekday for 2026-04-06 through 2026-04-09
|
||||
avoid = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="09/04/2026")
|
||||
w = Worker(name="Range Worker", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||
Rota.add_worker(w)
|
||||
Rota.build_workers()
|
||||
|
||||
# Collect dates between start and end
|
||||
sd = datetime.date(2026,4,6)
|
||||
ed = datetime.date(2026,4,9)
|
||||
dates_in_range = [d for d in Rota.get_date_range(sd, ed)]
|
||||
assert len(dates_in_range) == 4
|
||||
|
||||
# Ensure the rota-level constraint was added
|
||||
found = False
|
||||
for c in Rota.constraint_options_model.avoid_shifts_by_worker_dates:
|
||||
if w.name in c.get("names", []):
|
||||
found = True
|
||||
assert c.get("start_date") == sd
|
||||
assert c.get("end_date") == ed
|
||||
assert found
|
||||
|
||||
|
||||
def test_export_rota_for_visual_inspection():
|
||||
Rota = build_simple_rota()
|
||||
# add two workers
|
||||
avoid = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="26/04/2026")
|
||||
w1 = Worker(name="Export A", site="exeter", grade=3, fte=100, avoid_shifts_on_dates=[avoid])
|
||||
w2 = Worker(name="Export B", site="exeter", grade=3, fte=100)
|
||||
|
||||
|
||||
|
||||
Rota.add_workers((w1, w2))
|
||||
|
||||
# Build model but do not solve, then export
|
||||
Rota.name = "export_test_rota"
|
||||
Rota.build_and_solve(solve=True)
|
||||
|
||||
assert Rota.results.solver.status == "ok", "Rota should be feasible"
|
||||
|
||||
# Next we check that worker w1 has no night_weekday shifts assigned between 06/04/2026 and 26/04/2026
|
||||
sd = datetime.date(2026,4,6)
|
||||
ed = datetime.date(2026,4,26)
|
||||
for single_date in Rota.get_date_range(sd, ed):
|
||||
wk_day = Rota.get_week_day_by_date(single_date)
|
||||
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w1.id, wk_day[0], wk_day[1])
|
||||
for sh in assigned_shifts:
|
||||
assert sh.name != "night_weekday", f"Worker {w1.name} was assigned forbidden shift on {single_date}"
|
||||
|
||||
# We also check that w1 has 4 night shifts in total (the max possible in 4 weeks)
|
||||
night_shift_count = 0
|
||||
for wk, day in Rota.weeks_days_product:
|
||||
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w1.id, wk, day)
|
||||
for sh in assigned_shifts:
|
||||
if sh.name == "night_weekday":
|
||||
night_shift_count += 1
|
||||
assert night_shift_count == 4, f"Worker {w1.name} should have 4 night shifts, has {night_shift_count}"
|
||||
|
||||
# And that w2 has 12 night shifts (no restrictions)
|
||||
night_shift_count_w2 = 0
|
||||
for wk, day in Rota.weeks_days_product:
|
||||
assigned_shifts = Rota.get_assigned_shifts_for_worker_on_day(w2.id, wk, day)
|
||||
for sh in assigned_shifts:
|
||||
if sh.name == "night_weekday":
|
||||
night_shift_count_w2 += 1
|
||||
assert night_shift_count_w2 == 12, f"Worker {w2.name} should have 12 night shifts, has {night_shift_count_w2}"
|
||||
|
||||
# Next check that adding a short avoid to w2 results in an infeasible model
|
||||
avoid2 = AvoidShiftOnDates(shifts=["night_weekday"], start_date="06/04/2026", end_date="07/04/2026")
|
||||
w2.avoid_shifts_on_dates.append(avoid2)
|
||||
Rota.build_and_solve(solve=True)
|
||||
|
||||
assert Rota.results.solver.termination_condition == "infeasible", "Rota should be infeasible due to avoid shifts on dates constraints"
|
||||
|
||||
|
||||
Rota.export_rota_to_html("avoid_shifts_on_dates_test.html", folder="tests")
|
||||
|
||||
+59
-2
@@ -163,7 +163,7 @@ def test_nwd_force_as_block_force_split():
|
||||
for worker in Rota.workers:
|
||||
shifts = Rota.get_worker_shift_list(worker)
|
||||
shifts_string = "".join([i if i != "" else "-" for i in shifts])
|
||||
for week in weeks_from_list(shifts_string[7 * 5 :]):
|
||||
for week in weeks_from_list(shifts_string[7 * 6 :]):
|
||||
assert week in ("-----ww", "dddddww")
|
||||
if worker.name == "worker1":
|
||||
assert shifts_string[: 7 * 5].count("ww-") == 4
|
||||
@@ -213,4 +213,61 @@ def test_nwd_testing():
|
||||
if worker.name == "worker1":
|
||||
assert week == "dddddww"
|
||||
else:
|
||||
assert week == "-----ww"
|
||||
assert week == "-----ww"
|
||||
|
||||
|
||||
def test_force_as_block_unless_nwd_with_subblocks():
|
||||
# Test that a Fri/Sat/Sun shift can be split into Fri and Sat/Sun blocks
|
||||
# if a worker does not work on Fridays.
|
||||
Rota = setup_rota(weeks_to_rota=2)
|
||||
start_date = Rota.start_date
|
||||
|
||||
# worker1 does not work on Fridays (Fri is NWD)
|
||||
worker1 = Worker(
|
||||
name="worker1", site="group1", grade=1,
|
||||
nwds=[{"day": "Fri", "start_date": start_date, "end_date": start_date + datetime.timedelta(weeks=2)}],
|
||||
)
|
||||
# worker2 works normally
|
||||
worker2 = Worker(name="worker2", site="group1", grade=1)
|
||||
|
||||
Rota.add_workers((worker1, worker2))
|
||||
|
||||
# Fri/Sat/Sun shift, requiring 1 worker, forced as block unless NWD
|
||||
# We specify sub-blocks: [["Fri"], ["Sat", "Sun"]]
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",), name="weekend", length=12.5,
|
||||
days=["Fri", "Sat", "Sun"],
|
||||
workers_required=1,
|
||||
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
|
||||
),
|
||||
)
|
||||
|
||||
# We remove no valid shifts warning
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
|
||||
Rota.build_and_solve()
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
# Check assignments:
|
||||
# Since worker1 has NWD on Friday:
|
||||
# - worker1 cannot work Friday.
|
||||
# - But worker1 can work Sat and Sun as a block!
|
||||
# So on Saturday and Sunday, worker1 should be assigned.
|
||||
# On Friday, worker2 should be assigned.
|
||||
for week in Rota.weeks:
|
||||
w1_fri = Rota.model.works[worker1.id, week, "Fri", "weekend"].value
|
||||
w1_sat = Rota.model.works[worker1.id, week, "Sat", "weekend"].value
|
||||
w1_sun = Rota.model.works[worker1.id, week, "Sun", "weekend"].value
|
||||
|
||||
w2_fri = Rota.model.works[worker2.id, week, "Fri", "weekend"].value
|
||||
w2_sat = Rota.model.works[worker2.id, week, "Sat", "weekend"].value
|
||||
w2_sun = Rota.model.works[worker2.id, week, "Sun", "weekend"].value
|
||||
|
||||
assert w1_fri == 0
|
||||
|
||||
# Either worker1 works Sat/Sun and worker2 works Fri (split block)
|
||||
# OR worker2 works Fri/Sat/Sun (full block) and worker1 works nothing
|
||||
is_split = (w1_sat == 1 and w1_sun == 1 and w2_fri == 1 and w2_sat == 0 and w2_sun == 0)
|
||||
is_full_w2 = (w1_sat == 0 and w1_sun == 0 and w2_fri == 1 and w2_sat == 1 and w2_sun == 1)
|
||||
assert is_split or is_full_w2
|
||||
+168
-1
@@ -1,11 +1,19 @@
|
||||
import datetime
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from rota_generator.shifts import (
|
||||
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
|
||||
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
|
||||
WarningTermination, days, WorkerRequirement
|
||||
)
|
||||
from rota_generator.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
|
||||
from rota_generator.workers import (
|
||||
MaxDaysPerWeekBlockConstraint,
|
||||
MaxUniqueShiftsPerWeekBlockConstraint,
|
||||
NotAvailableToWork,
|
||||
WorkRequests,
|
||||
Worker,
|
||||
generate_not_available_to_works,
|
||||
)
|
||||
|
||||
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
|
||||
Rota = RotaBuilder(
|
||||
@@ -89,6 +97,165 @@ def test_max_shifts_per_week_block_shift_number_fail():
|
||||
Rota.export_rota_to_html("max_shifts_per_week_block")
|
||||
assert Rota.results.solver.status in ("warning", "error")
|
||||
|
||||
|
||||
def test_worker_max_days_per_week_block_fail():
|
||||
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||
Rota.add_worker(
|
||||
Worker(
|
||||
name="worker1",
|
||||
site="group1",
|
||||
grade=1,
|
||||
max_days_per_week_block=[
|
||||
MaxDaysPerWeekBlockConstraint(max_days=1, week_block=1)
|
||||
],
|
||||
)
|
||||
)
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=days[:3],
|
||||
workers_required=1,
|
||||
)
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.termination_condition == "infeasible"
|
||||
|
||||
|
||||
def test_worker_max_unique_shifts_per_week_block_fail():
|
||||
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
|
||||
Rota.add_worker(
|
||||
Worker(
|
||||
name="worker1",
|
||||
site="group1",
|
||||
grade=1,
|
||||
max_unique_shifts_per_week_block=[
|
||||
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=1, week_block=1)
|
||||
],
|
||||
)
|
||||
)
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=["Mon"],
|
||||
workers_required=1,
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="b",
|
||||
length=12.5,
|
||||
days=["Tue"],
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.termination_condition == "infeasible"
|
||||
|
||||
|
||||
def test_worker_block_constraint_validation():
|
||||
with pytest.raises(ValidationError):
|
||||
MaxDaysPerWeekBlockConstraint(max_days=0, week_block=1)
|
||||
with pytest.raises(ValidationError):
|
||||
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
|
||||
|
||||
|
||||
def test_worker_max_days_per_week_block_pass():
|
||||
"""Positive test: constraint allowing reasonable days."""
|
||||
Rota = generate_basic_rota()
|
||||
# Add constraint allowing up to 4 days per week
|
||||
Rota.workers[0].max_days_per_week_block = [
|
||||
MaxDaysPerWeekBlockConstraint(max_days=4, week_block=1)
|
||||
]
|
||||
# Add shifts to the rota
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1", "group2"),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=days,
|
||||
)
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.status == "ok"
|
||||
assert Rota.results.solver.termination_condition == "optimal"
|
||||
|
||||
|
||||
def test_worker_max_unique_shifts_per_week_block_pass():
|
||||
"""Positive test: allowing multiple shifts within week block."""
|
||||
Rota = generate_basic_rota()
|
||||
# Add constraint allowing up to 2 unique shifts per week
|
||||
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=2, week_block=1)
|
||||
]
|
||||
# Add shifts to the rota
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1", "group2"),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=days,
|
||||
)
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.status == "ok"
|
||||
assert Rota.results.solver.termination_condition == "optimal"
|
||||
|
||||
|
||||
def test_worker_max_days_per_week_block_with_ignore_dates():
|
||||
"""Test max_days_per_week_block with ignored dates works without error."""
|
||||
Rota = generate_basic_rota()
|
||||
rota_start = Rota.start_date
|
||||
ignored_date = rota_start # Ignore first Monday
|
||||
# Add constraint with ignore_dates
|
||||
Rota.workers[0].max_days_per_week_block = [
|
||||
MaxDaysPerWeekBlockConstraint(
|
||||
max_days=3,
|
||||
week_block=1,
|
||||
ignore_dates=[ignored_date],
|
||||
)
|
||||
]
|
||||
# Add shifts to the rota
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1", "group2"),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=days,
|
||||
)
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
|
||||
def test_worker_max_unique_shifts_per_week_block_with_ignore_dates():
|
||||
"""Test max_unique_shifts_per_week_block with ignored dates works without error."""
|
||||
Rota = generate_basic_rota()
|
||||
rota_start = Rota.start_date
|
||||
ignored_date = rota_start + datetime.timedelta(days=1) # Ignore Tuesday
|
||||
# Add constraint with ignore_dates
|
||||
Rota.workers[0].max_unique_shifts_per_week_block = [
|
||||
MaxUniqueShiftsPerWeekBlockConstraint(
|
||||
max_unique_shifts=1,
|
||||
week_block=1,
|
||||
ignore_dates=[ignored_date],
|
||||
)
|
||||
]
|
||||
# Add shifts to the rota
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1", "group2"),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=days,
|
||||
)
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.000})
|
||||
# This should still be feasible with only one shift type in main rota
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
def test_pre_shift_constraint():
|
||||
Rota = generate_basic_rota()
|
||||
for i, d in enumerate(days[:6]):
|
||||
|
||||
+508
-2
@@ -1,8 +1,9 @@
|
||||
import pytest
|
||||
from rota_generator.shifts import InvalidShift, MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
|
||||
from pydantic import ValidationError
|
||||
|
||||
import datetime
|
||||
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
|
||||
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works, ShiftStartDate, ShiftEndDate
|
||||
from rota_generator.workers import WorkRequests
|
||||
from rota_generator.shifts import PreShiftConstraint
|
||||
|
||||
@@ -1624,4 +1625,509 @@ def test_worker_hard_day_exclusion2():
|
||||
for week in range(16):
|
||||
sat_idx = week * 7 + days.index("Sat")
|
||||
sun_idx = week * 7 + days.index("Sun")
|
||||
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
|
||||
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
|
||||
|
||||
|
||||
def test_exact_shifts_distribution():
|
||||
weeks_to_rota = 10
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
|
||||
workers = [
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||
Worker(name="worker2", site="group1", grade=1, fte=100),
|
||||
Worker(name="worker3", site="group1", grade=1, fte=50),
|
||||
]
|
||||
Rota.add_workers(workers)
|
||||
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
hard_constrain_shift=True,
|
||||
balance_offset=0,
|
||||
),
|
||||
)
|
||||
|
||||
Rota.build_and_solve()
|
||||
|
||||
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
|
||||
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 4
|
||||
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 2
|
||||
|
||||
# Verify actual assigned counts are exactly correct
|
||||
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
|
||||
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||
for worker in Rota.get_workers():
|
||||
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||
if val is not None and val > 0.5:
|
||||
if shiftname == "a":
|
||||
worker_shift_counts[worker.name] += 1
|
||||
|
||||
assert worker_shift_counts["worker1"] == 4
|
||||
assert worker_shift_counts["worker2"] == 4
|
||||
assert worker_shift_counts["worker3"] == 2
|
||||
|
||||
|
||||
def test_exact_shifts_distribution_unbalanced():
|
||||
weeks_to_rota = 16
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
|
||||
workers = [
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||
Worker(name="worker2", site="group1", grade=1, fte=100),
|
||||
Worker(name="worker3", site="group1", grade=1, fte=50),
|
||||
]
|
||||
Rota.add_workers(workers)
|
||||
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
hard_constrain_shift=True,
|
||||
balance_offset=0,
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="b",
|
||||
length=12.5,
|
||||
days=("Tue",),
|
||||
workers_required=1,
|
||||
hard_constrain_shift=True,
|
||||
balance_offset=0.8,
|
||||
),
|
||||
)
|
||||
|
||||
Rota.build_and_solve()
|
||||
Rota.export_rota_to_html("test_exact_shifts_distribution_unbalanced", folder="tests")
|
||||
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
|
||||
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 8
|
||||
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 4
|
||||
|
||||
assert Rota.workers_name_map["worker1"].shift_target_number["b"] == 6.4
|
||||
assert Rota.workers_name_map["worker2"].shift_target_number["b"] == 6.4
|
||||
assert Rota.workers_name_map["worker3"].shift_target_number["b"] == 3.2
|
||||
|
||||
# Verify actual assigned counts are exactly correct
|
||||
worker_shift_counts_a = {w.name: 0 for w in Rota.get_workers()}
|
||||
worker_shift_counts_b = {w.name: 0 for w in Rota.get_workers()}
|
||||
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||
for worker in Rota.get_workers():
|
||||
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||
if val is not None and val > 0.5:
|
||||
if shiftname == "a":
|
||||
worker_shift_counts_a[worker.name] += 1
|
||||
elif shiftname == "b":
|
||||
worker_shift_counts_b[worker.name] += 1
|
||||
|
||||
assert worker_shift_counts_a["worker1"] == 4
|
||||
assert worker_shift_counts_a["worker2"] == 8
|
||||
assert worker_shift_counts_a["worker3"] == 4
|
||||
|
||||
assert worker_shift_counts_b["worker1"] in (6, 7) # Allowing for rounding differences
|
||||
assert worker_shift_counts_b["worker2"] in (6, 7) # Allowing for rounding differences
|
||||
assert worker_shift_counts_b["worker3"] in (3, 4) # Allowing for rounding differences
|
||||
|
||||
|
||||
def test_exact_shifts_invalid_site_warning():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(name="worker1", site="group2", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||
])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
SingleShift(
|
||||
sites=("group2",),
|
||||
name="b",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_workers()
|
||||
|
||||
warnings = Rota.get_warnings("Invalid exact shift site")
|
||||
assert len(warnings) > 0
|
||||
assert "group2 is not in the shift sites" in warnings[0][1]
|
||||
|
||||
|
||||
def test_exact_shifts_invalid_shift_warning():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"nonexistent": 4}),
|
||||
])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_workers()
|
||||
|
||||
warnings = Rota.get_warnings("Invalid exact shift")
|
||||
assert len(warnings) > 0
|
||||
assert "non-existent shift nonexistent" in warnings[0][1]
|
||||
|
||||
|
||||
def test_exact_shifts_exceed_total_warning():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
|
||||
])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_workers()
|
||||
Rota.build_model()
|
||||
|
||||
warnings = Rota.get_warnings("Exact shifts exceed total shifts")
|
||||
assert len(warnings) > 0
|
||||
assert "total exact shifts (4) exceeds total required shifts" in warnings[0][1]
|
||||
|
||||
|
||||
def test_html_export_worker_details_and_unavailable_reason():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(
|
||||
name="worker1",
|
||||
site="group1",
|
||||
grade=1,
|
||||
fte=100,
|
||||
exact_shifts={"a": 1},
|
||||
start_date=datetime.date(2022, 3, 14)
|
||||
),
|
||||
])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_workers()
|
||||
Rota.build_model()
|
||||
|
||||
html = Rota.get_worker_timetable_html()
|
||||
|
||||
assert 'data-exact-shifts=\'{"a": 1}\'' in html
|
||||
assert 'title=\' (2022-03-07) / START DATE: 2022-03-14\'' in html
|
||||
|
||||
|
||||
def test_worker_groups_selector():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100, groups={"groupA", "groupB"}),
|
||||
Worker(name="worker2", site="group1", grade=1, fte=100, groups={"groupB", "groupC"}),
|
||||
])
|
||||
|
||||
workers_A = Rota.get_workers_in_group("groupA")
|
||||
workers_B = Rota.get_workers_in_group("groupB")
|
||||
workers_C = Rota.get_workers_in_group("groupC")
|
||||
workers_D = Rota.get_workers_in_group("groupD")
|
||||
|
||||
assert [w.name for w in workers_A] == ["worker1"]
|
||||
assert set(w.name for w in workers_B) == {"worker1", "worker2"}
|
||||
assert [w.name for w in workers_C] == ["worker2"]
|
||||
assert workers_D == []
|
||||
|
||||
|
||||
def test_shift_groups_overlapping_validation():
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
include_groups={"pool1", "pool2"},
|
||||
exclude_groups={"pool2", "pool3"},
|
||||
)
|
||||
assert "include_groups and exclude_groups cannot overlap" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_shift_group_availability_inclusion():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100),
|
||||
Worker(name="worker2", site="group2", grade=1, fte=100, groups={"senior"}),
|
||||
Worker(name="worker3", site="group2", grade=1, fte=100, groups={"junior"}),
|
||||
])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
include_groups={"senior"},
|
||||
),
|
||||
)
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
Rota.build_and_solve()
|
||||
Rota.export_rota_to_html("test_shift_group_availability_inclusion", folder="tests")
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
|
||||
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||
for worker in Rota.get_workers():
|
||||
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||
if val is not None and val > 0.5:
|
||||
worker_shift_counts[worker.name] += 1
|
||||
|
||||
assert worker_shift_counts["worker1"] in (1, 2)
|
||||
assert worker_shift_counts["worker2"] in (1, 2)
|
||||
assert worker_shift_counts["worker3"] == 0
|
||||
|
||||
|
||||
def test_shift_group_availability_exclusion():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
Rota.add_workers([
|
||||
Worker(name="worker1", site="group1", grade=1, fte=100, groups={"senior"}),
|
||||
Worker(name="worker2", site="group1", grade=1, fte=100, groups={"junior"}),
|
||||
])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
exclude_groups={"junior"},
|
||||
),
|
||||
)
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
Rota.build_and_solve()
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
|
||||
for week, day, shiftname in Rota.get_all_shiftname_combinations():
|
||||
for worker in Rota.get_workers():
|
||||
val = Rota.model.works[worker.id, week, day, shiftname].value
|
||||
if val is not None and val > 0.5:
|
||||
worker_shift_counts[worker.name] += 1
|
||||
|
||||
assert worker_shift_counts["worker1"] == 2
|
||||
assert worker_shift_counts["worker2"] == 0
|
||||
|
||||
|
||||
def test_shift_dependent_active_dates_constraints():
|
||||
weeks_to_rota = 10
|
||||
start_date = datetime.date(2022, 3, 7) # Mon
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1, fte=100)
|
||||
worker2 = Worker(
|
||||
name="worker2", site="group1", grade=1, fte=100,
|
||||
shift_start_dates={"a": datetime.date(2022, 3, 28)},
|
||||
shift_end_dates={"a": datetime.date(2022, 4, 25)},
|
||||
)
|
||||
Rota.add_workers([worker1, worker2])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.0})
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
# Verify assignments for worker2
|
||||
for week in range(1, weeks_to_rota + 1):
|
||||
val = Rota.model.works[worker2.id, week, "Mon", "a"].value
|
||||
assigned = val is not None and val > 0.5
|
||||
if week < 4 or week >= 8:
|
||||
assert not assigned, f"Worker 2 should not be assigned 'a' in week {week}"
|
||||
|
||||
|
||||
def test_shift_dependent_fte_target_scaling():
|
||||
weeks_to_rota = 10
|
||||
start_date = datetime.date(2022, 3, 7) # Mon
|
||||
|
||||
Rota = RotaBuilder(
|
||||
start_date,
|
||||
weeks_to_rota=weeks_to_rota,
|
||||
)
|
||||
worker1 = Worker(name="worker1", site="group1", grade=1, fte=100)
|
||||
worker2 = Worker(
|
||||
name="worker2", site="group1", grade=1, fte=100,
|
||||
shift_start_dates={"a": datetime.date(2022, 3, 28)},
|
||||
shift_end_dates={"a": datetime.date(2022, 4, 25)},
|
||||
)
|
||||
Rota.add_workers([worker1, worker2])
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
Rota.build_and_solve(options={"ratio": 0.0})
|
||||
assert Rota.results.solver.status == "ok"
|
||||
|
||||
# Targets check
|
||||
assert abs(worker1.shift_target_number["a"] - 7.14) < 0.1
|
||||
assert abs(worker2.shift_target_number["a"] - 2.86) < 0.1
|
||||
|
||||
|
||||
def test_shift_dependent_dates_validation_invalid_shift():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
|
||||
worker = Worker(
|
||||
name="worker1", site="group1", grade=1, fte=100,
|
||||
shift_start_dates=[ShiftStartDate(shift="non_existent", start_date=datetime.date(2022, 3, 7))]
|
||||
)
|
||||
Rota.add_worker(worker)
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group1",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
)
|
||||
)
|
||||
with pytest.raises(InvalidShift):
|
||||
Rota.build_workers()
|
||||
|
||||
|
||||
def test_shift_dependent_dates_validation_ineligible_warning():
|
||||
weeks_to_rota = 2
|
||||
start_date = datetime.date(2022, 3, 7)
|
||||
|
||||
Rota = RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
|
||||
worker = Worker(
|
||||
name="worker1", site="group1", grade=1, fte=100,
|
||||
shift_start_dates=[ShiftStartDate(shift="a", start_date=datetime.date(2022, 3, 7))]
|
||||
)
|
||||
Rota.add_worker(worker)
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("group2",),
|
||||
name="a",
|
||||
length=12.5,
|
||||
days=("Mon",),
|
||||
workers_required=1,
|
||||
)
|
||||
)
|
||||
Rota.terminate_on_warning.remove("Worker/no valid shifts")
|
||||
Rota.build_workers()
|
||||
|
||||
warnings = Rota.get_warnings("Worker/ineligible shift date constraint")
|
||||
assert len(warnings) == 1
|
||||
assert "is not eligible to work it" in warnings[0][1]
|
||||
|
||||
|
||||
def test_shift_dependent_dates_model_definition():
|
||||
worker = Worker(
|
||||
name="worker1", site="group1", grade=1, fte=100,
|
||||
shift_start_dates=[{"shift": "a", "start_date": "2022-03-07"}],
|
||||
shift_end_dates=[{"shift": "a", "end_date": "2022-04-07"}]
|
||||
)
|
||||
assert len(worker.shift_start_dates) == 1
|
||||
assert isinstance(worker.shift_start_dates[0], ShiftStartDate)
|
||||
assert worker.shift_start_dates[0].shift == "a"
|
||||
assert worker.shift_start_dates[0].start_date == datetime.date(2022, 3, 7)
|
||||
|
||||
assert len(worker.shift_end_dates) == 1
|
||||
assert isinstance(worker.shift_end_dates[0], ShiftEndDate)
|
||||
assert worker.shift_end_dates[0].shift == "a"
|
||||
assert worker.shift_end_dates[0].end_date == datetime.date(2022, 4, 7)
|
||||
|
||||
|
||||
def test_parse_time_limit():
|
||||
from gen_proc import parse_time_limit
|
||||
assert parse_time_limit(3600) == 3600
|
||||
assert parse_time_limit("3600") == 3600
|
||||
assert parse_time_limit("10h") == 36000
|
||||
assert parse_time_limit("30m") == 1800
|
||||
assert parse_time_limit("45s") == 45
|
||||
assert parse_time_limit(" 1.5h ") == 5400
|
||||
with pytest.raises(ValueError):
|
||||
parse_time_limit("invalid")
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to generate a small rota for testing ICS export functionality.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from rota_generator.shifts import RotaBuilder, SingleShift, WorkerRequirement, days, NightConstraint, PreShiftConstraint
|
||||
from rota_generator.workers import Worker, NotAvailableToWork, NonWorkingDays
|
||||
|
||||
def create_test_rota():
|
||||
"""Create a small test rota with a few shifts and workers."""
|
||||
|
||||
# Define sites
|
||||
sites = ("truro", "exeter", "plymouth")
|
||||
|
||||
# Create rota builder
|
||||
rota_start_date = datetime.date(2025, 12, 29) # Start from today-ish
|
||||
Rota = RotaBuilder(
|
||||
rota_start_date,
|
||||
weeks_to_rota=4, # Small rota for testing
|
||||
balance_offset_modifier=2,
|
||||
name="test_rota",
|
||||
)
|
||||
|
||||
# Add some shifts
|
||||
Rota.add_shifts(
|
||||
SingleShift(
|
||||
sites=("truro", "exeter", "plymouth"),
|
||||
name="night_weekday",
|
||||
length=12.25,
|
||||
days=days[:5], # Mon-Fri
|
||||
balance_offset=3.9,
|
||||
workers_required=1,
|
||||
force_as_block=True,
|
||||
constraints=[NightConstraint(), PreShiftConstraint(days=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("truro", "exeter", "plymouth"),
|
||||
name="weekend_truro",
|
||||
length=12.5,
|
||||
days=days[5:], # Sat-Sun
|
||||
balance_offset=3,
|
||||
workers_required=1,
|
||||
force_as_block=True,
|
||||
constraints=[PreShiftConstraint(days=1)],
|
||||
),
|
||||
SingleShift(
|
||||
sites=("plymouth",),
|
||||
name="plymouth_twilight",
|
||||
length=12.5,
|
||||
days=days[:5],
|
||||
balance_offset=4,
|
||||
workers_required=1,
|
||||
),
|
||||
)
|
||||
|
||||
# Add some test workers
|
||||
workers = [
|
||||
Worker(
|
||||
name="Alice",
|
||||
site="truro",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="AL",
|
||||
),
|
||||
Worker(
|
||||
name="Bob",
|
||||
site="exeter",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="BO",
|
||||
),
|
||||
Worker(
|
||||
name="Charlie",
|
||||
site="plymouth",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="CH",
|
||||
),
|
||||
Worker(
|
||||
name="Chester",
|
||||
site="plymouth",
|
||||
fte=100, # 1.0 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=1,
|
||||
initials="CH",
|
||||
),
|
||||
Worker(
|
||||
name="Dino",
|
||||
site="truro",
|
||||
fte=100, # 0.8 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=2,
|
||||
initials="DI",
|
||||
),
|
||||
Worker(
|
||||
name="Diana",
|
||||
site="truro",
|
||||
fte=80, # 0.8 FTE
|
||||
start_date=rota_start_date,
|
||||
end_date=None,
|
||||
grade=2,
|
||||
initials="DI",
|
||||
),
|
||||
]
|
||||
|
||||
Rota.add_workers(workers)
|
||||
|
||||
# Build and solve
|
||||
Rota.build_and_solve(options={"ratio": 0.1, "seconds": 30, "threads": 10})
|
||||
|
||||
return Rota
|
||||
|
||||
def generate_html_output(rota, output_dir="output/test"):
|
||||
"""Generate HTML output for the rota."""
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Use the built-in export method
|
||||
rota.export_rota_to_html(filename="index")
|
||||
|
||||
html_path = os.path.join("output", "test", "index.html")
|
||||
|
||||
print(f"Test rota HTML generated at: {html_path}")
|
||||
print("Open this file in a web browser to test the ICS export functionality.")
|
||||
|
||||
return html_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Generating test rota...")
|
||||
|
||||
try:
|
||||
rota = create_test_rota()
|
||||
html_path = generate_html_output(rota)
|
||||
|
||||
print("\nTest rota generation complete!")
|
||||
print(f"To test ICS export, open: file://{os.path.abspath(html_path)}")
|
||||
print("Then use the 'Set Shift Times' button to configure times and download ICS files.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating test rota: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
Reference in New Issue
Block a user