63ed4d81e5
필기 세션과 자료(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>
145 lines
6.0 KiB
Python
145 lines
6.0 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)
|
|
|
|
# 학습 워크스페이스(study_topic) 1:N. NULL 허용 — 미분류 세션이 정상 상태.
|
|
study_topic_id: Mapped[int | None] = mapped_column(
|
|
BigInteger, ForeignKey("study_topics.id", ondelete="SET NULL")
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
# 연관 학습 워크스페이스
|
|
study_topic: Mapped["StudyTopic | None"] = relationship(
|
|
"StudyTopic", back_populates="sessions", lazy="noload"
|
|
)
|
|
|
|
|
|
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")
|