diff --git a/anatomy/templates/anatomy/exam_scores_user.html b/anatomy/templates/anatomy/exam_scores_user.html index 8c38fdc0..f5c31df7 100644 --- a/anatomy/templates/anatomy/exam_scores_user.html +++ b/anatomy/templates/anatomy/exam_scores_user.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load help_tags %} {% block content %}
@@ -52,6 +53,15 @@ {% if request.user.is_staff %} {% 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 %} + History + {% endif %} + {% endwith %} + {% endwith %} + {% endif %}
{% endfor %} diff --git a/atlas/models.py b/atlas/models.py index fb796b48..2f5b6177 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -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) diff --git a/atlas/templates/atlas/collection_take_overview.html b/atlas/templates/atlas/collection_take_overview.html index 890fdee0..5c1be6fd 100644 --- a/atlas/templates/atlas/collection_take_overview.html +++ b/atlas/templates/atlas/collection_take_overview.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load help_tags %} {% block content %}
@@ -98,6 +99,19 @@ {% endif %}
+ {% if request.user.is_staff or request.user.is_superuser or request.user in collection.author.all %} + {% if answer %} + {% if answer.user %} + + History + + {% else %} + + History + + {% endif %} + {% endif %} + {% endif %} Open Case diff --git a/generic/templates/generic/answer_history.html b/generic/templates/generic/answer_history.html new file mode 100644 index 00000000..c05c27de --- /dev/null +++ b/generic/templates/generic/answer_history.html @@ -0,0 +1,108 @@ +{% extends 'generic/base.html' %} +{% load help_tags %} + +{% block content %} +
+
+
+
+
+

Answer Revision History

+ {{ history|length }} version{{ history|pluralize }} +
+
+
+
+ App/Model: {{ app_name }}.{{ model_name }}
+ Answer ID: {{ answer.pk }} +
+
+ {% if exam %} + Exam: {{ exam }} + {% elif collection %} + Case Collection: {{ collection }} + {% endif %} +
+
+ +
+ + + + + + + + + + + + + {% for rev in history %} + + + + + + + + + {% endfor %} + +
Rev IDDate / TimeEditorAnswer ValueScoreComment
#{{ rev.revision_id }}{{ rev.date }} + {% if rev.user %} + {{ rev.user.username }} + {% else %} + System / Anon + {% endif %} + +
{{ rev.answer_text }}
+
+ {% if rev.score is not None and rev.score != "" %} + {{ rev.score }} + {% else %} + - + {% endif %} + {{ rev.comment|default:"-" }}
+
+ + +
+
+ Compact History Settings +
+
+

+ 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. +

+
+
+ {% csrf_token %} + + +
+ + {% if exam or collection %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+
+ +
+ +
+
+
+
+
+
+{% endblock %} diff --git a/generic/templates/generic/import_exam_answers_success.html b/generic/templates/generic/import_exam_answers_success.html index 025630a3..c7249303 100644 --- a/generic/templates/generic/import_exam_answers_success.html +++ b/generic/templates/generic/import_exam_answers_success.html @@ -15,7 +15,7 @@

Answers Imported Successfully

-
+
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/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 %}