This commit is contained in:
Ross
2026-07-06 22:06:58 +01:00
parent 50b80fd0c7
commit fa98541e31
24 changed files with 1258 additions and 98 deletions
+57 -18
View File
@@ -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 (&ge; 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 &amp; IQR</div>
<div class="fs-3 fw-semibold text-light">&sigma; = {{ 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 &amp; 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 (&lt;20% max) excluded from statistics</span>
</div>
<div class="card-body p-0">
<div id="stats-plot-wrapper">
{{ plot|safe }}
</div>
</div>
</div>
{% endif %}