2026/01/01 MainProject - 19

김기훈·2026년 1월 1일

TIL

목록 보기
100/194

오늘 학습 내용 ✅

구조 생각

  • 질문 상세조회는 띄우기 쉽다 하지만 답변이 문제다.
    • 수강생 / 조교 / 매니저 구분은 그냥 user에 있는 역할을 기준으로 권한검사해서 권한에 맞는
    • 답변목록을 띄우는건 알겠는데 질문파트 답변파트 둘다 과정이 문제다.
    • 일단 질문 등록이나 답변 등록시에 현재 자신이 어떤 과정을 수행중인지 입력하지 않는다.
    • course관련 app가 존재하지만 user / question / answer과 어떠한 관련이 없다.

질의응답 상세 조회

  • 수강생들이 등록한 질의응답을 상세 조회 가능
  • 조회된 질의응답 목록에서 특정 항목을 클릭하여 해당 항목에 대한 상세를 조회 가능
    • 질의응답 상세 조회 항목

      • 질문 작성자 정보

        • 프로필 썸네일 이미지 / 유저 닉네임 / 과정 (ex.초격차 프론트엔드 14기)
      • 질의응답 제목 / 질문 내용
      • 조회수 / 답변 작성 여부 ( Y / N ) / 질문 작성일시 / 질문 수정일시
      • 답변 목록

        • 일반 수강생의 경우
          • 프로필 썸네일 이미지 / 유저 닉네임 / 과정 + 기수 정보 (ex.초격차 프론트엔드 8기)
        • 조교의 경우
          • 프로필 썸네일 이미지 / 유저 닉네임 / 과정 + 직함 (ex.초격차 프론트엔드 8기 조교)
        • 운영매니저, 러닝코치, 어드민의 경우
          • 프로필 썸네일 이미지 / 유저 닉네임 / 직함 (ex. 교육 운영 매니저, 러닝 코치, 관리자)

User / Course / Cohort

  • User: 사용자 정보 (이름, 이메일, 역할 등)
  • Cohort: 특정 과정의 특정 기수 (예: 프론트엔드 14기)
  • Course: 과정 자체의 정보 (예: 초격차 프론트엔드)

User 모델 (중심점)

# user/models/user.py

class User(AbstractBaseUser, PermissionsMixin, TimeStampedModel):
    # ... (기본 필드 생략)
    
    # 역할(Role) 구분: 이 값에 따라 어떤 중간 테이블을 참조할지 결정
    role = models.CharField(
        choices=RoleChoices.choices, 
        max_length=2, 
        default=RoleChoices.USER
    )

    @property
    def in_progress_cohortstudent(self) -> "CohortStudent | None":
        # cohortstudent_set: CohortStudent 모델이 User를 ForeignKey로 참조하므로 자동 생성된 역참조 이름
        # select_related: 성능 최적화를 위해 cohort와 course 정보를 미리 가져옴
        return self.cohortstudent_set.select_related("cohort__course").first()

# role.py

class CohortStudent(TimeStampedModel):
    cohort = models.ForeignKey(Cohort, on_delete=models.CASCADE, related_name="cohortstudent")
    user = models.ForeignKey(User, on_delete=models.CASCADE)
  • role 필드와, 연결된 정보를 쉽게 가져오기 위해 정의된 @property 메서드
    • User 객체에서 바로 '현재 수강 중인 정보'에 접근할 수 있게 해주는 헬퍼
  • user.in_progress_cohortstudent를 호출하면,
    • 자동으로 CohortStudent 테이블을 조회하여 해당 유저의 수강 정보를 가져옴

중간 테이블 (연결 고리)

  • 유저의 역할(수강생, 조교, 매니저 등)에 따라 서로 다른 모델이 유저와 기수(Cohort)를 연결
    • 이 테이블들은 user_id와 cohort_id를 모두 가지고 있어 양쪽을 연결하는 다리 역할을 함
# user/models/enrollment.py

## --- [ 수강생의 경우 (CohortStudent) ] ---
class CohortStudent(TimeStampedModel):
    # User와 연결 (1:N)
    user = models.ForeignKey(
        "user.User", 
        on_delete=models.CASCADE, 
        # User 입장에서 user.cohortstudent_set 으로 접근 가능하게 함
    )
    
    # Cohort(기수)와 연결 (N:1)
    cohort = models.ForeignKey(
        "courses.Cohort", 
        on_delete=models.CASCADE,
        related_name="students"
    )
    
    is_active = models.BooleanField(default=True) # 수강 상태 등
    
## --- [ 운영 매니저의 경우 (OperationManager) ] ---
class OperationManager(TimeStampedModel):
    user = models.ForeignKey(
        "user.User", 
        related_name="operationmanager_set" # 명시적이지 않으면 Django 기본값 사용
    )
    cohort = models.ForeignKey(
        "courses.Cohort", 
        related_name="managers"
    )

Cohort (기수)와 Course (과정)

  • 기수(Cohort)는 자신이 어떤 과정(Course)에 속해 있는지 알고 있다.
# courses/models/cohorts_models.py

class Cohort(TimeStampedModel):
    # Course와 연결 (N:1) - 하나의 과정에 여러 기수가 존재 (예: 프론트엔드 1기, 2기...)
    course = models.ForeignKey(
        "courses.Course", 
        on_delete=models.CASCADE, 
        related_name="cohorts"
    )
    
    number = models.PositiveIntegerField() # 기수 번호 (예: 14)
    # ...

실제 데이터 조회 흐름 (Result)

  • 이러한 구조로 인하여 코드 한 줄로 유저가 속한 "과정명""기수"를 찾아낼 수 있다.
# --- [ 수강생 유저(student_user)의 과정명 찾기 ] ---
# 1. User 모델의 프로퍼티를 통해 중간 테이블(CohortStudent) 접근
enrollment = student_user.in_progress_cohortstudent 

if enrollment:
    # 2. 중간 테이블에서 기수(Cohort) 정보 접근
    cohort = enrollment.cohort 
    
    # 3. 기수에서 과정(Course) 정보 접근
    course = cohort.course 

    # 결과 출력
    print(f"과정명: {course.name}")  # 예: 초격차 프론트엔드
    print(f"기수: {cohort.number}기") # 예: 14기
    
# --- [ 어드민 페이지에서의 활용 ] ---
if role == "ST":  # 수강생인 경우
    # User 모델에 정의된 프로퍼티 사용
    target_obj = getattr(user, "in_progress_cohortstudent", None)

    # 연결된 Cohort가 있다면 (STEP 2, 3의 연결 활용)
    if target_obj and target_obj.cohort:
        course_name = target_obj.cohort.course.name # 과정명 추출
        generation = f"{target_obj.cohort.number}기" # 기수 추출

qna/admin/utils/user_info.py

# --- [ 이전 코드 ] ---
from typing import Any

from django.utils.html import format_html
from django.utils.safestring import mark_safe


def get_user_display_info(user: Any) -> str:
    """
    유저의 Role에 따라 (썸네일 + 닉네임 + 과정/직함) 정보를 HTML로 반환
    """
    if not user:
        return "-"

    # 1. 기본 정보
    # User 모델 필드: nickname, profile_image_url, role
    nickname = getattr(user, "nickname", user.username)
    profile_url = getattr(user, "profile_image_url", "")
    role = getattr(user, "role", "U")  # 기본값 USER

    # 2. 썸네일 처리
    if profile_url:
        img_html = f'<img src="{profile_url}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 12px; border: 1px solid #e0e0e0;">'
    else:
        img_html = '<div style="display:inline-block; width: 40px; height: 40px; border-radius: 50%; background-color: #ccc; margin-right: 12px; vertical-align: middle;"></div>'

    # 3. 텍스트 정보 구성 (과정명, 기수, 직함)
    info_text = ""

    # 3-1. 과정/기수 정보 추출 로직
    course_name = ""
    generation = ""

    target_obj = None

    if role == "ST":  # Student
        target_obj = getattr(user, "in_progress_cohortstudent", None)

    elif role == "TA":  # Teaching Assistant
        target_obj = user.trainingassistant_set.select_related("cohort__course").first()

    elif role == "LC":  # Learning Coach
        target_obj = user.learningcoach_set.select_related("cohort__course").first()

    elif role == "OM":  # Office Manager
        target_obj = user.operationmanager_set.select_related("cohort__course").first()

    # 추출된 객체에서 데이터 파싱
    if target_obj and hasattr(target_obj, "cohort"):
        cohort = target_obj.cohort
        course_name = cohort.course.name
        generation = f"{cohort.number}기"

    # 4. 역할별 최종 텍스트 포맷팅
    # RoleChoices: ST(Student), TA(TA), LC(Learning Coach), OM(Office Manager), AD(Admin), U(User)

    if role == "ST":
        # 예: 초격차 프론트엔드 14기
        info_text = f"{course_name} {generation}".strip()

    elif role == "TA":
        # 예: 초격차 프론트엔드 14기 [조교]
        info_text = f"{course_name} {generation} 조교".strip()

    elif role == "LC":
        # 예: 러닝 코치 (요구사항에 따라 직함 우선)
        info_text = "러닝 코치"

    elif role == "OM":
        info_text = "교육 운영 매니저"

    elif role == "AD":
        info_text = "관리자"

    else:  # 일반 유저 (U) 등
        info_text = "일반 회원"

    # 정보가 비어있으면 역할 이름이라도 표시
    if not info_text:
        info_text = user.get_role_display()

    # 5. HTML 조립
    return format_html(
        '''
        <div style="display: flex; align-items: center; padding: 5px 0;">
            {img}
            <div style="display: flex; flex-direction: column; justify-content: center;">
                <span style="font-weight: bold; font-size: 14px; color: #333; line-height: 1.2;">{nick}</span>
                <span style="font-size: 12px; color: #666; margin-top: 4px;">{info}</span>
            </div>
        </div>
        ''',
        img=mark_safe(img_html),
        nick=nickname,
        info=info_text
    )

# ===========================================================================================
# --- [ 변경 후 ] ---
from typing import Any

from django.utils.html import format_html
from django.utils.safestring import mark_safe


def get_user_display_info(user: Any) -> str:
    """
    유저의 Role에 따라 (썸네일 + 닉네임 + 과정/직함) 정보를 HTML로 반환
    """

    if not user:
        return "-"

    # 1. 기본 정보 추출
    # [수정 포인트] user.username은 존재하지 않으므로 user.name 사용
    # User 모델에 nickname 필드가 없거나 비어있을 경우를 대비해 name을 fallback으로 사용
    nickname = getattr(user, "nickname", "")
    if not nickname:
        nickname = getattr(user, "name", "이름 없음")
        
    profile_url = getattr(user, "profile_image_url", "")
    role = getattr(user, "role", "U")  # 기본값 USER

    # 2. 썸네일 이미지 HTML 생성
    if profile_url:
        img_html = f'<img src="{profile_url}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 12px; border: 1px solid #e0e0e0;">'
    else:
        # 이미지가 없을 경우 회색 원으로 대체
        img_html = '<div style="display:inline-block; width: 40px; height: 40px; border-radius: 50%; background-color: #ccc; margin-right: 12px; vertical-align: middle;"></div>'

    # 3. 텍스트 정보 구성 (과정명, 기수, 직함)
    info_text = ""

    # 3-1. 과정/기수 정보 추출 로직
    # [주의] 역참조 이름(_set)이 모델 정의와 다를 경우 여기서 에러가 날 수 있습니다.
    # User 모델의 in_progress_cohortstudent 프로퍼티 등을 활용합니다.
    target_obj = None

    try:
        if role == "ST":  # Student
            # User 모델에 정의된 프로퍼티 활용
            target_obj = getattr(user, "in_progress_cohortstudent", None)

        elif role == "TA":  # Teaching Assistant
            # trainingassistant_set이 맞는지 확인 필요 (related_name이 없으면 기본값 사용)
            if hasattr(user, "trainingassistant_set"):
                target_obj = user.trainingassistant_set.select_related("cohort__course").first()

        elif role == "LC":  # Learning Coach
            if hasattr(user, "learningcoach_set"):
                target_obj = user.learningcoach_set.select_related("cohort__course").first()

        elif role == "OM":  # Office Manager
            if hasattr(user, "operationmanager_set"):
                target_obj = user.operationmanager_set.select_related("cohort__course").first()
                
    except Exception:
        # DB 관계 설정 문제로 에러 발생 시, 텍스트 정보 없음으로 처리하여 페이지 로딩 방해 방지
        target_obj = None

    # 추출된 객체에서 데이터 파싱
    course_name = ""
    generation = ""
    
    if target_obj and hasattr(target_obj, "cohort"):
        cohort = target_obj.cohort
        course_name = getattr(cohort.course, "name", "") # Course 모델 필드명 확인 (title인지 name인지)
        generation = f"{cohort.number}기"

    # 4. 역할별 최종 텍스트 포맷팅
    if role == "ST":
        info_text = f"{course_name} {generation}".strip()
    elif role == "TA":
        info_text = f"{course_name} {generation} 조교".strip()
    elif role == "LC":
        info_text = "러닝 코치"
    elif role == "OM":
        info_text = "교육 운영 매니저"
    elif role == "AD":
        info_text = "관리자"
    else:
        info_text = "일반 회원"

    # 정보가 비어있으면 역할 이름이라도 표시
    if not info_text:
        info_text = user.get_role_display()

    # 5. HTML 조립
    return format_html(
        '''
        <div style="display: flex; align-items: center; padding: 5px 0;">
            {img}
            <div style="display: flex; flex-direction: column; justify-content: center;">
                <span style="font-weight: bold; font-size: 14px; color: #333; line-height: 1.2;">{nick}</span>
                <span style="font-size: 12px; color: #666; margin-top: 4px;">{info}</span>
            </div>
        </div>
        ''',
        img=mark_safe(img_html),
        nick=nickname,
        info=info_text
    )

CohortStudent 테이블에 저장되는 기준

  • CohortStudent는 특정 수강생(User)이 특정 기수(Cohort)에 소속되었음을 정의하는 연결 모델

새롭게 알게된 내용 ✅

  • truncatechars

    • Django(장고)와 같은 웹 프레임워크의 템플릿 언어에서 사용되는 '문자열 자르기'필터(Filter)

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

[ 🔴 문제: qna/admin/ 폴더 내에 qna_admin.py와 qna_category_admin.py로 파일을 분리하여
구조를 변경한 후, Django 어드민 페이지에서 '질문(Question)'과 '카테고리(Category)' 관리 기능이 모두 사라짐. ]


[ 🟡 원인: 패키지 초기화 파일(__init__.py) 내 임포트 누락 ]

1. Django는 앱의 admin 모듈을 찾을 때 기본적으로 admin.py 파일을 찾거나, 
폴더 형태일 경우 admin/__init__.py를 실행함
2. 일을 분리만 하고 __init__.py에서 해당 클래스들을 불러오지(import) 않으면,
Django는 새롭게 만든 파일들을 인식하지 못해 어드민 등록 과정을 건너뛰게 됨

[ 🔵 해결: qna/admin/__init__.py 파일에 분리된 파일들의 어드민 클래스를 임포트하여 Django가 인식 가능하게 함]
[ 🔴 문제: ]


[ 🟡 원인: ]


[ 🔵 해결: ]
profile
안녕하세요.

0개의 댓글