2025/12/31 MainProject - 18

김기훈·2025년 12월 31일

TIL

목록 보기
99/194

오늘 학습 내용 ✅

어드민 구현 고민

  • 카테고리 관리 어드민과 질의응답 관리 어드민 2가지를 구현해야하는데
    • 각각 다른 어드민파일에 작성하는게 좋을까 아니면 두개 다 합쳐서 구현하는게 좋을까
  • 전략

    • 두개를 같이 관리하기
    • 질의응답에서 사용하는 카테고리는 다른 앱에서 사용하지는 않기 때문에 굳이 분리할 이유가 없음
from django.contrib import admin
from .models import Category, Question, Answer

# 1. 카테고리 관리 (독립적으로 관리)
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug'] # 보여줄 필드 설정
    # 카테고리 관리는 심플하게 유지

# 2. 답변(Answer)을 질문 페이지 안에 넣기 위한 Inline 설정
class AnswerInline(admin.StackedInline): # 또는 admin.TabularInline (가로 정렬)
    model = Answer
    extra = 0 # 기본으로 보여줄 빈 입력폼 개수 (0으로 설정하여 필요할 때만 추가)
    min_num = 0 # 최소 입력 개수
    can_delete = True # 삭제 가능 여부
    
    # 답변은 보통 관리자만 달기 때문에, 읽기 전용 필드 등을 설정할 수도 있음
    # readonly_fields = ('created_at',) 

# 3. 질문(Question) 관리 (여기가 메인 컨트롤 타워)
@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']
    
    # 여기에 Inline을 연결합니다.
    inlines = [AnswerInline]
    
    # 관리자가 답변을 달면 자동으로 '답변 완료' 처리를 하는 등의 로직을 save_model에 추가할 수도 있습니다.
    def save_model(self, request, obj, form, change):
        # 예: 답변이 달렸는지 체크하는 추가 로직이 필요하다면 여기서 처리 가능
        super().save_model(request, obj, form, change)

    # (옵션) Answer 모델은 별도로 admin.register 하지 않습니다.
    # Question 페이지 안에서 관리하는 것이 데이터 무결성에 더 좋기 때문입니다.
  • 장점

    • 업무 흐름 최적화
      • 관리자는 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):
    # 1. [목록 조회 시 보여줄 항목]
    list_display = (
        "id",
        "name",
        "get_type_display_custom",
        "get_children_names",
        "get_parent_name",
        "created_at",
        "updated_at",
    )

    # 2. [검색 및 필터 설정]
    list_filter = ("type", "created_at")
    search_fields = ("name", "parent__name")
    ordering = ("type", "parent", "id")

    # 3. [성능 최적화]
    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:  # small
                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:
        # 1. 타입별 색상 매핑 (Bootstrap 색상 참고)
        color_map = {
            "large": "28a745",  # Green (대분류)
            "medium": "17a2b8",  # Cyan (중분류)
            "small": "6c757d",  # Grey (소분류)
        }
        color = color_map.get(obj.type, "333")  # 기본값: 검정

        # 2. format_html로 안전하게 뱃지 생성
        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):
    # 1. [목록 조회 항목]
    list_display = (
        "id",
        "title",
        "get_category_hierarchy",  # 카테고리 경로
        "get_content_preview",  # 내용 미리보기
        "get_author_nickname",  # 작성자 닉네임
        "view_count",
        "get_is_answered",  # 답변 여부 (Y/N)
        "created_at",
        "updated_at",
    )

    # 2. [클릭 시 상세 페이지로 이동할 링크 설정]
    list_display_links = ("id", "title")

    # 3. [검색 설정]
    # 작성자 닉네임, 제목, 내용으로 검색 가능
    search_fields = (
        "title",
        "content",
        "author__nickname",
        "author__email",
    )

    # 4. [필터 설정]
    list_filter = (
        "created_at",
        "category__type",  # 카테고리 타입별 필터
    )

    # 5. [기본 정렬]
    ordering = ("-created_at",)

    # 6. [성능 최적화 및 Annotation]
    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)"""
        # User 모델 구조에 따라 obj.author.profile.nickname 등으로 변경될 수 있음
        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:
            # 초록색 Y 뱃지
            return format_html(
                '<span style="color: white; background-color: #28a745; padding: 4px 8px; border-radius: 50%;">Y</span>'
            )
        else:
            # 회색 N 뱃지
            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  # 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 "-"

    # 1. 기본 정보 추출
    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>'

    # 2. 직함/과정 정보 구성 로직
    # (실제 프로젝트에서는 User 모델의 필드나 메서드를 통해 가져와야 합니다.)
    # 예시: user.role, user.course_name, user.batch 등
    info_text = ""
    
    # RoleChoices가 있다고 가정하고 로직 분기
    role = getattr(user, "role", "USER") # 기본값 USER
    
    # 예시 데이터 (실제 DB 필드 연동 필요)
    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 = "알 수 없는 사용자"

    # 3. HTML 조립
    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
    )


# --- [답변 목록을 위한 Inline 설정] ---
class AnswerInline(_AnswerInlineBase):
    model = Answer
    extra = 0  # 빈 입력 폼 보이지 않기
    verbose_name = "등록된 답변"
    verbose_name_plural = "답변 목록"
    
    # 답변 목록에서도 작성자 정보를 상세하게 보여주기 위해 readonly 설정
    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)


# --- [Question Admin 설정] ---
@admin.register(Question)
class QuestionAdmin(_QuestionBaseAdmin):
    # ... (이전의 list_display, list_filter 등의 설정 유지) ...
    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",)

    # 상세 조회 페이지(Change View) 구성
    inlines = [AnswerInline]  # 답변 목록을 하단에 표시

    # 상세 페이지에서 보여줄 필드 그룹화
    fieldsets = (
        ("질문 정보", {
            "fields": (
                "title", 
                "get_category_hierarchy", 
                "content"
            )
        }),
        ("작성자 및 상태", {
            "fields": (
                "get_detail_author_info",  # 상세한 작성자 정보 (이미지 포함)
                "view_count",
                "get_is_answered_text",    # 답변 여부 (Y/N 텍스트)
                "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 표시"""
        # get_queryset에서 annotate된 값을 사용하거나, 객체에서 직접 조회
        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)

    # 기존 QuestionCategoryAdmin 코드는 파일 하단에 유지...

새롭게 알게된 내용 ✅

오늘 발생한 문제(발생 했다면) ✅

  • 문제: 어드민페이지 로컬에서는 카테고리 관리가 보이는데 배포환경에서는 안보임

  • 원인: 요구사항대로 만들었으나 어드민에 적용한 권한으로 인한 권한부족으로 배포환경에서는 어드민 아이디라도 카테고리 관리창이 보이지 않음

  • 해결: 어차피 Django Admin이기 때문에 어드민 권한은 제거함

profile
안녕하세요.

0개의 댓글