diff --git a/djangorota/djangorota/templates/rota/partials/worker_import_modal.html b/djangorota/djangorota/templates/rota/partials/worker_import_modal.html new file mode 100644 index 0000000..68b34a8 --- /dev/null +++ b/djangorota/djangorota/templates/rota/partials/worker_import_modal.html @@ -0,0 +1,43 @@ + diff --git a/djangorota/djangorota/templates/rota/rota_detail.html b/djangorota/djangorota/templates/rota/rota_detail.html index 27002a6..466911e 100644 --- a/djangorota/djangorota/templates/rota/rota_detail.html +++ b/djangorota/djangorota/templates/rota/rota_detail.html @@ -42,7 +42,8 @@

- + + All leave requests

{% include 'rota/partials/worker_list.html' %} diff --git a/djangorota/rota/urls.py b/djangorota/rota/urls.py index 3255787..dab9593 100644 --- a/djangorota/rota/urls.py +++ b/djangorota/rota/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path("rota//shift//delete/", views.shift_delete, name="shift_delete"), path("rota//edit/", views.rota_edit, name="rota_edit"), path("worker/add/", views.worker_create, name="worker_add"), + path("rota//workers/import/", views.worker_import, name="worker_import"), path("worker///edit/", views.worker_edit, name="worker_edit"), path("worker///overview/", views.worker_overview, name="worker_overview"), path("worker///delete/", views.worker_delete, name="worker_delete"), diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py index 0b895e5..466c63c 100644 --- a/djangorota/rota/views.py +++ b/djangorota/rota/views.py @@ -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'
{worker_list_html}
' + 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":