From 650b3d2a15997f1993be1e9f2aafc6a94ca36004 Mon Sep 17 00:00:00 2001 From: kochanyeol Date: Fri, 15 May 2026 16:33:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix=20:=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20answer=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user_exam_submission_serializer.py | 6 +- .../services/admin_exam_deployment_service.py | 5 + apps/exams/tests/test_user_exam_submission.py | 143 ++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) diff --git a/apps/exams/serializers/user_exam_submission_serializer.py b/apps/exams/serializers/user_exam_submission_serializer.py index 2710da89..8e44bafe 100644 --- a/apps/exams/serializers/user_exam_submission_serializer.py +++ b/apps/exams/serializers/user_exam_submission_serializer.py @@ -27,8 +27,6 @@ def get_questions(self, obj: ExamSubmission) -> list[dict[str, Any]]: q_id = str(q["id"]) submitted = answer_json.get(q_id, []) options_json = q.get("options_json") - raw_answer = q.get("answer") - answer = raw_answer if isinstance(raw_answer, list) else ([raw_answer] if raw_answer is not None else []) result.append( { "id": q["id"], @@ -37,10 +35,10 @@ def get_questions(self, obj: ExamSubmission) -> list[dict[str, Any]]: "blank_count": q.get("blank_count"), "options": json.loads(options_json) if options_json else None, "type": q.get("type"), - "answer": answer, + "answer": q.get("answer"), "point": q["point"], "explanation": q.get("explanation", ""), - "is_correct": submitted == answer, + "is_correct": submitted == q.get("answer", []), "submitted_answer": submitted, } ) diff --git a/apps/exams/services/admin_exam_deployment_service.py b/apps/exams/services/admin_exam_deployment_service.py index 875d286f..876a08d3 100644 --- a/apps/exams/services/admin_exam_deployment_service.py +++ b/apps/exams/services/admin_exam_deployment_service.py @@ -64,6 +64,11 @@ def create_deployment(validated_data: dict[str, Any]) -> ExamDeployment: if not snapshot: raise DeploymentNoQuestionsError() + for q in snapshot: + answer = q.get("answer", []) + if isinstance(answer, str): + q["answer"] = [answer] + access_code = create_access_code() deployment = ExamDeployment.objects.create( diff --git a/apps/exams/tests/test_user_exam_submission.py b/apps/exams/tests/test_user_exam_submission.py index cca7a9db..1ee1b0d3 100644 --- a/apps/exams/tests/test_user_exam_submission.py +++ b/apps/exams/tests/test_user_exam_submission.py @@ -440,3 +440,146 @@ def test_create_submission_already_exists(self) -> None: self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.assertEqual(response.data.get("error_detail"), self.error_409) + + +class TestSnapshotAnswerNormalization(APITestCase): + """배포 생성 시 스냅샷 answer가 항상 리스트로 저장되고, 제출 시 correct_answer_count가 정상 계산되는지 테스트""" + + student: User + course: Course + subject: Subject + cohort: Cohort + cohort2: Cohort + exam: Exam + question_list_answer: ExamQuestion + question_string_answer: ExamQuestion + + @classmethod + def setUpTestData(cls) -> None: + cls.student = User.objects.create_user( + email="snap_student@test.com", + password="test1234!", + name="스냅샷테스트", + nickname="snapstud", + phone_number="010777777", + is_active=True, + role="STUDENT", + ) + cls.course = Course.objects.create(name="스냅샷테스트과정", tag="WEB") + cls.subject = Subject.objects.create( + course=cls.course, + title="스냅샷테스트과목", + number_of_days=30, + number_of_hours=60, + ) + cls.cohort = Cohort.objects.create( + course=cls.course, + number=1, + max_student=30, + start_date="2024-01-01", + end_date="2024-12-31", + ) + cls.cohort2 = Cohort.objects.create( + course=cls.course, + number=2, + max_student=30, + start_date="2024-01-01", + end_date="2024-12-31", + ) + + # answer가 리스트인 문제 (정상 케이스) + cls.exam = Exam.objects.create(subject=cls.subject, title="스냅샷테스트시험") + cls.question_list_answer = ExamQuestion.objects.create( + exam=cls.exam, + question="Django는 Python 웹 프레임워크이다", + type="ox", + answer=["O"], + point=10, + explanation="맞습니다.", + ) + + # answer가 문자열인 문제 (구버전 데이터 케이스) + cls.question_string_answer = ExamQuestion.objects.create( + exam=cls.exam, + question="Python은 인터프리터 언어이다", + type="ox", + answer="O", # 구버전 방식으로 문자열 저장 + point=10, + explanation="맞습니다.", + ) + + def setUp(self) -> None: + self.client = APIClient() + self.client.force_authenticate(user=self.student) + self.submit_url = reverse("user-exam-submission-create") + + def _create_deployment_via_service(self, cohort: Cohort) -> ExamDeployment: + from apps.exams.services.admin_exam_deployment_service import create_deployment + + return create_deployment( + { + "exam_id": self.exam.id, + "cohort_id": cohort.id, + "open_at": "2024-01-01T00:00:00Z", + "close_at": "2024-12-31T23:59:59Z", + "duration_time": 60, + } + ) + + def _get_submit_data(self, deployment: ExamDeployment) -> dict[str, Any]: + return { + "deployment_id": deployment.id, + "started_at": "2024-01-01T10:00:00Z", + "cheating_count": 0, + "answers": [ + { + "question_id": self.question_list_answer.id, + "type": "ox", + "submitted_answer": "O", + }, + { + "question_id": self.question_string_answer.id, + "type": "ox", + "submitted_answer": "O", + }, + ], + } + + # 배포 생성 시 스냅샷 answer가 항상 리스트로 저장되는지 확인 + def test_snapshot_answer_is_always_list_on_deployment_creation(self) -> None: + deployment = self._create_deployment_via_service(self.cohort) + + for q in deployment.questions_snapshot_json: + self.assertIsInstance(q["answer"], list, f"question {q['id']}의 answer가 리스트가 아님: {q['answer']}") + + # 배포 생성 후 제출 시 correct_answer_count 정상 계산 확인 + def test_correct_answer_count_after_deployment_creation(self) -> None: + deployment = self._create_deployment_via_service(self.cohort2) + response = self.client.post(self.submit_url, self._get_submit_data(deployment), format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["score"], 20) + self.assertEqual(response.data["correct_answer_count"], 2) + + # 제출 후 결과 조회 시 is_correct 정상 표시 확인 + def test_get_submission_is_correct_after_deployment_creation(self) -> None: + deployment = self._create_deployment_via_service( + Cohort.objects.create( + course=self.course, + number=3, + max_student=30, + start_date="2024-01-01", + end_date="2024-12-31", + ) + ) + post_response = self.client.post(self.submit_url, self._get_submit_data(deployment), format="json") + submission_id = post_response.data["submission_id"] + + get_response = self.client.get(reverse("user-exam-submission-get", kwargs={"submission_id": submission_id})) + + self.assertEqual(get_response.status_code, status.HTTP_200_OK) + self.assertEqual(get_response.data["score"], 20) + self.assertEqual(get_response.data["correct_answer_count"], 2) + for question in get_response.data["questions"]: + self.assertTrue(question["is_correct"]) + self.assertIsInstance(question["answer"], list) From ad6771fbc48e70a31df9554d2dc9ba123acf8e4a Mon Sep 17 00:00:00 2001 From: kochanyeol Date: Fri, 15 May 2026 16:44:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix=20:=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?api=20=EC=8B=9C=EB=A6=AC=EC=96=BC=EB=9D=BC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user_exam_submission_serializer.py | 6 +- apps/exams/tests/test_user_exam_submission.py | 99 +++++++------------ 2 files changed, 41 insertions(+), 64 deletions(-) diff --git a/apps/exams/serializers/user_exam_submission_serializer.py b/apps/exams/serializers/user_exam_submission_serializer.py index 8e44bafe..2710da89 100644 --- a/apps/exams/serializers/user_exam_submission_serializer.py +++ b/apps/exams/serializers/user_exam_submission_serializer.py @@ -27,6 +27,8 @@ def get_questions(self, obj: ExamSubmission) -> list[dict[str, Any]]: q_id = str(q["id"]) submitted = answer_json.get(q_id, []) options_json = q.get("options_json") + raw_answer = q.get("answer") + answer = raw_answer if isinstance(raw_answer, list) else ([raw_answer] if raw_answer is not None else []) result.append( { "id": q["id"], @@ -35,10 +37,10 @@ def get_questions(self, obj: ExamSubmission) -> list[dict[str, Any]]: "blank_count": q.get("blank_count"), "options": json.loads(options_json) if options_json else None, "type": q.get("type"), - "answer": q.get("answer"), + "answer": answer, "point": q["point"], "explanation": q.get("explanation", ""), - "is_correct": submitted == q.get("answer", []), + "is_correct": submitted == answer, "submitted_answer": submitted, } ) diff --git a/apps/exams/tests/test_user_exam_submission.py b/apps/exams/tests/test_user_exam_submission.py index 1ee1b0d3..9db18361 100644 --- a/apps/exams/tests/test_user_exam_submission.py +++ b/apps/exams/tests/test_user_exam_submission.py @@ -443,16 +443,17 @@ def test_create_submission_already_exists(self) -> None: class TestSnapshotAnswerNormalization(APITestCase): - """배포 생성 시 스냅샷 answer가 항상 리스트로 저장되고, 제출 시 correct_answer_count가 정상 계산되는지 테스트""" + """배포 생성 시 스냅샷 answer가 항상 리스트로 저장되고, 제출/조회 시 정상 동작하는지 테스트""" student: User course: Course subject: Subject - cohort: Cohort + cohort1: Cohort cohort2: Cohort + cohort3: Cohort exam: Exam - question_list_answer: ExamQuestion - question_string_answer: ExamQuestion + question1: ExamQuestion + question2: ExamQuestion @classmethod def setUpTestData(cls) -> None: @@ -472,24 +473,19 @@ def setUpTestData(cls) -> None: number_of_days=30, number_of_hours=60, ) - cls.cohort = Cohort.objects.create( - course=cls.course, - number=1, - max_student=30, - start_date="2024-01-01", - end_date="2024-12-31", + cls.cohort1 = Cohort.objects.create( + course=cls.course, number=1, max_student=30, start_date="2024-01-01", end_date="2024-12-31" ) cls.cohort2 = Cohort.objects.create( - course=cls.course, - number=2, - max_student=30, - start_date="2024-01-01", - end_date="2024-12-31", + course=cls.course, number=2, max_student=30, start_date="2024-01-01", end_date="2024-12-31" + ) + cls.cohort3 = Cohort.objects.create( + course=cls.course, number=3, max_student=30, start_date="2024-01-01", end_date="2024-12-31" ) - - # answer가 리스트인 문제 (정상 케이스) cls.exam = Exam.objects.create(subject=cls.subject, title="스냅샷테스트시험") - cls.question_list_answer = ExamQuestion.objects.create( + + # answer가 리스트인 문제 + cls.question1 = ExamQuestion.objects.create( exam=cls.exam, question="Django는 Python 웹 프레임워크이다", type="ox", @@ -497,13 +493,12 @@ def setUpTestData(cls) -> None: point=10, explanation="맞습니다.", ) - - # answer가 문자열인 문제 (구버전 데이터 케이스) - cls.question_string_answer = ExamQuestion.objects.create( + # answer가 문자열인 문제 (구버전 데이터) + cls.question2 = ExamQuestion.objects.create( exam=cls.exam, question="Python은 인터프리터 언어이다", type="ox", - answer="O", # 구버전 방식으로 문자열 저장 + answer="O", point=10, explanation="맞습니다.", ) @@ -513,18 +508,15 @@ def setUp(self) -> None: self.client.force_authenticate(user=self.student) self.submit_url = reverse("user-exam-submission-create") - def _create_deployment_via_service(self, cohort: Cohort) -> ExamDeployment: + def _create_deployment(self, cohort: Cohort) -> ExamDeployment: from apps.exams.services.admin_exam_deployment_service import create_deployment - - return create_deployment( - { - "exam_id": self.exam.id, - "cohort_id": cohort.id, - "open_at": "2024-01-01T00:00:00Z", - "close_at": "2024-12-31T23:59:59Z", - "duration_time": 60, - } - ) + return create_deployment({ + "exam_id": self.exam.id, + "cohort_id": cohort.id, + "open_at": "2024-01-01T00:00:00Z", + "close_at": "2024-12-31T23:59:59Z", + "duration_time": 60, + }) def _get_submit_data(self, deployment: ExamDeployment) -> dict[str, Any]: return { @@ -532,54 +524,37 @@ def _get_submit_data(self, deployment: ExamDeployment) -> dict[str, Any]: "started_at": "2024-01-01T10:00:00Z", "cheating_count": 0, "answers": [ - { - "question_id": self.question_list_answer.id, - "type": "ox", - "submitted_answer": "O", - }, - { - "question_id": self.question_string_answer.id, - "type": "ox", - "submitted_answer": "O", - }, + {"question_id": self.question1.id, "type": "ox", "submitted_answer": "O"}, + {"question_id": self.question2.id, "type": "ox", "submitted_answer": "O"}, ], } # 배포 생성 시 스냅샷 answer가 항상 리스트로 저장되는지 확인 - def test_snapshot_answer_is_always_list_on_deployment_creation(self) -> None: - deployment = self._create_deployment_via_service(self.cohort) + def test_snapshot_answer_is_always_list(self) -> None: + deployment = self._create_deployment(self.cohort1) for q in deployment.questions_snapshot_json: - self.assertIsInstance(q["answer"], list, f"question {q['id']}의 answer가 리스트가 아님: {q['answer']}") + self.assertIsInstance(q["answer"], list) - # 배포 생성 후 제출 시 correct_answer_count 정상 계산 확인 - def test_correct_answer_count_after_deployment_creation(self) -> None: - deployment = self._create_deployment_via_service(self.cohort2) + # 제출 시 correct_answer_count 정상 계산 확인 + def test_correct_answer_count_is_correct(self) -> None: + deployment = self._create_deployment(self.cohort2) response = self.client.post(self.submit_url, self._get_submit_data(deployment), format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data["score"], 20) self.assertEqual(response.data["correct_answer_count"], 2) - # 제출 후 결과 조회 시 is_correct 정상 표시 확인 - def test_get_submission_is_correct_after_deployment_creation(self) -> None: - deployment = self._create_deployment_via_service( - Cohort.objects.create( - course=self.course, - number=3, - max_student=30, - start_date="2024-01-01", - end_date="2024-12-31", - ) - ) + # 결과 조회 시 answer가 리스트, is_correct 정상 표시 확인 + def test_get_submission_answer_is_list_and_is_correct(self) -> None: + deployment = self._create_deployment(self.cohort3) post_response = self.client.post(self.submit_url, self._get_submit_data(deployment), format="json") submission_id = post_response.data["submission_id"] get_response = self.client.get(reverse("user-exam-submission-get", kwargs={"submission_id": submission_id})) self.assertEqual(get_response.status_code, status.HTTP_200_OK) - self.assertEqual(get_response.data["score"], 20) self.assertEqual(get_response.data["correct_answer_count"], 2) for question in get_response.data["questions"]: - self.assertTrue(question["is_correct"]) self.assertIsInstance(question["answer"], list) + self.assertTrue(question["is_correct"]) From ce8f6ca68b7a8b18f2de0a05d4cd1dc68982f8b6 Mon Sep 17 00:00:00 2001 From: kochanyeol Date: Fri, 15 May 2026 16:49:39 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20style=20:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=A4=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/exams/tests/test_user_exam_submission.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/exams/tests/test_user_exam_submission.py b/apps/exams/tests/test_user_exam_submission.py index 9db18361..72ed83e6 100644 --- a/apps/exams/tests/test_user_exam_submission.py +++ b/apps/exams/tests/test_user_exam_submission.py @@ -510,13 +510,16 @@ def setUp(self) -> None: def _create_deployment(self, cohort: Cohort) -> ExamDeployment: from apps.exams.services.admin_exam_deployment_service import create_deployment - return create_deployment({ - "exam_id": self.exam.id, - "cohort_id": cohort.id, - "open_at": "2024-01-01T00:00:00Z", - "close_at": "2024-12-31T23:59:59Z", - "duration_time": 60, - }) + + return create_deployment( + { + "exam_id": self.exam.id, + "cohort_id": cohort.id, + "open_at": "2024-01-01T00:00:00Z", + "close_at": "2024-12-31T23:59:59Z", + "duration_time": 60, + } + ) def _get_submit_data(self, deployment: ExamDeployment) -> dict[str, Any]: return {