.
This commit is contained in:
@@ -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 %}
|
{% endif %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
|
|
||||||
|
{# Token area: shows a copyable self-service link and a regenerate button when editing a worker #}
|
||||||
|
{% if worker %}
|
||||||
|
{% include 'rota/partials/worker_token_area.html' with worker=worker token_link=token_link %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
|
<!-- Structured Non-working days editor (keeps #id_nwds in sync) -->
|
||||||
<div class="box" id="nwds-editor">
|
<div class="box" id="nwds-editor">
|
||||||
<h4 class="title is-6">Non-working days</h4>
|
<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>
|
<head>
|
||||||
<title>{{ worker.name }}</title>
|
<title>{{ worker.name }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" rel="stylesheet">
|
||||||
{% 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="subtitle">Request leave on calendar</h2>
|
||||||
|
<div id="calendar" 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>
|
<h1 class="title">{{ worker.name }}</h1>
|
||||||
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
|
<p class="subtitle">{{ worker.email }} - {{ worker.site }}</p>
|
||||||
|
|
||||||
@@ -36,5 +49,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</body>
|
||||||
</html>
|
</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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class RotaSchedule(models.Model):
|
class RotaSchedule(models.Model):
|
||||||
@@ -130,6 +131,14 @@ class Worker(models.Model):
|
|||||||
|
|
||||||
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
|
rotas = models.ManyToManyField(RotaSchedule, through="Assignment", related_name="workers")
|
||||||
|
|
||||||
|
# Token used to allow workers (without Django accounts) to access
|
||||||
|
# self-service links (request leave, view their calendar, etc.). This
|
||||||
|
# is a UUID4 and should be treated as a secret. Regenerate to revoke.
|
||||||
|
# During migration we add this field as nullable and non-unique first,
|
||||||
|
# then populate unique values in a data migration and finally make it
|
||||||
|
# non-null and unique. See migrations/ for the migration sequence.
|
||||||
|
request_token = models.UUIDField(default=uuid.uuid4, editable=False, null=True, unique=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ urlpatterns = [
|
|||||||
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
path("worker/<int:rota_id>/<int:worker_id>/delete/", views.worker_delete, name="worker_delete"),
|
||||||
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
path("worker/<int:worker_id>/", views.worker_detail, name="worker_detail"),
|
||||||
path("leave/request/", views.request_leave, name="request_leave"),
|
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>/", views.rota_run_detail, name="rota_run_detail"),
|
||||||
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
path("run/<int:run_id>/export/html/", views.rota_run_export_html, name="rota_run_export_html"),
|
||||||
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
path("run/<int:run_id>/export/download/", views.rota_run_export_download, name="rota_run_export_download"),
|
||||||
|
|||||||
+57
-10
@@ -304,7 +304,18 @@ def worker_detail(request, worker_id):
|
|||||||
leave.save()
|
leave.save()
|
||||||
return redirect("rota:worker_detail", worker_id=worker.id)
|
return redirect("rota:worker_detail", worker_id=worker.id)
|
||||||
else:
|
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})
|
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)
|
form = WorkerForm(instance=worker)
|
||||||
modal_html = render_to_string(
|
modal_html = render_to_string(
|
||||||
"rota/partials/worker_form_modal.html",
|
"rota/partials/worker_form_modal.html",
|
||||||
{"form": form, "form_action": request.path, "rota": rota},
|
{"form": form, "form_action": request.path, "rota": rota, "worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if worker and getattr(worker, 'request_token', None) else ''},
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return HttpResponse(modal_html)
|
return HttpResponse(modal_html)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def regenerate_worker_token(request, worker_id):
|
||||||
|
# Only allow staff users to regenerate tokens from the admin modal
|
||||||
|
if not (request.user and request.user.is_authenticated and request.user.is_staff):
|
||||||
|
return HttpResponseBadRequest("Permission denied")
|
||||||
|
|
||||||
|
worker = get_object_or_404(Worker, pk=worker_id)
|
||||||
|
# generate unique token
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
new_token = uuid.uuid4()
|
||||||
|
# guard against collision
|
||||||
|
while Worker.objects.filter(request_token=new_token).exclude(pk=worker.pk).exists():
|
||||||
|
new_token = uuid.uuid4()
|
||||||
|
worker.request_token = new_token
|
||||||
|
worker.save(update_fields=["request_token"])
|
||||||
|
|
||||||
|
# render only the token area so HTMX can swap it
|
||||||
|
token_html = render_to_string(
|
||||||
|
"rota/partials/worker_token_area.html",
|
||||||
|
{"worker": worker, "token_link": request.build_absolute_uri(reverse('rota:request_leave_token', args=[worker.request_token])) if getattr(worker, 'request_token', None) else ''},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
response = HttpResponse(token_html)
|
||||||
|
response["HX-Trigger"] = "workerTokenRegenerated"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def worker_delete(request, rota_id, worker_id):
|
def worker_delete(request, rota_id, worker_id):
|
||||||
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
rota = get_object_or_404(RotaSchedule, pk=rota_id)
|
||||||
@@ -421,7 +460,7 @@ def rota_options_update(request, rota_id):
|
|||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def request_leave(request):
|
def request_leave(request, token=None):
|
||||||
"""Allow a worker to request leave for themselves.
|
"""Allow a worker to request leave for themselves.
|
||||||
|
|
||||||
Resolution strategy:
|
Resolution strategy:
|
||||||
@@ -434,14 +473,22 @@ def request_leave(request):
|
|||||||
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
is_hx = request.headers.get("HX-Request", "false").lower() == "true"
|
||||||
|
|
||||||
worker = None
|
worker = None
|
||||||
# try authenticated user -> worker by email
|
# 1) token link (preferred for unauthenticated users)
|
||||||
try:
|
if token:
|
||||||
if request.user and request.user.is_authenticated and getattr(request.user, "email", None):
|
try:
|
||||||
worker = Worker.objects.filter(email=request.user.email).first()
|
worker = get_object_or_404(Worker, request_token=token)
|
||||||
except Exception:
|
except Exception:
|
||||||
worker = None
|
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:
|
if worker is None:
|
||||||
worker_id = request.GET.get("worker_id") or request.POST.get("worker_id")
|
worker_id = request.GET.get("worker_id") or request.POST.get("worker_id")
|
||||||
if worker_id:
|
if worker_id:
|
||||||
|
|||||||
Reference in New Issue
Block a user