Add worker import functionality with CSV upload and text input options
This commit is contained in:
@@ -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">
|
<div class="mb-4">
|
||||||
<p>
|
<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-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>
|
<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>
|
</p>
|
||||||
{% include 'rota/partials/worker_list.html' %}
|
{% include 'rota/partials/worker_list.html' %}
|
||||||
|
|||||||
@@ -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>/shift/<int:idx>/delete/", views.shift_delete, name="shift_delete"),
|
||||||
path("rota/<int:rota_id>/edit/", views.rota_edit, name="rota_edit"),
|
path("rota/<int:rota_id>/edit/", views.rota_edit, name="rota_edit"),
|
||||||
path("worker/add/", views.worker_create, name="worker_add"),
|
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>/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>/overview/", views.worker_overview, name="worker_overview"),
|
||||||
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"),
|
||||||
|
|||||||
@@ -376,6 +376,116 @@ def worker_create(request):
|
|||||||
return render(request, "rota/worker_form.html", {"form": form})
|
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):
|
def worker_detail(request, worker_id):
|
||||||
worker = get_object_or_404(Worker, pk=worker_id)
|
worker = get_object_or_404(Worker, pk=worker_id)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|||||||
Reference in New Issue
Block a user