Files
hyungi_document_server/app/models/study_session.py
T
Hyungi Ahn 7804f22dce feat(study): study_sessions backend (Phase 1) — 자격증/어학 일반 학습 세션 + assets 연결
iPad 손글씨 필사 / 모바일 암기노트 / 모바일 퀴즈가 같은 데이터를 공유하는
일반 학습 세션 backend. study_type 으로 certification/language 분기.

- migrations/164: study_sessions + study_session_assets DDL + 5 partial indexes
- app/models/study_session.py: StudySession + StudySessionAsset ORM (cascade)
- app/api/study_sessions.py: CRUD + snapshot(PNG) + assets + filter + groups
  - ownership: 모든 endpoint user_id 검증, mismatch 도 404 (정보 누설 방지)
  - 409 중복: UNIQUE(session, document, asset_type, role) 사전 SELECT + IntegrityError 폴백
  - enum 422: study_type / mode / asset_type / role / review_state / order
  - filter: 11개 (study_type, certification, language_code, learning_level,
    subject, topic, review_state, document_id, asset_type, mode, due_before)
  - groups: certification 트리 + language 트리 + has_audio/has_video
  - snapshot: documents.py atomic rename + error_code 패턴 차용
- app/main.py: /api/study-sessions router 등록

plan: ~/.claude/plans/scalable-chasing-stonebraker.md
Phase 1 미사용 필드 (review_state/quiz/ocr/ai_summary/prompt) 는 NULL 허용,
자동 로직은 Phase 2~4 별도 PR 에서 활성.
2026-04-27 08:15:28 +09:00

135 lines
5.6 KiB
Python

"""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")