.
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 %}
|
||||
{{ 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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user