Skip to content

Commit 20d367c

Browse files
committed
add stats report to list all responses for a specific option
Mostly for hunting down when people have selected a bad option for e.g. District Councils where not all questions responses might be valid. Fixes #217
1 parent 1ee0d0a commit 20d367c

File tree

5 files changed

+329
-2
lines changed

5 files changed

+329
-2
lines changed

ceuk-marking/urls.py

+15
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,21 @@
229229
stats.SessionPropertiesCSVView.as_view(),
230230
name="session_properties",
231231
),
232+
path(
233+
"stats/response_report",
234+
stats.ResponseReportView.as_view(),
235+
name="response_report",
236+
),
237+
path(
238+
"stats/available_questions",
239+
stats.AvailableResponseQuestionsView.as_view(),
240+
name="available_questions_json",
241+
),
242+
path(
243+
"stats/available_options",
244+
stats.AvailableResponseOptionsView.as_view(),
245+
name="available_options_json",
246+
),
232247
# audit screens
233248
path(
234249
"section/audit/<section_title>/authorities/",

crowdsourcer/filters.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django.contrib.auth.models import User
2+
from django.db.models import Q
23

34
import django_filters
45

5-
from crowdsourcer.models import ResponseType, Section
6+
from crowdsourcer.models import Option, Question, Response, ResponseType, Section
67

78

89
def filter_not_empty(queryset, name, value):
@@ -32,3 +33,36 @@ class Meta:
3233
"marker__response_type": ["exact"],
3334
"is_active": ["exact"],
3435
}
36+
37+
38+
class ResponseFilter(django_filters.FilterSet):
39+
response_type = django_filters.ChoiceFilter(
40+
label="Stage", choices=ResponseType.choices()
41+
)
42+
question__section = django_filters.ChoiceFilter(
43+
label="Section",
44+
empty_label=None,
45+
choices=Section.objects.values_list("id", "title"),
46+
)
47+
question = django_filters.ChoiceFilter(
48+
field_name="question",
49+
label="Question",
50+
choices=Question.objects.values_list("id", "number"),
51+
)
52+
option = django_filters.ChoiceFilter(
53+
field_name="option",
54+
label="Answer",
55+
method="option_check",
56+
choices=Option.objects.values_list("id", "description"),
57+
)
58+
59+
def option_check(self, queryset, name, value):
60+
queryset = queryset.filter(Q(option=value) | Q(multi_option=value))
61+
return queryset
62+
63+
class Meta:
64+
model = Response
65+
fields = {
66+
"question": ["exact"],
67+
"option": ["exact"],
68+
}

crowdsourcer/static/js/stats.esm.js

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
async function getAvailableQuestions($fs) {
2+
var url = "/stats/available_questions";
3+
4+
var $section = $fs.find('#id_question__section').eq(0);
5+
6+
if ($section.val() == "") {
7+
return { results: [] };
8+
}
9+
10+
var params = {
11+
s: $section.val(),
12+
ms: $fs.find('#session').eq(0).val()
13+
};
14+
15+
16+
url = url + '?' + $.param(params);
17+
18+
const response = await fetch(url, {
19+
method: 'GET',
20+
mode: 'cors',
21+
credentials: 'same-origin',
22+
headers: {
23+
"Content-Type": 'application/x-www-form-urlencoded',
24+
"Accept": 'application/json; charset=utf-8',
25+
},
26+
})
27+
28+
return response.json()
29+
}
30+
31+
async function getAvailableOptions($fs) {
32+
var url = "/stats/available_options";
33+
34+
var $question = $fs.find('#id_question').eq(0);
35+
36+
if ($question.val() == "") {
37+
return { results: [] };
38+
}
39+
40+
var params = {
41+
q: $question.val(),
42+
ms: $fs.find('#session').eq(0).val()
43+
};
44+
45+
46+
url = url + '?' + $.param(params);
47+
48+
const response = await fetch(url, {
49+
method: 'GET',
50+
mode: 'cors',
51+
credentials: 'same-origin',
52+
headers: {
53+
"Content-Type": 'application/x-www-form-urlencoded',
54+
"Accept": 'application/json; charset=utf-8',
55+
},
56+
})
57+
58+
return response.json()
59+
}
60+
$(function(){
61+
$('#id_question__section').on('change', function(e){
62+
var $d = $(this);
63+
var $fs = $d.parents('form');
64+
65+
var $q = $fs.find('#id_question').eq(0);
66+
67+
getAvailableQuestions($fs).then(function(data){
68+
if (data["results"].length > 0) {
69+
$q.empty()
70+
let $blank = $('<option></option>').text("---------");
71+
$q.append($blank);
72+
$.each(data["results"], function(i, opt) {
73+
let $o = $('<option></option>').attr('value', opt["id"]).text(opt["number_and_part"]);
74+
$q.append($o)
75+
});
76+
}
77+
});
78+
});
79+
80+
$('#id_question').on('change', function(e){
81+
var $d = $(this);
82+
var $fs = $d.parents('form');
83+
84+
var $opt = $fs.find('#id_option').eq(0);
85+
86+
getAvailableOptions($fs).then(function(data){
87+
if (data["results"].length > 0) {
88+
$opt.empty()
89+
let $blank = $('<option></option>').text("---------");
90+
$opt.append($blank);
91+
$.each(data["results"], function(i, opt) {
92+
let $o = $('<option></option>').attr('value', opt["id"]).text(opt["description"]);
93+
$opt.append($o)
94+
});
95+
}
96+
});
97+
});
98+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{% extends 'crowdsourcer/base.html' %}
2+
3+
{% load crowdsourcer_tags %}
4+
{% load django_bootstrap5 %}
5+
{% load static %}
6+
7+
{% block script %}
8+
<script type="module" src="{% static 'js/stats.esm.js' %}"></script>
9+
{% endblock %}
10+
11+
{% block content %}
12+
{% if show_login %}
13+
<h1 class="mb-4">Sign in</h1>
14+
<a href="{% url 'login' %}">Sign in</a>
15+
{% else %}
16+
<div class="d-md-flex align-items-center mb-4">
17+
<h1 class="mb-md-0 me-md-auto">Responses</h1>
18+
</div>
19+
20+
<form method="GET" id="response_filter" class="bg-light p-3 rounded-sm mb-3">
21+
<input type="hidden" id="session" value="{{ marking_session.id }}">
22+
<div class="row align-items-end flex-wrap mb-n3 mb-md-n4">
23+
<div class="col" style="min-width: 10rem">
24+
{% bootstrap_field filter.form.question__section %}
25+
</div>
26+
<div class="col" style="min-width: 10rem">
27+
{% bootstrap_field filter.form.question %}
28+
</div>
29+
<div class="col" style="min-width: 10rem">
30+
{% bootstrap_field filter.form.option %}
31+
</div>
32+
<div class="col" style="min-width: 10rem">
33+
{% bootstrap_field filter.form.response_type %}
34+
</div>
35+
<div class="col" style="min-width: 10rem">
36+
<button type="submit" class="btn btn-primary btn-block mb-3 mb-md-4">Filter list</button>
37+
</div>
38+
</div>
39+
</form>
40+
41+
<table class="table">
42+
<thead>
43+
<tr>
44+
<th>Question</th>
45+
<th>Authority</th>
46+
<th>Response</th>
47+
</tr>
48+
</thead>
49+
<tbody>
50+
{% for response in responses %}
51+
<tr>
52+
<td>
53+
<a href="{% session_url url_pattern response.authority.name response.question.section.title %}">{{ response.question.number_and_part }}</a>
54+
</td>
55+
<td>
56+
{{ response.authority.name }}
57+
</td>
58+
<td>
59+
{% if response.multi_option.values %}
60+
<p>
61+
{% for option in response.multi_option.values %}
62+
{{ option.description }},
63+
{% empty %}
64+
(none)
65+
{% endfor %}
66+
</p>
67+
{% else %}
68+
{{ response.option|default:"(none)"|linebreaks }}
69+
{% endif %}
70+
</td>
71+
</tr>
72+
{% endfor %}
73+
</tbody>
74+
</table>
75+
{% endif %}
76+
{% endblock %}

crowdsourcer/views/stats.py

+105-1
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@
55

66
from django.contrib.auth.mixins import UserPassesTestMixin
77
from django.db.models import Count
8-
from django.http import HttpResponse
8+
from django.http import HttpResponse, JsonResponse
99
from django.utils.text import slugify
1010
from django.views.generic import ListView, TemplateView
1111

12+
from django_filters.views import FilterView
13+
14+
from crowdsourcer.filters import ResponseFilter
1215
from crowdsourcer.models import (
16+
MarkingSession,
1317
Option,
1418
PublicAuthority,
1519
Question,
1620
Response,
21+
ResponseType,
22+
Section,
1723
SessionPropertyValues,
1824
)
1925
from crowdsourcer.scoring import (
@@ -858,3 +864,101 @@ def render_to_response(self, context, **response_kwargs):
858864
for row in context["rows"]:
859865
writer.writerow(row)
860866
return response
867+
868+
869+
class ResponseReportView(StatsUserTestMixin, FilterView):
870+
template_name = "crowdsourcer/stats/response_report.html"
871+
context_object_name = "responses"
872+
filterset_class = ResponseFilter
873+
874+
def get_filterset(self, filterset_class):
875+
fs = super().get_filterset(filterset_class)
876+
877+
fs.filters["question__section"].field.choices = Section.objects.filter(
878+
marking_session=self.request.current_session
879+
).values_list("id", "title")
880+
881+
questions = Question.objects.filter(
882+
section__marking_session=self.request.current_session
883+
).order_by("section", "number", "number_part")
884+
# if self.request.GET.get("question__section") is not None:
885+
# questions = questions.filter(
886+
# section__id=self.request.GET["question__section"]
887+
# )
888+
889+
question_choices = [(q.id, q.number_and_part) for q in questions]
890+
fs.filters["question"].field.choices = question_choices
891+
892+
options = Option.objects.filter(
893+
question__section__marking_session=self.request.current_session
894+
).order_by("ordering")
895+
if self.request.GET.get("question") is not None:
896+
options = options.filter(question__id=self.request.GET["question"])
897+
898+
fs.filters["option"].field.choices = options.values_list("id", "description")
899+
900+
return fs
901+
902+
def get_queryset(self):
903+
return Response.objects.filter(
904+
question__section__marking_session=self.request.current_session
905+
).select_related("question", "authority")
906+
907+
def get_context_data(self, **kwargs):
908+
context = super().get_context_data(**kwargs)
909+
910+
stage = "First Mark"
911+
if self.request.GET.get("response_type") is not None:
912+
stage = ResponseType.objects.get(
913+
id=self.request.GET.get("response_type")
914+
).type
915+
url_pattern = "authority_question_edit"
916+
917+
if stage == "Right of Reply":
918+
url_pattern = "authority_ror"
919+
elif stage == "Audit":
920+
url_pattern = "authority_audit"
921+
922+
context["url_pattern"] = url_pattern
923+
return context
924+
925+
926+
class AvailableResponseQuestionsView(StatsUserTestMixin, ListView):
927+
context_object_name = "questions"
928+
929+
def get_queryset(self):
930+
if self.request.GET.get("ms") is None or self.request.GET.get("s") is None:
931+
return []
932+
933+
marking_session = MarkingSession.objects.get(id=self.request.GET["ms"])
934+
s = Section.objects.get(
935+
marking_session=marking_session, id=self.request.GET["s"]
936+
)
937+
return Question.objects.filter(section=s).order_by("number", "number_part")
938+
939+
def render_to_response(self, context, **response_kwargs):
940+
data = []
941+
942+
for q in context["questions"]:
943+
data.append({"number_and_part": q.number_and_part, "id": q.id})
944+
945+
return JsonResponse({"results": data})
946+
947+
948+
class AvailableResponseOptionsView(StatsUserTestMixin, ListView):
949+
context_object_name = "options"
950+
951+
def get_queryset(self):
952+
if self.request.GET.get("q") is None:
953+
return []
954+
955+
q = Question.objects.get(id=self.request.GET["q"])
956+
return Option.objects.filter(question=q).order_by("ordering")
957+
958+
def render_to_response(self, context, **response_kwargs):
959+
data = []
960+
961+
for o in context["options"]:
962+
data.append({"description": o.description, "id": o.id})
963+
964+
return JsonResponse({"results": data})

0 commit comments

Comments
 (0)