Add bulk update functionality for trainees from spreadsheet input
This commit is contained in:
@@ -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 %}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user