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:
|
||||
|
||||
Reference in New Issue
Block a user