feat: add import exam answers functionality with UI and backend handling
This commit is contained in:
@@ -8,6 +8,13 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-0">Manage Candidates</h2>
|
||||
<a href="{% url 'generic:import_exam_answers' %}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-upload me-1"></i> Import Exam Answers
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span id="manage-span">
|
||||
{% render_table table %}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends 'generic/base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="card-title mb-0">Import Exam Answers</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Upload a JSON file containing exam answers manually exported from RTS.
|
||||
</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="answers_file" class="form-label">Answers JSON File</label>
|
||||
<input type="file" class="form-control" id="answers_file" name="answers_file" accept=".json" required>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||
<a href="{% url 'generic:manage_cids' %}" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Import Answers</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -170,3 +170,65 @@ def test_candidate_submit_success(anonymous_client, exam_data):
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["question_count"] == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cid_manager_client(db):
|
||||
from django.contrib.auth.models import Group
|
||||
user = User.objects.create_user(username="manager1", password="password", email="manager1@test.com")
|
||||
g, _ = Group.objects.get_or_create(name="cid_user_manager")
|
||||
user.groups.add(g)
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_exam_answers_unauthorized(anonymous_client):
|
||||
# Anonymous or non-manager users should be redirected or blocked
|
||||
url = reverse("generic:import_exam_answers")
|
||||
response = anonymous_client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_exam_answers_get(cid_manager_client):
|
||||
url = reverse("generic:import_exam_answers")
|
||||
response = cid_manager_client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b"Import Exam Answers" in response.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_import_exam_answers_post_success(cid_manager_client, exam_data, logged_in_user):
|
||||
exam, question = exam_data
|
||||
from io import BytesIO
|
||||
from anatomy.models import UserAnswer as AnatomyUserAnswer
|
||||
|
||||
import_data = {
|
||||
"eid": f"anatomy/{exam.pk}",
|
||||
"cid": f"u-{logged_in_user.pk}",
|
||||
"start_time": timezone.now().timestamp(),
|
||||
"answers": [
|
||||
{
|
||||
"qid": question.pk,
|
||||
"ans": "Imported Test Answer",
|
||||
"qidn": "1",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
file_content = json.dumps(import_data).encode("utf-8")
|
||||
answers_file = BytesIO(file_content)
|
||||
answers_file.name = "backup.json"
|
||||
|
||||
url = reverse("generic:import_exam_answers")
|
||||
response = cid_manager_client.post(url, {"answers_file": answers_file}, format="multipart")
|
||||
|
||||
assert response.status_code == 302 # redirects to manage_cids on success
|
||||
|
||||
# Verify answers were saved
|
||||
answers = AnatomyUserAnswer.objects.filter(exam=exam, user=logged_in_user)
|
||||
assert answers.count() == 1
|
||||
assert answers.first().answer == "Imported Test Answer"
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ urlpatterns = [
|
||||
name="candidate_email_details",
|
||||
),
|
||||
path("cids/manage/", views.CidUserView.as_view(), name="manage_cids"),
|
||||
path("cids/manage/import-answers/", views.import_exam_answers, name="import_exam_answers"),
|
||||
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"),
|
||||
|
||||
@@ -4900,6 +4900,216 @@ def cid_group_view(request):
|
||||
{"groups": groups},
|
||||
)
|
||||
|
||||
|
||||
@user_is_cid_user_manager
|
||||
def import_exam_answers(request):
|
||||
"""
|
||||
Imports manual JSON exam answers files exported from RTS when direct API submission failed.
|
||||
"""
|
||||
from django.contrib import messages
|
||||
if request.method == "POST":
|
||||
json_file = request.FILES.get("answers_file")
|
||||
if not json_file:
|
||||
messages.error(request, "No file uploaded")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
try:
|
||||
data = json.load(json_file)
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to parse JSON: {e}")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
eid_str = data.get("eid")
|
||||
cid_str = data.get("cid")
|
||||
start_time_val = data.get("start_time")
|
||||
answers_data = data.get("answers")
|
||||
|
||||
if not eid_str or not cid_str or answers_data is None:
|
||||
messages.error(request, "Uploaded JSON is missing required fields (eid, cid, answers)")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
try:
|
||||
exam_type, exam_id_str = eid_str.split("/")
|
||||
eid = int(exam_id_str)
|
||||
except Exception:
|
||||
messages.error(request, f"Invalid eid format: {eid_str}")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
EXAM_IMPORT_MAP = {
|
||||
"physics": (PhysicsExam, PhysicsUserAnswer, "physics"),
|
||||
"anatomy": (AnatomyExam, AnatomyUserAnswer, "anatomy"),
|
||||
"rapid": (RapidsExam, RapidsUserAnswer, "rapids"),
|
||||
"rapids": (RapidsExam, RapidsUserAnswer, "rapids"),
|
||||
"short": (ShortsExam, ShortsUserAnswer, "shorts"),
|
||||
"shorts": (ShortsExam, ShortsUserAnswer, "shorts"),
|
||||
"long": (LongsExam, LongsUserAnswer, "longs"),
|
||||
"longs": (LongsExam, LongsUserAnswer, "longs"),
|
||||
"sba": (SbasExam, SbasUserAnswer, "sbas"),
|
||||
"sbas": (SbasExam, SbasUserAnswer, "sbas"),
|
||||
}
|
||||
|
||||
mapping = EXAM_IMPORT_MAP.get(exam_type.lower())
|
||||
if not mapping:
|
||||
messages.error(request, f"Unsupported exam type: {exam_type}")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
ExamModel, UserAnswerModel, app_name = mapping
|
||||
|
||||
# Check CID/UID type
|
||||
uid = False
|
||||
cid = False
|
||||
if str(cid_str).startswith("u-"):
|
||||
try:
|
||||
uid = int(cid_str[2:])
|
||||
except ValueError:
|
||||
messages.error(request, f"Invalid user CID format: {cid_str}")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
else:
|
||||
try:
|
||||
cid = int(cid_str)
|
||||
except ValueError:
|
||||
messages.error(request, f"Invalid candidate CID format: {cid_str}")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
try:
|
||||
exam = ExamModel.objects.get(pk=eid)
|
||||
except ExamModel.DoesNotExist:
|
||||
messages.error(request, f"Exam {eid} of type {exam_type} does not exist")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
# Parse answers (can be string or array)
|
||||
if isinstance(answers_data, str):
|
||||
try:
|
||||
answers_list = json.loads(answers_data)
|
||||
except Exception as e:
|
||||
messages.error(request, f"Failed to parse answers string JSON: {e}")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
elif isinstance(answers_data, list):
|
||||
answers_list = answers_data
|
||||
else:
|
||||
messages.error(request, "answers field must be a list or a JSON string representation of a list")
|
||||
return HttpResponseRedirect(reverse("generic:import_exam_answers"))
|
||||
|
||||
# Import each answer
|
||||
n = 0
|
||||
for answer in answers_list:
|
||||
try:
|
||||
qid = int(answer["qid"])
|
||||
posted_answer = answer["ans"]
|
||||
qidn = str(answer.get("qidn", "1"))
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if uid:
|
||||
existing_answers = UserAnswerModel.objects.filter(
|
||||
question__id=qid, exam__id=eid, user__id=uid
|
||||
)
|
||||
else:
|
||||
existing_answers = UserAnswerModel.objects.filter(
|
||||
question__id=qid, exam__id=eid, cid=cid
|
||||
)
|
||||
|
||||
ans = None
|
||||
if app_name == "rapids":
|
||||
normal = (posted_answer == "Normal")
|
||||
if not existing_answers:
|
||||
if uid:
|
||||
ans = UserAnswerModel(answer=posted_answer, normal=normal, user=User.objects.get(id=uid))
|
||||
else:
|
||||
ans = UserAnswerModel(answer=posted_answer, normal=normal, cid=cid)
|
||||
ans.question_id = qid
|
||||
ans.exam_id = eid
|
||||
else:
|
||||
ans = existing_answers[0]
|
||||
if qidn == "1":
|
||||
ans.normal = normal
|
||||
if normal:
|
||||
ans.answer = ""
|
||||
elif qidn == "2" and not ans.normal:
|
||||
ans.answer = posted_answer
|
||||
elif app_name == "shorts":
|
||||
if not existing_answers:
|
||||
if uid:
|
||||
ans = UserAnswerModel(answer=posted_answer, user=User.objects.get(id=uid))
|
||||
else:
|
||||
ans = UserAnswerModel(answer=posted_answer, cid=cid)
|
||||
ans.question_id = qid
|
||||
ans.exam_id = eid
|
||||
ans.score = None
|
||||
else:
|
||||
ans = existing_answers[0]
|
||||
ans.answer = posted_answer
|
||||
ans.score = None
|
||||
elif app_name == "anatomy":
|
||||
from anatomy.models import Answer as AnatomyAnswer
|
||||
if not existing_answers:
|
||||
if uid:
|
||||
ans = UserAnswerModel(answer=posted_answer, user=User.objects.get(id=uid))
|
||||
else:
|
||||
ans = UserAnswerModel(answer=posted_answer, cid=cid)
|
||||
ans.question_id = qid
|
||||
ans.exam_id = eid
|
||||
ans.score = AnatomyAnswer.MarkOptions.UNMARKED
|
||||
else:
|
||||
ans = existing_answers[0]
|
||||
ans.answer = posted_answer
|
||||
ans.score = AnatomyAnswer.MarkOptions.UNMARKED
|
||||
elif app_name == "longs":
|
||||
if not existing_answers:
|
||||
if uid:
|
||||
ans = UserAnswerModel(user=User.objects.get(id=uid))
|
||||
else:
|
||||
ans = UserAnswerModel(cid=cid)
|
||||
ans.question_id = qid
|
||||
ans.exam_id = eid
|
||||
else:
|
||||
ans = existing_answers[0]
|
||||
|
||||
if qidn == "1":
|
||||
ans.answer_observations = posted_answer
|
||||
elif qidn == "2":
|
||||
ans.answer_interpretation = posted_answer
|
||||
elif qidn == "3":
|
||||
ans.answer_principle_diagnosis = posted_answer
|
||||
elif qidn == "4":
|
||||
ans.answer_differential_diagnosis = posted_answer
|
||||
elif qidn == "5":
|
||||
ans.answer_management = posted_answer
|
||||
ans.score = ans.ScoreOptions.UNMARKED
|
||||
|
||||
if ans:
|
||||
time_answered_str = answer.get("time_answered")
|
||||
if time_answered_str:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
parsed_time = parse_datetime(time_answered_str)
|
||||
if parsed_time:
|
||||
if timezone.is_naive(parsed_time):
|
||||
parsed_time = timezone.make_aware(parsed_time)
|
||||
ans.time_answered = parsed_time
|
||||
try:
|
||||
ans.full_clean()
|
||||
ans.save()
|
||||
n += 1
|
||||
except Exception as e:
|
||||
messages.warning(request, f"Failed to save answer for question {qid}: {e}")
|
||||
|
||||
# Also get or create CidUserExam
|
||||
try:
|
||||
if uid:
|
||||
t = timezone.now()
|
||||
if start_time_val:
|
||||
t = timezone.datetime.fromtimestamp(start_time_val, tz=timezone.utc)
|
||||
exam.get_or_create_cid_user_exam(
|
||||
user_user=User.objects.get(id=uid), start_time=t
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
messages.success(request, f"Successfully imported {n} answers for CID/UID {cid_str} on exam {eid_str}")
|
||||
return HttpResponseRedirect(reverse("generic:manage_cids"))
|
||||
|
||||
return render(request, "generic/import_exam_answers.html")
|
||||
|
||||
@user_is_cid_user_manager
|
||||
def user_group_add_by_email_user(request, group_id):
|
||||
if request.htmx:
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
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.).
|
||||
|
||||
We need to update RTS have consistent logging that can be enabled and disabled via both the gui and url parameters. All current log statements need to be set with an appropriate level.
|
||||
|
||||
RTS currently supports user login in addition to the cid / passcode however this is not particuarly well implemented and is not secure (if someone knows a users user id they can submit results for that user). this needs to be fixed with a more robust implementation.
|
||||
|
||||
In RTS we need to tidy up the resuming of exams that have already been started.
|
||||
|
||||
In RTS we need to improve the fallback that is shown if we fail to manually submit an exam via the finish button.
|
||||
Add manual instructions to rts about how to fully reset the app if the reset parameter fails.
|
||||
Reference in New Issue
Block a user