7804f22dce
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 에서 활성.
135 lines
5.6 KiB
Python
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")
|