Skip to content

Commit 21868f0

Browse files
committed
bulk reponse editing interface
Allows uploading a CSV with updated responses to a question for bulk editing
1 parent cbd444f commit 21868f0

File tree

5 files changed

+241
-4
lines changed

5 files changed

+241
-4
lines changed

ceuk-marking/urls.py

+5
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@
328328
questions.SectionList.as_view(),
329329
name="question_sections",
330330
),
331+
path(
332+
"questions/update/<section_name>/<question>/",
333+
questions.QuestionBulkUpdateView.as_view(),
334+
name="question_bulk_update",
335+
),
331336
]
332337

333338
urlpatterns = [

crowdsourcer/forms.py

+73
Original file line numberDiff line numberDiff line change
@@ -584,3 +584,76 @@ def __init__(self, properties: {}, **kwargs):
584584
QuestionFormset = modelformset_factory(
585585
Question, fields=["question_type", "weighting"], extra=0, can_delete=False
586586
)
587+
588+
589+
class QuestionBulkUploadForm(Form):
590+
question = CharField(widget=HiddenInput)
591+
stage = ChoiceField(required=True, choices=[])
592+
updated_responses = FileField()
593+
594+
def clean(self):
595+
data = self.cleaned_data.get("updated_responses")
596+
597+
try:
598+
df = pd.read_csv(
599+
data,
600+
usecols=[
601+
"authority",
602+
"answer",
603+
"score",
604+
"public_notes",
605+
"page_number",
606+
"evidence",
607+
"private_notes",
608+
],
609+
)
610+
except ValueError as v:
611+
raise ValidationError(f"Problem processing csv file: {v}")
612+
613+
self.responses_df = df
614+
615+
try:
616+
question = Question.objects.get(id=self.cleaned_data["question"])
617+
except Question.DoesNotExist:
618+
raise ValidationError(f"Bad question id: {self.cleaned_data['question']}")
619+
620+
is_multi = question.question_type == "multiple_choice"
621+
print(question.question_type, is_multi)
622+
623+
file_errors = []
624+
for _, row in self.responses_df.iterrows():
625+
desc = row["answer"].strip()
626+
try:
627+
PublicAuthority.objects.get(
628+
name=row["authority"],
629+
marking_session=self.session,
630+
)
631+
except PublicAuthority.DoesNotExist:
632+
file_errors.append(f"No such authority: {row['authority']}")
633+
continue
634+
635+
if desc == "-":
636+
continue
637+
638+
if not is_multi:
639+
answers = [desc]
640+
else:
641+
answers = desc.split("|")
642+
643+
for answer in answers:
644+
try:
645+
Option.objects.get(question=question, description=answer)
646+
except Option.DoesNotExist:
647+
file_errors.append(
648+
f"No such answer for {row['authority']}: {answer}"
649+
)
650+
continue
651+
652+
if len(file_errors) > 0:
653+
raise ValidationError(file_errors)
654+
655+
def __init__(self, question_id, stage_choices, session, **kwargs):
656+
super().__init__(**kwargs)
657+
self.session = session
658+
self.initial["question"] = question_id
659+
self.fields["stage"].choices = stage_choices

crowdsourcer/scoring.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ def get_response_data(
614614
for opt in response.multi_option.all():
615615
descs.append(opt.description)
616616
score += opt.score
617-
answer = ",".join(descs)
617+
answer = "|".join(descs)
618618
elif response.option is not None:
619619
score = response.option.score
620620
answer = response.option.description
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{% extends 'crowdsourcer/base.html' %}
2+
3+
{% load crowdsourcer_tags django_bootstrap5 %}
4+
5+
{% block content %}
6+
{% if show_login %}
7+
<h1 class="mb-4">Sign in</h1>
8+
<a href="{% url 'login' %}">Sign in</a>
9+
{% else %}
10+
<h1 class="mb-4">Update Responses for {{ section.title }} {{ question.number_and_part }}</h1>
11+
<h4 class="mb-4">{{ question.description }}</h4>
12+
13+
14+
<form enctype="multipart/form-data" action="" method="post">
15+
{% csrf_token %}
16+
{% bootstrap_form form %}
17+
<input type="submit" value="Update">
18+
</form>
19+
20+
{% endif %}
21+
{% endblock %}

crowdsourcer/views/questions.py

+141-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
import re
2+
13
from django.contrib.auth.mixins import UserPassesTestMixin
4+
from django.db import transaction
25
from django.shortcuts import get_object_or_404
36
from django.urls import reverse
47
from django.views.generic import FormView, ListView
58

6-
from crowdsourcer.forms import OptionFormset, QuestionFormset
7-
from crowdsourcer.models import Option, Question, Section
9+
import pandas as pd
10+
11+
from crowdsourcer.forms import OptionFormset, QuestionBulkUploadForm, QuestionFormset
12+
from crowdsourcer.models import (
13+
Option,
14+
PublicAuthority,
15+
Question,
16+
Response,
17+
ResponseType,
18+
Section,
19+
)
820

921

1022
class SectionList(ListView):
@@ -27,7 +39,7 @@ def get_success_url(self):
2739
"session_urls:edit_options",
2840
kwargs={
2941
"marking_session": self.request.current_session.label,
30-
"section_name": "Buildings & Heating",
42+
"section_name": self.kwargs["section_name"],
3143
},
3244
)
3345

@@ -97,3 +109,129 @@ def get_context_data(self, **kwargs):
97109
def form_valid(self, form):
98110
form.save()
99111
return super().form_valid(form)
112+
113+
114+
class QuestionBulkUpdateView(UserPassesTestMixin, FormView):
115+
template_name = "crowdsourcer/questions/question_bulk_upload.html"
116+
form_class = QuestionBulkUploadForm
117+
118+
def test_func(self):
119+
return self.request.user.has_perm("crowdsourcer.can_manage_users")
120+
121+
def get_success_url(self):
122+
return reverse(
123+
"session_urls:question_bulk_update",
124+
kwargs={
125+
"marking_session": self.request.current_session.label,
126+
"section_name": self.kwargs["section_name"],
127+
"question": self.kwargs["question"],
128+
},
129+
)
130+
131+
def get_form(self):
132+
self.section = get_object_or_404(
133+
Section,
134+
title=self.kwargs["section_name"],
135+
marking_session=self.request.current_session,
136+
)
137+
138+
q_parts = re.match(r"(\d+)([a-z]?)", self.kwargs["question"])
139+
q_kwargs = {
140+
"section": self.section,
141+
"number": q_parts.groups()[0],
142+
}
143+
144+
if len(q_parts.groups()) == 2 and q_parts.groups()[1] != "":
145+
q_kwargs["number_part"] = q_parts.groups()[1]
146+
147+
self.question = get_object_or_404(Question, **q_kwargs)
148+
return self.form_class(
149+
self.question.pk,
150+
[(rt.type, rt.type) for rt in ResponseType.objects.all()],
151+
self.request.current_session,
152+
**self.get_form_kwargs(),
153+
)
154+
155+
def get_context_data(self, **kwargs):
156+
context = super().get_context_data(**kwargs)
157+
158+
context["section"] = self.section
159+
context["question"] = self.question
160+
161+
return context
162+
163+
def form_valid(self, form):
164+
data = form.cleaned_data
165+
166+
question = get_object_or_404(Question, id=data["question"])
167+
stage = get_object_or_404(ResponseType, type=data["stage"])
168+
is_multi = question.question_type == "multiple_choice"
169+
170+
with transaction.atomic():
171+
for index, row in form.responses_df.iterrows():
172+
answer = row["answer"].strip()
173+
if answer == "-":
174+
continue
175+
176+
if is_multi:
177+
answers = answer.split("|")
178+
179+
authority = PublicAuthority.objects.get(
180+
name=row["authority"],
181+
marking_session=self.request.current_session,
182+
)
183+
if not is_multi:
184+
option = Option.objects.get(question=question, description=answer)
185+
186+
try:
187+
response = Response.objects.get(
188+
question=question, response_type=stage, authority=authority
189+
)
190+
changed = False
191+
opts = {}
192+
for col in ["page_number", "evidence", "public_notes"]:
193+
if pd.isna(row[col]) is False:
194+
opts[col] = row[col]
195+
if row[col] != getattr(response, col):
196+
changed = True
197+
if not is_multi and response.option != option:
198+
changed = True
199+
opts["option"] = option
200+
201+
if changed:
202+
response.user = self.request.user
203+
for k, v in opts.items():
204+
setattr(response, k, v)
205+
response.save()
206+
207+
if is_multi:
208+
response.multi_option.clear()
209+
for a in answers:
210+
option = Option.objects.get(
211+
question=question, description=a
212+
)
213+
response.multi_option.add(option.id)
214+
215+
except Response.DoesNotExist:
216+
opts = {
217+
"question": question,
218+
"response_type": stage,
219+
"authority": authority,
220+
"user": self.request.user,
221+
}
222+
for col in ["page_number", "evidence", "public_notes"]:
223+
if pd.isna(row[col]) is False:
224+
opts[col] = row[col]
225+
if not is_multi:
226+
opts["option"] = option
227+
228+
response = Response.objects.create(**opts)
229+
230+
if is_multi:
231+
for a in answers:
232+
option = Option.objects.get(
233+
question=question, description=a
234+
)
235+
response.multi_option.add(option.id)
236+
237+
return super().form_valid(form)

0 commit comments

Comments
 (0)