From d9602eeae951a76946cbb22807559391c6821b19 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 27 Oct 2025 13:07:50 +0000 Subject: [PATCH] Add bulk update functionality for trainees from spreadsheet input --- generic/templates/generic/trainees.html | 1 + .../generic/trainees_bulk_update.html | 50 ++++++++ generic/views.py | 115 ++++++++++++++++++ rad/urls.py | 3 + 4 files changed, 169 insertions(+) create mode 100644 generic/templates/generic/trainees_bulk_update.html diff --git a/generic/templates/generic/trainees.html b/generic/templates/generic/trainees.html index 67471066..86253770 100644 --- a/generic/templates/generic/trainees.html +++ b/generic/templates/generic/trainees.html @@ -11,6 +11,7 @@ {% endfor %} (Add trainee) (Bulk create) + (Bulk update)

{% if grade %} diff --git a/generic/templates/generic/trainees_bulk_update.html b/generic/templates/generic/trainees_bulk_update.html new file mode 100644 index 00000000..b43fdf36 --- /dev/null +++ b/generic/templates/generic/trainees_bulk_update.html @@ -0,0 +1,50 @@ +{% extends 'generic/base.html' %} + +{% block content %} +

Bulk update trainees (paste spreadsheet text)

+ +

Paste the spreadsheet rows below. Headings like "Year 1 - Group 1" are used to set the grade (Year N -> STN). Rows should contain "Name Supervisor".

+ +
+ {% csrf_token %} + +
+ + +
+
+ +{% if preview %} +

Preview ({{ preview|length }} rows)

+ + + + + + + + + + + + + {% for r in preview %} + + + + + + + + + {% endfor %} + +
RawNameGradeMatched userSupervisor textMatched supervisor
{{ r.raw }}
{{ r.norm_name }}{{ r.grade }}{% if r.matched_user %}{{ r.matched_user.first_name }} {{ r.matched_user.last_name }}{% else %}No match{% endif %}{{ r.supervisor_text }}{% if r.matched_supervisor %}{{ r.matched_supervisor.name }}{% else %}No match{% endif %}
+{% endif %} + +{% if report %} +

Apply report

+

Updated: {{ report.updated }}; Skipped (no user matched): {{ report.skipped }}

+{% endif %} + +{% endblock %} diff --git a/generic/views.py b/generic/views.py index 427a037c..389b8974 100644 --- a/generic/views.py +++ b/generic/views.py @@ -4254,6 +4254,121 @@ def trainees(request, grade: None | str = None): return render(request, "generic/trainees.html", context) +@user_is_cid_user_manager +def trainees_bulk_update_from_spreadsheet(request): + """Bulk update trainees based on pasted spreadsheet-like data. + + The view works in two stages: + - Preview: parse the pasted text and show rows with any matched user / supervisor + - Apply: apply grade and supervisor updates for matched users + """ + logger.debug("Trainees bulk update called") + + import re + + preview = [] + report = None + + if request.method == "POST": + action = request.POST.get("action", "preview") + data = request.POST.get("data", "") + + lines = [l.strip() for l in data.splitlines()] + + current_grade = None + + for line in lines: + if not line: + continue + + # detect grade headings like 'Year 1 - Group 1' -> ST1 + m = re.search(r"Year\s*(\d+)", line, re.IGNORECASE) + if m: + num = m.group(1) + current_grade = f"ST{num}" + continue + + # split columns by tab or two+ spaces + parts = re.split(r"\t| {2,}", line) + if len(parts) < 2: + # maybe single-space separated; try splitting on tab or single tab + parts = [p.strip() for p in line.split("\t") if p.strip()] + + if not parts: + continue + + name_part = parts[0].strip() + supervisor_part = parts[1].strip() if len(parts) > 1 else "" + + # normalize name (remove parenthetical annotations) + norm_name = re.sub(r"\s*\([^)]*\)", "", name_part).strip() + # split into tokens; assume first token is first name, last token is last name + tokens = [t for t in re.split(r"\s+", norm_name) if t] + first = tokens[0] if tokens else "" + last = tokens[-1] if tokens else "" + + matched_user = None + if first and last: + qs = User.objects.filter(first_name__iexact=first, last_name__iexact=last) + if not qs.exists(): + qs = User.objects.filter(last_name__iexact=last) + if qs.exists(): + matched_user = qs.first() + + matched_supervisor = None + if supervisor_part: + sup_qs = Supervisor.objects.filter(name__icontains=supervisor_part) + if sup_qs.exists(): + matched_supervisor = sup_qs.first() + + preview.append( + { + "raw": line, + "name": name_part, + "norm_name": norm_name, + "first": first, + "last": last, + "supervisor_text": supervisor_part, + "matched_user": matched_user, + "matched_supervisor": matched_supervisor, + "grade": current_grade, + } + ) + + if action == "apply": + updated = 0 + skipped = 0 + for row in preview: + user = row.get("matched_user") + if not user: + skipped += 1 + continue + + profile = user.userprofile + + # apply grade if present + grade_name = row.get("grade") + if grade_name: + grade_obj, _ = UserGrades.objects.get_or_create(name=grade_name) + profile.grade = grade_obj + + # apply supervisor if matched + sup = row.get("matched_supervisor") + if sup: + profile.supervisor = sup + + profile.save() + updated += 1 + + report = {"updated": updated, "skipped": skipped} + + return render( + request, + "generic/trainees_bulk_update.html", + {"preview": preview, "report": report}, + ) + + def create_trainee(request, context=None): return create_user(request, context, trainee=True) diff --git a/rad/urls.py b/rad/urls.py index 8a522fc9..3f892463 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -74,6 +74,9 @@ urlpatterns = [ path( "accounts/trainees//", generic_views.trainees, name="trainees_grade" ), + path( + "accounts/trainees/bulk_update/spreadsheet", generic_views.trainees_bulk_update_from_spreadsheet, name="trainees_bulk_update_from_spreadsheet" + ), path( "accounts/update//", views.UpdateUserView.as_view(),