Add user answer viewing and compaction

This commit is contained in:
Ross
2026-07-06 09:07:48 +01:00
parent 5ce4a67b31
commit 5a2baaeab8
16 changed files with 370 additions and 4 deletions
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="container anatomy my-3">
@@ -52,6 +53,15 @@
{% if request.user.is_staff %}
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
{% endif %}
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with q=questions|get_item:forloop.counter0 %}
{% with ua=user_answers_map|get_item:q.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='anatomy' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2">History</a>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
</div>
</li>
{% endfor %}
+2
View File
@@ -2592,6 +2592,7 @@ class BaseReportAnswer(models.Model):
abstract = True
@reversion.register
class CidReportAnswer(BaseReportAnswer):
cid = models.BigIntegerField(
blank=False,
@@ -2599,6 +2600,7 @@ class CidReportAnswer(BaseReportAnswer):
)
@reversion.register
class UserReportAnswer(BaseReportAnswer):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
@@ -98,6 +99,19 @@
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2">
{% if request.user.is_staff or request.user.is_superuser or request.user in collection.author.all %}
{% if answer %}
{% if answer.user %}
<a href="{% url 'generic:view_answer_history' app_name='atlas' model_name='UserReportAnswer' pk=answer.pk %}" class="btn btn-sm btn-outline-info">
History
</a>
{% else %}
<a href="{% url 'generic:view_answer_history' app_name='atlas' model_name='CidReportAnswer' pk=answer.pk %}" class="btn btn-sm btn-outline-info">
History
</a>
{% endif %}
{% endif %}
{% endif %}
<a class="btn btn-sm btn-primary"
href="{% if cid is not None %}{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}{% endif %}">
Open Case
@@ -0,0 +1,108 @@
{% extends 'generic/base.html' %}
{% load help_tags %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-10">
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">Answer Revision History</h3>
<span class="badge bg-secondary fs-6">{{ history|length }} version{{ history|pluralize }}</span>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<strong>App/Model:</strong> <span class="text-primary">{{ app_name }}.{{ model_name }}</span><br>
<strong>Answer ID:</strong> <span class="text-primary">{{ answer.pk }}</span>
</div>
<div class="col-md-6 text-md-end">
{% if exam %}
<strong>Exam:</strong> <span class="text-info">{{ exam }}</span>
{% elif collection %}
<strong>Case Collection:</strong> <span class="text-info">{{ collection }}</span>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th style="width: 80px;">Rev ID</th>
<th style="width: 180px;">Date / Time</th>
<th style="width: 150px;">Editor</th>
<th>Answer Value</th>
<th style="width: 100px;">Score</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{% for rev in history %}
<tr>
<td><code>#{{ rev.revision_id }}</code></td>
<td>{{ rev.date }}</td>
<td>
{% if rev.user %}
<span class="badge bg-light text-dark">{{ rev.user.username }}</span>
{% else %}
<span class="text-muted italic">System / Anon</span>
{% endif %}
</td>
<td>
<pre class="mb-0 bg-light text-dark p-2 rounded" style="white-space: pre-wrap; font-size: 0.85rem;">{{ rev.answer_text }}</pre>
</td>
<td>
{% if rev.score is not None and rev.score != "" %}
<span class="badge bg-primary">{{ rev.score }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td><small class="text-muted">{{ rev.comment|default:"-" }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Compacting tools panel -->
<div class="card mt-4 border-warning">
<div class="card-header bg-warning-subtle text-warning-emphasis fw-bold">
<i class="bi bi-scissors me-1"></i> Compact History Settings
</div>
<div class="card-body">
<p class="text-muted small">
Compacting history deletes all older versions and their associated revision logs, retaining only the final answer database state. This is permanent and cannot be undone.
</p>
<div class="d-flex flex-wrap gap-3">
<form method="post" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="action" value="compact_single">
<button type="submit" class="btn btn-warning btn-sm" onclick="return confirm('Are you sure you want to delete all historical edits for this individual answer?');">
Compact Single Answer
</button>
</form>
{% if exam or collection %}
<form method="post" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="action" value="compact_bulk">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete all historical revisions for ALL candidate answers in this entire exam/collection?');">
Compact Entire {% if exam %}Exam{% else %}Collection{% endif %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-secondary" onclick="window.history.back();">Back</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -15,7 +15,7 @@
<h4 class="mb-3">Answers Imported Successfully</h4>
<div class="bg-light p-3 rounded mb-4 text-start">
<div class="bg-dark p-3 rounded mb-4 text-start">
<div class="row mb-2">
<div class="col-6 text-muted">Exam:</div>
<div class="col-6 fw-bold">{{ eid_str }}</div>
+8
View File
@@ -4,6 +4,14 @@ from django.template import Node, TemplateSyntaxError, Variable
register = template.Library()
@register.filter
def get_item(obj, key):
try:
return obj[key]
except (KeyError, TypeError, IndexError):
return None
@register.simple_tag(takes_context=True)
def update_query_param(context, key, value):
"""Return a querystring with a dynamic key updated.
@@ -244,3 +244,50 @@ def test_import_exam_answers_post_success(cid_manager_client, exam_data, logged_
assert answers.count() == 1
assert answers.first().answer == "Imported Test Answer"
@pytest.mark.django_db
def test_answer_history_and_compacting(cid_manager_client, exam_data, logged_in_user):
exam, question = exam_data
from anatomy.models import UserAnswer as AnatomyUserAnswer
import reversion
# 1. Create an answer within a revision context
with reversion.create_revision():
ans = AnatomyUserAnswer.objects.create(
exam=exam,
question=question,
user=logged_in_user,
answer="Version 1",
)
reversion.set_comment("Initial answer")
# 2. Modify the answer within a revision context
with reversion.create_revision():
ans.answer = "Version 2"
ans.save()
reversion.set_comment("Correction")
url = reverse("generic:view_answer_history", kwargs={
"app_name": "anatomy",
"model_name": "UserAnswer",
"pk": ans.pk,
})
# Check GET revision list
response = cid_manager_client.get(url)
assert response.status_code == 200
assert b"Version 1" in response.content
assert b"Version 2" in response.content
assert b"Initial answer" in response.content
# Test Compact Single action
response_compact = cid_manager_client.post(url, {"action": "compact_single"})
assert response_compact.status_code == 302 # redirect
# Verify only 1 version remains
from reversion.models import Version
versions = Version.objects.get_for_object(ans)
assert versions.count() == 1
assert versions.first().field_dict["answer"] == "Version 2"
+1
View File
@@ -85,6 +85,7 @@ urlpatterns = [
),
path("cids/manage/", views.CidUserView.as_view(), name="manage_cids"),
path("cids/manage/import-answers/", views.import_exam_answers, name="import_exam_answers"),
path("answer-history/<str:app_name>/<str:model_name>/<int:pk>/", views.view_answer_history, name="view_answer_history"),
path("cids/manage/exams", views.CidUserExamView.as_view(), name="manage_cid_exams"),
path("cids/group/", views.cid_group_view, name="cid_group_view"),
path("cids/group/all", views.cid_group_view_all, name="cid_group_view_all"),
+130
View File
@@ -3897,6 +3897,7 @@ class ExamViews(View, LoginRequiredMixin):
"total_score": total_score,
"max_score": max_score,
"answers_and_marks": answers_and_marks,
"user_answers_map": user_answers_map,
"view_all_results": view_all_results,
"supervisor": supervisor,
#"cid_user_exam": cid_user_exam,
@@ -5238,6 +5239,135 @@ def import_exam_answers(request):
del request.session["pending_import_data"]
return render(request, "generic/import_exam_answers.html")
def compact_history_for_object(obj):
"""
Helper function to delete all historical revisions for a registered object,
leaving only the most recent version.
"""
from reversion.models import Version
versions = Version.objects.get_for_object(obj).order_by("-revision__date_created")
count = versions.count()
if count > 1:
# Keep the latest version, delete older versions and their revisions
recent_version = versions[0]
old_versions = list(versions[1:])
for v in old_versions:
revision = v.revision
v.delete()
if not revision.version_set.exists():
revision.delete()
return count - 1
return 0
@login_required
def view_answer_history(request, app_name, model_name, pk):
"""
View to display the revision history for a user/candidate answer,
and compact history (individual and bulk).
"""
from django.contrib import messages
from django.apps import apps
from reversion.models import Version
try:
Model = apps.get_model(app_name, model_name)
except LookupError:
raise Http404("Model not found")
answer = get_object_or_404(Model, pk=pk)
# Check permissions
can_view = (
request.user.is_staff
or request.user.is_superuser
or request.user.groups.filter(name__in=[
"cid_user_manager", "anatomy_checker", "rapid_checker", "long_checker"
]).exists()
)
exam = getattr(answer, "exam", None)
collection = None
if hasattr(answer, "question") and hasattr(answer.question, "collection"):
collection = answer.question.collection
if not can_view:
if exam and hasattr(exam, "author") and request.user in exam.author.all():
can_view = True
if collection and hasattr(collection, "author") and request.user in collection.author.all():
can_view = True
if not can_view:
raise PermissionDenied("You do not have permission to view this answer's history")
if request.method == "POST":
action = request.POST.get("action")
if action == "compact_single":
compacted = compact_history_for_object(answer)
messages.success(request, f"Compacted single answer history. Removed {compacted} old revisions.")
return HttpResponseRedirect(request.path)
elif action == "compact_bulk":
compacted_total = 0
if exam:
answers_qs = Model.objects.filter(exam=exam)
for ans in answers_qs:
compacted_total += compact_history_for_object(ans)
messages.success(request, f"Bulk compacted history for all answers in Exam '{exam}'. Removed {compacted_total} old revisions.")
elif collection:
from atlas.models import CidReportAnswer, UserReportAnswer
cids_qs = CidReportAnswer.objects.filter(question__collection=collection)
users_qs = UserReportAnswer.objects.filter(question__collection=collection)
for ans in cids_qs:
compacted_total += compact_history_for_object(ans)
for ans in users_qs:
compacted_total += compact_history_for_object(ans)
messages.success(request, f"Bulk compacted history for all answers in Case Collection '{collection}'. Removed {compacted_total} old revisions.")
return HttpResponseRedirect(request.path)
# GET request
versions = Version.objects.get_for_object(answer).select_related("revision__user").order_by("-revision__date_created")
history_data = []
for v in versions:
fields = v.field_dict
# Format answer contents nicely based on model/app
ans_text = fields.get("answer", "")
if not ans_text:
if model_name.lower() == "useranswer" and app_name.lower() == "longs":
ans_text = (
f"Observations: {fields.get('answer_observations', '')}\n"
f"Interpretation: {fields.get('answer_interpretation', '')}\n"
f"Diagnosis: {fields.get('answer_principle_diagnosis', '')}\n"
f"Differential: {fields.get('answer_differential_diagnosis', '')}\n"
f"Management: {fields.get('answer_management', '')}"
)
elif model_name.lower() == "useranswer" and app_name.lower() == "physics":
ans_text = f"A: {fields.get('a')}, B: {fields.get('b')}, C: {fields.get('c')}, D: {fields.get('d')}, E: {fields.get('e')}"
history_data.append({
"revision_id": v.revision_id,
"date": v.revision.date_created,
"user": v.revision.user,
"answer_text": ans_text,
"score": fields.get("score", ""),
"comment": v.revision.comment,
})
context = {
"answer": answer,
"history": history_data,
"exam": exam,
"collection": collection,
"app_name": app_name,
"model_name": model_name,
}
return render(request, "generic/answer_history.html", context)
@user_is_cid_user_manager
def user_group_add_by_email_user(request, group_id):
if request.htmx:
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="question-display-block">
@@ -23,6 +24,15 @@
{% endif %}
" data-question-number="{{forloop.counter}}"><b>Question {{forloop.counter}}</b> <span class="view-question-link-longs"
data-qn={{forloop.counter0}}>View</span>
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with q=questions|get_item:forloop.counter0 %}
{% with ua=user_answers_map|get_item:q.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='longs' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2" style="font-size: 11px; padding: 2px 6px; vertical-align: middle;">History</a>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
<ul class="">
<li class="Observations" data-qidn="1">Observations</br>
<pre>{{ans.0}}</pre>
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="container physics my-3">
@@ -28,6 +29,13 @@
{% else %}
<a href="{% url 'physics:exam_take' exam.pk forloop.counter0 cid passcode %}" class="text-decoration-none text-body">{{ question.stem|safe }}</a>
{% endif %}
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with ua=user_answers_map|get_item:question.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='physics' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2" style="font-size: 11px; padding: 2px 6px; vertical-align: middle;">History</a>
{% endif %}
{% endwith %}
{% endif %}
</div>
<div class="mb-2">
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="question-display-block">
@@ -21,6 +22,15 @@
{% for ans, score, correct_answer in answers_and_marks %}
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with q=questions|get_item:forloop.counter0 %}
{% with ua=user_answers_map|get_item:q.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='rapids' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2" style="font-size: 11px; padding: 2px 6px; vertical-align: middle;">History</a>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
<br/>
<span class="user-answer-score user-answer-score-{{score}}">
<span class="submitted-user-answer">
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="sbas">
@@ -25,6 +26,13 @@
{% if exam.publish_results or view_all_results %}
<span class="answer-{{score}}">(Score: {{score}})</span>
{% endif %}
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with ua=user_answers_map|get_item:question.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='sbas' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2" style="font-size: 11px; padding: 2px 6px; vertical-align: middle;">History</a>
{% endif %}
{% endwith %}
{% endif %}
</li>
+2
View File
@@ -9,6 +9,7 @@ from rad.settings import REMOTE_URL
from django.utils.translation import gettext_lazy as _
import dicognito
import json
import reversion
# Create your models here.
from generic.models import (
@@ -590,6 +591,7 @@ class Exam(ExamBase):
return reverse("shorts:exam_overview", kwargs={"pk": self.pk})
@reversion.register
class UserAnswer(UserAnswerBase):
"""User answers by candidate"""
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="question-display-block card mb-4" style="background-color: #111; color: #fff;">
@@ -24,6 +25,15 @@
{% for ans, score, correct_answer, feedback in answers_and_marks %}
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with q=questions|get_item:forloop.counter0 %}
{% with ua=user_answers_map|get_item:q.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='shorts' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2" style="font-size: 11px; padding: 2px 6px; vertical-align: middle;">History</a>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
<br/>
<span class="user-answer-score user-answer-score-{{score}}">
<span class="submitted-user-answer">
+1 -3
View File
@@ -1,3 +1 @@
Import exam answers should be accesible as its own page from the admin submenu. it should have a preview mode that shows what is to be imported (and what it will overwrite / update). it should also give some feedback about the imported answers (so we know what has been imported.).
Add manual instructions to rts about how to fully reset the app if the reset parameter fails.
User answer compaction should only be available to admin users and on an exam wide basis.