Skip to content

Commit 14ba3b8

Browse files
committed
♻️ refactor(#58):service 코드에서 검증 제거
- view에서 검증하도록 할 예정
1 parent d53b4d9 commit 14ba3b8

File tree

2 files changed

+157
-132
lines changed

2 files changed

+157
-132
lines changed
Lines changed: 113 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,101 @@
1-
import uuid
1+
import random
2+
import string
23
from datetime import datetime
3-
from typing import Any, Dict, List, Optional, TypedDict
4+
from typing import Any, Dict, List, Optional
45

56
from 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
88
from rest_framework.exceptions import ValidationError
99

1010
from apps.courses.models import Cohort
11+
12+
# from apps.courses.models import CohortStudent
1113
from apps.exams.models import Exam, ExamDeployment, ExamQuestion, ExamSubmission
1214
from apps.exams.models.exam_deployment import DeploymentStatus
1315
from 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
96100
def 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
122124
def 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
139148
def 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
157168
def 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

Comments
 (0)