오늘 학습 내용 ✅
구조 생각
- 질문 상세조회는 띄우기 쉽다 하지만 답변이 문제다.
- 수강생 / 조교 / 매니저 구분은 그냥 user에 있는 역할을 기준으로 권한검사해서 권한에 맞는
- 답변목록을 띄우는건 알겠는데 질문파트 답변파트 둘다 과정이 문제다.
- 일단 질문 등록이나 답변 등록시에 현재 자신이 어떤 과정을 수행중인지 입력하지 않는다.
- course관련 app가 존재하지만 user / question / answer과 어떠한 관련이 없다.
질의응답 상세 조회
- 수강생들이 등록한 질의응답을 상세 조회 가능
- 조회된 질의응답 목록에서 특정 항목을 클릭하여 해당 항목에 대한 상세를 조회 가능
질의응답 상세 조회 항목
질문 작성자 정보
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 (ex.초격차 프론트엔드 14기)
- 질의응답 제목 / 질문 내용
- 조회수 / 답변 작성 여부 ( Y / N ) / 질문 작성일시 / 질문 수정일시
답변 목록
- 일반 수강생의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 + 기수 정보 (ex.초격차 프론트엔드 8기)
- 조교의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 과정 + 직함 (ex.초격차 프론트엔드 8기 조교)
- 운영매니저, 러닝코치, 어드민의 경우
- 프로필 썸네일 이미지 / 유저 닉네임 / 직함 (ex. 교육 운영 매니저, 러닝 코치, 관리자)
User / Course / Cohort
- User: 사용자 정보 (이름, 이메일, 역할 등)
- Cohort: 특정 과정의 특정 기수 (예: 프론트엔드 14기)
- Course: 과정 자체의 정보 (예: 초격차 프론트엔드)
User 모델 (중심점)
class User(AbstractBaseUser, PermissionsMixin, TimeStampedModel):
role = models.CharField(
choices=RoleChoices.choices,
max_length=2,
default=RoleChoices.USER
)
@property
def in_progress_cohortstudent(self) -> "CohortStudent | None":
return self.cohortstudent_set.select_related("cohort__course").first()
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를 모두 가지고 있어 양쪽을 연결하는 다리 역할을 함
class CohortStudent(TimeStampedModel):
user = models.ForeignKey(
"user.User",
on_delete=models.CASCADE,
)
cohort = models.ForeignKey(
"courses.Cohort",
on_delete=models.CASCADE,
related_name="students"
)
is_active = models.BooleanField(default=True)
class OperationManager(TimeStampedModel):
user = models.ForeignKey(
"user.User",
related_name="operationmanager_set"
)
cohort = models.ForeignKey(
"courses.Cohort",
related_name="managers"
)
Cohort (기수)와 Course (과정)
- 기수(Cohort)는 자신이 어떤 과정(Course)에 속해 있는지 알고 있다.
class Cohort(TimeStampedModel):
course = models.ForeignKey(
"courses.Course",
on_delete=models.CASCADE,
related_name="cohorts"
)
number = models.PositiveIntegerField()
실제 데이터 조회 흐름 (Result)
- 이러한 구조로 인하여 코드 한 줄로 유저가 속한 "과정명"과 "기수"를 찾아낼 수 있다.
enrollment = student_user.in_progress_cohortstudent
if enrollment:
cohort = enrollment.cohort
course = cohort.course
print(f"과정명: {course.name}")
print(f"기수: {cohort.number}기")
if role == "ST":
target_obj = getattr(user, "in_progress_cohortstudent", None)
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 "-"
nickname = getattr(user, "nickname", user.username)
profile_url = getattr(user, "profile_image_url", "")
role = getattr(user, "role", "U")
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>'
info_text = ""
course_name = ""
generation = ""
target_obj = None
if role == "ST":
target_obj = getattr(user, "in_progress_cohortstudent", None)
elif role == "TA":
target_obj = user.trainingassistant_set.select_related("cohort__course").first()
elif role == "LC":
target_obj = user.learningcoach_set.select_related("cohort__course").first()
elif role == "OM":
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}기"
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()
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 "-"
nickname = getattr(user, "nickname", "")
if not nickname:
nickname = getattr(user, "name", "이름 없음")
profile_url = getattr(user, "profile_image_url", "")
role = getattr(user, "role", "U")
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>'
info_text = ""
target_obj = None
try:
if role == "ST":
target_obj = getattr(user, "in_progress_cohortstudent", None)
elif role == "TA":
if hasattr(user, "trainingassistant_set"):
target_obj = user.trainingassistant_set.select_related("cohort__course").first()
elif role == "LC":
if hasattr(user, "learningcoach_set"):
target_obj = user.learningcoach_set.select_related("cohort__course").first()
elif role == "OM":
if hasattr(user, "operationmanager_set"):
target_obj = user.operationmanager_set.select_related("cohort__course").first()
except Exception:
target_obj = None
course_name = ""
generation = ""
if target_obj and hasattr(target_obj, "cohort"):
cohort = target_obj.cohort
course_name = getattr(cohort.course, "name", "")
generation = f"{cohort.number}기"
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()
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가 인식 가능하게 함]
[ 🔴 문제: ]
[ 🟡 원인: ]
[ 🔵 해결: ]