Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ff6ee74
:sparkles: feat(#58): 시험 배포 서비스 코드 작성
ji-min0 Dec 11, 2025
0dc735a
:white_check_mark: test(#58): 시험배포 서비스 테스트 코드 작성
ji-min0 Dec 11, 2025
975ef7a
:bulb: chore(#58): 파이썬패키지화를 위한 __init__.py 추가
ji-min0 Dec 12, 2025
a758e96
:recycle: refactor(#58): ValidationError를 dict로 통일 / 시간 검증 로직을 함수로 분리
ji-min0 Dec 12, 2025
b925537
:white_check_mark: test(#58): 리팩토링에 따른 테스트 코드 수정
ji-min0 Dec 12, 2025
d05de3d
:bug: fix(#58): api 명세에 맞춰 수정
ji-min0 Dec 12, 2025
71f7d5a
:bulb: chore(#58): try-except 제거
ji-min0 Dec 12, 2025
e19e775
:bulb: chore(#58): try-except 제거
ji-min0 Dec 12, 2025
f53e832
:bulb: chore(#58): 미사용 함수 제거
ji-min0 Dec 12, 2025
e03ff20
:recycle: refactor(#58): exam - cohort 관계 검증
ji-min0 Dec 12, 2025
d977bcb
:white_check_mark: test(#58): access_code 입력값에서 삭제
ji-min0 Dec 12, 2025
389adf1
:recycle: refactor(#58):service 코드에서 검증 제거
ji-min0 Dec 12, 2025
9b4978c
:bulb: chore(#58):CohortStudent 관련 코드 주석 해제
ji-min0 Dec 15, 2025
0de2a92
:recycle: refactor(#58): base62 함수 util에서 임포트
ji-min0 Dec 15, 2025
4a5b0f1
:sparkles: feat(#58): 시험 배포 수정 api 추가에 따른 코드 추가
ji-min0 Dec 16, 2025
69e4882
:recycle: refactor(#58): 시험 배포 수정 api 추가에 따른 코드 변경
ji-min0 Dec 16, 2025
8fb1230
:white_check_mark: test(#58): 시험 배포 수정 api 추가에 따른 코드 추가에 따른 test 코드 추가
ji-min0 Dec 16, 2025
d050d3d
:white_check_mark: test(#58): 누락된 테스트 코드 추가
ji-min0 Dec 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/exams/serializers/admin/admin_deployment_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class Meta:
"close_at",
"status",
]
read_only_fields = ("id",)
read_only_fields = ("id", "access_code")

# 시간 검증
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
Expand All @@ -38,8 +38,15 @@ def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
DeploymentValidator.validate_time(open_at, close_at)

# create 일 경우
if instance is None:
if open_at is not None:
DeploymentValidator.validate_open(open_at)
if instance is None and open_at is not None:
DeploymentValidator.validate_open(open_at)

# exam - cohort 관계 검증
exam = attrs.get("exam", getattr(instance, "exam", None))
cohort = attrs.get("cohort", getattr(instance, "cohort", None))
if exam and cohort and exam.subject.course_id != cohort.course_id:
raise serializers.ValidationError(
{"cohort": "시험(exam)과 기수(cohort)의 과정(course)이 일치하지 않습니다."}
)

return attrs
176 changes: 176 additions & 0 deletions apps/exams/services/admin/admin_deployment_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional

from django.db import transaction
from django.db.models import Avg, Count, QuerySet
from rest_framework.exceptions import ValidationError

from apps.core.utils.base62 import Base62
from apps.courses.models import Cohort
from apps.exams.models import Exam, ExamDeployment, ExamQuestion
from apps.exams.models.exam_deployment import DeploymentStatus
from apps.exams.services.admin.validators.deployment_validator import (
DeploymentValidator,
)


# 시험 배포 목록 조회 -------------------------------------------------------
def list_admin_deployments(
*,
cohort: Optional[Cohort] = None,
status: Optional[str] = None,
search_keyword: Optional[str] = None,
sort: str = "created_at",
order: str = "desc",
) -> QuerySet[ExamDeployment]:

qs: QuerySet[ExamDeployment] = ExamDeployment.objects.select_related("cohort", "exam").annotate(
participant_count=Count("submissions__submitter", distinct=True),
avg_score=Avg("submissions__score"),
)

# 필터링(과정별, 기수별)
if cohort is not None:
qs = qs.filter(cohort=cohort)

if status is not None:
qs = qs.filter(status=status)

# 검색(검색 키워드와 일부 또는 완전 일치)
if search_keyword:
qs = qs.filter(exam__title__icontains=search_keyword)

# 정렬(최신순, 응시횟수 많은 순, 평균 점수 높은 순)
if sort not in {"created_at", "participant_count", "avg_score"}:
sort = "created_at"

prefix = "-" if order == "desc" else ""

return qs.order_by(f"{prefix}{sort}")


# 단일 시험 배포 상세 조회 ---------------------------------------------------
def get_admin_deployment_detail(*, deployment_id: int) -> ExamDeployment:
try:
deployment = (
ExamDeployment.objects.select_related("exam", "cohort", "exam__subject")
.annotate(
submit_count=Count("submissions__submitter", distinct=True),
total_target_count=Count("cohort__cohortstudent", distinct=True),
)
.get(pk=deployment_id)
)
except ExamDeployment.DoesNotExist as exc:
raise ValidationError({"deployment_id": "해당 배포 정보를 찾을 수 없습니다."}) from exc

# 시험 문항 조회 - 배포 시점 스냅샷 사용
questions = deployment.questions_snapshot

# 미응시자수
not_submitted_count = max(
deployment.total_target_count - deployment.submit_count,
0,
)

setattr(deployment, "questions", questions)
setattr(deployment, "not_submitted_count", not_submitted_count)

return deployment


# 새 시험 배포 생성 --------------------------------------------------------
@transaction.atomic
def create_deployment(
*,
cohort: Cohort,
exam: Exam,
duration_time: int,
open_at: datetime,
close_at: datetime,
) -> ExamDeployment:

return ExamDeployment.objects.create(
cohort=cohort,
exam=exam,
duration_time=duration_time,
access_code=Base62.uuid_encode(uuid.uuid4(), length=6),
open_at=open_at,
close_at=close_at,
status=DeploymentStatus.ACTIVATED,
questions_snapshot=_build_questions_snapshot(exam),
)


# 시험 배포 정보 수정 (open_at, close_at, duration_time) -------------------------
@transaction.atomic
def update_deployment(*, deployment: ExamDeployment, data: Dict[str, Any]) -> ExamDeployment:

# 시작된 시험 수정 불가
DeploymentValidator.validate_not_started(
open_at=deployment.open_at,
status=deployment.status,
)

# 종료된 시험 수정 불가
DeploymentValidator.validate_not_finished(
close_at=deployment.close_at,
status=deployment.status,
)

for field, value in data.items():
setattr(deployment, field, value)

deployment.save(update_fields=list(data.keys()) + ["updated_at"])
return deployment


# 시험 배포 상태 on/off (activated / deactivated) --------------------------
@transaction.atomic
def set_deployment_status(
*,
deployment: ExamDeployment,
status: str,
) -> ExamDeployment:

# 종료된 시험 상태 변경 불가
DeploymentValidator.validate_not_finished(
close_at=deployment.close_at,
status=deployment.status,
)

# 비활성화시 응시 중인 시험은 즉시 종료되어야 함
if deployment.status == DeploymentStatus.ACTIVATED and status == DeploymentStatus.DEACTIVATED:
pass

deployment.status = status
deployment.save(update_fields=["status", "updated_at"])
return deployment


# 시험 배포 삭제 ---------------------------------------------------------
@transaction.atomic
def delete_deployment(*, deployment: ExamDeployment) -> None:

deployment.delete()


# ExamQuestion 목록을 기반으로 배포용 문항 스냅샷 생성 --------------------------
# 어드민용
def _build_questions_snapshot(exam: Exam) -> List[Dict[str, Any]]:
questions: QuerySet[ExamQuestion] = ExamQuestion.objects.filter(exam=exam).order_by("id")

return [
{
"id": q.id,
"question": q.question,
"prompt": q.prompt,
"blank_count": q.blank_count,
"options": q.options,
"type": q.type,
"answer": q.answer,
"point": q.point,
"explanation": q.explanation,
}
for q in questions
]
Empty file.
28 changes: 24 additions & 4 deletions apps/exams/services/admin/validators/deployment_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,36 @@
from django.utils import timezone
from rest_framework.exceptions import ValidationError

from apps.exams.models.exam_deployment import DeploymentStatus

# 시험 배포 시간 검증 (open_at < close_at)

# 시험 배포 시간 검증
class DeploymentValidator:

@staticmethod
def _now() -> datetime:
return timezone.now()

# open_at < close_at
@staticmethod
def validate_time(open_at: datetime, close_at: datetime) -> None:
if open_at >= close_at:
raise ValidationError("open_at must be earlier than close_at")
raise ValidationError({"open_at": "시험 시작 시간(open_at)은 종료 시간(close_at)보다 빨라야 합니다."})

# 과거 배포 금지
@staticmethod
def validate_open(open_at: datetime) -> None:
if open_at <= timezone.now():
raise ValidationError("open_at must be earlier than now")
if open_at <= DeploymentValidator._now():
raise ValidationError({"open_at": "시험 시작 시간(open_at)은 현재 시각 이후여야 합니다."})

# 상태 기반 규칙 - 시작된 시험의 시간 변경 불가
@staticmethod
def validate_not_started(*, open_at: datetime, status: str) -> None:
if open_at <= DeploymentValidator._now() and status == DeploymentStatus.ACTIVATED:
raise ValidationError({"open_at": "이미 시작된 시험의 시작 시간은 수정/삭제할 수 없습니다."})

# 상태 기반 규칙 - 종료된 시험 변경 불가
@staticmethod
def validate_not_finished(*, close_at: datetime, status: str) -> None:
if close_at <= DeploymentValidator._now() and status == DeploymentStatus.DEACTIVATED:
raise ValidationError({"status": "종료된 시험은 변경할 수 없습니다."})
Loading
Loading