This commit is contained in:
Ross
2025-12-13 22:41:32 +00:00
parent d509922450
commit 249e09766d
10 changed files with 296 additions and 11 deletions
+27
View File
@@ -0,0 +1,27 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Proc Rota{% endblock %}</title>
<link rel="stylesheet" href="{% static 'output/timetable.css' %}">
{% block head %}{% endblock %}
</head>
<body>
<header>
<h1>{% block header %}Proc Rota{% endblock %}</h1>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<small>{% block footer %}© Proc Rota{% endblock %}</small>
</footer>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -15,6 +15,11 @@
{% endif %}
{{ form|crispy }}
{# Token area: shows a copyable self-service link and a regenerate button when editing a worker #}
{% if worker %}
{% include 'rota/partials/worker_token_area.html' with worker=worker token_link=token_link %}
{% endif %}
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
<div class="box" id="nwds-editor">
<h4 class="title is-6">Non-working days</h4>
@@ -0,0 +1,58 @@
<div id="worker-token-area">
{% if worker.request_token %}
<div class="field is-grouped is-align-items-center">
<div class="control is-expanded">
<label class="label">Self-service link</label>
<input class="input" type="text" readonly id="worker-token-link" value="{{ token_link }}" />
</div>
<div class="control">
<button type="button" class="button" id="copy-token">Copy</button>
</div>
<div class="control" id="regenerate-controls">
<button type="button" class="button is-danger" id="regenerate-token-trigger">Regenerate</button>
</div>
</div>
<script>
(function(){
const btn = document.getElementById('copy-token');
if(btn){
btn.addEventListener('click', function(){
const inp = document.getElementById('worker-token-link');
if(!inp) return;
inp.select();
try{ document.execCommand('copy'); }catch(e){ navigator.clipboard && navigator.clipboard.writeText(inp.value); }
});
}
// Confirmation workflow for regenerating token: replace the regenerate
// button with a small confirm UI (Yes / Cancel). The Yes button uses
// HTMX to POST to the regenerate endpoint and swap the token area.
const trigger = document.getElementById('regenerate-token-trigger');
if(trigger){
const controls = document.getElementById('regenerate-controls');
const original = controls.innerHTML;
trigger.addEventListener('click', function(){
const yes = document.createElement('button');
yes.className = 'button is-danger';
yes.setAttribute('hx-post', '{% url "rota:regenerate_worker_token" worker.id %}');
yes.setAttribute('hx-target', '#worker-token-area');
yes.setAttribute('hx-swap', 'innerHTML');
yes.textContent = 'Yes, regenerate';
const cancel = document.createElement('button');
cancel.className = 'button';
cancel.style.marginLeft = '0.5rem';
cancel.textContent = 'Cancel';
cancel.addEventListener('click', function(){ controls.innerHTML = original; });
controls.innerHTML = '';
controls.appendChild(yes);
controls.appendChild(cancel);
});
}
})();
</script>
{% else %}
<div class="notification is-warning">No token generated yet. Save the worker to create one or contact admin.</div>
{% endif %}
</div>
@@ -3,11 +3,24 @@
<head>
<title>{{ worker.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
{% include 'rota/partials/flatpickr_includes.html' %}
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
{% include 'rota/partials/flatpickr_includes.html' %}
<style>
.calendar { display:flex; flex-direction:column; gap:0.5rem; }
.calendar .week { display:flex; gap:0.5rem; }
.calendar .day { flex:1; padding:0.5rem; border:1px solid #eee; min-height:3rem; cursor:pointer; }
.calendar .day.other-month { background:#f9f9f9; color:#999; }
.calendar .day.leave { background:#ffecec; }
</style>
</head>
<body>
<section class="section">
<div class="container">
<div class="box">
<h2 class="subtitle">Request leave on calendar</h2>
<div id="calendar" class="calendar" data-worker-id="{{ worker.id }}"></div>
<p class="help">Click a date to request leave for that day.</p>
</div>
<h1 class="title">{{ worker.name }}</h1>
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
@@ -36,5 +49,69 @@
</div>
</div>
</section>
<script>
(function(){
// Render a simple month calendar for current month; mark existing leaves
const container = document.getElementById('calendar');
if(!container) return;
const workerId = container.dataset.workerId;
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
// Collect leave dates from template variable
const leaves = [
{% for l in worker.leaves.all %}
{start:'{{ l.start_date }}', end:'{{ l.end_date }}'},
{% endfor %}
];
function dateInLeaves(y,m,d){
const s = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
for(const L of leaves){
if(!L.start) continue;
if(L.start <= s && s <= L.end) return true;
}
return false;
}
function renderMonth(y, m){
container.innerHTML='';
const first = new Date(y,m,1);
const startDay = first.getDay(); // 0 Sun..6 Sat
const days = new Date(y, m+1, 0).getDate();
let cur = 1 - startDay; // offset to start week
// header
const header = document.createElement('div'); header.style.display='flex'; header.style.justifyContent='space-between'; header.style.alignItems='center'; header.style.marginBottom='0.5rem';
const title = document.createElement('div'); title.textContent = first.toLocaleString(undefined,{month:'long', year:'numeric'}); header.appendChild(title);
container.appendChild(header);
while(cur <= days){
const week = document.createElement('div'); week.className='week';
for(let i=0;i<7;i++){
const dayDiv = document.createElement('div'); dayDiv.className='day';
const day = cur;
if(day < 1 || day > days){ dayDiv.classList.add('other-month'); dayDiv.textContent=''; }
else{
dayDiv.textContent = day;
const inLeave = dateInLeaves(y,m,day);
if(inLeave) dayDiv.classList.add('leave');
// clicking opens leave request modal prefilled for that date
dayDiv.onclick = function(){
const dateStr = `${y}-${String(m+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
const url = '{% url "rota:request_leave" %}' + '?worker_id=' + workerId + '&date=' + dateStr;
// HTMX GET into #modal
try{ htmx.ajax('GET', url, {target:'#modal', swap:'innerHTML'}); }catch(e){ window.location = url; }
};
}
week.appendChild(dayDiv);
cur++;
}
container.appendChild(week);
}
}
renderMonth(year, month);
})();
</script>
</body>
</html>
@@ -0,0 +1,18 @@
# Generated by assistant for adding nullable request_token
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
("rota", "0004_worker_options"),
]
operations = [
migrations.AddField(
model_name="worker",
name="request_token",
field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
),
]
@@ -0,0 +1,26 @@
from django.db import migrations
import uuid
def generate_tokens(apps, schema_editor):
Worker = apps.get_model('rota', 'Worker')
db_alias = schema_editor.connection.alias
for w in Worker.objects.using(db_alias).all():
if not getattr(w, 'request_token', None):
new_token = uuid.uuid4()
# extremely unlikely collision, but guard anyway
while Worker.objects.using(db_alias).filter(request_token=new_token).exists():
new_token = uuid.uuid4()
w.request_token = new_token
w.save(update_fields=['request_token'])
class Migration(migrations.Migration):
dependencies = [
('rota', '0005_add_request_token'),
]
operations = [
migrations.RunPython(generate_tokens, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rota', '0006_populate_request_token'),
]
operations = [
migrations.AlterField(
model_name='worker',
name='request_token',
field=models.UUIDField(editable=False, unique=True),
),
]
+9
View File
@@ -1,5 +1,6 @@
from django.db import models
from django.utils import timezone
import uuid
class RotaSchedule(models.Model):
@@ -130,6 +131,14 @@ class Worker(models.Model):
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
# Token used to allow workers (without Django accounts) to access
# self-service links (request leave, view their calendar, etc.). This
# is a UUID4 and should be treated as a secret. Regenerate to revoke.
# During migration we add this field as nullable and non-unique first,
# then populate unique values in a data migration and finally make it
# non-null and unique. See migrations/ for the migration sequence.
request_token = models.UUIDField(default=uuid.uuid4, editable=False, null=True, unique=False)
def __str__(self):
return self.name
+2
View File
@@ -17,6 +17,8 @@ urlpatterns = [
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
path("leave/request/", views.request_leave, name="request_leave"),
path("leave/request/token/<uuid:token>/", views.request_leave, name="request_leave_token"),
path("worker/<int:worker_id>/regenerate_token/", views.regenerate_worker_token, name="regenerate_worker_token"),
path("run/<int:run_id>/", views.rota_run_detail, name="rota_run_detail"),
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
+57 -10
View File
@@ -304,7 +304,18 @@ def worker_detail(request, worker_id):
leave.save()
return redirect("rota:worker_detail", worker_id=worker.id)
else:
form = LeaveForm()
# Pre-fill dates when `date` query param is provided (ISO YYYY-MM-DD)
date_q = request.GET.get("date") or request.POST.get("date")
if date_q:
try:
import datetime
d = datetime.date.fromisoformat(date_q)
form = LeaveForm(initial={"start_date": d, "end_date": d})
except Exception:
form = LeaveForm()
else:
form = LeaveForm()
return render(request, "rota/worker_detail.html", {"worker": worker, "form": form})
@@ -340,12 +351,40 @@ def worker_edit(request, rota_id, worker_id):
form = WorkerForm(instance=worker)
modal_html = render_to_string(
"rota/partials/worker_form_modal.html",
{"form": form, "form_action": request.path, "rota": rota},
{"form": form, "form_action": request.path, "rota": rota, "worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if worker and getattr(worker, 'request_token', None) else ''},
request=request,
)
return HttpResponse(modal_html)
@require_POST
def regenerate_worker_token(request, worker_id):
# Only allow staff users to regenerate tokens from the admin modal
if not (request.user and request.user.is_authenticated and request.user.is_staff):
return HttpResponseBadRequest("Permission denied")
worker = get_object_or_404(Worker, pk=worker_id)
# generate unique token
import uuid
new_token = uuid.uuid4()
# guard against collision
while Worker.objects.filter(request_token=new_token).exclude(pk=worker.pk).exists():
new_token = uuid.uuid4()
worker.request_token = new_token
worker.save(update_fields=["request_token"])
# render only the token area so HTMX can swap it
token_html = render_to_string(
"rota/partials/worker_token_area.html",
{"worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if getattr(worker, 'request_token', None) else ''},
request=request,
)
response = HttpResponse(token_html)
response["HX-Trigger"] = "workerTokenRegenerated"
return response
@require_http_methods(["GET", "POST"])
def worker_delete(request, rota_id, worker_id):
rota = get_object_or_404(RotaSchedule, pk=rota_id)
@@ -421,7 +460,7 @@ def rota_options_update(request, rota_id):
@require_http_methods(["GET", "POST"])
def request_leave(request):
def request_leave(request, token=None):
"""Allow a worker to request leave for themselves.
Resolution strategy:
@@ -434,14 +473,22 @@ def request_leave(request):
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
worker = None
# try authenticated user -> worker by email
try:
if request.user and request.user.is_authenticated and getattr(request.user, "email", None):
worker = Worker.objects.filter(email=request.user.email).first()
except Exception:
worker = None
# 1) token link (preferred for unauthenticated users)
if token:
try:
worker = get_object_or_404(Worker, request_token=token)
except Exception:
worker = None
# fallback to explicit worker_id
# 2) try authenticated user -> worker by email
if worker is None:
try:
if request.user and request.user.is_authenticated and getattr(request.user, "email", None):
worker = Worker.objects.filter(email=request.user.email).first()
except Exception:
worker = None
# 3) fallback to explicit worker_id param
if worker is None:
worker_id = request.GET.get("worker_id") or request.POST.get("worker_id")
if worker_id: