feat: restrict user answer compaction to admin users and update related tests

This commit is contained in:
Ross
2026-07-06 09:43:01 +01:00
parent daebdc1844
commit e56c1f2d0f
7 changed files with 53 additions and 45 deletions
+2 -3
View File
@@ -240,12 +240,11 @@ def test_exams(db, client):
res = client.post(reverse("global_exam_answers_submit"), data=post_json) res = client.post(reverse("global_exam_answers_submit"), data=post_json)
json_res = json.loads(res.content) json_res = json.loads(res.content)
assert json_res["success"] == False assert json_res["success"] == False
assert json_res["error"] == "Invalid data" assert json_res["error"] in ("Invalid data", "user / cid mismatch")
valid_answers = [] valid_answers = []
for questions in exam_json["questions"]: for qid in exam_json["questions"]:
for qid in questions:
post_data = { post_data = {
"eid": exam_json["eid"], "eid": exam_json["eid"],
"cid": post_cid, "cid": post_cid,
@@ -66,6 +66,7 @@
</table> </table>
</div> </div>
{% if request.user.is_superuser or request.user.is_staff %}
<!-- Compacting tools panel --> <!-- Compacting tools panel -->
<div class="card mt-4 border-warning"> <div class="card mt-4 border-warning">
<div class="card-header bg-warning-subtle text-warning-emphasis fw-bold"> <div class="card-header bg-warning-subtle text-warning-emphasis fw-bold">
@@ -76,14 +77,6 @@
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. 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> </p>
<div class="d-flex flex-wrap gap-3"> <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 %} {% if exam or collection %}
<form method="post" style="display:inline;"> <form method="post" style="display:inline;">
{% csrf_token %} {% csrf_token %}
@@ -96,6 +89,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-secondary" onclick="window.history.back();">Back</button> <button type="button" class="btn btn-secondary" onclick="window.history.back();">Back</button>
+18 -3
View File
@@ -280,11 +280,26 @@ def test_answer_history_and_compacting(cid_manager_client, exam_data, logged_in_
assert b"Version 2" in response.content assert b"Version 2" in response.content
assert b"Initial answer" in response.content assert b"Initial answer" in response.content
# Test Compact Single action # Test that compact_single is forbidden
response_compact = cid_manager_client.post(url, {"action": "compact_single"}) response_compact_single = cid_manager_client.post(url, {"action": "compact_single"})
assert response_compact_single.status_code == 403 # forbidden/denied
# Test Compact Bulk action is forbidden for non-admin/non-staff
response_compact_bulk_fail = cid_manager_client.post(url, {"action": "compact_bulk"})
assert response_compact_bulk_fail.status_code == 403
# Elevate the user to superuser/admin to test successful bulk compaction
from django.contrib.auth import get_user_model
User = get_user_model()
manager_user = User.objects.get(username="manager1")
manager_user.is_superuser = True
manager_user.save()
# Test Compact Bulk action (exam-wide compaction) succeeds for admin
response_compact = cid_manager_client.post(url, {"action": "compact_bulk"})
assert response_compact.status_code == 302 # redirect assert response_compact.status_code == 302 # redirect
# Verify only 1 version remains # Verify only 1 version remains for our answer object
from reversion.models import Version from reversion.models import Version
versions = Version.objects.get_for_object(ans) versions = Version.objects.get_for_object(ans)
assert versions.count() == 1 assert versions.count() == 1
+7 -7
View File
@@ -3784,7 +3784,7 @@ class ExamViews(View, LoginRequiredMixin):
# Load questions; prefetch 'answers' for apps that use them to avoid repeated DB hits # Load questions; prefetch 'answers' for apps that use them to avoid repeated DB hits
questions_qs = exam.get_questions() questions_qs = exam.get_questions()
if self.app_name not in ("physics", "sbas"): if self.app_name == "anatomy":
questions_qs = questions_qs.prefetch_related("answers") questions_qs = questions_qs.prefetch_related("answers")
# Materialize questions list for iteration # Materialize questions list for iteration
@@ -5302,13 +5302,11 @@ def view_answer_history(request, app_name, model_name, pk):
raise PermissionDenied("You do not have permission to view this answer's history") raise PermissionDenied("You do not have permission to view this answer's history")
if request.method == "POST": if request.method == "POST":
action = request.POST.get("action") if not (request.user.is_superuser or request.user.is_staff):
if action == "compact_single": raise PermissionDenied("Only admin users can compact history")
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": action = request.POST.get("action")
if action == "compact_bulk":
compacted_total = 0 compacted_total = 0
if exam: if exam:
answers_qs = Model.objects.filter(exam=exam) answers_qs = Model.objects.filter(exam=exam)
@@ -5325,6 +5323,8 @@ def view_answer_history(request, app_name, model_name, pk):
compacted_total += compact_history_for_object(ans) 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.") messages.success(request, f"Bulk compacted history for all answers in Case Collection '{collection}'. Removed {compacted_total} old revisions.")
return HttpResponseRedirect(request.path) return HttpResponseRedirect(request.path)
else:
raise PermissionDenied("Invalid compaction action or action not allowed on an individual answer basis.")
# GET request # GET request
versions = Version.objects.get_for_object(answer).select_related("revision__user").order_by("-revision__date_created") versions = Version.objects.get_for_object(answer).select_related("revision__user").order_by("-revision__date_created")
+15 -16
View File
@@ -353,7 +353,7 @@ def test_exams(db, client):
res = client.post(reverse("global_exam_answers_submit"), data=post_json) res = client.post(reverse("global_exam_answers_submit"), data=post_json)
json_res = json.loads(res.content) json_res = json.loads(res.content)
assert json_res["success"] == False assert json_res["success"] == False
assert json_res["error"] == "Invalid data" assert json_res["error"] in ("Invalid data", "user / cid mismatch")
# Long answers currently send a seperate object for each component # Long answers currently send a seperate object for each component
valid_answers = [] valid_answers = []
@@ -369,22 +369,21 @@ def test_exams(db, client):
) )
i = 0 i = 0
for questions in exam_json["questions"]: for qid in exam_json["questions"]:
for qid in questions: # Long answers submit 5 sections
# Long answers submit 5 sections for n, name in sections:
for n, name in sections: post_data = {
post_data = { "eid": exam_json["eid"],
"eid": exam_json["eid"], "cid": post_cid,
"cid": post_cid, "qid": qid,
"qid": qid, "qidn": n,
"qidn": n, "ans": f"{name} - {q}",
"ans": f"{name} - {q}", }
} if testing_cid_user:
if testing_cid_user: post_data["passcode"] = cid_user.passcode
post_data["passcode"] = cid_user.passcode
valid_answers_extra.append(post_data) valid_answers_extra.append(post_data)
valid_answers_by_question[i][n] = post_data valid_answers_by_question[i][n] = post_data
i = i + 1 i = i + 1
post_json = { post_json = {
+5 -6
View File
@@ -256,15 +256,14 @@ def test_exams(db, client):
res = client.post(reverse("global_exam_answers_submit"), data=post_json) res = client.post(reverse("global_exam_answers_submit"), data=post_json)
json_res = json.loads(res.content) json_res = json.loads(res.content)
assert json_res["success"] == False assert json_res["success"] == False
assert json_res["error"] == "Invalid data" assert json_res["error"] in ("Invalid data", "user / cid mismatch")
# Rapid answers currently send a seperate object for each component # Rapid answers currently send a seperate object for each component
valid_answers = [] valid_answers = []
valid_answers_extra = [] valid_answers_extra = []
for questions in exam_json["questions"]: for qid in exam_json["questions"]:
for qid in questions:
post_data = { post_data = {
"eid": exam_json["eid"], "eid": exam_json["eid"],
"cid": post_cid, "cid": post_cid,
@@ -340,7 +339,7 @@ def test_exams(db, client):
) )
assert search_exam assert search_exam
assert str(exam) in str(search_exam) assert str(exam) in str(search_exam)
assert search_exam.find("a", string="Start") assert len(search_exam.find("button", {"class": "start-button"})) > 0
results_exam = ( results_exam = (
cid_scores_soup.find("div", {"id": "exam-results"}) cid_scores_soup.find("div", {"id": "exam-results"})
@@ -353,9 +352,9 @@ def test_exams(db, client):
results_exam results_exam
) # It should not be published yet ) # It should not be published yet
else: else:
search_exam = cid_scores_soup.find("a", href=exam.get_take_url()) search_exam = cid_scores_soup.find("a", href=exam.get_take_url_exam_mode())
assert search_exam assert search_exam
assert search_exam.string == "Start" assert search_exam.find("button").string == "Start"
results_exam = cid_scores_soup.find( results_exam = cid_scores_soup.find(
"a", "a",
+4 -2
View File
@@ -1,6 +1,8 @@
User answer compaction should only be available to admin users and on an exam wide basis. User answer compaction should only be available to admin users and only on an exam wide basis.
We need to update tests so that they can handle the new format of answer submission from rts: res = client.post(reverse("global_exam_answers_submit"), data=post_json) We need to update tests so that they can handle the new format of answer submission from rts (although this may actually be due to a bug in the underlying submission code):
res = client.post(reverse("global_exam_answers_submit"), data=post_json)
json_res = json.loads(res.content) json_res = json.loads(res.content)
assert json_res["success"] == False assert json_res["success"] == False
> assert json_res["error"] == "Invalid data" > assert json_res["error"] == "Invalid data"