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 에서 활성.
902 lines
32 KiB
Python
902 lines
32 KiB
Python
"""학습 세션 API — Phase 1 MVP (자격증 + 어학 일반화)
|
|
|
|
iPad 손글씨 필사 / 모바일 암기노트 / 모바일 퀴즈 가 같은 study_sessions 데이터를
|
|
공유. 본 모듈은 Phase 1 = iPad 필사 세션 + DB/API 일반화 까지만 다룬다.
|
|
|
|
핵심:
|
|
- study_type 'certification' | 'language' 분기. metadata jsonb 가 도메인별 자유 메타.
|
|
- 단일 *_document_id 컬럼 ❌. 모든 미디어 연결은 study_session_assets 로 통일.
|
|
- documents 본체는 절대 삭제하지 않음 (assets 연결만 해제).
|
|
- ownership 검증: study_sessions.user_id == current_user.id (필수).
|
|
documents 는 single-user 시스템이라 컬럼 부재 — 미래 multi-user 대비
|
|
`getattr(doc, 'user_id', None)` 로 부드럽게 검증 (값 있으면 비교, 없으면 통과).
|
|
- 409 중복: UNIQUE(study_session_id, document_id, asset_type, role) 위반.
|
|
|
|
Phase 2~4 미사용 필드 (review_state / quiz / ocr_text / ai_summary / prompt 등) 는
|
|
스키마에만 존재, 자동 로직 없음. 별도 PR 에서 활성.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
Depends,
|
|
Form,
|
|
HTTPException,
|
|
Query,
|
|
Request,
|
|
UploadFile,
|
|
)
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import and_, delete, func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
from starlette.requests import ClientDisconnect
|
|
|
|
from core.auth import get_current_user
|
|
from core.config import settings
|
|
from core.database import get_session
|
|
from core.utils import file_hash
|
|
from models.document import Document
|
|
from models.queue import enqueue_stage
|
|
from models.study_session import StudySession, StudySessionAsset
|
|
from models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
# ─── Enum 검증 상수 ───
|
|
|
|
VALID_STUDY_TYPES: set[str] = {"certification", "language"}
|
|
VALID_MODES: set[str] = {
|
|
"copy", "trace", "blank-repeat",
|
|
"dictation", "shadowing",
|
|
"quiz", "flashcard", # Phase 2~4 활성, schema 만 수용
|
|
}
|
|
VALID_ASSET_TYPES: set[str] = {
|
|
"source_scan", "handwriting_png", "audio", "video", "transcript", "reference",
|
|
}
|
|
VALID_ROLES: set[str | None] = {
|
|
None,
|
|
"prompt", "answer", "pronunciation", "lecture",
|
|
"listening_source", "shadowing_source", "reference",
|
|
}
|
|
VALID_REVIEW_STATES: set[str | None] = {
|
|
None, "new", "learning", "weak", "mastered",
|
|
}
|
|
VALID_ORDERS: set[str] = {"created_at", "next_review_at", "last_quiz_at"}
|
|
|
|
|
|
# ─── Helpers ───
|
|
|
|
|
|
def _upload_error(status_code: int, error_code: str, message: str) -> HTTPException:
|
|
"""업로드 실패 응답 — documents.py 와 동일한 패턴."""
|
|
return HTTPException(
|
|
status_code=status_code,
|
|
detail={"error_code": error_code, "message": message},
|
|
)
|
|
|
|
|
|
def _verify_session_ownership(
|
|
sess: StudySession | None, user: User
|
|
) -> StudySession:
|
|
"""세션 ownership 검증. 정보 누설 방지로 mismatch 도 404."""
|
|
if sess is None or sess.user_id != user.id:
|
|
raise HTTPException(status_code=404, detail="학습 세션을 찾을 수 없습니다")
|
|
return sess
|
|
|
|
|
|
def _verify_document_ownership(doc: Document | None, user: User) -> Document:
|
|
"""문서 ownership 검증.
|
|
|
|
documents.user_id 컬럼은 현재 single-user 시스템이라 부재.
|
|
미래 multi-user 대비 `getattr` 로 안전하게 비교.
|
|
"""
|
|
if doc is None or getattr(doc, "deleted_at", None) is not None:
|
|
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
|
doc_user_id = getattr(doc, "user_id", None)
|
|
if doc_user_id is not None and doc_user_id != user.id:
|
|
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
|
return doc
|
|
|
|
|
|
# ─── Pydantic Schemas ───
|
|
|
|
|
|
class StudySessionAssetCreate(BaseModel):
|
|
document_id: int
|
|
asset_type: str
|
|
role: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class StudySessionAssetResponse(BaseModel):
|
|
id: int
|
|
document_id: int
|
|
asset_type: str
|
|
role: str | None
|
|
sort_order: int
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class StudySessionCreate(BaseModel):
|
|
study_type: str = "certification"
|
|
certification: str | None = None
|
|
language_code: str | None = None
|
|
learning_level: str | None = None
|
|
subject: str | None = None
|
|
topic: str | None = None
|
|
source_text: str | None = None
|
|
source_page: int | None = None
|
|
mode: str = "copy"
|
|
prompt_question: str | None = None
|
|
expected_answer: str | None = None
|
|
metadata: dict[str, Any] | None = None
|
|
target_count: int | None = None
|
|
canvas_width: int | None = None
|
|
canvas_height: int | None = None
|
|
strokes_json: dict[str, Any] | None = None
|
|
|
|
|
|
class StudySessionUpdate(BaseModel):
|
|
"""PATCH 부분 업데이트 — 명시 set 된 필드만 반영."""
|
|
|
|
certification: str | None = None
|
|
language_code: str | None = None
|
|
learning_level: str | None = None
|
|
subject: str | None = None
|
|
topic: str | None = None
|
|
source_text: str | None = None
|
|
source_page: int | None = None
|
|
mode: str | None = None
|
|
prompt_question: str | None = None
|
|
expected_answer: str | None = None
|
|
metadata: dict[str, Any] | None = None
|
|
target_count: int | None = None
|
|
repetition_count: int | None = None
|
|
canvas_width: int | None = None
|
|
canvas_height: int | None = None
|
|
strokes_json: dict[str, Any] | None = None
|
|
ocr_text: str | None = None
|
|
user_corrected_text: str | None = None
|
|
review_state: str | None = None
|
|
next_review_at: datetime | None = None
|
|
|
|
|
|
class StudySessionResponse(BaseModel):
|
|
id: int
|
|
user_id: int
|
|
study_type: str
|
|
certification: str | None
|
|
language_code: str | None
|
|
learning_level: str | None
|
|
subject: str | None
|
|
topic: str | None
|
|
source_text: str | None
|
|
source_page: int | None
|
|
mode: str
|
|
prompt_question: str | None
|
|
expected_answer: str | None
|
|
metadata: dict[str, Any] | None = Field(default=None)
|
|
target_count: int | None
|
|
repetition_count: int
|
|
canvas_width: int | None
|
|
canvas_height: int | None
|
|
schema_version: int
|
|
strokes_json: dict[str, Any] | None
|
|
ocr_text: str | None
|
|
user_corrected_text: str | None
|
|
ai_summary: str | None
|
|
review_state: str | None
|
|
next_review_at: datetime | None
|
|
last_quiz_at: datetime | None
|
|
correct_count: int
|
|
incorrect_count: int
|
|
assets: list[StudySessionAssetResponse]
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class StudySessionListResponse(BaseModel):
|
|
items: list[StudySessionResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
def _to_session_response(sess: StudySession) -> StudySessionResponse:
|
|
return StudySessionResponse(
|
|
id=sess.id,
|
|
user_id=sess.user_id,
|
|
study_type=sess.study_type,
|
|
certification=sess.certification,
|
|
language_code=sess.language_code,
|
|
learning_level=sess.learning_level,
|
|
subject=sess.subject,
|
|
topic=sess.topic,
|
|
source_text=sess.source_text,
|
|
source_page=sess.source_page,
|
|
mode=sess.mode,
|
|
prompt_question=sess.prompt_question,
|
|
expected_answer=sess.expected_answer,
|
|
metadata=sess.metadata_json,
|
|
target_count=sess.target_count,
|
|
repetition_count=sess.repetition_count,
|
|
canvas_width=sess.canvas_width,
|
|
canvas_height=sess.canvas_height,
|
|
schema_version=sess.schema_version,
|
|
strokes_json=sess.strokes_json,
|
|
ocr_text=sess.ocr_text,
|
|
user_corrected_text=sess.user_corrected_text,
|
|
ai_summary=sess.ai_summary,
|
|
review_state=sess.review_state,
|
|
next_review_at=sess.next_review_at,
|
|
last_quiz_at=sess.last_quiz_at,
|
|
correct_count=sess.correct_count,
|
|
incorrect_count=sess.incorrect_count,
|
|
assets=[
|
|
StudySessionAssetResponse.model_validate(a) for a in (sess.assets or [])
|
|
],
|
|
created_at=sess.created_at,
|
|
updated_at=sess.updated_at,
|
|
)
|
|
|
|
|
|
def _validate_create_payload(body: StudySessionCreate) -> None:
|
|
if body.study_type not in VALID_STUDY_TYPES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"study_type 은 {sorted(VALID_STUDY_TYPES)} 중 하나여야 합니다",
|
|
)
|
|
if body.mode not in VALID_MODES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"mode 는 {sorted(VALID_MODES)} 중 하나여야 합니다",
|
|
)
|
|
|
|
|
|
# ─── 엔드포인트 ───
|
|
|
|
|
|
@router.post("/", response_model=StudySessionResponse, status_code=201)
|
|
async def create_study_session(
|
|
body: StudySessionCreate,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""새 학습 세션 생성.
|
|
|
|
자격증 예: study_type='certification', certification='산업안전기사',
|
|
subject='산업안전보건법', topic='안전보건관리책임자의 직무', mode='copy'
|
|
어학 예: study_type='language', language_code='ja', learning_level='JLPT N3',
|
|
subject='漢字', topic='安全', source_text='安全',
|
|
metadata={'reading':'あんぜん','meaning':'안전','unit_type':'kanji'}
|
|
"""
|
|
_validate_create_payload(body)
|
|
|
|
sess = StudySession(
|
|
user_id=user.id,
|
|
study_type=body.study_type,
|
|
certification=body.certification,
|
|
language_code=body.language_code,
|
|
learning_level=body.learning_level,
|
|
subject=body.subject,
|
|
topic=body.topic,
|
|
source_text=body.source_text,
|
|
source_page=body.source_page,
|
|
mode=body.mode,
|
|
prompt_question=body.prompt_question,
|
|
expected_answer=body.expected_answer,
|
|
metadata_json=body.metadata,
|
|
target_count=body.target_count,
|
|
canvas_width=body.canvas_width,
|
|
canvas_height=body.canvas_height,
|
|
strokes_json=body.strokes_json,
|
|
)
|
|
session.add(sess)
|
|
await session.flush()
|
|
await session.commit()
|
|
|
|
# assets 빈 리스트로 초기화된 상태로 응답
|
|
sess.assets = []
|
|
return _to_session_response(sess)
|
|
|
|
|
|
@router.get("/", response_model=StudySessionListResponse)
|
|
async def list_study_sessions(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
study_type: str | None = Query(None),
|
|
certification: str | None = Query(None),
|
|
language_code: str | None = Query(None),
|
|
learning_level: str | None = Query(None),
|
|
subject: str | None = Query(None),
|
|
topic: str | None = Query(None),
|
|
review_state: str | None = Query(None),
|
|
document_id: int | None = Query(None, description="이 문서가 연결된 세션만"),
|
|
asset_type: str | None = Query(None, description="이 asset_type 보유 세션만"),
|
|
mode: str | None = Query(None),
|
|
due_before: datetime | None = Query(None, description="next_review_at <= due_before"),
|
|
order: str = Query("created_at"),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
"""학습 세션 목록 — Phase 1 부터 모든 filter 수용 (Phase 3/4 활성 대비)."""
|
|
if study_type is not None and study_type not in VALID_STUDY_TYPES:
|
|
raise HTTPException(status_code=422, detail="study_type 값이 올바르지 않습니다")
|
|
if review_state is not None and review_state not in VALID_REVIEW_STATES:
|
|
raise HTTPException(status_code=422, detail="review_state 값이 올바르지 않습니다")
|
|
if asset_type is not None and asset_type not in VALID_ASSET_TYPES:
|
|
raise HTTPException(status_code=422, detail="asset_type 값이 올바르지 않습니다")
|
|
if mode is not None and mode not in VALID_MODES:
|
|
raise HTTPException(status_code=422, detail="mode 값이 올바르지 않습니다")
|
|
if order not in VALID_ORDERS:
|
|
raise HTTPException(status_code=422, detail="order 값이 올바르지 않습니다")
|
|
|
|
base = select(StudySession).where(StudySession.user_id == user.id)
|
|
if study_type is not None:
|
|
base = base.where(StudySession.study_type == study_type)
|
|
if certification is not None:
|
|
base = base.where(StudySession.certification == certification)
|
|
if language_code is not None:
|
|
base = base.where(StudySession.language_code == language_code)
|
|
if learning_level is not None:
|
|
base = base.where(StudySession.learning_level == learning_level)
|
|
if subject is not None:
|
|
base = base.where(StudySession.subject == subject)
|
|
if topic is not None:
|
|
base = base.where(StudySession.topic == topic)
|
|
if review_state is not None:
|
|
base = base.where(StudySession.review_state == review_state)
|
|
if mode is not None:
|
|
base = base.where(StudySession.mode == mode)
|
|
if due_before is not None:
|
|
base = base.where(StudySession.next_review_at <= due_before)
|
|
|
|
# assets join filter — EXISTS 서브쿼리
|
|
if document_id is not None or asset_type is not None:
|
|
asset_conditions = [StudySessionAsset.study_session_id == StudySession.id]
|
|
if document_id is not None:
|
|
asset_conditions.append(StudySessionAsset.document_id == document_id)
|
|
if asset_type is not None:
|
|
asset_conditions.append(StudySessionAsset.asset_type == asset_type)
|
|
base = base.where(
|
|
select(StudySessionAsset.id)
|
|
.where(and_(*asset_conditions))
|
|
.exists()
|
|
)
|
|
|
|
count_query = select(func.count()).select_from(base.subquery())
|
|
total = (await session.execute(count_query)).scalar() or 0
|
|
|
|
if order == "next_review_at":
|
|
ordered = base.order_by(StudySession.next_review_at.asc().nullslast(), StudySession.id.desc())
|
|
elif order == "last_quiz_at":
|
|
ordered = base.order_by(StudySession.last_quiz_at.desc().nullslast(), StudySession.id.desc())
|
|
else:
|
|
ordered = base.order_by(StudySession.created_at.desc(), StudySession.id.desc())
|
|
|
|
ordered = (
|
|
ordered.options(selectinload(StudySession.assets))
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
rows = (await session.execute(ordered)).scalars().all()
|
|
|
|
return StudySessionListResponse(
|
|
items=[_to_session_response(s) for s in rows],
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
@router.get("/groups")
|
|
async def get_study_groups(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""도메인별 그룹 카운트 (Phase 3 모바일 카드 메뉴 대비, Phase 1 부터 endpoint 제공).
|
|
|
|
응답: {by_type: {certification: {...}, language: {...}}}
|
|
"""
|
|
# certification 그룹: certification → subject → topic
|
|
cert_query = (
|
|
select(
|
|
StudySession.certification,
|
|
StudySession.subject,
|
|
StudySession.topic,
|
|
func.count().label("session_count"),
|
|
func.count().filter(StudySession.review_state == "weak").label("weak_count"),
|
|
func.count()
|
|
.filter(
|
|
and_(
|
|
StudySession.next_review_at.is_not(None),
|
|
StudySession.next_review_at <= datetime.now(timezone.utc),
|
|
)
|
|
)
|
|
.label("due_count"),
|
|
)
|
|
.where(
|
|
StudySession.user_id == user.id,
|
|
StudySession.study_type == "certification",
|
|
)
|
|
.group_by(StudySession.certification, StudySession.subject, StudySession.topic)
|
|
)
|
|
cert_rows = (await session.execute(cert_query)).all()
|
|
|
|
# language 그룹: language_code → learning_level → subject → topic + assets 보유 여부
|
|
lang_query = (
|
|
select(
|
|
StudySession.language_code,
|
|
StudySession.learning_level,
|
|
StudySession.subject,
|
|
StudySession.topic,
|
|
func.count().label("session_count"),
|
|
func.count().filter(StudySession.review_state == "weak").label("weak_count"),
|
|
func.count()
|
|
.filter(
|
|
and_(
|
|
StudySession.next_review_at.is_not(None),
|
|
StudySession.next_review_at <= datetime.now(timezone.utc),
|
|
)
|
|
)
|
|
.label("due_count"),
|
|
)
|
|
.where(
|
|
StudySession.user_id == user.id,
|
|
StudySession.study_type == "language",
|
|
)
|
|
.group_by(
|
|
StudySession.language_code,
|
|
StudySession.learning_level,
|
|
StudySession.subject,
|
|
StudySession.topic,
|
|
)
|
|
)
|
|
lang_rows = (await session.execute(lang_query)).all()
|
|
|
|
# 어학 그룹의 has_audio / has_video — 별도 카운트 (assets 와 join)
|
|
media_query = (
|
|
select(
|
|
StudySession.language_code,
|
|
StudySession.learning_level,
|
|
StudySession.subject,
|
|
StudySession.topic,
|
|
StudySessionAsset.asset_type,
|
|
func.count().label("c"),
|
|
)
|
|
.join(StudySessionAsset, StudySessionAsset.study_session_id == StudySession.id)
|
|
.where(
|
|
StudySession.user_id == user.id,
|
|
StudySession.study_type == "language",
|
|
StudySessionAsset.asset_type.in_(["audio", "video"]),
|
|
)
|
|
.group_by(
|
|
StudySession.language_code,
|
|
StudySession.learning_level,
|
|
StudySession.subject,
|
|
StudySession.topic,
|
|
StudySessionAsset.asset_type,
|
|
)
|
|
)
|
|
media_rows = (await session.execute(media_query)).all()
|
|
media_map: dict[tuple, dict[str, int]] = {}
|
|
for r in media_rows:
|
|
key = (r.language_code, r.learning_level, r.subject, r.topic)
|
|
media_map.setdefault(key, {"audio": 0, "video": 0})[r.asset_type] = r.c
|
|
|
|
# certification 트리 빌드
|
|
cert_groups: dict[str | None, dict[str | None, dict[str | None, dict]]] = {}
|
|
for r in cert_rows:
|
|
cert_groups.setdefault(r.certification, {}).setdefault(r.subject, {})[r.topic] = {
|
|
"session_count": r.session_count,
|
|
"weak_count": r.weak_count,
|
|
"due_count": r.due_count,
|
|
}
|
|
|
|
cert_out = []
|
|
for cert_name, subjects in cert_groups.items():
|
|
subj_list = []
|
|
sess_total = weak_total = due_total = 0
|
|
for subj_name, topics in subjects.items():
|
|
topic_list = []
|
|
s_count = w_count = d_count = 0
|
|
for topic_name, stats in topics.items():
|
|
topic_list.append({
|
|
"topic": topic_name,
|
|
"session_count": stats["session_count"],
|
|
"weak_count": stats["weak_count"],
|
|
"due_count": stats["due_count"],
|
|
})
|
|
s_count += stats["session_count"]
|
|
w_count += stats["weak_count"]
|
|
d_count += stats["due_count"]
|
|
subj_list.append({
|
|
"subject": subj_name,
|
|
"topics": topic_list,
|
|
"session_count": s_count,
|
|
"weak_count": w_count,
|
|
"due_count": d_count,
|
|
})
|
|
sess_total += s_count
|
|
weak_total += w_count
|
|
due_total += d_count
|
|
cert_out.append({
|
|
"certification": cert_name,
|
|
"subjects": subj_list,
|
|
"session_count": sess_total,
|
|
"weak_count": weak_total,
|
|
"due_count": due_total,
|
|
})
|
|
|
|
# language 트리 빌드
|
|
lang_groups: dict[str | None, dict[str | None, dict[str | None, dict[str | None, dict]]]] = {}
|
|
for r in lang_rows:
|
|
media = media_map.get(
|
|
(r.language_code, r.learning_level, r.subject, r.topic),
|
|
{"audio": 0, "video": 0},
|
|
)
|
|
(
|
|
lang_groups
|
|
.setdefault(r.language_code, {})
|
|
.setdefault(r.learning_level, {})
|
|
.setdefault(r.subject, {})[r.topic]
|
|
) = {
|
|
"session_count": r.session_count,
|
|
"weak_count": r.weak_count,
|
|
"due_count": r.due_count,
|
|
"has_audio": media["audio"] > 0,
|
|
"has_video": media["video"] > 0,
|
|
}
|
|
|
|
lang_out = []
|
|
for lang_code, levels in lang_groups.items():
|
|
for level_name, subjects in levels.items():
|
|
subj_list = []
|
|
for subj_name, topics in subjects.items():
|
|
topic_list = []
|
|
for topic_name, stats in topics.items():
|
|
topic_list.append({
|
|
"topic": topic_name,
|
|
"session_count": stats["session_count"],
|
|
"weak_count": stats["weak_count"],
|
|
"due_count": stats["due_count"],
|
|
"has_audio": stats["has_audio"],
|
|
"has_video": stats["has_video"],
|
|
})
|
|
subj_list.append({"subject": subj_name, "topics": topic_list})
|
|
lang_out.append({
|
|
"language_code": lang_code,
|
|
"learning_level": level_name,
|
|
"subjects": subj_list,
|
|
})
|
|
|
|
return {
|
|
"by_type": {
|
|
"certification": {"groups": cert_out},
|
|
"language": {"groups": lang_out},
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/{session_id}", response_model=StudySessionResponse)
|
|
async def get_study_session(
|
|
session_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
sess = await session.get(
|
|
StudySession, session_id, options=[selectinload(StudySession.assets)]
|
|
)
|
|
sess = _verify_session_ownership(sess, user)
|
|
return _to_session_response(sess)
|
|
|
|
|
|
@router.patch("/{session_id}", response_model=StudySessionResponse)
|
|
async def update_study_session(
|
|
session_id: int,
|
|
body: StudySessionUpdate,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
sess = await session.get(
|
|
StudySession, session_id, options=[selectinload(StudySession.assets)]
|
|
)
|
|
sess = _verify_session_ownership(sess, user)
|
|
|
|
# 명시 set 된 필드만 적용
|
|
fields_set = body.model_fields_set
|
|
if "mode" in fields_set:
|
|
if body.mode not in VALID_MODES:
|
|
raise HTTPException(status_code=422, detail="mode 값이 올바르지 않습니다")
|
|
sess.mode = body.mode
|
|
if "review_state" in fields_set:
|
|
if body.review_state not in VALID_REVIEW_STATES:
|
|
raise HTTPException(status_code=422, detail="review_state 값이 올바르지 않습니다")
|
|
sess.review_state = body.review_state
|
|
|
|
# 단순 매핑 필드 (검증 불필요)
|
|
SIMPLE_FIELDS = {
|
|
"certification", "language_code", "learning_level", "subject", "topic",
|
|
"source_text", "source_page", "prompt_question", "expected_answer",
|
|
"target_count", "repetition_count",
|
|
"canvas_width", "canvas_height", "strokes_json",
|
|
"ocr_text", "user_corrected_text", "next_review_at",
|
|
}
|
|
for fname in SIMPLE_FIELDS & fields_set:
|
|
setattr(sess, fname, getattr(body, fname))
|
|
|
|
if "metadata" in fields_set:
|
|
sess.metadata_json = body.metadata
|
|
|
|
sess.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(sess, attribute_names=["assets"])
|
|
return _to_session_response(sess)
|
|
|
|
|
|
@router.delete("/{session_id}", status_code=204)
|
|
async def delete_study_session(
|
|
session_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""학습 세션 삭제. 연관 assets 도 cascade 로 함께 제거 (DB ON DELETE CASCADE).
|
|
documents 본체는 유지 — assets row 만 사라진다.
|
|
"""
|
|
sess = await session.get(StudySession, session_id)
|
|
sess = _verify_session_ownership(sess, user)
|
|
await session.delete(sess)
|
|
await session.commit()
|
|
|
|
|
|
# ─── Assets 엔드포인트 ───
|
|
|
|
|
|
@router.post(
|
|
"/{session_id}/assets",
|
|
response_model=StudySessionAssetResponse,
|
|
status_code=201,
|
|
)
|
|
async def link_study_asset(
|
|
session_id: int,
|
|
body: StudySessionAssetCreate,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""기존 documents 의 id 를 study_session 에 asset 으로 연결.
|
|
|
|
409: 같은 (session, document, asset_type, role) 조합 이미 존재.
|
|
"""
|
|
if body.asset_type not in VALID_ASSET_TYPES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"asset_type 은 {sorted(VALID_ASSET_TYPES)} 중 하나여야 합니다",
|
|
)
|
|
if body.role not in VALID_ROLES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"role 은 {sorted(r for r in VALID_ROLES if r is not None)} 중 하나 또는 NULL 이어야 합니다",
|
|
)
|
|
|
|
sess = await session.get(StudySession, session_id)
|
|
sess = _verify_session_ownership(sess, user)
|
|
|
|
doc = await session.get(Document, body.document_id)
|
|
_verify_document_ownership(doc, user)
|
|
|
|
# 사전 SELECT 로 중복 검사 + DB UNIQUE 제약 둘 다 — race condition 안전.
|
|
existing = await session.execute(
|
|
select(StudySessionAsset).where(
|
|
StudySessionAsset.study_session_id == session_id,
|
|
StudySessionAsset.document_id == body.document_id,
|
|
StudySessionAsset.asset_type == body.asset_type,
|
|
StudySessionAsset.role.is_(body.role) if body.role is None
|
|
else StudySessionAsset.role == body.role,
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none() is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"error_code": "asset_already_linked",
|
|
"message": "해당 문서가 이미 같은 asset_type/role 로 연결되어 있습니다",
|
|
},
|
|
)
|
|
|
|
asset = StudySessionAsset(
|
|
study_session_id=session_id,
|
|
document_id=body.document_id,
|
|
asset_type=body.asset_type,
|
|
role=body.role,
|
|
sort_order=body.sort_order,
|
|
)
|
|
session.add(asset)
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError:
|
|
await session.rollback()
|
|
# UNIQUE 위반 — 위 사전 SELECT 와 race 했을 가능성. 동일 메시지로 응답.
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"error_code": "asset_already_linked",
|
|
"message": "해당 문서가 이미 같은 asset_type/role 로 연결되어 있습니다",
|
|
},
|
|
)
|
|
await session.refresh(asset)
|
|
return StudySessionAssetResponse.model_validate(asset)
|
|
|
|
|
|
@router.delete(
|
|
"/{session_id}/assets/{asset_id}", status_code=204
|
|
)
|
|
async def unlink_study_asset(
|
|
session_id: int,
|
|
asset_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""asset 연결 해제. documents 본체는 유지."""
|
|
sess = await session.get(StudySession, session_id)
|
|
sess = _verify_session_ownership(sess, user)
|
|
|
|
asset = await session.get(StudySessionAsset, asset_id)
|
|
if asset is None or asset.study_session_id != session_id:
|
|
raise HTTPException(status_code=404, detail="asset 을 찾을 수 없습니다")
|
|
await session.delete(asset)
|
|
await session.commit()
|
|
|
|
|
|
# ─── Snapshot (PNG 업로드) ───
|
|
|
|
|
|
@router.post("/{session_id}/snapshot", response_model=StudySessionAssetResponse, status_code=201)
|
|
async def upload_handwriting_snapshot(
|
|
session_id: int,
|
|
request: Request,
|
|
file: UploadFile,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
sort_order: int = Form(0),
|
|
):
|
|
"""캔버스 PNG 업로드 → documents 등록 + handwriting_png asset 연결.
|
|
|
|
documents.py upload_document 의 atomic rename + error_code 패턴을 PNG 전용으로 차용.
|
|
동일 세션에 여러 snapshot 누적 가능 (UNIQUE 제약은 (session, document, type, role) 단위라
|
|
document_id 가 매번 새로 생기므로 충돌 없음).
|
|
"""
|
|
sess = await session.get(StudySession, session_id)
|
|
sess = _verify_session_ownership(sess, user)
|
|
|
|
if not file.filename:
|
|
raise _upload_error(400, "invalid_input", "파일명이 필요합니다")
|
|
safe_name = Path(file.filename).name
|
|
if not safe_name or safe_name.startswith("."):
|
|
raise _upload_error(400, "invalid_input", "유효하지 않은 파일명")
|
|
ext = Path(safe_name).suffix.lower()
|
|
if ext != ".png":
|
|
raise _upload_error(
|
|
400, "invalid_input", "snapshot 은 PNG 파일만 지원합니다",
|
|
)
|
|
|
|
max_bytes = settings.upload.max_bytes
|
|
slack_ratio = settings.upload.content_length_slack_ratio
|
|
chunk_size = settings.upload.stream_chunk_bytes
|
|
|
|
# Content-Length 사전 차단
|
|
cl_header = request.headers.get("content-length")
|
|
if cl_header:
|
|
try:
|
|
cl = int(cl_header)
|
|
if cl > int(max_bytes * slack_ratio):
|
|
raise _upload_error(413, "body_too_large", "파일이 너무 큽니다")
|
|
except ValueError:
|
|
pass
|
|
|
|
# NAS Inbox 경로 결정 + 충돌 회피
|
|
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
|
inbox_dir.mkdir(parents=True, exist_ok=True)
|
|
target = (inbox_dir / safe_name).resolve()
|
|
if not str(target).startswith(str(inbox_dir.resolve())):
|
|
raise _upload_error(400, "invalid_input", "잘못된 파일 경로")
|
|
|
|
counter = 1
|
|
stem, suffix = target.stem, target.suffix
|
|
staging = target.with_name(target.name + ".uploading")
|
|
while target.exists() or staging.exists():
|
|
target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}"
|
|
staging = target.with_name(target.name + ".uploading")
|
|
counter += 1
|
|
|
|
# 스트리밍 저장 + 누적 사이즈 검증
|
|
written = 0
|
|
try:
|
|
with staging.open("wb") as f:
|
|
while chunk := await file.read(chunk_size):
|
|
written += len(chunk)
|
|
if written > max_bytes:
|
|
raise _upload_error(413, "body_too_large", "파일이 너무 큽니다")
|
|
f.write(chunk)
|
|
if written == 0:
|
|
raise _upload_error(400, "empty_file", "빈 파일은 업로드할 수 없습니다")
|
|
except ClientDisconnect:
|
|
staging.unlink(missing_ok=True)
|
|
logger.info("snapshot aborted by client: %s (written=%d)", safe_name, written)
|
|
raise _upload_error(499, "network_abort", "업로드가 취소되었습니다")
|
|
except asyncio.TimeoutError:
|
|
staging.unlink(missing_ok=True)
|
|
logger.warning("snapshot timeout: %s (written=%d)", safe_name, written)
|
|
raise _upload_error(408, "upload_timeout", "업로드 시간 초과")
|
|
except HTTPException:
|
|
staging.unlink(missing_ok=True)
|
|
raise
|
|
except Exception:
|
|
staging.unlink(missing_ok=True)
|
|
logger.exception("snapshot internal error: %s (written=%d)", safe_name, written)
|
|
raise _upload_error(500, "internal", "업로드 처리 중 오류가 발생했습니다")
|
|
|
|
# atomic rename → 최종 경로
|
|
try:
|
|
staging.replace(target)
|
|
except OSError:
|
|
staging.unlink(missing_ok=True)
|
|
logger.exception("snapshot rename failed: %s -> %s", staging, target)
|
|
raise _upload_error(500, "internal", "파일 저장 후 정리 중 오류가 발생했습니다")
|
|
|
|
# Document + ProcessingQueue('extract') + StudySessionAsset 단일 트랜잭션
|
|
rel_path = str(target.relative_to(Path(settings.nas_mount_path)))
|
|
fhash = file_hash(target)
|
|
|
|
# 학습 세션 메타에서 user_tags 합성
|
|
domain_tag = sess.certification or sess.language_code or "general"
|
|
user_tags = ["handwriting", domain_tag]
|
|
if sess.subject:
|
|
user_tags.append(sess.subject)
|
|
|
|
title = f"필기 — {sess.topic or sess.subject or 'study session'} #{session_id}"
|
|
|
|
try:
|
|
doc = Document(
|
|
file_path=rel_path,
|
|
file_hash=fhash,
|
|
file_format="png",
|
|
file_size=written,
|
|
file_type="immutable",
|
|
title=title,
|
|
user_tags=user_tags,
|
|
)
|
|
session.add(doc)
|
|
await session.flush()
|
|
|
|
await enqueue_stage(session, doc.id, "extract")
|
|
|
|
asset = StudySessionAsset(
|
|
study_session_id=session_id,
|
|
document_id=doc.id,
|
|
asset_type="handwriting_png",
|
|
role="answer",
|
|
sort_order=sort_order,
|
|
)
|
|
session.add(asset)
|
|
|
|
await session.commit()
|
|
await session.refresh(asset)
|
|
except Exception:
|
|
# DB 트랜잭션은 자동 rollback. 파일은 별도 자원 → 명시 unlink.
|
|
target.unlink(missing_ok=True)
|
|
raise
|
|
|
|
return StudySessionAssetResponse.model_validate(asset)
|