Files
hyungi_document_server/app/models/study_topic.py
T
Hyungi Ahn 63ed4d81e5 feat(study): study_topics 학습 워크스페이스 컨테이너 도입
필기 세션과 자료(library document)를 한 학습 주제(예: 가스기사) 아래로 묶는
1차 컨테이너. 향후 단어장/오디오/문제세트 등 학습 자산이 같은 묶음으로 들어올 수
있도록 응답 구조(sections + stats)를 dict 기반으로 설계.

데이터 모델 (migrations 179~185):
- study_topics: user_id × name partial unique (active 행만), soft delete
- study_sessions.study_topic_id: 1:N nullable FK (ON DELETE SET NULL)
- study_topic_documents: 자료 N:M 매핑 (user_id 반정규화로 권한 격리)

설계 원칙:
- documents.category(자료실 UI 축)와 직교 → 자료실 facet/카테고리 미터치
- StudySession.certification/subject/topic 보존 (세부 메타로 계속 사용)
- study_type은 느슨한 분류 (강한 enum 미사용, jlpt_n3 등 확장 여지)
- polymorphic study_topic_items 영구 금지 → 자산 타입별 조인 테이블 추가 방식

API: /api/study-topics CRUD + /by-document/{id} + 자료/세션 매핑 엔드포인트.
프론트: /study/topics 목록 + /study/topics/[id] 통합 뷰(필기·자료 두 트랙) +
        write 폼에 워크스페이스 드롭다운 + study hub 진입 카드.

후속 PR-2 어학 UX, PR-3 오디오 자산, PR-4 AI retrieval scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:06:37 +09:00

81 lines
3.5 KiB
Python

"""study_topics / study_topic_documents 테이블 ORM — 학습 워크스페이스 1차 컨테이너
목적: 필기 세션(StudySession) 과 자료(documents) 를 한 학습 주제(예: 가스기사)
아래로 묶는 컨테이너. 향후 단어장/오디오/문제세트 같은 학습 자산이 같은
컨테이너 아래로 들어올 수 있도록 설계.
설계 원칙:
- documents.category(자료실 UI 축) 와 직교한 별도 분류 축. 자료실 facet/카테고리 미터치.
- StudySession.certification/subject/topic 컬럼은 보존, 본 컨테이너 와 직교 세부 메타.
- study_type 은 느슨한 분류. DB/Pydantic 강한 enum 미사용. 권장값: certification /
language / school / work / general (UI 드롭다운에서만 안내).
- soft delete (deleted_at). 동일 user_id+name 의 active 행만 partial unique index 로
중복 방지 — 삭제된 주제명 재생성 가능.
- 자산 다대다 매핑: 본 PR 은 documents 만 (study_topic_documents). 향후 자산 타입별
조인 테이블 추가 (study_topic_audio_assets 등). polymorphic 단일 테이블 금지.
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
class StudyTopic(Base):
__tablename__ = "study_topics"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
color: Mapped[str | None] = mapped_column(String(20))
# 느슨한 분류 (certification/language/school/work/general 권장)
study_type: Mapped[str | None] = mapped_column(String(40))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 연관 — 세션 (1:N), 자료 매핑 (N:M)
sessions: Mapped[list["StudySession"]] = relationship( # type: ignore[name-defined] # noqa: F821
"StudySession", back_populates="study_topic", lazy="noload"
)
document_links: Mapped[list["StudyTopicDocument"]] = relationship(
back_populates="topic",
cascade="all, delete-orphan",
order_by="StudyTopicDocument.sort_order",
lazy="noload",
)
class StudyTopicDocument(Base):
__tablename__ = "study_topic_documents"
study_topic_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), primary_key=True
)
document_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
topic: Mapped["StudyTopic"] = relationship(back_populates="document_links")