"""학습 진행 상태 (progress) API — review-complete + review-queue (Phase 1). review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여. review-queue: 5 탭 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.database import get_session from models.study_question import StudyQuestion from models.study_question_progress import StudyQuestionProgress from models.study_topic import StudyTopic from models.user import User router = APIRouter(prefix="/study-topics", tags=["study-progress"]) # 1차 due_at 부여 시 디폴트 1일 뒤 DEFAULT_FIRST_DUE_DAYS = 1 def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None: if topic is None or topic.deleted_at is not None or topic.user_id != user.id: raise HTTPException(status_code=404, detail="주제를 찾을 수 없습니다") @router.post("/{topic_id}/questions/{question_id}/review-complete", status_code=204) async def review_complete( topic_id: int, question_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """확인완료 처리 — last_reviewed_at + (wrong/unsure 인 경우) due_at 최초 부여. 이미 due_at 박힌 문제면 due_at 그대로 유지 (큐 위치 보존). 정답 맞춘 문제면 due_at 박지 않음 (큐 폭발 방지). """ topic = await session.get(StudyTopic, topic_id) _verify_topic_owner(topic, user) q = await session.get(StudyQuestion, question_id) if q is None or q.deleted_at is not None or q.user_id != user.id or q.study_topic_id != topic_id: raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다") progress = ( await session.execute( select(StudyQuestionProgress).where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestionProgress.study_question_id == question_id, ) ) ).scalar_one_or_none() if progress is None: # attempt 없는데 review-complete 시도. 진척 상태가 없어 의미 없음. raise HTTPException(status_code=409, detail="아직 시도한 적이 없는 문제입니다") now = datetime.now(timezone.utc) progress.last_reviewed_at = now # due_at 최초 부여는 wrong/unsure 일 때만. 이미 박혀있으면 유지. if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None: progress.review_stage = 0 progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS) await session.commit() # ─── review-queue ─── class ReviewQueueItem(BaseModel): question_id: int question_text: str subject: str | None scope: str | None exam_round: str | None exam_question_number: int | None last_outcome: str | None last_attempted_at: datetime | None last_reviewed_at: datetime | None due_at: datetime | None review_stage: int | None pattern_state: str | None class ReviewQueueResponse(BaseModel): tab: str total: int items: list[ReviewQueueItem] page: int page_size: int def _truncate(text: str, n: int = 80) -> str: if not text: return "" s = text.strip() return s if len(s) <= n else s[:n].rstrip() + "…" @router.get("/{topic_id}/review-queue", response_model=ReviewQueueResponse) async def review_queue( topic_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], tab: str = Query(..., pattern="^(due_today|pending_review|chronic|regressed|mastered)$"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): """5 탭 진행 상태 조회. - due_today: progress.due_at <= now() AND review_stage < 4 - pending_review: last_outcome IN (wrong, unsure) AND (last_reviewed_at IS NULL OR last_reviewed_at < last_attempted_at) - chronic: pattern_state = 'chronic_wrong' - regressed: pattern_state = 'regressed' - mastered: review_stage >= 4 """ topic = await session.get(StudyTopic, topic_id) _verify_topic_owner(topic, user) base = ( select(StudyQuestionProgress, StudyQuestion) .join(StudyQuestion, StudyQuestion.id == StudyQuestionProgress.study_question_id) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestion.deleted_at.is_(None), ) ) now = datetime.now(timezone.utc) if tab == "due_today": base = base.where( StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at <= now, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ).order_by(StudyQuestionProgress.due_at.asc()) elif tab == "pending_review": base = base.where( StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")), or_( StudyQuestionProgress.last_reviewed_at.is_(None), and_( StudyQuestionProgress.last_reviewed_at.is_not(None), StudyQuestionProgress.last_attempted_at.is_not(None), StudyQuestionProgress.last_reviewed_at < StudyQuestionProgress.last_attempted_at, ), ), ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) elif tab == "chronic": base = base.where( StudyQuestionProgress.pattern_state == "chronic_wrong", ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) elif tab == "regressed": base = base.where( StudyQuestionProgress.pattern_state == "regressed", ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) elif tab == "mastered": base = base.where( StudyQuestionProgress.review_stage.is_not(None), StudyQuestionProgress.review_stage >= 4, ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last()) # total total_row = await session.execute( select(func.count()).select_from(base.subquery()) ) total = int(total_row.scalar() or 0) # paged rows = ( await session.execute( base.offset((page - 1) * page_size).limit(page_size) ) ).all() items = [ ReviewQueueItem( question_id=q.id, question_text=_truncate(q.question_text, 80), subject=q.subject, scope=q.scope, exam_round=q.exam_round, exam_question_number=q.exam_question_number, last_outcome=p.last_outcome, last_attempted_at=p.last_attempted_at, last_reviewed_at=p.last_reviewed_at, due_at=p.due_at, review_stage=p.review_stage, pattern_state=p.pattern_state, ) for (p, q) in rows ] return ReviewQueueResponse( tab=tab, total=total, items=items, page=page, page_size=page_size, )