Add user answer viewing and compaction
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user