오늘 학습 내용 ✅
어드민 구현 고민
- 카테고리 관리 어드민과 질의응답 관리 어드민 2가지를 구현해야하는데
- 각각 다른 어드민파일에 작성하는게 좋을까 아니면 두개 다 합쳐서 구현하는게 좋을까
전략
- 두개를 같이 관리하기
- 질의응답에서 사용하는 카테고리는 다른 앱에서 사용하지는 않기 때문에 굳이 분리할 이유가 없음
from django.contrib import admin
from .models import Category, Question, Answer
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'slug']
class AnswerInline(admin.StackedInline):
model = Answer
extra = 0
min_num = 0
can_delete = True
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ['id', 'category', 'title', 'user', 'is_answered', 'created_at']
list_filter = ['is_answered', 'category', 'created_at']
search_fields = ['title', 'content', 'user__username']
inlines = [AnswerInline]
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
장점
- 업무 흐름 최적화
- 관리자는 Question 목록만 보면 됨
- 클릭해서 들어가면 질문 내용 아래에 바로 답변 입력창이 뜸
- 데이터 파편화 방지
- 답변이 없는 고아(Orphan) 데이터가 생기거나,
- 어떤 질문에 대한 답변인지 헷갈릴 일이 없음
- 카테고리 관리
- 카테고리는 질문 작성의 "기준"이 되는 데이터이므로 별도로 등록하여,
- 필요시 관리자가 쉽게 추가/수정할 수 있게 함
마이그레이션의 두려움
- 어드민 기능을 구현하다가 피할수없는 모델수정이 생겨서 마이그레이션을 해야하는데 충돌이 무섭다
class QuestionCategory(TimeStampedModel):
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL, related_name="children")
name = models.CharField(max_length=15)
class Meta:
db_table = "question_categories"
def __str__(self) -> str:
return self.name
class QuestionCategory(TimeStampedModel):
CATEGORY_TYPES = (
('large', '대분류'),
('medium', '중분류'),
('small', '소분류'),
)
name = models.CharField(max_length=50, verbose_name="카테고리 이름")
type = models.CharField(
max_length=10,
choices=CATEGORY_TYPES,
default='large',
verbose_name="카테고리 종류"
)
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children',
verbose_name="부모 카테고리"
)
class Meta:
db_table = "question_categories"
verbose_name = "질의응답 카테고리"
verbose_name_plural = "질의응답 카테고리 목록"
def __str__(self) -> str:
return f"[{self.get_type_display()}] {self.name}"
def clean(self):
"""계층 구조 유효성 검사"""
if self.type == 'large' and self.parent is not None:
raise ValidationError({'parent': '대분류는 부모 카테고리를 가질 수 없습니다.'})
if self.type == 'medium':
if self.parent is None or self.parent.type != 'large':
raise ValidationError({'parent': '중분류의 부모는 [대분류]여야 합니다.'})
if self.type == 'small':
if self.parent is None or self.parent.type != 'medium':
raise ValidationError({'parent': '소분류의 부모는 [중분류]여야 합니다.'})
변경 분석
- name 길이 변경 (15 -> 50)
- type 필드 추가
- default='large'가 설정되어 있어, 기존에 저장된
- 모든 카테고리는 자동으로 '대분류(large)'로 값이 채워집니다. 따라서 에러가 나지 않음
- parent 옵션 변경 (SET_NULL -> CASCADE)
- 스키마 변경 발생
- 다만, 이는 데이터 삭제 시 동작 방식이 바뀌는 것이므로 마이그레이션 자체는 성공
- 예전에는 부모 카테고리를 지우면 자식 카테고리가 남아있었지만(parent=NULL)
앵간해서 makemigrations 하면 괜찮음
admin
if TYPE_CHECKING:
_BaseAdmin = admin.ModelAdmin[QuestionCategory]
else:
_BaseAdmin = admin.ModelAdmin
@admin.register(QuestionCategory)
class QuestionCategoryAdmin(_BaseAdmin):
list_display = (
"id",
"name",
"get_type_display_custom",
"get_children_names",
"get_parent_name",
"created_at",
"updated_at",
)
list_filter = ("type", "created_at")
search_fields = ("name", "parent__name")
ordering = ("type", "parent", "id")
def get_queryset(self, request: HttpRequest) -> QuerySet[QuestionCategory]:
queryset = super().get_queryset(request)
return queryset.select_related("parent").prefetch_related("children")
def delete_view(
self, request: HttpRequest, object_id: str, extra_context: Optional[dict[str, Any]] = None
) -> HttpResponse:
obj = self.get_object(request, object_id)
extra_context = extra_context or {}
if obj:
warning_msg = ""
base_msg = " 해당 카테고리의 질의응답은 '일반질문'으로 자동 전환되며, 삭제된 카테고리는 복구할 수 없습니다."
if obj.type == "large":
warning_msg = f"⚠️ [대분류 삭제 경고] 하위 '중분류' 및 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
elif obj.type == "medium":
warning_msg = f"⚠️ [중분류 삭제 경고] 하위 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
else:
warning_msg = f"⚠️ [소분류 삭제 경고]{base_msg}"
extra_context["title"] = warning_msg
return super().delete_view(request, object_id, extra_context=extra_context)
@admin.display(description="분류 타입")
def get_type_display_custom(self, obj: QuestionCategory) -> str:
color_map = {
"large": "28a745",
"medium": "17a2b8",
"small": "6c757d",
}
color = color_map.get(obj.type, "333")
return format_html(
'<span style="color: white; background-color: #{}; padding: 4px 8px; border-radius: 4px; font-weight: bold;">{}</span>',
color,
obj.get_type_display(),
)
@admin.display(description="자식 카테고리")
def get_children_names(self, obj: QuestionCategory) -> str:
if obj.type == "small":
return "-"
children = obj.children.all()
return ", ".join([child.name for child in children]) if children else "-"
@admin.display(description="부모 카테고리")
def get_parent_name(self, obj: QuestionCategory) -> str:
if obj.type == "large":
return "-"
return f"{obj.parent.name} ({obj.parent.get_type_display()})" if obj.parent else "-"
완료!

질의응답(admin) 관리
질의응답 목록 조회
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴에 접속하여
- 수강생들이 등록한 질의응답을 목록으로 조회 가능
- 질의응답 목록 조회 시 검색, 정렬 기능을 적용 가능
목록 조회 시 확인 가능한 항목
- 질문 고유 ID / 질문 제목 / 대분류 카테고리 > 중분류 카테고리 > 소분류 카테고리
- 내용 / 작성자 ( 닉네임 ) / 조회수 / 답변 작성 여부 ( Y / N ) / 작성일시 / 수정일시
질의응답 상세 조회
- 수강생들이 등록한 질의응답을 상세 조회 가능
- 조회된 질의응답 목록에서 특정 항목을 클릭하여 해당 항목에 대한 상세를 조회 가능
질의응답 상세 조회 항목
- 질의응답 제목 / 질문 내용 / 질문 작성자 정보
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 (ex.초격차 프론트엔드 14기)
- 조회수 / 답변 작성 여부 ( Y / N ) / 질문 작성일시 / 질문 수정일시 / 답변 목록
- 일반 수강생의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 - 기수 정보 (ex.초격차 프론트엔드 8기)
- 조교의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 직함 (ex.초격차 프론트엔드 8기 조교)
- 운영매니저, 러닝코치, 어드민의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 직함 (ex. 교육 운영 매니저, 러닝 코치, 관리자)
- 답변 내용 / 답변 채택 여부 / 답변 작성일시 / 답변 수정일시
질의응답 내역 삭제
- 수강생들이 등록한 질의응답 내역을 삭제 가능
- 조회된 질의응답 목록에서 항목을 선택하고 삭제하기 버튼을 클릭하여 삭제
- 질의응답 상세 조회 모달 내에 위치한 삭제하기 버튼을 클릭하여 삭제
- 질의응답 내역 삭제 시 해당 질문, 질문에 작성된 답변, 답변에 작성된 댓글들이 모두 함께 삭제
답변 삭제
- 수강생들이 질문에 대해 작성한 답변을 삭제 가능
- 질의응답 상세 조회 모달 내
- 답변 목록에서 각 항목에 위치한 삭제버튼 ( x 아이콘
질의응답 목록 조회
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴에 접속하여
- 수강생들이 등록한 질의응답을 목록으로 조회 가능
- 질의응답 목록 조회 시 검색, 정렬 기능을 적용 가능
목록 조회 시 확인 가능한 항목
- 질문 고유 ID / 질문 제목 / 대분류 카테고리 > 중분류 카테고리 > 소분류 카테고리
- 내용 / 작성자 ( 닉네임 ) / 조회수 / 답변 작성 여부 ( Y / N ) / 작성일시 / 수정일시

코드 리뷰
@admin.register(Question)
class QuestionAdmin(_QuestionBaseAdmin):
list_display = (
"id",
"title",
"get_category_hierarchy",
"get_content_preview",
"get_author_nickname",
"view_count",
"get_is_answered",
"created_at",
"updated_at",
)
list_display_links = ("id", "title")
search_fields = (
"title",
"content",
"author__nickname",
"author__email",
)
list_filter = (
"created_at",
"category__type",
)
ordering = ("-created_at",)
def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
"""
N+1 문제를 방지하고 답변 개수를 미리 계산(annotate)합니다.
"""
queryset = super().get_queryset(request)
return queryset.select_related(
"author",
"category",
"category__parent",
"category__parent__parent"
).annotate(
answers_count=Count("answers")
)
@admin.display(description="카테고리 경로")
def get_category_hierarchy(self, obj: Question) -> str:
"""대분류 > 중분류 > 소분류 형태로 표시"""
category = obj.category
path = []
curr = category
while curr:
path.append(curr.name)
curr = curr.parent
full_path = " > ".join(reversed(path))
return full_path
@admin.display(description="내용")
def get_content_preview(self, obj: Question) -> str:
"""내용이 길 경우 30자로 자름"""
return truncatechars(obj.content, 30)
@admin.display(description="작성자", ordering="author__nickname")
def get_author_nickname(self, obj: Question) -> str:
"""작성자 닉네임 표시 (없을 경우 username)"""
return getattr(obj.author, "nickname", obj.author.name)
@admin.display(description="답변 여부", ordering="answers_count")
def get_is_answered(self, obj: Question) -> str:
"""
답변 개수(answers_count)를 기반으로 Y/N 표시
"""
has_answer = getattr(obj, "answers_count", 0) > 0
if has_answer:
return format_html(
'<span style="color: white; background-color: #28a745; padding: 4px 8px; border-radius: 50%;">Y</span>'
)
else:
return format_html(
'<span style="color: white; background-color: #dc3545; padding: 4px 8px; border-radius: 50%;">N</span>'
)
질의응답 상세 조회
- 수강생들이 등록한 질의응답을 상세 조회 가능
- 조회된 질의응답 목록에서 특정 항목을 클릭하여 해당 항목에 대한 상세를 조회 가능
질의응답 상세 조회 항목
- 질의응답 제목 / 질문 내용 / 질문 작성자 정보
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 (ex.초격차 프론트엔드 14기)
- 조회수 / 답변 작성 여부 ( Y / N ) / 질문 작성일시 / 질문 수정일시 / 답변 목록
- 일반 수강생의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 - 기수 정보 (ex.초격차 프론트엔드 8기)
- 조교의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 직함 (ex.초격차 프론트엔드 8기 조교)
- 운영매니저, 러닝코치, 어드민의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 직함 (ex. 교육 운영 매니저, 러닝 코치, 관리자)
코드
from typing import TYPE_CHECKING, Any, Optional
from django.contrib import admin
from django.db.models import QuerySet, Count
from django.http import HttpRequest, HttpResponse
from django.utils.html import format_html
from django.template.defaultfilters import truncatechars
from django.conf import settings
from apps.qna.models.question.question_category import QuestionCategory
from apps.qna.models.question.question_base import Question
from apps.qna.models.answer.answers import Answer
if TYPE_CHECKING:
_BaseAdmin = admin.ModelAdmin[QuestionCategory]
_QuestionBaseAdmin = admin.ModelAdmin[Question]
_AnswerInlineBase = admin.StackedInline
else:
_BaseAdmin = admin.ModelAdmin
_QuestionBaseAdmin = admin.ModelAdmin
_AnswerInlineBase = admin.StackedInline
def get_user_display_html(user) -> str:
"""
요청된 유저 타입별(수강생/조교/매니저) 표시 로직을 처리하여 HTML을 반환합니다.
"""
if not user:
return "-"
nickname = getattr(user, "nickname", user.username)
profile_url = getattr(user, "profile_image_url", "")
img_tag = ""
if profile_url:
img_tag = f'<img src="{profile_url}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 10px; vertical-align: middle;">'
else:
img_tag = '<div style="display:inline-block; width: 40px; height: 40px; border-radius: 50%; background-color: #ccc; margin-right: 10px; vertical-align: middle;"></div>'
info_text = ""
role = getattr(user, "role", "USER")
course_name = getattr(user, "course_name", "초격차 프론트엔드")
batch_num = getattr(user, "batch", 14)
if role == "USER":
info_text = f"{course_name} {batch_num}기"
elif role == "ASSISTANT":
info_text = f"{course_name} {batch_num}기 조교"
elif role in ["MANAGER", "COACH", "ADMIN", "STAFF"]:
role_map = {
"MANAGER": "교육 운영 매니저",
"COACH": "러닝 코치",
"ADMIN": "관리자",
"STAFF": "스태프"
}
title = role_map.get(role, "관리자")
info_text = title
else:
info_text = "알 수 없는 사용자"
return format_html(
'<div style="display: flex; align-items: center;">'
' {}'
' <div>'
' <div style="font-weight: bold; font-size: 14px;">{}</div>'
' <div style="color: #666; font-size: 12px;">{}</div>'
' </div>'
'</div>',
format_html(img_tag),
nickname,
info_text
)
class AnswerInline(_AnswerInlineBase):
model = Answer
extra = 0
verbose_name = "등록된 답변"
verbose_name_plural = "답변 목록"
readonly_fields = ("get_author_info", "created_at")
fieldsets = (
(None, {
"fields": ("get_author_info", "content", "is_adopted", "created_at")
}),
)
@admin.display(description="답변 작성자 정보")
def get_author_info(self, obj):
return get_user_display_html(obj.author)
@admin.register(Question)
class QuestionAdmin(_QuestionBaseAdmin):
list_display = (
"id",
"title",
"get_category_hierarchy",
"get_author_nickname",
"view_count",
"get_is_answered_badge",
"created_at",
)
list_display_links = ("id", "title")
search_fields = ("title", "content", "author__nickname", "author__email")
list_filter = ("created_at", "category__type")
ordering = ("-created_at",)
inlines = [AnswerInline]
fieldsets = (
("질문 정보", {
"fields": (
"title",
"get_category_hierarchy",
"content"
)
}),
("작성자 및 상태", {
"fields": (
"get_detail_author_info",
"view_count",
"get_is_answered_text",
"created_at",
"updated_at",
)
}),
)
readonly_fields = (
"get_category_hierarchy",
"get_detail_author_info",
"get_is_answered_text",
"created_at",
"updated_at",
"view_count"
)
def get_queryset(self, request: HttpRequest) -> QuerySet[Question]:
queryset = super().get_queryset(request)
return queryset.select_related(
"author",
"category",
"category__parent"
).annotate(
answers_count=Count("answers")
)
@admin.display(description="작성자 정보")
def get_detail_author_info(self, obj: Question) -> str:
"""상세 페이지 상단에 표시될 작성자 프로필 카드"""
return get_user_display_html(obj.author)
@admin.display(description="답변 작성 여부")
def get_is_answered_text(self, obj: Question) -> str:
"""상세 페이지용: 텍스트로 명확하게 Y/N 표시"""
count = getattr(obj, "answers_count", obj.answers.count())
return "Y (답변 있음)" if count > 0 else "N (답변 없음)"
@admin.display(description="답변")
def get_is_answered_badge(self, obj: Question) -> str:
"""목록 페이지용: 색상 뱃지"""
has_answer = getattr(obj, "answers_count", 0) > 0
color = "28a745" if has_answer else "dc3545"
text = "Y" if has_answer else "N"
return format_html(
'<span style="color: white; background-color: #{}; padding: 4px 8px; border-radius: 50%;">{}</span>',
color, text
)
@admin.display(description="카테고리")
def get_category_hierarchy(self, obj: Question) -> str:
curr = obj.category
path = []
while curr:
path.append(curr.name)
curr = curr.parent
return " > ".join(reversed(path))
@admin.display(description="작성자")
def get_author_nickname(self, obj: Question) -> str:
return getattr(obj.author, "nickname", obj.author.username)
새롭게 알게된 내용 ✅
오늘 발생한 문제(발생 했다면) ✅
문제: 어드민페이지 로컬에서는 카테고리 관리가 보이는데 배포환경에서는 안보임
원인: 요구사항대로 만들었으나 어드민에 적용한 권한으로 인한 권한부족으로 배포환경에서는 어드민 아이디라도 카테고리 관리창이 보이지 않음
해결: 어차피 Django Admin이기 때문에 어드민 권한은 제거함