
요약 ❗️
[2025.12.08] 모델 확정 및 프로젝트 설정 / 기본 서류 작성
[2025.12.09] SpecAPI 학습 및 질문등록 SpecAPI 작성
[2025.12.10 ~ 2025.12.11] 질문 등록 API 구현 및 테스트 코드 작성
[2025.12.12] 질문 답변 각각의 앱을 qna로 통합하여 구조 확정 및 질문등록 API 전체 리셋
apps/questions/
├── spec/
│ ├── serializers.py
│ └── views.py
├── serializers.py
├── views.py
├── urls.py
from rest_framework import serializers
from apps.questions.models import Question, QuestionImage
class QuestionImageSpecSerializer(serializers.ModelSerializer):
class Meta:
model = QuestionImage
fields = ["img_url"]
class QuestionCreateSpecSerializer(serializers.ModelSerializer):
category = serializers.IntegerField() # specapi에서만 잠시 변경
image_urls = serializers.ListField(
child=serializers.CharField(),
# child=serializers.URLField(),
write_only=True,
required=False
)
images = QuestionImageSpecSerializer(many=True, read_only=True)
class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls", # 요청
"images", # 응답
"created_at",
]
read_only_fields = ["id", "created_at"]
class QuestionImageSpecSerializerclass Metaclass QuestionCreateSpecSerializercategory = serializers.IntegerField()image_urls = serializers.ListFieldListField 로 리스트를 받고, 각 원소는 CharField 로 받음 → URL 형식이 아니어도 통과write_only=True → 요청(body)에서만 사용, 응답에는 안 나옴required=False → 안 보내도 유효성 검사를 통과(이미지 없는 질문도 허용)images = QuestionImageSpecSerializermany=True → 이미지 여러 개 read_only=True → 클라이언트가 직접 입력할 수 없고, 서버에서만class Metaimage_urls: 요청에서만 받는 이미지 URL 리스트images: 응답으로 보여줄 이미지 목록 (QuestionImageSpecSerializer)read_only_fields: 서버에서 채우는 값이므로 입력 불가능, 응답 전용from datetime import datetime
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from .serializers import QuestionCreateSpecSerializer
class QuestionCreateSpecAPIView(APIView):
permission_classes = [AllowAny]
serializer_class = QuestionCreateSpecSerializer
@extend_schema(
tags=["Questions"],
summary="질문 등록 API (Spec)",
description="실제 저장 없이 mock 데이터로 동작하는 질문 등록 Spec API입니다.",
request=QuestionCreateSpecSerializer,
responses={
201: QuestionCreateSpecSerializer,
400: {
"object": "object",
"example": {"error": "Bad Request"}
},
}
)
def post(self, request: Request) -> Response:
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
validated = serializer.validated_data
mock_response = {
"id": 1,
"title": validated["title"],
"content": validated["content"],
"category": validated["category"],
"image_urls": validated.get("image_urls", []),
"images": [{"img_url": url} for url in validated.get("image_urls", [])],
"created_at": datetime.now().isoformat(),
}
response_serializer = self.serializer_class(mock_response)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
status → HTTP 상태 코드 상수(예: status.HTTP_201_CREATED)
AllowAny → 누구나 접근 가능하게 하는 권한 클래스
@extend_schema → drf-spectacular에서 Swagger 문서를 꾸밀 때 사용하는 decorator
class QuestionCreateSpecAPIViewpermission_classes = [AllowAny] serializer_class = QuestionCreateSpecSerializer@extend_schemarequest=QuestionCreateSpecSerializerresponsesdef post(self, request: Request) -> Response:serializer = self.serializer_class(data=request.data)serializer.is_valid(raise_exception=True)validated = serializer.validated_dataimage_urls": validated.get("image_urls", []) images": [{"img_url": url} for url in validated.get("image_urls", [])][{"img_url": "..."}, ...] 형태로 변환created_atresponse_serializer = self.serializer_class(mock_response)return Response(response_serializer.data, status=status.HTTP_201_CREATED)
from rest_framework import serializers
from apps.questions.models import Question, QuestionImage, QuestionCategory
class QuestionImageCreateSerializer(serializers.ModelSerializer):
class Meta:
model = QuestionImage
fields = ["img_url"]
class QuestionCreateSerializer(serializers.ModelSerializer):
category = serializers.PrimaryKeyRelatedField(
queryset=QuestionCategory.objects.all()
)
image_urls = serializers.ListField(
child=serializers.URLField(),
write_only=True,
required=False,
)
images = QuestionImageCreateSerializer(many=True, read_only=True)
class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls", # 요청
"images", # 응답
"created_at",
]
read_only_fields = ["id", "created_at"]
def create(self, validated_data):
request = self.context["request"]
image_urls = validated_data.pop("image_urls", [])
question = Question.objects.create(
author=request.user,
**validated_data
)
for url in image_urls:
QuestionImage.objects.create(
question=question,
img_url=url
)
return question
child=serializers.URLField()PrimaryKeyRelatedFieldclass QuestionImageCreateSerializer(serializers.ModelSerializer):
class Meta:
model = QuestionImage
fields = ["img_url"]
QuestionImage 모델을 직렬화/역직렬화하기 위한 Serializer 선언class Meta → ModelSerializer 에서 어떤 모델을 기준으로 할지, 어떤 필드를 쓸지 설정img_url 하나 / id, created_at 같은 것들은 필요 없어서 제외모델에 없는 필드도 시리얼라이저에서 “추가 선언” 하여
fields 리스트에 넣기 가능 ❗️
class QuestionCreateSerializer(serializers.ModelSerializer):
category = serializers.PrimaryKeyRelatedField(
queryset=QuestionCategory.objects.all()
)
image_urls = serializers.ListField(
child=serializers.URLField(),
write_only=True,
required=False,
)
images = QuestionImageCreateSerializer(many=True, read_only=True)
categoryPrimaryKeyRelatedField(category는 ForeignKey이기 때문에 검증 필요)queryset= 을 넣어줘야 FK 검증이 가능image_urlspresigned URL 을 받아서 저장할 때 사용 예정child=serializers.URLField() → 리스트 내 각 요소는 URL 형식이어야 함write_only=True → 요청 시에만 받고 응답에서는 제외required=False → 이미지가 없어도 질문 등록 가능imagesQuestionImage 목록을 응답에 표시하기 위한 필드read_only=True → 직접 입력받지 않음related_name="images" 로 연결된 FK 데이터를 자동으로 가져옴# serializers.py
class QuestionImage(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="images")
related_name="images" 때문에 Question.images 로 해당 Question의 이미지 목록을 조회 가능images = ... → QuestionSerializer 에 images 라는 응답 필드를 추가하는 것QuestionImageCreateSerializer → 각 이미지 요소를 이 Serializer로 변환해서 JSON 형태로 보여줌many=True → 여러 개의 이미지가 연결될 수 있으므로 리스트 형태 직렬화.read_only=True → 클라이언트가 요청(body)에서 images를 보내면 안 됨.image_urls 로만 받고, 저장은 create()에서 직접 수행 class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls", # 요청 전용
"images", # 응답 전용
"created_at",
]
read_only_fields = ["id", "created_at"]
Serializer.save()호출 시 실행되는 로직DB 저장 책임
def create(self, validated_data):
request = self.context["request"]
image_urls = validated_data.pop("image_urls", [])
question = Question.objects.create(
author=request.user,
**validated_data
)
for url in image_urls:
QuestionImage.objects.create(
question=question,
img_url=url
)
return question
requestimage_urlsquestionfor url in image_urlsfrom rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.questions.serializers import QuestionCreateSerializer
from apps.user.models import RoleChoices
class QuestionCreateAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
if request.user.role != RoleChoices.ST:
return Response(
{"detail": "수강생만 질문을 등록할 수 있습니다."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = QuestionCreateSerializer(
data=request.data,
context={"request": request}
)
if serializer.is_valid():
question = serializer.save()
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class QuestionCreateAPIView(APIView):
permission_classes = [IsAuthenticated]
# apps/user/models.py
class RoleChoices(models.TextChoices):
...
ST = "ST", "Student"
# apps/questions/views.py
from apps.users.models import RoleChoices
if request.user.role != RoleChoices.ST:
return Response(
{"detail": "수강생만 질문을 등록할 수 있습니다."},
status=status.HTTP_403_FORBIDDEN,
)
if request.user.role != "ST": serializer = QuestionCreateSerializer(
data=request.data,
context={"request": request}
)
serializer.create에서 author 설정하기 위함 if serializer.is_valid():
question = serializer.save()
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if serializer.is_valid():question = serializer.save().save() 호출 시 → Question 생성 / image_urls 리스트에서 QuestionImage들 생성class QuestionCreateSerializer(serializers.ModelSerializer):
.
.
class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls", # 요청
"images", # 응답
"created_at",
]
read_only_fields = ["id", "created_at"]
serializer.is_valid()read_only_fields = ["id", "created_at"]Meta.fields 안에서 “입력 가능한 필드만” 검증def create(self, validated_data):
request = self.context["request"]
image_urls = validated_data.pop("image_urls", [])
question = Question.objects.create(
author=request.user,
**validated_data
)
for url in image_urls:
QuestionImage.objects.create(
question=question,
img_url=url
)
return question
question = serializer.save().save() 호출 → 내부에서 Serializer의 .create() 메서드를 실행validated_data.pop("image_urls")image_urls 는 Question 모델에 없는 필드이기 때문에 create() 전에 빼야 함image_urls를 Question.objects.create()에 넘기면 에러 발생popimage_urls라는 필드가 없어서, Question.objects.create(**validated_data)에 보내면 에러남[] 사용def save(self, **kwargs):
if self.instance is None:
# Create new object
return self.create(validated_data)
else:
# Update existing object
return self.update(self.instance, validated_data)
from rest_framework.test import APITestCase
from rest_framework import status
from apps.user.models import User, RoleChoices
from apps.questions.models import QuestionCategory
django.test.TestCase 를 상속받아서 HTTP 요청/응답을 테스트하기 편하게 해줌status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUESTclass QuestionCreateAPITests(APITestCase):
def setUp(self):
# 테스트용 카테고리 생성
self.category = QuestionCategory.objects.create(name="프론트엔드")
# API URL
self.url = "/api/v1/qna/questions"
QuestionCreateAPITestsAPITestCase 상속def setUp(self):self.category = QuestionCategory.objects.create(...)self.url = "/api/v1/qna/questions"이름이 test_로 시작해야 Django가 테스트로 인식
def test_question_create_success(self):
# 학생 유저 생성
student = User.objects.create_user(
email="student@test.com",
password="test1234",
name="학생A",
role=RoleChoices.ST,
)
# 인증 설정
self.client.force_authenticate(user=student)
payload = {
"title": "정상 등록 테스트",
"content": "내용입니다.",
"category": self.category.id,
"image_urls": ["https://test.com/img.png"],
}
response = self.client.post(self.url, payload, format="json")
# 검증
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn("question_id", response.data)
self.clientforce_authenticate(user=student)payloadresponse = self.client.post(self.url, payload, format="json")format="json" → DRF가 내부에서 JSON으로 인코딩해서 보내도록 전달self.assertEqual(a, b) → a == b 여야 테스트 통과. 아니면 실패(F).self.assertIn("question_id", response.data)response def test_question_create_validation_fail(self):
student = User.objects.create_user(
email="student2@test.com",
password="test1234",
name="학생B",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
payload = {
"title": "", # 빈 값 → 유효성 실패
"content": "",
"category": None, # 존재하지 않는 카테고리
}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_question_create_permission_fail(self):
normal_user = User.objects.create_user(
email="normal@test.com",
password="test1234",
name="일반유저",
role=RoleChoices.USER, # 학생 아님
)
self.client.force_authenticate(user=normal_user)
payload = {
"title": "권한 실패 테스트",
"content": "내용",
"category": self.category.id,
}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

from typing import Any, Dict
from rest_framework import serializers
from apps.questions.models import Question, QuestionCategory, QuestionImage
from apps.user.models import RoleChoices, User
class QuestionImageCreateSerializer(serializers.ModelSerializer[QuestionImage]):
class Meta:
model = QuestionImage
fields = ["img_url"]
class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
category = serializers.PrimaryKeyRelatedField(queryset=QuestionCategory.objects.all())
image_urls = serializers.ListField(
child=serializers.URLField(),
write_only=True,
required=False,
)
images = QuestionImageCreateSerializer(many=True, read_only=True)
class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls", # 요청 전용
"images", # 응답 전용
"created_at",
]
read_only_fields = ["id", "created_at"]
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
request = self.context["request"]
assert isinstance(request.user, User)
# 403 - 수강생 권한
if request.user.role != RoleChoices.ST:
raise serializers.ValidationError({"type": "permission_denied"})
# 409 - 중복 제목
title = attrs.get("title")
if title and Question.objects.filter(title=title).exists():
raise serializers.ValidationError({"type": "title_conflict"})
# category 존재 여부는 PrimaryKeyRelatedField 가 검증
return attrs
def create(self, validated_data: Dict[str, Any]) -> Question:
request = self.context["request"]
image_urls = validated_data.pop("image_urls", [])
question = Question.objects.create(author=request.user, **validated_data)
for url in image_urls:
QuestionImage.objects.create(question=question, img_url=url)
return question
class QuestionImageCreateSerializer(serializers.ModelSerializer):
class Meta:
model = QuestionImage
fields = ["img_url"]
class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
category = serializers.PrimaryKeyRelatedField(queryset=QuestionCategory.objects.all())
image_urls = serializers.ListField(
child=serializers.URLField(),
write_only=True,
required=False,
)
images = QuestionImageCreateSerializer(many=True, read_only=True)
image_urlswrite_only=True → 요청으로만 받고 응답에는 포함되지 않음imagesread_only=True → DB에서 읽는 용도만. class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls", # 요청 데이터
"images", # 응답 데이터
"created_at",
]
read_only_fields = ["id", "created_at"]
Serializer의 validate()
serializer.is_valid() 호출되면 이 validate()가 실행됨 🔥
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
request = self.context["request"]
assert isinstance(request.user, User)
request = self.context["request"]context={"request": request} 를 넣어주면self.context["request"] 로 접근 가능함assert isinstance(request.user, User) # 403 - 수강생 권한
if request.user.role != RoleChoices.ST:
raise serializers.ValidationError({"type": "permission_denied"})
# 409 - 중복 제목
title = attrs.get("title")
if title and Question.objects.filter(title=title).exists():
raise serializers.ValidationError({"type": "title_conflict"})
# category 존재 여부는 PrimaryKeyRelatedField 가 검증
return attrs
if request.user.role != RoleChoices.ST{"type": "permission_denied"} 형태로 던지면title = attrs.get("title")serializer.save() 시 실행 🔥
def create(self, validated_data: Dict[str, Any]) -> Question:
request = self.context["request"]
image_urls = validated_data.pop("image_urls", [])
question = Question.objects.create(author=request.user, **validated_data)
for url in image_urls:
QuestionImage.objects.create(question=question, img_url=url)
return question
validated_data 는 이미 검증된 안전한 데이터image_urlsquestion → Question 인스턴스 생성for url in image_urls → 이미지가 있다면 반복문으로 QuestionImage 객체 생성return question → View에서 사용from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.questions.serializers import QuestionCreateSerializer
class QuestionCreateAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
serializer = QuestionCreateSerializer(
data=request.data,
context={"request": request},
)
if serializer.is_valid():
question = serializer.save()
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED,
)
# 에러 매핑
error_type = serializer.errors.get("type")
if error_type == ["permission_denied"]:
return Response(
{"error_detail": "질문 등록 권한이 없습니다."},
status=status.HTTP_403_FORBIDDEN,
)
if error_type == ["title_conflict"]:
return Response(
{"error_detail": "중복된 질문 제목이 이미 존재합니다."},
status=status.HTTP_409_CONFLICT,
)
if "category" in serializer.errors:
return Response(
{"error_detail": "선택한 카테고리를 찾을 수 없습니다."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
{"error_detail": "유효하지 않은 질문 등록 요청입니다."},
status=status.HTTP_400_BAD_REQUEST,
)
class QuestionCreateAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
serializer = QuestionCreateSerializer(
data=request.data,
context={"request": request},
)
if serializer.is_valid():
question = serializer.save()
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED,
)
permission_classes = [IsAuthenticated] → 로그인한 사용자만 접근 가능serializer = QuestionCreateSerializercontext={"request": request}self.context["request"] 로 접근 가능if serializer.is_valid():question = serializer.save()“에러 타입”에 따른 분기 처리
error_type = serializer.errors.get("type")
if error_type == ["permission_denied"]:
return Response(
{"error_detail": "질문 등록 권한이 없습니다."},
status=status.HTTP_403_FORBIDDEN,
)
if error_type == ["title_conflict"]:
return Response(
{"error_detail": "중복된 질문 제목이 이미 존재합니다."},
status=status.HTTP_409_CONFLICT,
)
error_type = serializer.errors.get("type") if "category" in serializer.errors:
return Response(
{"error_detail": "선택한 카테고리를 찾을 수 없습니다."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
{"error_detail": "유효하지 않은 질문 등록 요청입니다."},
status=status.HTTP_400_BAD_REQUEST,
)
from typing import Any
from rest_framework import status
from rest_framework.test import APITestCase
from apps.questions.models import Question, QuestionCategory, QuestionImage
from apps.user.models import RoleChoices, User
class QuestionCreateAPITests(APITestCase):
def setUp(self) -> None:
self.category = QuestionCategory.objects.create(name="프론트엔드")
self.url = "/api/v1/qna/questions"
def create_test_user(self, **kwargs: Any) -> User:
default = {
"phone_number": "010-0000-0000",
"gender": "M",
"birthday": "2000-01-12",
}
default.update(kwargs)
return User.objects.create_user(**default)
# 1) 정상 생성
def test_question_create_success(self) -> None:
student = self.create_test_user(
email="student@test.com",
password="test1234",
name="학생A",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
payload = {
"title": "정상 등록 테스트",
"content": "내용",
"category": self.category.id,
"image_urls": ["https://test.com/img.png"],
}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertIn("question_id", response.data)
self.assertTrue(QuestionImage.objects.filter(question_id=response.data["question_id"]).exists())
# 2) 400 - title / content / category 없는 경우
def test_question_create_validation_fail(self) -> None:
student = self.create_test_user(
email="student2@test.com",
password="test1234",
name="학생B",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
payload = {"title": "", "content": "", "category": None}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# 3) 403 - 학생(ST) 아님
def test_question_create_permission_fail(self) -> None:
user = self.create_test_user(
email="normal@test.com",
password="test1234",
name="일반유저",
role=RoleChoices.USER,
)
self.client.force_authenticate(user=user)
payload = {"title": "권한 실패", "content": "내용", "category": self.category.id}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.data["error_detail"], "질문 등록 권한이 없습니다.")
# 4) 409 - 제목 중복
def test_question_create_title_conflict(self) -> None:
student = self.create_test_user(
email="student3@test.com",
password="test1234",
name="학생C",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
Question.objects.create(
author=student,
category=self.category,
title="중복 제목",
content="내용",
)
payload = {"title": "중복 제목", "content": "다른", "category": self.category.id}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(response.data["error_detail"], "중복된 질문 제목이 이미 존재합니다.")
# 5) 404 - 존재하지 않는 카테고리
def test_question_create_invalid_category(self) -> None:
student = self.create_test_user(
email="student4@test.com",
password="test1234",
name="학생D",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
payload = {"title": "카테고리 실패", "content": "내용", "category": 9999}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data["error_detail"], "선택한 카테고리를 찾을 수 없습니다.")
# 6) image_urls 없어도 정상
def test_question_create_without_image_urls(self) -> None:
student = self.create_test_user(
email="student5@test.com",
password="test1234",
name="학생E",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
payload = {"title": "이미지 없음", "content": "내용", "category": self.category.id}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# 7) image_urls = [] 여도 정상
def test_question_create_empty_image_urls(self) -> None:
student = self.create_test_user(
email="student6@test.com",
password="test1234",
name="학생F",
role=RoleChoices.ST,
)
self.client.force_authenticate(user=student)
payload = {
"title": "빈 이미지 리스트",
"content": "내용",
"category": self.category.id,
"image_urls": [],
}
response = self.client.post(self.url, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
class QuestionCreateAPITests(APITestCase):
def setUp(self) -> None:
self.category = QuestionCategory.objects.create(name="프론트엔드")
self.url = "/api/v1/qna/questions"
def create_test_user(self, **kwargs: Any) -> User:
default = {
"phone_number": "010-0000-0000",
"gender": "M",
"birthday": "2000-01-12",
}
default.update(kwargs)
return User.objects.create_user(**default)
def setUp(self)def create_test_user(self, **kwargs: Any)self.client.force_authenticate(user=student)response = self.client.post(self.url, payload, format="json")self.assertEqual(response.status_code, status.HTTP_201_CREATED)self.assertTrue
(QuestionImage.objects.filter(question_id=response.data["question_id"]
).exists()){
"title": "질문 제목",
"content": "내용",
"category": 1,
"image_urls": [
"https://cdn.com/img1.png",
"https://cdn.com/img2.png"
]
}
image_urls = validated_data.pop("image_urls", [])
question = Question.objects.create(**validated_data)
for url in image_urls:
QuestionImage.objects.create(question=question, img_url=url)
{
"title": "좋은 질문",
"content": "내용입니다",
"category": 1,
"image_urls": ["https://cdn.com/a.png"]
}
{
"title": "좋은 질문",
"content": "내용입니다",
"category": <QuestionCategory: 프론트엔드>,
"image_urls": ["https://cdn.com/a.png"]
}
from typing import Any, Dict
from rest_framework import serializers
from apps.qna.models import Question, QuestionCategory, QuestionImage
from apps.qna.permissions.question.question_create_permission import (
validate_question_create_permission,
validate_question_title_unique, validate_question_category,
)
from apps.qna.services.question.question_create_service import create_question
from apps.user.models import User
class QuestionImageCreateSerializer(serializers.ModelSerializer[QuestionImage]):
class Meta:
model = QuestionImage
fields = ["img_url"]
class QuestionCreateSerializer(serializers.ModelSerializer[Question]):
category = serializers.IntegerField()
image_urls = serializers.ListField(
child=serializers.URLField(),
write_only=True,
required=False,
)
images = QuestionImageCreateSerializer(many=True, read_only=True)
class Meta:
model = Question
fields = [
"id",
"title",
"content",
"category",
"image_urls",
"images",
"created_at",
]
read_only_fields = ["id", "created_at"]
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
request = self.context["request"]
user: User = request.user
validate_question_create_permission(user)
validate_question_title_unique(attrs.get("title"))
category_id = attrs.get("category")
validate_question_category(category_id)
attrs["category"] = QuestionCategory.objects.get(id=category_id)
return attrs
def create(self, validated_data: Dict[str, Any]) -> Question:
request = self.context["request"]
image_urls = validated_data.pop("image_urls", [])
return create_question(
author=request.user,
image_urls=image_urls,
**validated_data,
)
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.qna.serializers.question.question_create import QuestionCreateSerializer
class QuestionCreateAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
serializer = QuestionCreateSerializer(
data=request.data,
context={"request": request},
)
if serializer.is_valid():
question = serializer.save()
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=status.HTTP_201_CREATED,
)
error_type = serializer.errors.get("type")
if error_type == ["permission_denied"]:
return Response(
{"error_detail": "질문 등록 권한이 없습니다."},
status=status.HTTP_403_FORBIDDEN,
)
if error_type == ["title_conflict"]:
return Response(
{"error_detail": "중복된 질문 제목이 이미 존재합니다."},
status=status.HTTP_409_CONFLICT,
)
if error_type == ["category_not_found"]:
return Response(
{"error_detail": "선택한 카테고리를 찾을 수 없습니다."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(
{"error_detail": "유효하지 않은 질문 등록 요청입니다."},
status=status.HTTP_400_BAD_REQUEST,
)
from apps.qna.models import Question
from apps.user.models import RoleChoices, User
from rest_framework import serializers
from apps.qna.models import QuestionCategory
def validate_question_create_permission(user: User) -> None:
if user.role != RoleChoices.ST:
raise serializers.ValidationError({"type": "permission_denied"})
def validate_question_title_unique(title: str) -> None:
if Question.objects.filter(title=title).exists():
raise serializers.ValidationError({"type": "title_conflict"})
def validate_question_category(category_id: int | None) -> None:
"""
카테고리 검증
- None / 누락 → invalid_request 400
- 존재하지 않는 PK → category_not_found 404
"""
if category_id is None:
raise serializers.ValidationError({"type": "invalid_request"})
if not QuestionCategory.objects.filter(id=category_id).exists():
raise serializers.ValidationError({"type": "category_not_found"})
from typing import List
from apps.qna.models import Question, QuestionImage, QuestionCategory
from apps.user.models import User
def create_question(
*,
author: User,
title: str,
content: str,
category: QuestionCategory,
image_urls: List[str],
) -> Question:
question = Question.objects.create(
author=author,
title=title,
content=content,
category=category,
)
for url in image_urls:
QuestionImage.objects.create(
question=question,
img_url=url,
)
return question
| 상황 | 의미 |
|---|---|
* 있음 | 순서 무시 / 키워드 필수 |
* 없음 | 순서 가능 / 키워드도 가능 |
"title": "정상 등록 테스트",
"content": "내용입니다.",
"category": 3,
"image_urls": ["https://test.com/a.png", "https://test.com/b.png"]
path("questions", QuestionCreateAPIView.as_view(), name="question_create"),
# (1) 인증 확인 (DRF가 먼저 처리)
permission_classes = [IsAuthenticated] → 로그인 안 됨 → DRF가 post() 들어오기 전에 401로 끊음
# (2) Serializer에 “요청 데이터”와 “request”를 넣어줌
serializer = QuestionCreateSerializer(
data=request.data, → 사용자가 보낸 JSON
context={"request": request}, → Serializer 내부에서 request.user 쓰려고 넣음
)
# (3) serializer.is_valid() → 호출 DRF가 “검증 파이프라인”을 자동으로 타기 시작
if serializer.is_valid():
question = serializer.save()
category: "abc" → 바로 400 / image_urls: ["not-a-url"] → 바로 400attrs["category"] = QuestionCategory.objects.get(id=category_id)return create_question(
author=request.user,
image_urls=image_urls,
**validated_data,
)
# 5-1) Question 생성
question = Question.objects.create(
author=author,
title=title,
content=content,
category=category,
)
# 5-2) QuestionImage 여러 개 생성
for url in image_urls:
QuestionImage.objects.create(
question=question,
img_url=url,
)
5-3) question 반환
return question
return Response(
{
"message": "질문이 성공적으로 등록되었습니다.",
"question_id": question.id,
},
status=201,
)