diff --git a/anatomy/migrations/0030_useranswer_time_answered.py b/anatomy/migrations/0030_useranswer_time_answered.py new file mode 100644 index 00000000..d1936408 --- /dev/null +++ b/anatomy/migrations/0030_useranswer_time_answered.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-29 09:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0029_exam_post_survey_mandatory_exam_pre_survey_mandatory'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='time_answered', + field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True), + ), + ] diff --git a/deploy/nginx/dev.conf b/deploy/nginx/dev.conf index 5c5fdc81..7f25adcf 100644 --- a/deploy/nginx/dev.conf +++ b/deploy/nginx/dev.conf @@ -13,6 +13,15 @@ server { client_max_body_size 100M; + location /rts/ { + #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + #proxy_set_header X-Forwarded-Proto $scheme; + #proxy_set_header Host $host; + #proxy_redirect off; + #proxy_buffering off; + alias /usr/src/app/rts; + } + location /static/ { alias /usr/src/app/static/; expires 30d; diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8b430a06..c9d62a12 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -19,6 +19,7 @@ services: - ../logs:/var/log/rad - ../../media:/usr/src/app/media - ../../static:/usr/src/app/static + - /home/ross/rts:/usr/src/app/rts worker: env_file: @@ -28,6 +29,7 @@ services: - ../logs:/var/log/rad - ../../media:/usr/src/app/media - ../../static:/usr/src/app/static + - /home/ross/rts:/usr/src/app/rts entrypoint: [ "sh", @@ -45,6 +47,7 @@ services: - ../logs:/var/log/rad:rw - ../../media:/usr/src/app/media:ro - ../../static:/usr/src/app/static:ro + - /home/ross/rts:/usr/src/app/rts:ro loki: image: grafana/loki:2.8.2 diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml index 9658e3de..ec7371fd 100644 --- a/docker/docker-compose.local.yml +++ b/docker/docker-compose.local.yml @@ -25,6 +25,7 @@ services: - ../backups:/usr/src/app/backups - ../../media:/usr/src/app/media - ../../static:/usr/src/app/static + - /home/ross/rts:/usr/src/app/rts ports: - 8000:8000 - 3459:3459 diff --git a/generic/models.py b/generic/models.py index b7eff8c3..b1c80466 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1326,6 +1326,11 @@ class UserAnswerBase(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + time_answered = models.DateTimeField( + null=True, + blank=True, + help_text="Time the question was answered (client-side)", + ) class Meta: abstract = True diff --git a/generic/views.py b/generic/views.py index ab0ea1e8..e3e214b2 100644 --- a/generic/views.py +++ b/generic/views.py @@ -3440,6 +3440,15 @@ class ExamViews(View, LoginRequiredMixin): # Mark as unmarked ans.score = ans.ScoreOptions.UNMARKED + 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 + ans.full_clean() ans.save() @@ -3467,14 +3476,16 @@ class ExamViews(View, LoginRequiredMixin): cid_user_exam = exam.get_or_create_cid_user_exam( cid_user=c, start_time=t ) - exam.exam_user_status.create( - cid_user_exam=cid_user_exam, - status="submitted", - extra="manual submission", - ) + in_progress = request.POST.get("in_progress") == "true" + if not in_progress: + exam.exam_user_status.create( + cid_user_exam=cid_user_exam, + status="submitted", + extra="manual submission", + ) - cid_user_exam.end_time = timezone.now() - cid_user_exam.save() + cid_user_exam.end_time = timezone.now() + cid_user_exam.save() return JsonResponse({"success": True, "question_count": n}) return JsonResponse({"success": False, "error": "Invalid data"}) diff --git a/longs/migrations/0038_useranswer_time_answered.py b/longs/migrations/0038_useranswer_time_answered.py new file mode 100644 index 00000000..f705fc36 --- /dev/null +++ b/longs/migrations/0038_useranswer_time_answered.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-29 09:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('longs', '0037_exam_post_survey_mandatory_exam_pre_survey_mandatory'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='time_answered', + field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True), + ), + ] diff --git a/physics/migrations/0021_useranswer_time_answered.py b/physics/migrations/0021_useranswer_time_answered.py new file mode 100644 index 00000000..a79b2b31 --- /dev/null +++ b/physics/migrations/0021_useranswer_time_answered.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-29 09:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('physics', '0020_exam_post_survey_mandatory_exam_pre_survey_mandatory'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='time_answered', + field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True), + ), + ] diff --git a/rapids/migrations/0020_useranswer_time_answered.py b/rapids/migrations/0020_useranswer_time_answered.py new file mode 100644 index 00000000..e8a7b209 --- /dev/null +++ b/rapids/migrations/0020_useranswer_time_answered.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-29 09:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0019_exam_post_survey_mandatory_exam_pre_survey_mandatory'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='time_answered', + field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True), + ), + ] diff --git a/rapids/tests/test_rts_integration.py b/rapids/tests/test_rts_integration.py new file mode 100644 index 00000000..d31d88b2 --- /dev/null +++ b/rapids/tests/test_rts_integration.py @@ -0,0 +1,111 @@ +import json +import pytest +from django.urls import reverse +from django.utils import timezone +from rapids.models import Exam, Rapid as Question, UserAnswer, Answer +from generic.models import CidUser, CidUserExam, ExamUserStatus +from django.contrib.auth.models import User + +@pytest.fixture +def test_setup(db): + # Create a test candidate + cid_user = CidUser.objects.create(cid=9999, passcode="pass123", name="RTS Candidate") + + # Create an exam + exam = Exam.objects.create(name="RTS Integration Exam", exam_mode=True, active=True) + exam.valid_cid_users.add(cid_user) + + # Create questions + q1 = Question.objects.create(history="Q1 History", normal=True) + q1.exams.add(exam) + + # Create correct answers + Answer.objects.create(question=q1, answer="Normal", answer_compare="Normal", status=Answer.MarkOptions.CORRECT) + + return { + "cid_user": cid_user, + "exam": exam, + "q1": q1, + } + +def test_autosubmit_in_progress(client, test_setup): + exam = test_setup["exam"] + cid_user = test_setup["cid_user"] + q1 = test_setup["q1"] + + answers = [ + { + "aid": "session-1", + "cid": str(cid_user.cid), + "eid": f"rapid/{exam.pk}", + "qid": q1.pk, + "qidn": "1", + "passcode": cid_user.passcode, + "ans": "Normal", + "time_answered": "2026-06-29T12:00:00.000Z", + } + ] + + payload = { + "eid": f"rapid/{exam.pk}", + "cid": str(cid_user.cid), + "start_time": 1782730000.0, + "answers": json.dumps(answers), + "in_progress": "true", + } + + response = client.post(reverse("rapids:exam_answers_submit"), payload) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["question_count"] == 1 + + # Verify answers stored with correct timestamp + user_answer = UserAnswer.objects.get(exam=exam, question=q1, cid=cid_user.cid) + assert user_answer.answer == "Normal" + assert user_answer.time_answered is not None + assert user_answer.time_answered.year == 2026 + assert user_answer.time_answered.month == 6 + assert user_answer.time_answered.day == 29 + + # Verify exam session is NOT complete / finalized + cid_user_exam = CidUserExam.objects.get(object_id=exam.pk, cid_user=cid_user) + assert cid_user_exam.end_time is None + assert cid_user_exam.completed is False + assert not ExamUserStatus.objects.filter(cid_user_exam=cid_user_exam, status="submitted").exists() + +def test_final_submit(client, test_setup): + exam = test_setup["exam"] + cid_user = test_setup["cid_user"] + q1 = test_setup["q1"] + + answers = [ + { + "aid": "session-1", + "cid": str(cid_user.cid), + "eid": f"rapid/{exam.pk}", + "qid": q1.pk, + "qidn": "1", + "passcode": cid_user.passcode, + "ans": "Normal", + "time_answered": "2026-06-29T12:00:00.000Z", + } + ] + + payload = { + "eid": f"rapid/{exam.pk}", + "cid": str(cid_user.cid), + "start_time": 1782730000.0, + "answers": json.dumps(answers), + # in_progress absent (defaults to false) + } + + response = client.post(reverse("rapids:exam_answers_submit"), payload) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify exam session IS complete / finalized + cid_user_exam = CidUserExam.objects.get(object_id=exam.pk, cid_user=cid_user) + assert cid_user_exam.end_time is not None + assert ExamUserStatus.objects.filter(cid_user_exam=cid_user_exam, status="submitted").exists() diff --git a/sbas/migrations/0026_useranswer_time_answered.py b/sbas/migrations/0026_useranswer_time_answered.py new file mode 100644 index 00000000..0a9a9d7f --- /dev/null +++ b/sbas/migrations/0026_useranswer_time_answered.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-29 09:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sbas', '0025_exam_post_survey_mandatory_exam_pre_survey_mandatory'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='time_answered', + field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True), + ), + ] diff --git a/shorts/migrations/0014_useranswer_time_answered.py b/shorts/migrations/0014_useranswer_time_answered.py new file mode 100644 index 00000000..a253ca94 --- /dev/null +++ b/shorts/migrations/0014_useranswer_time_answered.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-29 09:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0013_exam_post_survey_mandatory_exam_pre_survey_mandatory'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='time_answered', + field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True), + ), + ]