Exam:
{{ eid_str }}
diff --git a/generic/templatetags/help_tags.py b/generic/templatetags/help_tags.py
index bbdb2d10..4197f0df 100644
--- a/generic/templatetags/help_tags.py
+++ b/generic/templatetags/help_tags.py
@@ -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.
diff --git a/generic/tests/test_exam_submit_security.py b/generic/tests/test_exam_submit_security.py
index 8445e84c..72142bb1 100644
--- a/generic/tests/test_exam_submit_security.py
+++ b/generic/tests/test_exam_submit_security.py
@@ -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"
+
+
diff --git a/generic/urls.py b/generic/urls.py
index 3fa1e2c7..fdbf59d7 100755
--- a/generic/urls.py
+++ b/generic/urls.py
@@ -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/
///", 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"),
diff --git a/generic/views.py b/generic/views.py
index c82ffa9a..519f81ee 100644
--- a/generic/views.py
+++ b/generic/views.py
@@ -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:
diff --git a/longs/templates/longs/exam_scores_user.html b/longs/templates/longs/exam_scores_user.html
index 63321671..efd85906 100644
--- a/longs/templates/longs/exam_scores_user.html
+++ b/longs/templates/longs/exam_scores_user.html
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
+{% load help_tags %}
{% block content %}
@@ -23,6 +24,15 @@
{% endif %}
" data-question-number="{{forloop.counter}}">
Question {{forloop.counter}} View
+ {% 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 %}
+
History
+ {% endif %}
+ {% endwith %}
+ {% endwith %}
+ {% endif %}
- Observations
{{ans.0}}
diff --git a/physics/templates/physics/exam_scores_user.html b/physics/templates/physics/exam_scores_user.html
index cf5e1074..5f1e6a5d 100644
--- a/physics/templates/physics/exam_scores_user.html
+++ b/physics/templates/physics/exam_scores_user.html
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
+{% load help_tags %}
{% block content %}
@@ -28,6 +29,13 @@
{% else %}
{{ question.stem|safe }}
{% 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 %}
+
History
+ {% endif %}
+ {% endwith %}
+ {% endif %}
diff --git a/rapids/templates/rapids/exam_scores_user.html b/rapids/templates/rapids/exam_scores_user.html
index 5be9d332..e59577b2 100644
--- a/rapids/templates/rapids/exam_scores_user.html
+++ b/rapids/templates/rapids/exam_scores_user.html
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
+{% load help_tags %}
{% block content %}
@@ -21,6 +22,15 @@
{% for ans, score, correct_answer in answers_and_marks %}
- Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}{{correct_answer}}{% endif %}
View
+ {% 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 %}
+ History
+ {% endif %}
+ {% endwith %}
+ {% endwith %}
+ {% endif %}
diff --git a/sbas/templates/sbas/exam_scores_user.html b/sbas/templates/sbas/exam_scores_user.html
index ca2abc81..6fa2c7eb 100644
--- a/sbas/templates/sbas/exam_scores_user.html
+++ b/sbas/templates/sbas/exam_scores_user.html
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
+{% load help_tags %}
{% block content %}
@@ -25,6 +26,13 @@
{% if exam.publish_results or view_all_results %}
(Score: {{score}})
{% 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 %}
+
History
+ {% endif %}
+ {% endwith %}
+ {% endif %}
diff --git a/shorts/models.py b/shorts/models.py
index cc6556fb..88d96f4a 100644
--- a/shorts/models.py
+++ b/shorts/models.py
@@ -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"""
diff --git a/shorts/templates/shorts/exam_scores_user.html b/shorts/templates/shorts/exam_scores_user.html
index 79c376f3..62bf1b6c 100644
--- a/shorts/templates/shorts/exam_scores_user.html
+++ b/shorts/templates/shorts/exam_scores_user.html
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
+{% load help_tags %}
{% block content %}
@@ -24,6 +25,15 @@
{% for ans, score, correct_answer, feedback in answers_and_marks %}
- Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}{{correct_answer}}{% endif %}
View
+ {% 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 %}
+ History
+ {% endif %}
+ {% endwith %}
+ {% endwith %}
+ {% endif %}
diff --git a/todo b/todo
index cc2cfe17..fa867a17 100644
--- a/todo
+++ b/todo
@@ -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.
\ No newline at end of file
+User answer compaction should only be available to admin users and on an exam wide basis.
\ No newline at end of file