.
This commit is contained in:
@@ -3,9 +3,6 @@
|
||||
{% block content %}
|
||||
<div class="">
|
||||
<h2>{{ exam }}</h2>
|
||||
{% if cached_scores %}
|
||||
User answer scores are cached, if you update or change an answer you will need to <a href="{% url exam.app_name|add:':exam_scores_refresh' exam.pk %}">refresh the scores</a>.
|
||||
{% endif %}
|
||||
|
||||
{% if missing_cids or missing_users %}
|
||||
<details>
|
||||
@@ -38,18 +35,31 @@
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<div id="stats-plot">{{plot|safe}}</div>
|
||||
<div id="stats-block" title="low scores (<20% max) are excluded from statistics" class="mb-2 small">
|
||||
<h5 class="mb-1">Stats</h5>
|
||||
<div>Candidates: <span class="fw-semibold">{{cids|length}}</span></div>
|
||||
<div>Questions: <span id="question-number">{{question_number}}</span></div>
|
||||
<div title="Max score">Available marks: {{max_score}}</div>
|
||||
<div>Mean: {{mean}}, Median {{median}}, Mode {{mode}}</div>
|
||||
<div>Top score: {{exam.stats_max}}</div>
|
||||
<!-- Stats Container Lazy-Loader -->
|
||||
<div id="stats-container"
|
||||
hx-get="{% url exam.app_name|add:':exam_scores_all' exam.pk %}?stats_only=true"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<div class="card mb-3 bg-dark text-light border-secondary shadow-sm">
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="spinner-border text-info" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div class="text-muted mt-2">Loading statistics...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidate Search/Filter Input -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<input type="text" id="candidate-filter" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Filter by candidate name or ID..." oninput="filterCandidates(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small">
|
||||
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small" id="candidate-scores-list-table">
|
||||
<tr>
|
||||
<th title="click to sort by candidate / user id">Candidate ID</th>
|
||||
<th title="click to sort by score">Score</th>
|
||||
@@ -58,7 +68,7 @@
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% for cid in cids %}
|
||||
<tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}}>
|
||||
<tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}} data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">
|
||||
{% if cid|slice:":1" == "u" %}
|
||||
<td class="cid" >{{cids_user_id_map|get_item:cid}}</td>
|
||||
{% else %}
|
||||
@@ -136,19 +146,48 @@
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
function filterCandidates(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
// Filter candidate list rows
|
||||
document.querySelectorAll('.candidate-row').forEach(row => {
|
||||
const name = row.getAttribute('data-cid-name') || '';
|
||||
const val = row.getAttribute('data-cid-val') || '';
|
||||
if (name.includes(q) || val.includes(q)) {
|
||||
row.classList.remove('d-none');
|
||||
} else {
|
||||
row.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// Filter columns in answers table
|
||||
document.querySelectorAll('.col-cid-cell').forEach(cell => {
|
||||
const name = cell.getAttribute('data-cid-name') || '';
|
||||
const val = cell.getAttribute('data-cid-val') || '';
|
||||
if (name.includes(q) || val.includes(q)) {
|
||||
cell.classList.remove('d-none');
|
||||
} else {
|
||||
cell.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(() => {
|
||||
let question_number = document.getElementById("question-number").innerHTML;
|
||||
console.log(question_number)
|
||||
const question_number = {{ question_number }};
|
||||
$(".candidate-row").each((index, el) => {
|
||||
console.log(index, el)
|
||||
if (el.dataset.answerCount != question_number) {
|
||||
$(el).addClass("missing-answers")
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
// Re-run filter after HTMX content is swapped in
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
const filterInput = document.getElementById('candidate-filter');
|
||||
if (filterInput && filterInput.value) {
|
||||
filterCandidates(filterInput.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.missing-answers td:first-child:before {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<tr>
|
||||
<th>Candidate</th>
|
||||
{% for cid in cids %}
|
||||
<th>
|
||||
<th class="col-cid-cell col-cid-{{ cid|slugify }}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">
|
||||
{% if cid|slice:":1" == "u" %}
|
||||
<a href="{% url exam.app_name|add:':exam_scores_user_admin' exam.pk cid|slice:'2:' %}">
|
||||
{% else %}
|
||||
@@ -20,7 +20,7 @@
|
||||
<tr>
|
||||
<td>Score:</td>
|
||||
{% for cid in cids %}
|
||||
<td>{{user_scores|get_item:cid}}</td>
|
||||
<td class="col-cid-cell col-cid-{{ cid|slugify }}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">{{user_scores|get_item:cid}}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Card 1: Completion -->
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Completion Rate</div>
|
||||
<div class="fs-3 fw-bold text-success">{{ completion_rate }}%</div>
|
||||
<div class="small text-muted mt-1">{{ user_answer_count_total }} of {{ expected_answer_count }} answers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 2: Pass Rate -->
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Pass Rate (≥ 50%)</div>
|
||||
<div class="fs-3 fw-bold text-info">{{ pass_rate }}%</div>
|
||||
<div class="small text-muted mt-1">{{ passed_candidates_count }} of {{ cids|length }} candidates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 3: Class Average -->
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Class Average (Mean)</div>
|
||||
<div class="fs-3 fw-bold text-warning">{{ mean }} <span class="fs-6 text-muted">/ {{ max_score }}</span></div>
|
||||
<div class="small text-muted mt-1">Median: {{ median }} | Mode: {{ mode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 4: Range -->
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Score Range</div>
|
||||
<div class="fs-3 fw-bold text-primary">{{ min_score }} - {{ max_score_achieved }}</div>
|
||||
<div class="small text-muted mt-1">Lowest vs Highest score achieved</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 5: Dispersion & IQR -->
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Dispersion & IQR</div>
|
||||
<div class="fs-3 fw-semibold text-light">σ = {{ std_dev }}</div>
|
||||
<div class="small text-muted mt-1">Q1: {{ q1 }} | Q3: {{ q3 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 6: Candidates & Questions -->
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Candidates & Questions</div>
|
||||
<div class="fs-3 fw-bold text-light">{{ cids|length }} <span class="fs-6 text-muted">candidates</span></div>
|
||||
<div class="small text-muted mt-1">{{ question_number }} questions ({{ unmarked_count }} unmarked answers)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if plot %}
|
||||
<div class="card bg-dark text-light border-secondary mb-4 shadow-sm">
|
||||
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Distribution of Scores</h6>
|
||||
<span class="badge bg-secondary">low scores (<20% max) excluded from statistics</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="stats-plot-wrapper">
|
||||
{{ plot|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
+1
-6
@@ -490,12 +490,7 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
generic_exam_view.exam_stats,
|
||||
name="exam_stats",
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/scores/refresh",
|
||||
generic_exam_view.exam_scores_refresh,
|
||||
# cache_page(60 * 1)(exam_scores_all),
|
||||
name="exam_scores_refresh",
|
||||
),
|
||||
|
||||
path(
|
||||
"exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
||||
generic_exam_view.exam_scores_cid_user,
|
||||
|
||||
+85
-61
@@ -2370,33 +2370,7 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
},
|
||||
)
|
||||
|
||||
@method_decorator(login_required)
|
||||
def exam_scores_refresh(self, request, pk):
|
||||
exam = get_object_or_404(self.Exam, pk=pk)
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
# We don't worry about order here
|
||||
questions = exam.exam_questions.all()
|
||||
|
||||
cids = self.UserAnswer.objects.filter(question__in=questions, exam__id=pk)
|
||||
|
||||
# Force a score update
|
||||
for c in cids:
|
||||
c.get_answer_score(cached=False)
|
||||
|
||||
return render(
|
||||
request,
|
||||
f"{self.app_name}/base.html",
|
||||
{
|
||||
"simple_content": format_html(
|
||||
"""Answer scores updated <br/>
|
||||
<a href='{}'>Return</a>""",
|
||||
reverse(f"{self.app_name}:exam_scores_all", kwargs={"pk": exam.pk}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def exam_cids(self, request, exam_id):
|
||||
"""View that displays an overview of users that have been added to the exam.
|
||||
@@ -3941,11 +3915,12 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
raise PermissionDenied
|
||||
|
||||
table_only = request.GET.get("table_only") == "true" or request.headers.get("HX-Target") == "answers-table-container"
|
||||
stats_only = request.GET.get("stats_only") == "true" or request.headers.get("HX-Target") == "stats-container"
|
||||
|
||||
user_answers_marks = defaultdict(list)
|
||||
by_question = defaultdict(dict)
|
||||
unmarked = set()
|
||||
cached_scores = True
|
||||
cached_scores = False
|
||||
|
||||
valid_cid_users = set(exam.valid_cid_users.all().values_list("cid", flat=True))
|
||||
valid_user_users = set(exam.valid_user_users.all().values_list("pk", flat=True))
|
||||
@@ -3955,7 +3930,6 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
user_answers_callstates_counted = {}
|
||||
|
||||
if self.app_name in ("physics", "sbas", "longs", "shorts"):
|
||||
cached_scores = False
|
||||
questions_qs = exam.get_questions()
|
||||
else:
|
||||
questions_qs = exam.get_questions().prefetch_related("answers")
|
||||
@@ -4004,6 +3978,7 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
index = question_index_map.get(q)
|
||||
if index is not None:
|
||||
unmarked.add(index)
|
||||
|
||||
user_answers_marks[cid].append(answer_score)
|
||||
|
||||
if self.app_name == "rapids":
|
||||
@@ -4051,34 +4026,63 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
|
||||
user_answer_count[user] = len(user_answers_marks[user])
|
||||
|
||||
# ignore scores of 0 for stats
|
||||
user_scores_list = [i for i in user_scores.values() if i > 0]
|
||||
# If table_only and rapids, populate user_answers_callstates_counted
|
||||
if table_only and self.app_name == "rapids":
|
||||
for u in cids:
|
||||
user_answers_callstates_counted[u] = dict(Counter(user_answers_callstates[u]))
|
||||
|
||||
missing_user_ids = valid_user_users - user_ids
|
||||
missing_users = []
|
||||
if missing_user_ids:
|
||||
missing_users = User.objects.filter(pk__in=missing_user_ids)
|
||||
|
||||
max_score = self.get_max_score(questions)
|
||||
|
||||
mean = 0
|
||||
median = 0
|
||||
mode = 0
|
||||
fig_html = ""
|
||||
if stats_only:
|
||||
user_scores_list = [i for i in user_scores.values() if i > 0]
|
||||
mean = 0
|
||||
median = 0
|
||||
mode = 0
|
||||
fig_html = ""
|
||||
std_dev = 0
|
||||
q1 = 0
|
||||
q3 = 0
|
||||
min_score = 0
|
||||
max_score_achieved = 0
|
||||
passed_candidates_count = 0
|
||||
pass_rate = 0.0
|
||||
|
||||
if not table_only:
|
||||
if len(user_scores_list) < 1:
|
||||
mean = 0
|
||||
median = 0
|
||||
mode = 0
|
||||
fig_html = ""
|
||||
else:
|
||||
# Exclude very low scores from statistics
|
||||
if len(user_scores_list) >= 1:
|
||||
lower_score_bound = max_score / 5
|
||||
bounded_scores = [i for i in user_scores_list if i > lower_score_bound]
|
||||
if bounded_scores:
|
||||
user_scores_list = bounded_scores
|
||||
mean = statistics.mean(user_scores_list)
|
||||
median = statistics.median(user_scores_list)
|
||||
|
||||
mean = round(statistics.mean(user_scores_list), 2)
|
||||
median = round(statistics.median(user_scores_list), 2)
|
||||
try:
|
||||
mode = statistics.mode(user_scores_list)
|
||||
if isinstance(mode, (int, float)):
|
||||
mode = round(mode, 2)
|
||||
except statistics.StatisticsError:
|
||||
mode = "No unique mode"
|
||||
|
||||
std_dev = round(statistics.pstdev(user_scores_list), 2)
|
||||
|
||||
if len(user_scores_list) >= 2:
|
||||
quants = statistics.quantiles(user_scores_list, n=4)
|
||||
q1 = round(quants[0], 2)
|
||||
q3 = round(quants[2], 2)
|
||||
else:
|
||||
q1 = round(user_scores_list[0], 2)
|
||||
q3 = round(user_scores_list[0], 2)
|
||||
|
||||
min_score = round(min(user_scores_list), 2)
|
||||
max_score_achieved = round(max(user_scores_list), 2)
|
||||
|
||||
passed_candidates_count = sum(1 for score in user_scores.values() if score >= max_score / 2)
|
||||
pass_rate = round((passed_candidates_count / len(cids)) * 100, 1) if cids else 0.0
|
||||
|
||||
df = user_scores_list
|
||||
fig = px.histogram(
|
||||
df,
|
||||
@@ -4097,8 +4101,8 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
exam.stats_candidates = len(user_scores_list)
|
||||
exam.stats_max_possible = max_score
|
||||
|
||||
exam.stats_min = min(user_scores_list)
|
||||
exam.stats_max = max(user_scores_list)
|
||||
exam.stats_min = min_score
|
||||
exam.stats_max = max_score_achieved
|
||||
|
||||
exam.stats_graph = fig_html
|
||||
|
||||
@@ -4112,20 +4116,45 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
if self.app_name == "rapids":
|
||||
counted_callstates = dict(Counter(user_answers_callstates[u]))
|
||||
user_data[u]["callstates"] = str(counted_callstates)
|
||||
user_answers_callstates_counted[u] = counted_callstates
|
||||
|
||||
exam.user_scores = user_data
|
||||
exam.save()
|
||||
else:
|
||||
# If table_only and rapids, populate user_answers_callstates_counted
|
||||
if self.app_name == "rapids":
|
||||
for u in cids:
|
||||
user_answers_callstates_counted[u] = dict(Counter(user_answers_callstates[u]))
|
||||
|
||||
missing_user_ids = valid_user_users - user_ids
|
||||
missing_users = []
|
||||
if missing_user_ids:
|
||||
missing_users = User.objects.filter(pk__in=missing_user_ids)
|
||||
expected_answer_count = len(cids) * question_number
|
||||
user_answer_count_total = sum(user_answer_count.values())
|
||||
completion_rate = round((user_answer_count_total / expected_answer_count) * 100, 1) if expected_answer_count > 0 else 0.0
|
||||
|
||||
if hasattr(self.Answer, "MarkOptions") and hasattr(self.UserAnswer, "score"):
|
||||
unmarked_count = sum(1 for c in cid_user_answers if c.score == self.Answer.MarkOptions.UNMARKED or c.score is None)
|
||||
elif hasattr(self.UserAnswer, "is_marked"):
|
||||
unmarked_count = sum(1 for c in cid_user_answers if not c.is_marked())
|
||||
elif hasattr(self.UserAnswer, "score"):
|
||||
unmarked_count = sum(1 for c in cid_user_answers if c.score == "" or c.score is None)
|
||||
else:
|
||||
unmarked_count = 0
|
||||
|
||||
context = {
|
||||
"exam": exam,
|
||||
"cids": sorted(cids),
|
||||
"question_number": question_number,
|
||||
"max_score": max_score,
|
||||
"mean": mean,
|
||||
"median": median,
|
||||
"mode": mode,
|
||||
"std_dev": std_dev,
|
||||
"q1": q1,
|
||||
"q3": q3,
|
||||
"min_score": min_score,
|
||||
"max_score_achieved": max_score_achieved,
|
||||
"passed_candidates_count": passed_candidates_count,
|
||||
"pass_rate": pass_rate,
|
||||
"expected_answer_count": expected_answer_count,
|
||||
"user_answer_count_total": user_answer_count_total,
|
||||
"completion_rate": completion_rate,
|
||||
"unmarked_count": unmarked_count,
|
||||
"plot": fig_html,
|
||||
}
|
||||
return render(request, "generic/partials/_exam_scores_stats.html", context)
|
||||
|
||||
template_variables = {
|
||||
"cids": sorted(cids),
|
||||
@@ -4141,11 +4170,6 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
"user_answer_count": user_answer_count,
|
||||
"cids_user_id_map": cids_user_id_map,
|
||||
"max_score": max_score,
|
||||
"mean": mean,
|
||||
"median": median,
|
||||
"mode": mode,
|
||||
"plot": fig_html,
|
||||
"cached_scores": cached_scores,
|
||||
"can_edit": exam.check_user_can_edit(request.user),
|
||||
"base_template": "generic/exam_scores_partial_base.html" if table_only else "generic/exam_scores_base.html",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user