4b7156061e
study_topic 워크스페이스에 4지선다 문제은행 자산 트랙 추가. 기사시험 필기
대비 시나리오 — 빠른 반복 입력 + 과목별 균등 추출 복습 + 정오답 누적.
데이터 모델 (migrations 186~190):
- study_questions: study_topic 1:N, soft delete, is_active 토글, correct_choice
SMALLINT CHECK 1~4
- study_question_attempts: 답 제출 1행 누적. study_question_id FK는 ON DELETE
RESTRICT (이력 보존 원칙 — hard delete 실수로 풀이 기록 소실 차단)
설계 원칙:
- 문제 삭제는 API 에서 soft delete only. attempts FK RESTRICT 로 DB 레벨도 보호
- correct_choice 변경 시 기존 attempts.is_correct 재계산 안 함 (시점 사실 보존)
- 복습 default = 과목별 target_per_subject(20) 무작위 균등 추출. 한 과목이
부족하면 가용한 만큼만
- wrong_only=true 정의 = 가장 최근 attempt 가 오답인 문제 (latest-wrong, ever-wrong 아님)
- 출제 응답에서 정답·해설 비공개. 답 제출 시점에만 노출
- subject/scope 강한 enum 미사용 (자유 텍스트, 자동완성은 후속)
API: /api/study-topics/{id}/questions, /review/questions, /api/study-questions/{id},
/attempt. 통합뷰(/study-topics/{id}) 응답에 sections.questions / stats.question_count
추가. 기존 question_set_count 는 후속 PR(회차/모의고사 묶음)용으로 보존.
프론트: /study/topics/[id]에 문제 섹션 + "새 문제"/"복습 시작" 진입.
/questions/new (저장 후 계속 입력 + sessionStorage persistent),
/questions/[qid]/edit (정답 변경 시 attempts 재계산 안 됨 안내 배너),
/review (시작 옵션 → 풀이 → 마지막 요약).
후속 PR 예정: 오답노트/취약 과목 리포트, AI 해설/클러스터링, spaced
repetition, 이미지 OCR 입력, CSV import, study_question_sets 묶음.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
3.7 KiB
Python
84 lines
3.7 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), 문제 (1:N PR-2)
|
|
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",
|
|
)
|
|
questions: Mapped[list["StudyQuestion"]] = relationship( # type: ignore[name-defined] # noqa: F821
|
|
"StudyQuestion", back_populates="topic", 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")
|