Add worker import functionality with CSV upload and text input options

This commit is contained in:
Ross
2025-12-17 18:04:53 +00:00
parent dadf035627
commit f4ab0be347
4 changed files with 156 additions and 1 deletions
@@ -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>
@@ -43,6 +43,7 @@
<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>
<button class="button is-light" hx-get="{% url 'rota:worker_import' rota.id %}" hx-target="#modal" hx-swap="innerHTML" style="margin-left:0.5rem;">Import workers</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' %}
+1
View File
@@ -13,6 +13,7 @@ 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"),
+110
View File
@@ -376,6 +376,116 @@ 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
w = Worker.objects.create(name=name.strip(), email=email.strip(), site=site.strip(), grade=grade, fte=fte)
created.append(w)
if assign_to_rota and rota is not None:
try:
Assignment.objects.create(worker=w, rota=rota)
except Exception:
try:
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":