1- import uuid
1+ import random
2+ import string
23from datetime import datetime
3- from typing import Any , Dict , List , Optional , TypedDict
4+ from typing import Any , Dict , List , Optional
45
56from django .db import transaction
6- from django .db .models import QuerySet
7- from django .utils import timezone
7+ from django .db .models import Avg , Count , QuerySet
88from rest_framework .exceptions import ValidationError
99
1010from apps .courses .models import Cohort
11+
12+ # from apps.courses.models import CohortStudent
1113from apps .exams .models import Exam , ExamDeployment , ExamQuestion , ExamSubmission
1214from apps .exams .models .exam_deployment import DeploymentStatus
1315from apps .exams .services .admin .validators .deployment_validator import (
1416 DeploymentValidator ,
1517)
1618
1719
18- # validation helpers ---------------------------------------------------------
19- def _validate_create (* , open_at : datetime , close_at : datetime ) -> None :
20- DeploymentValidator .validate_open (open_at )
21- DeploymentValidator .validate_time (open_at , close_at )
22-
23-
24- def _validate_update_deployment (* , deployment : ExamDeployment , data : Dict [str , Any ]) -> None :
25- if "open_at" in data :
26- new_open_at = data ["open_at" ]
27- if new_open_at != deployment .open_at :
28- DeploymentValidator .validate_not_started (deployment .open_at )
29- DeploymentValidator .validate_time (new_open_at , deployment .close_at )
30-
31- if "open_at" in data and "close_at" in data :
32- DeploymentValidator .validate_time (data ["open_at" ], data ["close_at" ])
33-
34-
35- def _validate_status_change (* , deployment : ExamDeployment ) -> None :
36- DeploymentValidator .validate_not_closed (deployment .close_at )
37-
38-
39- def _validate_delete (* , deployment : ExamDeployment ) -> None :
40- DeploymentValidator .validate_not_started (deployment .open_at )
41-
42-
43- def _validate_exam_and_cohort (* , exam : Exam , cohort : Cohort ) -> None :
44- if exam .subject .course_id != cohort .course_id :
45- raise ValidationError ({"cohort" : "시험(exam)과 기수(cohort)의 과정(course)이 일치하지 않습니다." })
46-
47-
48- # ExamQuestion 목록을 기반으로 배포용 문항 스냅샷 생성 ---------------------------------------------------------
49- def _build_questions_snapshot (exam : Exam ) -> List [Dict [str , Any ]]:
50- questions : QuerySet [ExamQuestion ] = ExamQuestion .objects .filter (exam = exam ).order_by ("id" )
51-
52- return [
53- {
54- "id" : q .id ,
55- "question" : q .question ,
56- "prompt" : q .prompt ,
57- "blank_count" : q .blank_count ,
58- "options" : q .options ,
59- "type" : q .type ,
60- "answer" : q .answer ,
61- "point" : q .point ,
62- "explanation" : q .explanation ,
63- }
64- for q in questions
65- ]
66-
67-
68- # 시험 배포 목록 조회 ---------------------------------------------------------
69- def list_deployments (
20+ # 시험 배포 목록 조회 -------------------------------------------------------
21+ def list_admin_deployments (
7022 * ,
7123 cohort : Optional [Cohort ] = None ,
7224 status : Optional [str ] = None ,
25+ search_keyword : Optional [str ] = None ,
26+ sort : str = "created_at" ,
27+ order : str = "desc" ,
7328) -> QuerySet [ExamDeployment ]:
7429
75- qs : QuerySet [ExamDeployment ] = ExamDeployment .objects .select_related ("cohort" , "exam" ).order_by ("-created_at" )
30+ qs : QuerySet [ExamDeployment ] = ExamDeployment .objects .select_related ("cohort" , "exam" ).annotate (
31+ participant_count = Count ("submissions" , distinct = True ),
32+ avg_score = Avg ("submissions__score" ),
33+ )
7634
35+ # 필터링(과정별, 기수별)
7736 if cohort is not None :
7837 qs = qs .filter (cohort = cohort )
7938
8039 if status is not None :
40+ if status not in DeploymentStatus .values :
41+ raise ValidationError ({"status" : "유효하지 않은 배포 상태입니다." })
8142 qs = qs .filter (status = status )
8243
83- return qs
44+ # 검색(검색 키워드와 일부 또는 완전 일치)
45+ if search_keyword :
46+ qs = qs .filter (
47+ exam__title__icontains = search_keyword ,
48+ )
8449
50+ # 정렬(최신순, 응시횟수 많은 순, 평균 점수 높은 순)
51+ sort_map = {
52+ "created_at" : "created_at" ,
53+ "participant_count" : "participant_count" ,
54+ "avg_score" : "avg_score" ,
55+ }
8556
86- # 단일 시험 배포 상세 조회 ---------------------------------------------------------
87- def get_deployment (* , deployment_id : int ) -> ExamDeployment :
57+ sort_field = sort_map .get (sort , "created_at" )
58+ prefix = "-" if order == "desc" else ""
59+
60+ return qs .order_by (f"{ prefix } { sort_field } " )
61+
62+
63+ # 단일 시험 배포 상세 조회 ---------------------------------------------------
64+ def get_admin_deployment_detail (* , deployment_id : int ) -> ExamDeployment :
8865 try :
89- return ExamDeployment .objects .select_related ("cohort" , "exam" ).get (pk = deployment_id )
66+ deployment = ExamDeployment .objects .select_related (
67+ "exam" ,
68+ "cohort" ,
69+ "exam__subject" ,
70+ ).get (pk = deployment_id )
9071 except ExamDeployment .DoesNotExist as exc :
91- raise ValidationError ({"deployment_id" : "존재하지 않는 시험 배포입니다." }) from exc
72+ raise ValidationError ({"deployment_id" : "해당 배포 정보를 찾을 수 없습니다." }) from exc
73+
74+ # 시험 문항 조회
75+ questions = ExamQuestion .objects .filter (exam = deployment .exam ).values (
76+ "id" ,
77+ "type" ,
78+ "question" ,
79+ "point" ,
80+ )
81+ #
82+ # # 응시 대상자 수
83+ # total_target_count = CohortStudent.objects.filter(cohort_id=deployment.cohort.id).count()
84+ #
85+ # # 응시자 수
86+ # submit_count = ExamSubmission.objects.filter(deployment=deployment).count()
87+ #
88+ # # 미응시자 수
89+ # not_submitted_count = max(total_target_count - submit_count, 0)
90+ #
91+ # setattr(deployment, "questions", list(questions))
92+ # setattr(deployment, "submit_count", submit_count)
93+ # setattr(deployment, "not_submitted_count", not_submitted_count)
94+
95+ return deployment
9296
9397
94- # 새 시험 배포 생성 ---------------------------------------------------------
98+ # 새 시험 배포 생성 --------------------------------------------------------
9599@transaction .atomic
96100def create_deployment (
97101 * ,
@@ -102,39 +106,44 @@ def create_deployment(
102106 close_at : datetime ,
103107) -> ExamDeployment :
104108
105- _validate_create (open_at = open_at , close_at = close_at )
106- _validate_exam_and_cohort (exam = exam , cohort = cohort )
107-
108109 return ExamDeployment .objects .create (
109110 cohort = cohort ,
110111 exam = exam ,
111112 duration_time = duration_time ,
112- access_code = str ( uuid . uuid4 ())[: 8 ] ,
113+ access_code = _generate_base62_code () ,
113114 open_at = open_at ,
114115 close_at = close_at ,
115116 status = DeploymentStatus .ACTIVATED ,
116117 questions_snapshot = _build_questions_snapshot (exam ),
117118 )
118119
119120
120- # 시험 배포 정보 수정 (부분 수정 포함) ---------------------------------------------------------
121+ # 시험 배포 정보 수정 (open_at, close_at, duration_time) -------------------------
122+ # TODO: API명세에는 배포 수정이 없음. 근데 있는 편이 유리하지 않나? 물어보기
121123@transaction .atomic
122124def update_deployment (
123125 * ,
124126 deployment : ExamDeployment ,
125127 data : Dict [str , Any ],
126128) -> ExamDeployment :
127129
128- _validate_update_deployment (deployment = deployment , data = data )
130+ DeploymentValidator .validate_not_started (
131+ open_at = deployment .open_at ,
132+ status = deployment .status ,
133+ )
134+
135+ allowed_fields = {"open_at" , "close_at" , "duration_time" }
129136
130137 for field , value in data .items ():
138+ if field not in allowed_fields :
139+ raise ValidationError ({"field" : "수정할 수 없는 필드입니다." })
131140 setattr (deployment , field , value )
132141
133142 deployment .save (update_fields = list (data .keys ()) + ["updated_at" ])
134143 return deployment
135144
136145
137- # 시험 배포 상태 on/off (activated / deactivated) ---------------------------------------------------------
146+ # 시험 배포 상태 on/off (activated / deactivated) --------------------------
138147@transaction .atomic
139148def set_deployment_status (
140149 * ,
@@ -145,7 +154,9 @@ def set_deployment_status(
145154 if status not in DeploymentStatus .values :
146155 raise ValidationError ({"status" : "유효하지 않은 배포 상태입니다." })
147156
148- _validate_status_change (deployment = deployment )
157+ # 비활성화시 응시 중인 시험은 즉시 종료되어야 함
158+ if deployment .status == DeploymentStatus .ACTIVATED and status == DeploymentStatus .DEACTIVATED :
159+ pass
149160
150161 deployment .status = status
151162 deployment .save (update_fields = ["status" , "updated_at" ])
@@ -156,10 +167,40 @@ def set_deployment_status(
156167@transaction .atomic
157168def delete_deployment (* , deployment : ExamDeployment ) -> None :
158169
159- _validate_delete (deployment = deployment )
170+ DeploymentValidator .validate_not_started (
171+ open_at = deployment .open_at ,
172+ status = deployment .status ,
173+ )
160174
161175 # 응시 내역이 존재하면 삭제 불가
162176 if ExamSubmission .objects .filter (deployment = deployment ).exists ():
163177 raise ValidationError ({"deployment" : "응시 내역이 있는 시험 배포는 삭제할 수 없습니다." })
164178
165179 deployment .delete ()
180+
181+
182+ # ExamQuestion 목록을 기반으로 배포용 문항 스냅샷 생성 --------------------------
183+ # 어드민용
184+ def _build_questions_snapshot (exam : Exam ) -> List [Dict [str , Any ]]:
185+ questions : QuerySet [ExamQuestion ] = ExamQuestion .objects .filter (exam = exam ).order_by ("id" )
186+
187+ return [
188+ {
189+ "id" : q .id ,
190+ "question" : q .question ,
191+ "prompt" : q .prompt ,
192+ "blank_count" : q .blank_count ,
193+ "options" : q .options ,
194+ "type" : q .type ,
195+ "answer" : q .answer ,
196+ "point" : q .point ,
197+ "explanation" : q .explanation ,
198+ }
199+ for q in questions
200+ ]
201+
202+
203+ # Base62 코드 생성 --------------------------------------------------------
204+ def _generate_base62_code (length : int = 8 ) -> str :
205+ chars = string .ascii_letters + string .digits
206+ return "" .join (random .choice (chars ) for _ in range (length ))
0 commit comments