"""study_sessions / study_session_assets 테이블 ORM — Phase 1 MVP 목적: iPad 손글씨 학습 세션 (자격증 + 어학) + 모바일 암기노트/퀴즈를 위한 일반 학습 세션. 설계 원칙: - study_type 으로 certification / language 분기. metadata jsonb 가 도메인별 자유 메타. - 단일 audio_document_id / video_document_id / source_document_id / handwriting_document_id 컬럼 만들지 ❌. 모든 미디어 연결은 study_session_assets 로 통일. - documents 본체는 절대 삭제하지 않음. assets cascade 는 sessions 또는 documents 삭제 시. - Phase 1 미사용 필드 (review_state / quiz / ocr / ai_summary / prompt) 는 NULL 허용, 자동 로직은 Phase 2~4 에서 별도 PR 로 활성. """ from datetime import datetime from typing import Any from sqlalchemy import ( BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base class StudySession(Base): __tablename__ = "study_sessions" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) user_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) # 도메인 분기: 'certification' | 'language' study_type: Mapped[str] = mapped_column( String(30), default="certification", nullable=False ) # 자격증/어학 메타 certification: Mapped[str | None] = mapped_column(String(120)) language_code: Mapped[str | None] = mapped_column(String(20)) learning_level: Mapped[str | None] = mapped_column(String(80)) # 공통 과목/주제 subject: Mapped[str | None] = mapped_column(String(120)) topic: Mapped[str | None] = mapped_column(String(200)) # 원문 텍스트 snapshot (assets 의 source_scan 과 별개로 발췌 텍스트만 보존) source_text: Mapped[str | None] = mapped_column(Text) source_page: Mapped[int | None] = mapped_column(Integer) # 학습 모드: 'copy'/'trace'/'blank-repeat'/'dictation'/'shadowing'/'quiz'/'flashcard' mode: Mapped[str] = mapped_column(String(30), default="copy", nullable=False) prompt_question: Mapped[str | None] = mapped_column(Text) expected_answer: Mapped[str | None] = mapped_column(Text) # 도메인별 자유 메타 (어학 reading/meaning, 자격증 law_article 등) metadata_json: Mapped[dict[str, Any] | None] = mapped_column( "metadata", JSONB ) # 횟수 카운트 (보조) target_count: Mapped[int | None] = mapped_column(Integer) repetition_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) # 필기 데이터 (원본) — Phase 1 핵심 strokes_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB) canvas_width: Mapped[int | None] = mapped_column(Integer) canvas_height: Mapped[int | None] = mapped_column(Integer) schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) # 필기 파생 텍스트 — Phase 2 채움 (Phase 1 NULL) ocr_text: Mapped[str | None] = mapped_column(Text) user_corrected_text: Mapped[str | None] = mapped_column(Text) ai_summary: Mapped[str | None] = mapped_column(Text) # SRS / 퀴즈 통계 — Phase 4 활성, Phase 1 NULL review_state: Mapped[str | None] = mapped_column(String(20)) next_review_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_quiz_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) correct_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) incorrect_count: 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 ) # 연관 assets — 세션 삭제 시 함께 삭제 (DB ON DELETE CASCADE 와 일치) assets: Mapped[list["StudySessionAsset"]] = relationship( back_populates="session", cascade="all, delete-orphan", order_by="StudySessionAsset.sort_order", ) class StudySessionAsset(Base): __tablename__ = "study_session_assets" __table_args__ = ( # POST /assets 의 409 근거. NULL role 끼리는 Postgres 기본대로 다른 값으로 취급. UniqueConstraint( "study_session_id", "document_id", "asset_type", "role", name="study_session_assets_session_id_document_id_asset_type_rol_key", ), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True) study_session_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("study_sessions.id", ondelete="CASCADE"), nullable=False ) document_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False ) # 'source_scan' | 'handwriting_png' | 'audio' | 'video' | 'transcript' | 'reference' asset_type: Mapped[str] = mapped_column(String(30), nullable=False) # 'prompt' | 'answer' | 'pronunciation' | 'lecture' | 'listening_source' # | 'shadowing_source' | 'reference' role: 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 ) session: Mapped["StudySession"] = relationship(back_populates="assets")