Add bulk update functionality for trainees from spreadsheet input

This commit is contained in:
Ross
2025-10-27 13:07:50 +00:00
parent 5d0eb56b37
commit d9602eeae9
4 changed files with 169 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@
{% endfor %}
(<a href="{% url 'create_trainee' %}" title="Click to add a trainee to the platform, creating an account for them.">Add trainee</a>)
(<a href="{% url 'accounts_bulk_create' %}" title="Click to create multiple trainees at once.">Bulk create</a>)
(<a href="{% url 'trainees_bulk_update_from_spreadsheet' %}" title="Click to update multiple trainees at once.">Bulk update</a>)
<h2>
{% if grade %}
@@ -0,0 +1,50 @@
{% extends 'generic/base.html' %}
{% block content %}
<h2>Bulk update trainees (paste spreadsheet text)</h2>
<p>Paste the spreadsheet rows below. Headings like "Year 1 - Group 1" are used to set the grade (Year N -> STN). Rows should contain "Name <tab or 2+spaces> Supervisor".</p>
<form method="post">
{% csrf_token %}
<textarea name="data" rows="18" style="width:100%">{% if request.POST.data %}{{ request.POST.data }}{% endif %}</textarea>
<div style="margin-top:8px">
<button type="submit" name="action" value="preview" class="btn btn-primary">Preview</button>
<button type="submit" name="action" value="apply" class="btn btn-danger" onclick="return confirm('This will apply updates to matched trainee profiles. Are you sure?')">Apply updates</button>
</div>
</form>
{% if preview %}
<h3>Preview ({{ preview|length }} rows)</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Raw</th>
<th>Name</th>
<th>Grade</th>
<th>Matched user</th>
<th>Supervisor text</th>
<th>Matched supervisor</th>
</tr>
</thead>
<tbody>
{% for r in preview %}
<tr>
<td><pre style="margin:0">{{ r.raw }}</pre></td>
<td>{{ r.norm_name }}</td>
<td>{{ r.grade }}</td>
<td>{% if r.matched_user %}<a href="{% url 'account_profile' r.matched_user.username %}">{{ r.matched_user.first_name }} {{ r.matched_user.last_name }}</a>{% else %}<span style="color:red">No match</span>{% endif %}</td>
<td>{{ r.supervisor_text }}</td>
<td>{% if r.matched_supervisor %}<a href="{% url 'generic:supervisor_detail' r.matched_supervisor.pk %}">{{ r.matched_supervisor.name }}</a>{% else %}<span style="color:red">No match</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if report %}
<h3>Apply report</h3>
<p>Updated: {{ report.updated }}; Skipped (no user matched): {{ report.skipped }}</p>
{% endif %}
{% endblock %}
+115
View File
@@ -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)
+3
View File
@@ -74,6 +74,9 @@ urlpatterns = [
path(
"accounts/trainees/<str:grade>/", 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/<str:slug>/",
views.UpdateUserView.as_view(),