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
+3 -1
View File
@@ -10,10 +10,12 @@
{% endfor %} {% endcomment %}
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="anatomy-ans user-answer-score-{{ans_score.1}}"
<td class="anatomy-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}"
title="answer score: {{ans_score.1}}"
data-examquestionindex={{forloop.parentloop.counter0}}
data-cid={{cid}}
data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}"
data-cid-val="{{ cid|lower }}"
>{{ans_score.0}}<a class="show-on-hover" href="{% url 'anatomy:exam_question_user_answer' exam.pk forloop.parentloop.counter0 cid|slice:':1' cid|slice:'2:' %}"><i class="bi bi-pen"></i>
</a></td>
{% endwith %}
+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 %}
+1 -6
View File
@@ -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,
+81 -57
View File
@@ -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)
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",
}
+1 -1
View File
@@ -10,7 +10,7 @@
{% endfor %} {% endcomment %}
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="longs-ans user-answer-score-{{ans_score.1}}" title="{{ans_score.0}}">{{ans_score.1}}</td>
<td class="longs-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}" title="{{ans_score.0}}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">{{ans_score.1}}</td>
{% endwith %}
{% endfor %}
</tr>
+1 -1
View File
@@ -6,7 +6,7 @@
<td>Question {{forloop.counter}}
</td>
{% for cid in cids %}
<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 }}">
<ol class="physics-ans" style="list-style-type: lower-alpha;">
{% with by_question|get_item:question|get_item:cid as ans_score %}
{% for ans, score in ans_score %}
+62
View File
@@ -0,0 +1,62 @@
import pytest
from django.urls import reverse
@pytest.fixture
def logged_in_admin(client, django_user_model):
admin = django_user_model.objects.create_superuser(
username="admin",
email="admin@example.com",
password="adminpassword"
)
client.force_login(admin)
return admin
@pytest.mark.django_db
def test_physics_stats_only_no_attribute_error(client, logged_in_admin):
from physics.models import Exam, Question, UserAnswer
exam = Exam.objects.create(name="Physics Test Exam", exam_mode=True)
exam.author.add(logged_in_admin)
question = Question.objects.create(
stem="stem",
a="a", a_answer=True,
b="b", b_answer=False,
c="c", c_answer=True,
d="d", d_answer=False,
e="e", e_answer=True,
)
question.exams.add(exam)
UserAnswer.objects.create(
question=question,
exam=exam,
a=True, b=False, c=True, d=False, e=True,
cid=1
)
url = reverse("physics:exam_scores_all", kwargs={"pk": exam.pk})
response = client.get(f"{url}?stats_only=true")
assert response.status_code == 200
@pytest.mark.django_db
def test_sbas_stats_only_no_attribute_error(client, logged_in_admin):
from sbas.models import Exam, Question, UserAnswer
exam = Exam.objects.create(name="SBAs Test Exam", exam_mode=True)
exam.author.add(logged_in_admin)
question = Question.objects.create(
stem="stem",
best_answer="A",
)
question.exams.add(exam)
UserAnswer.objects.create(
question=question,
exam=exam,
answer="A",
cid=1
)
url = reverse("sbas:exam_scores_all", kwargs={"pk": exam.pk})
response = client.get(f"{url}?stats_only=true")
assert response.status_code == 200
+1 -1
View File
@@ -12,7 +12,7 @@
</td>
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="user-answer-score-{{ans_score.1}}" title="answer score: {{ans_score.1}} [{{ans_score.2}}]">{{ans_score.0}}</td>
<td class="user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}" title="answer score: {{ans_score.1}} [{{ans_score.2}}]" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">{{ans_score.0}}</td>
{% endwith %}
{% endfor %}
</tr>
+1
View File
@@ -16,6 +16,7 @@
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %}
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:mark2_overview' pk=exam.pk %}">Mark2</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a></li>
+314
View File
@@ -0,0 +1,314 @@
{% extends 'rapids/exams.html' %}
{% block content %}
<div class="row">
<div class="col-md-6" id="main-col">
<div class="card mb-3 bg-dark text-light border-secondary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title mb-1">Marking question {{ question_details.current }} of {{ question_details.total }}</h5>
<div class="mb-2">
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'rapids:question_detail' question.id %}" title="View the Question">View</a>
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'rapids:rapid_update' question.id %}" title="Edit the Question">Edit</a>
{% if request.user.is_superuser %}
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'admin:rapids_rapid_change' question.id %}" title="Admin Edit">Admin Edit</a>
{% endif %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'rapids:mark' exam.id question_number %}">Classic mark</a>
</div>
<h6 class="mb-1 text-info">{% if question.normal %}Normal film{% else %}Abnormal film{% endif %}</h6>
{% if not question.normal %}
<div class="small text-muted">Region: {{ question.get_regions }} | Abnormalities: {{ question.get_abnormalities }}</div>
<div class="mt-2">Primary answer: <span id="primary-answer" class="text-warning fw-semibold" title="The primary answer of the question">{{ question.get_primary_answer }}</span></div>
{% endif %}
</div>
<div class="text-end">
<button class="btn btn-sm btn-outline-primary" type="button" id="toggle-images">Toggle images</button>
</div>
</div>
<div class="mt-3">
<form method="POST" class="post-form">{% csrf_token %}
<p class="small text-muted">Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.</p>
<div class="d-flex justify-content-between align-items-center mb-2">
<div></div>
<div>
{% if question_details.current > 1 %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-1">Previous</button>
{% endif %}
<button type="submit" name="save" class="btn btn-primary btn-sm me-1">Save</button>
{% if question_details.current < question_details.total %}
<button type="submit" name="next" class="btn btn-outline-primary btn-sm me-1">Next</button>
{% endif %}
</div>
</div>
<div class="mb-2">
<button class="btn btn-sm btn-outline-info me-1" hx-get="{% url 'rapids:mark2_exam_marked' exam.id question_number %}" hx-target="#exam-marked-container" hx-swap="innerHTML">Show exam-submitted answers</button>
<button class="btn btn-sm btn-outline-info" hx-get="{% url 'rapids:mark2_question_marked' question.pk %}" hx-target="#question-marked-container" hx-swap="innerHTML">Show all stored answers for question</button>
</div>
<div class="row">
<div class="col-12">
<h6 class="mb-2">Answers</h6>
<ul id="answer-list" class="list-group answer-list mb-3">
{% include 'rapids/partials/_mark2_marked_list_fragment.html' %}
{% include 'rapids/partials/_mark2_unmarked_list_fragment.html' %}
</ul>
<div id="exam-marked-container" class="mt-2"></div>
<div id="question-marked-container" class="mt-2"></div>
</div>
</div>
<div class="mt-3">
<div class="small text-muted key">Key: <span class="key-item correct text-success fw-semibold">2 Marks</span>, <span class="key-item half-correct text-warning fw-semibold">1 Mark</span>, <span class="key-item incorrect text-danger fw-semibold">0 Marks</span></div>
</div>
<span class="visually-hidden">{{ form.as_p }}</span>
</form>
</div>
</div>
</div>
</div>
<div class="col-md-6" id="dicom-col">
<div id="single-dicom-viewer" class="marking-dicom" data-images="{{ question.get_image_url_array }}" data-annotations='{{ question.get_image_annotations }}'></div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
.answer { cursor: pointer; }
.answer.correct { color: #2a9d3f !important; background-color: transparent; }
.answer.half-correct { color: #ffb020 !important; background-color: transparent; }
.answer.incorrect { color: #d3413a !important; background-color: transparent; }
.answer.not-marked { color: inherit; background-color: transparent; }
.answer-list .list-group-item pre { margin: 0; font-family: monospace; white-space: pre-wrap; }
.mark-controls { display:flex; gap:0.25rem; }
.mark-btn { padding: 0.15rem 0.4rem; font-size: 0.8rem; }
.marked-correct { border-left: 4px solid #2a9d3f; }
.marked-half-correct { border-left: 4px solid #ffb020; }
.marked-incorrect { border-left: 4px solid #d3413a; }
.key .key-item { display:inline-block; padding-left:8px; margin-left:6px; }
.key .key-item.correct { border-left: 8px solid #2a9d3f; }
.key .key-item.half-correct { border-left: 8px solid #ffb020; }
.key .key-item.incorrect { border-left: 8px solid #d3413a; }
.answer-actions { min-width: 6rem; }
.answer-actions .action-icon { margin-left: 0.5rem; }
.answer-actions .copy-to-clipboard { cursor: pointer; }
.mark-btn.active { background-color: #0d6efd; color: #fff; border-color: #0d6efd; }
</style>
{% endblock %}
{% block js %}
<script>
(function(){
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Delegated click handler for answers
document.addEventListener('click', function(e){
if (e.target.closest('.answer-actions') || e.target.closest('.mark-controls') || e.target.closest('.copy-to-clipboard')) return;
let spanEl = e.target.closest('span.answer');
if (!spanEl) {
const li = e.target.closest('#answer-list li');
if (!li) return;
spanEl = li.querySelector('span.answer');
if (!spanEl) return;
}
if (!spanEl.classList.contains('correct') && !spanEl.classList.contains('half-correct') && !spanEl.classList.contains('incorrect') && !spanEl.classList.contains('not-marked')) {
spanEl.classList.add('not-marked');
}
const states = ['not-marked', 'correct', 'half-correct', 'incorrect'];
let currentState = 'not-marked';
for (let p of spanEl.classList) {
if (states.includes(p)) { currentState = p; break; }
}
let idx = (states.indexOf(currentState) + 1) % states.length;
const newState = states[idx];
let numeric = null;
if (newState === 'correct') numeric = 2;
else if (newState === 'half-correct') numeric = 1;
else if (newState === 'incorrect') numeric = 0;
setMarkForSpan(spanEl, numeric);
}, false);
function updateMarkedAnswersField() {
const marked = { 'correct': [], 'half-correct': [], 'incorrect': [] };
document.querySelectorAll('#answer-list span.answer').forEach(function(el) {
if (el.classList.contains('correct')) marked.correct.push((el.title || el.textContent).trim());
else if (el.classList.contains('half-correct')) marked['half-correct'].push((el.title || el.textContent).trim());
else if (el.classList.contains('incorrect')) marked.incorrect.push((el.title || el.textContent).trim());
});
let hidden = document.getElementById('id_marked_answers');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'marked_answers';
hidden.id = 'id_marked_answers';
const form = document.querySelector('form.post-form');
if (form) form.appendChild(hidden);
}
hidden.value = JSON.stringify(marked);
}
function attachNumericControls() {
function makeButton(label, cls) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-secondary me-1 mark-btn ' + cls;
b.textContent = label;
return b;
}
document.querySelectorAll('.answer-list li').forEach(function(li) {
if (li.querySelector('.mark-controls')) return;
const span = li.querySelector('span.answer');
if (!span) return;
if (!span.classList.contains('correct') && !span.classList.contains('half-correct') && !span.classList.contains('incorrect') && !span.classList.contains('not-marked')) {
span.classList.add('not-marked');
}
const controls = document.createElement('div');
controls.className = 'mark-controls mt-2';
const b2 = makeButton('2', 'mark-2');
const b1 = makeButton('1', 'mark-1');
const b0 = makeButton('0', 'mark-0');
const bClear = makeButton('', 'mark-clear');
controls.appendChild(b2);
controls.appendChild(b1);
controls.appendChild(b0);
controls.appendChild(bClear);
li.appendChild(controls);
b2.addEventListener('click', function(){ setMarkForSpan(span, 2); });
b1.addEventListener('click', function(){ setMarkForSpan(span, 1); });
b0.addEventListener('click', function(){ setMarkForSpan(span, 0); });
bClear.addEventListener('click', function(){ setMarkForSpan(span, null); });
(function(){
let state = null;
if (span.classList.contains('correct')) state = 2;
else if (span.classList.contains('half-correct')) state = 1;
else if (span.classList.contains('incorrect')) state = 0;
[b2, b1, b0, bClear].forEach(b => b.classList.remove('active'));
if (state === 2) b2.classList.add('active');
else if (state === 1) b1.classList.add('active');
else if (state === 0) b0.classList.add('active');
else bClear.classList.add('active');
})();
});
}
function setMarkForSpan(el, numeric) {
const li = el.closest('li');
let state = 'not-marked';
if (numeric === 2) state = 'correct';
else if (numeric === 1) state = 'half-correct';
else if (numeric === 0) state = 'incorrect';
el.className = 'answer ' + state;
if (li) {
li.classList.remove('marked-correct','marked-half-correct','marked-incorrect','marked-item');
if (state === 'correct') {
li.classList.add('marked-item','marked-correct');
} else if (state === 'half-correct') {
li.classList.add('marked-item','marked-half-correct');
} else if (state === 'incorrect') {
li.classList.add('marked-item','marked-incorrect');
}
}
try {
if (li) {
const btn2 = li.querySelector('.mark-2');
const btn1 = li.querySelector('.mark-1');
const btn0 = li.querySelector('.mark-0');
const btnClear = li.querySelector('.mark-clear');
[btn2, btn1, btn0, btnClear].forEach(b => { if (b) { b.classList.remove('active'); } });
if (numeric === 2 && btn2) btn2.classList.add('active');
else if (numeric === 1 && btn1) btn1.classList.add('active');
else if (numeric === 0 && btn0) btn0.classList.add('active');
else if (numeric === null && btnClear) btnClear.classList.add('active');
}
} catch (err) {}
updateMarkedAnswersField();
}
function initMark2() {
attachNumericControls();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMark2);
} else {
initMark2();
}
document.addEventListener('htmx:afterSwap', function(evt) {
initMark2();
});
const postForm = document.querySelector('form.post-form');
if (postForm) {
postForm.addEventListener('submit', function(e){
try { updateMarkedAnswersField(); } catch (err) {}
});
}
document.addEventListener('click', function(e){
const btn = e.target.closest('.copy-to-clipboard');
if (!btn) return;
const text = btn.getAttribute('data-text');
if (!text) return;
try {
navigator.clipboard.writeText(text);
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function(){ btn.textContent = prev; }, 1200);
} catch (err) {
console.warn('copy failed', err);
}
});
// Toggle images handler matching classic mode
const toggleBtn = document.getElementById("toggle-images");
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
const dicomCol = document.getElementById("dicom-col");
const mainCol = document.getElementById("main-col");
if (dicomCol && mainCol) {
if (dicomCol.style.display === "none") {
dicomCol.style.display = "";
mainCol.className = "col-md-6";
} else {
dicomCol.style.display = "none";
mainCol.className = "col-md-12";
}
}
});
}
})();
</script>
{% endblock %}
@@ -0,0 +1,79 @@
{% extends 'rapids/exams.html' %}
{% block content %}
<div class="container my-4 rapids">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
</div>
<div class="text-end">
<div class="btn-group me-2" role="group" aria-label="listing actions">
<button class="btn btn-sm btn-outline-secondary show-all-button">Show all</button>
<button class="btn btn-sm btn-outline-secondary show-unmarked-button">Show unmarked</button>
</div>
<a href="{% url 'rapids:mark2' exam_pk=exam.pk sk=0 %}" class="btn btn-sm btn-primary">Start mark2</a>
</div>
</div>
<div class="card bg-dark text-light border-secondary">
<div class="card-body p-2">
<ul class="list-group list-group-flush" id="question-mark-list">
{% for question, unmarked_count, unmarked_count2 in question_unmarked_map %}
<li class="list-group-item bg-dark text-light border-secondary d-flex justify-content-between align-items-start" data-unmarked="{{ unmarked_count }}">
<div class="me-3">
<a href="{% url 'rapids:mark2' exam_pk=exam.pk sk=forloop.counter0 %}" class="fw-semibold text-info">Question {{ forloop.counter }}:</a>
<div class="small text-truncate" style="max-width:60vw;">{{ question }}</div>
</div>
<div class="text-end">
<div class="small text-muted">Unmarked</div>
{% if unmarked_count > 0 %}
<span class="badge bg-danger">{{ unmarked_count }}</span>
{% else %}
<span class="badge bg-secondary">{{ unmarked_count }}</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item bg-dark text-muted small">No questions found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<script>
(function(){
function $(sel, ctx){ return (ctx||document).querySelector(sel); }
function $all(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
var showAllBtn = $('.show-all-button');
var showUnmarkedBtn = $('.show-unmarked-button');
var list = $('#question-mark-list');
if (!list) return;
function showAll(){
$all('#question-mark-list .list-group-item').forEach(function(li){ li.classList.remove('d-none'); });
showAllBtn && showAllBtn.classList.add('active');
showUnmarkedBtn && showUnmarkedBtn.classList.remove('active');
}
function showUnmarked(){
$all('#question-mark-list .list-group-item').forEach(function(li){
var u = parseInt(li.getAttribute('data-unmarked') || '0', 10);
if (u > 0) li.classList.remove('d-none'); else li.classList.add('d-none');
});
showUnmarkedBtn && showUnmarkedBtn.classList.add('active');
showAllBtn && showAllBtn.classList.remove('active');
}
if (showAllBtn) showAllBtn.addEventListener('click', function(e){ e.preventDefault(); showAll(); });
if (showUnmarkedBtn) showUnmarkedBtn.addEventListener('click', function(e){ e.preventDefault(); showUnmarked(); });
// Initialize to show all
showAll();
})();
</script>
<style>
.show-all-button.active, .show-unmarked-button.active { box-shadow: inset 0 -2px 0 rgba(0,0,0,0.2); }
</style>
{% endblock %}
@@ -0,0 +1,35 @@
<div class="card mt-2 bg-dark text-light border-secondary">
<div class="card-body">
<h6 class="card-title">Edit stored answer</h6>
<form hx-post="{% url 'rapids:mark2_edit_answer' %}" hx-target="closest .card" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="original" value="{{ original }}">
{% if exam_pk %}
<input type="hidden" name="exam_pk" value="{{ exam_pk }}">
{% endif %}
{% if sk is not None %}
<input type="hidden" name="sk" value="{{ sk }}">
{% endif %}
<div class="mb-2">
<label class="form-label">Original</label>
<pre class="small bg-secondary text-light p-2">{{ original }}</pre>
</div>
<div class="mb-2">
<label class="form-label">New answer text</label>
<textarea class="form-control bg-dark text-light border-secondary" name="new_answer" rows="3">{% if existing %}{{ existing.answer }}{% else %}{{ original }}{% endif %}</textarea>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
{% if exam_pk and sk is not None %}
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'rapids:mark2_exam_marked' exam_pk sk %}" hx-swap="outerHTML" hx-target="closest .card">Cancel</button>
{% else %}
<button type="button" class="btn btn-outline-secondary btn-sm" _="on click remove .card">Cancel</button>
{% endif %}
</div>
</form>
</div>
</div>
@@ -0,0 +1,78 @@
{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %}
<div class="card mt-2 bg-dark text-light border-secondary">
<div class="card-body">
<style>
/* Hide edit buttons and mark-action forms by default; show only when card has editing-enabled */
.card .edit-btn { display: none !important; }
.card.editing-enabled .edit-btn { display: inline-block !important; }
.card .mark-actions { display: none !important; }
.card.editing-enabled .mark-actions { display: inline-block !important; }
</style>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
<div class="d-flex gap-2 align-items-center">
<div class="btn-group btn-group-sm" role="group" aria-label="Order">
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'count' or not request.GET.order %} active{% endif %}" hx-get="{{ request.path }}?order=count" hx-swap="outerHTML" hx-target="closest .card">By count</button>
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'mark' %} active{% endif %}" hx-get="{{ request.path }}?order=mark" hx-swap="outerHTML" hx-target="closest .card">By score</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="this.closest('.card').classList.toggle('editing-enabled'); this.textContent = this.closest('.card').classList.contains('editing-enabled') ? 'Disable editing' : 'Enable editing'">Enable editing</button>
</div>
</div>
{% if not items %}
<div class="small text-muted">No answers submitted in this exam.</div>
{% else %}
<ul class="list-group">
{% for it in items %}
<li class="list-group-item bg-dark text-light border-secondary d-flex justify-content-between align-items-center">
<div class="flex-grow-1 me-3"><pre class="mb-0 text-light">{{ it.answer }}</pre></div>
<div class="text-end d-flex align-items-center gap-2">
{# Stored mark badge #}
{% if it.mark == 'correct' %}
<span class="badge bg-success me-1" title="Stored answer marked as correct (2)">2</span>
{% elif it.mark == 'half-correct' %}
<span class="badge bg-warning text-dark me-1" title="Stored answer marked as half-correct (1)">1</span>
{% elif it.mark == 'incorrect' %}
<span class="badge bg-danger me-1" title="Stored answer marked as incorrect (0)">0</span>
{% else %}
<span class="badge bg-secondary me-1" title="No stored mark for this answer">-</span>
{% endif %}
{# Submission count badge #}
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
<form hx-post="{% url 'rapids:mark2_toggle_answer' %}" hx-target="closest .card" hx-swap="outerHTML" class="d-inline mark-actions">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="answer" value="{{ it.answer }}">
<input type="hidden" name="exam_pk" value="{{ exam.pk }}">
<input type="hidden" name="sk" value="{{ sk }}">
<button class="btn btn-sm btn-outline-success" name="newmark" value="correct" title="Mark as correct (2)">2</button>
<button class="btn btn-sm btn-outline-warning text-dark" name="newmark" value="half-correct" title="Mark as half-correct (1)">1</button>
<button class="btn btn-sm btn-outline-danger" name="newmark" value="incorrect" title="Mark as incorrect (0)">0</button>
<button class="btn btn-sm btn-outline-secondary" name="newmark" value="clear" title="Clear stored mark"></button>
</form>
<button class="btn btn-sm btn-outline-secondary edit-btn" hx-get="{% url 'rapids:mark2_edit_answer' %}?question_pk={{ question.pk }}&original={{ it.answer|urlencode }}&exam_pk={{ exam.pk }}&sk={{ sk }}" hx-target="closest .card" hx-swap="outerHTML" title="Edit stored answer">Edit</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
(function(){
document.querySelectorAll('.card').forEach(function(card){
function update(){
const enable = card.classList.contains('editing-enabled');
card.querySelectorAll('.edit-btn').forEach(function(b){
b.classList.toggle('d-none', !enable);
});
}
update();
const obs = new MutationObserver(update);
obs.observe(card, { attributes: true, attributeFilter: ['class'] });
});
})();
</script>
@@ -0,0 +1,43 @@
{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %}
{% for answer in correct_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item bg-dark text-light border-secondary d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer correct text-success" title="{{ answer }}" data-answerurl="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon text-muted" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard text-muted" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon text-muted" href="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in half_mark_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item bg-dark text-light border-secondary d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer half-correct text-warning" title="{{ answer }}" data-answerurl="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon text-muted" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard text-muted" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon text-muted" href="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in incorrect_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item bg-dark text-light border-secondary d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect text-danger" title="{{ answer }}" data-answerurl="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon text-muted" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard text-muted" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon text-muted" href="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
@@ -0,0 +1,41 @@
{% comment %}All stored Answer choices for this question grouped by status{% endcomment %}
<div class="card mt-2 bg-dark text-light border-secondary">
<div class="card-body">
<h6 class="card-title">All stored answers for this question</h6>
<style>
.card .list-group-item pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
</style>
<div class="row">
<div class="col-12 col-lg-4">
<h6>2 marks</h6>
<ul class="list-group mb-2">
{% for answer in correct_answers %}
<li class="list-group-item bg-dark text-light border-secondary"><pre class="mb-0 text-light">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item bg-dark text-muted border-secondary small">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>1 mark</h6>
<ul class="list-group mb-2">
{% for answer in half_mark_answers %}
<li class="list-group-item bg-dark text-light border-secondary"><pre class="mb-0 text-light">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item bg-dark text-muted border-secondary small">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>0 marks</h6>
<ul class="list-group mb-2">
{% for answer in incorrect_answers %}
<li class="list-group-item bg-dark text-light border-secondary"><pre class="mb-0 text-light">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item bg-dark text-muted border-secondary small">None</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,15 @@
{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %}
{% for answer in user_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item bg-dark text-light border-secondary d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer not-marked text-light" title="{{ answer }}" data-answerurl="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon text-muted" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard text-muted" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon text-muted" href="{% url 'rapids:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
+28
View File
@@ -372,3 +372,31 @@ def test_cookies(db, client):
#assert "csrftoken" in [i.text for i in soup.find_all("th")]
#assert "sessionid" in [i.text for i in soup.find_all("th")]
def test_rapids_mark2_views(db, client, create_superuser):
client.force_login(create_superuser)
from rapids.models import Exam, Rapid
exam = Exam.objects.create(name="Test Exam", exam_mode=True)
rapid = Rapid.objects.create(laterality=Rapid.NONE, normal=True)
exam.exam_questions.add(rapid)
# 1. Test mark2_overview
url_overview = reverse("rapids:mark2_overview", kwargs={"pk": exam.pk})
resp = client.get(url_overview)
assert resp.status_code == 200
# 2. Test mark2 question view
url_mark2 = reverse("rapids:mark2", kwargs={"exam_pk": exam.pk, "sk": 0})
resp = client.get(url_mark2)
assert resp.status_code == 200
# 3. Test mark2_exam_marked partial
url_exam_marked = reverse("rapids:mark2_exam_marked", kwargs={"exam_pk": exam.pk, "sk": 0})
resp = client.get(url_exam_marked)
assert resp.status_code == 200
# 4. Test mark2_question_marked partial
url_question_marked = reverse("rapids:mark2_question_marked", kwargs={"pk": rapid.pk})
resp = client.get(url_question_marked)
assert resp.status_code == 200
+6
View File
@@ -57,6 +57,12 @@ urlpatterns = [
path(
"exam/<int:exam_pk>/<int:sk>/mark/review", views.mark_review, name="mark_review"
),
path("exam/<int:exam_pk>/<int:sk>/mark2", views.mark2, name="mark2"),
path("exam/<int:pk>/mark2", views.mark2_overview, name="mark2_overview"),
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
path("exam/<int:pk>/markers", views.ExamMarkersUpdate.as_view(), name="exam_markers"),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path("exam/<int:pk>/authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"),
+324
View File
@@ -19,6 +19,7 @@ from django.urls import reverse_lazy, reverse
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.utils.safestring import mark_safe
from django.db.models import Count
from .forms import (
ExamAuthorForm,
@@ -1054,3 +1055,326 @@ def question_add_exam(request, question_id: int):
raise PermissionDenied()
@login_required
def mark2(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
"""Improved marking UI (mark2) for rapids."""
exam = get_object_or_404(Exam, pk=exam_pk)
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
mark_url = "rapids:mark2"
if review:
mark_url = "rapids:mark2"
questions = exam.get_questions()
n = sk
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
if request.method == "POST":
form = MarkRapidQuestionForm(request.POST)
if form.is_valid():
if "skip" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
cd = form.cleaned_data
marked_answers = json.loads(cd.get("marked_answers"))
to_run = (
("correct", Answer.MarkOptions.CORRECT),
("half-correct", Answer.MarkOptions.HALF_MARK),
("incorrect", Answer.MarkOptions.INCORRECT),
)
for status_string, status in to_run:
for ans in marked_answers[status_string]:
ans = ans.strip()
if ans == "":
continue
a = Answer.objects.filter(answer__iexact=ans, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = ans
a.status = status
a.save()
if "next" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
elif "previous" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
else:
form = MarkRapidQuestionForm()
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
if question.normal:
incorrect_answers = question.get_user_answers(exam_pk=exam_pk)
if "" in incorrect_answers:
incorrect_answers.remove("")
if "normal" in incorrect_answers:
incorrect_answers.remove("normal")
else:
if review:
unmarked_user_answers = question.get_user_answers(exam_pk=exam_pk, include_normal=False)
elif unmarked_exam_answers_only:
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
else:
unmarked_user_answers = question.get_unmarked_user_answers()
if not unmarked_exam_answers_only:
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
if review and not question.normal:
for ans in unmarked_user_answers:
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
mark = "unmarked"
if marked_ans is not None:
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = "correct"
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
mark = "half-correct"
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = "incorrect"
if mark == "unmarked":
unmarked_answers_bool = True
review_user_answers.append((ans, mark))
context = {
"exam": exam,
"form": form,
"review": review,
"question": question,
"question_number": n,
"question_details": question_details,
"unmarked_answers_bool": unmarked_answers_bool,
"user_answers": unmarked_user_answers,
"review_user_answers": review_user_answers,
"correct_answers": correct_answers,
"half_mark_answers": half_mark_answers,
"incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only,
}
return render(request, "rapids/mark2.html", context)
@login_required
def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts."""
exam = get_object_or_404(Exam, pk=pk)
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
questions = exam.get_questions()
question_unmarked_map = []
for q in questions:
count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk))
question_unmarked_map.append((q, count, count))
return render(request, "rapids/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
@login_required
def mark2_toggle_answer(request):
"""Endpoint to toggle/set a single answer mark quickly."""
if request.method != "POST":
return JsonResponse({"status": "error", "message": "POST required"}, status=400)
question_pk = request.POST.get("question_pk") or request.POST.get("question")
answer_text = request.POST.get("answer")
newmark = request.POST.get("newmark")
if not question_pk or not answer_text or not newmark:
return JsonResponse({"status": "error", "message": "missing parameters"}, status=400)
try:
question = Rapid.objects.get(pk=question_pk)
except Rapid.DoesNotExist:
return JsonResponse({"status": "error", "message": "question not found"}, status=404)
exams = list(question.exams.all())
if exams:
exam_pk = exams[0].pk
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
mark_map = {
"correct": Answer.MarkOptions.CORRECT,
"half-correct": Answer.MarkOptions.HALF_MARK,
"incorrect": Answer.MarkOptions.INCORRECT,
}
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if newmark == 'clear':
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is not None:
a.delete()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
if newmark not in mark_map:
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
status_val = mark_map[newmark]
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = answer_text
a.status = status_val
a.save()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
@login_required
def mark2_exam_marked(request, exam_pk, sk):
"""Return a partial listing of answers submitted for this question within the given exam."""
exam = get_object_or_404(Exam, pk=exam_pk)
questions = exam.get_questions()
try:
question = questions[sk]
except Exception:
raise Http404("Question not found in exam")
ua_qs = question.cid_user_answers.filter(exam=exam)
order = request.GET.get('order', 'count')
grouped = ua_qs.values('answer_compare').annotate(count=Count('id'))
items = []
for row in grouped:
ans = row['answer_compare']
if ans == 'normal' or ans == '':
continue
cnt = row['count']
a = question.answers.filter(answer_compare__iexact=ans).first()
mark = None
if a is not None:
if a.status == Answer.MarkOptions.CORRECT:
mark = 'correct'
elif a.status == Answer.MarkOptions.HALF_MARK:
mark = 'half-correct'
elif a.status == Answer.MarkOptions.INCORRECT:
mark = 'incorrect'
items.append({'answer': ans, 'count': cnt, 'mark': mark})
if order == 'mark':
weight = {'correct': 3, 'half-correct': 2, 'incorrect': 1, None: 0}
items.sort(key=lambda x: (weight.get(x.get('mark')), x.get('count', 0)), reverse=True)
else:
items.sort(key=lambda x: x.get('count', 0), reverse=True)
context = {'exam': exam, 'question': question, 'items': items, 'sk': sk}
return render(request, 'rapids/partials/_mark2_exam_marked_fragment.html', context)
@login_required
def mark2_edit_answer(request):
"""HTMX-friendly endpoint to edit/create a stored Answer for a question."""
if request.method == 'GET' and request.htmx:
question_pk = request.GET.get('question_pk')
original = request.GET.get('original', '')
exam_pk = request.GET.get('exam_pk')
sk = request.GET.get('sk')
question = get_object_or_404(Rapid, pk=question_pk)
existing = question.answers.filter(answer_compare__iexact=original).first()
context = {
'question': question,
'original': original,
'existing': existing,
'exam_pk': exam_pk,
'sk': sk,
}
return render(request, 'rapids/partials/_mark2_edit_answer_fragment.html', context)
if request.method == 'POST' and request.htmx:
question_pk = request.POST.get('question_pk')
original = request.POST.get('original', '')
new_text = request.POST.get('new_answer', '').strip()
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if not question_pk or not new_text:
return HttpResponse('Missing parameters', status=400)
question = get_object_or_404(Rapid, pk=question_pk)
a = question.answers.filter(answer_compare__iexact=original).first()
if a is None:
a = Answer()
a.question = question
a.answer = new_text
else:
a.answer = new_text
a.save()
if exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return HttpResponse('OK')
return HttpResponse('OK')
raise PermissionDenied()
@login_required
def mark2_question_marked(request, pk):
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
question = get_object_or_404(Rapid, pk=pk)
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
context = {
'question': question,
'correct_answers': correct_answers,
'half_mark_answers': half_mark_answers,
'incorrect_answers': incorrect_answers,
}
return render(request, 'rapids/partials/_mark2_question_marked_fragment.html', context)
+1 -1
View File
@@ -11,7 +11,7 @@
{% endfor %} {% endcomment %}
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="sbas-ans user-answer-score-{{ans_score.1}}" title="answer score: {{ans_score.1}}">{{ans_score.0}}</td>
<td class="sbas-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}" title="answer score: {{ans_score.1}}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">{{ans_score.0}}</td>
{% endwith %}
{% endfor %}
</tr>
+1 -1
View File
@@ -10,7 +10,7 @@
{% endfor %} {% endcomment %}
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="shorts-ans " title="{{ans_score.0}}">{{ans_score.1|default_if_none:"Unmarked"}}</td>
<td class="shorts-ans col-cid-cell col-cid-{{ cid|slugify }}" title="{{ans_score.0}}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">{{ans_score.1|default_if_none:"Unmarked"}}</td>
{% endwith %}
{% endfor %}
</tr>
+2 -4
View File
@@ -1,7 +1,5 @@
Remove the cache links such as "refresh the scores" if they are no longer required.
It should be posssible to set the pass mark for an exam. this should be updatable from the scores page.
Load the stats panels as htmx partials. please also add additional stats that would be useful and style it more nicely.
when calculating stats we should be able to exclude users (such as those who have not completed the exam) from the stats calculations.
the anatomy app features a new marking mode mark2. we want to port a similar new marking mode to the rapids app. it should be styled in a similar way to the mark2 mode in anatomy and bring across any new features (that are applicable)
Both the candidate list and the answers table should allow filtering by candidates to allow them to be quickly viewed.