"""학습 진행 상태 (progress) API — review-complete + review-queue + stats. review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여. review-queue: 5 탭 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회. stats (Phase 2-D): 통계 대시보드 — 진척도 / 패턴 분포 / 복습 큐 / 세션 추이 / 일별 풀이량 / 과목별. """ from __future__ import annotations from datetime import date, datetime, timedelta, timezone from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, case, cast, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.types import Date as SQLDate from core.auth import get_current_user from core.database import get_session from models.study_question import StudyQuestion, StudyQuestionAttempt from models.study_question_progress import StudyQuestionProgress from models.study_quiz_session import StudyQuizSession 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, ) # ─── stats (Phase 2-D 통계 대시보드) ─── class StatsQuestions(BaseModel): total: int attempted: int unattempted: int class StatsDue(BaseModel): today: int this_week: int later: int mastered: int class StatsSessionTrendItem(BaseModel): id: int finished_at: datetime total: int correct_count: int wrong_count: int unsure_count: int accuracy: int # 0~100 newly_correct_count: int relapsed_count: int recovered_count: int class StatsDailyAttempt(BaseModel): date: date count: int class StatsSubjectBreakdown(BaseModel): subject: str total: int attempted: int last_correct: int accuracy: int # 0~100 pending_review: int chronic: int class StatsResponse(BaseModel): questions: StatsQuestions pattern_distribution: dict # state(or "unattempted") → count review_stage_distribution: dict # "0"/"1"/"2"/"3"/"mastered" → count due: StatsDue session_trend: list[StatsSessionTrendItem] # 최근 done 세션 newest→oldest daily_attempts_30d: list[StatsDailyAttempt] subject_breakdown: list[StatsSubjectBreakdown] @router.get("/{topic_id}/stats", response_model=StatsResponse) async def topic_stats( topic_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], session_trend_limit: int = Query(20, ge=1, le=100), ): """통계 대시보드 — progress + quiz_sessions + attempts 한 번에 집계. 가벼운 쿼리 6~7 묶음. 1인 운영 + 토픽당 progress 수천 행 가정 — 추가 인덱스 없이 OK. """ topic = await session.get(StudyTopic, topic_id) _verify_topic_owner(topic, user) now = datetime.now(timezone.utc) # 1. 문제 진척도 — 토픽의 question 총수 + progress 행 수 (attempted) total_q_row = await session.execute( select(func.count()) .select_from(StudyQuestion) .where( StudyQuestion.user_id == user.id, StudyQuestion.study_topic_id == topic_id, StudyQuestion.deleted_at.is_(None), ) ) total_q = int(total_q_row.scalar() or 0) attempted_row = await session.execute( select(func.count()) .select_from(StudyQuestionProgress) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestionProgress.last_outcome.is_not(None), ) ) attempted = int(attempted_row.scalar() or 0) unattempted = max(0, total_q - attempted) # 2. pattern_state 분포 (NULL 은 "unattempted" 로) pattern_rows = ( await session.execute( select( func.coalesce(StudyQuestionProgress.pattern_state, "unattempted").label("state"), func.count().label("cnt"), ) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, ) .group_by("state") ) ).all() pattern_distribution = {r.state: int(r.cnt) for r in pattern_rows} # 모든 키 default 0 채우기 (UI 가 빈 키 처리 안 해도 되게) for k in ("stable", "unstable", "unsure", "regressed", "recovered", "chronic_wrong", "unattempted"): pattern_distribution.setdefault(k, 0) # 한 번도 시도 안 한 (progress 행 자체 없음) 분량을 unattempted 에 합산 pattern_distribution["unattempted"] += unattempted # 3. review_stage 분포 — 0/1/2/3/mastered (>=4) stage_rows = ( await session.execute( select( StudyQuestionProgress.review_stage.label("stage"), func.count().label("cnt"), ) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, StudyQuestionProgress.review_stage.is_not(None), ) .group_by(StudyQuestionProgress.review_stage) ) ).all() review_stage_distribution = {"0": 0, "1": 0, "2": 0, "3": 0, "mastered": 0} for r in stage_rows: st = int(r.stage) if st >= 4: review_stage_distribution["mastered"] += int(r.cnt) elif 0 <= st <= 3: review_stage_distribution[str(st)] += int(r.cnt) # 4. due 분류 — today / this_week / later / mastered end_today = now.replace(hour=23, minute=59, second=59, microsecond=999999) end_week = end_today + timedelta(days=7) due_rows = ( await session.execute( select( func.count().filter( and_( StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at <= end_today, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ) ).label("today"), func.count().filter( and_( StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at > end_today, StudyQuestionProgress.due_at <= end_week, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ) ).label("this_week"), func.count().filter( and_( StudyQuestionProgress.due_at.is_not(None), StudyQuestionProgress.due_at > end_week, or_( StudyQuestionProgress.review_stage.is_(None), StudyQuestionProgress.review_stage < 4, ), ) ).label("later"), func.count().filter( and_( StudyQuestionProgress.review_stage.is_not(None), StudyQuestionProgress.review_stage >= 4, ) ).label("mastered"), ) .where( StudyQuestionProgress.user_id == user.id, StudyQuestionProgress.study_topic_id == topic_id, ) ) ).first() due = StatsDue( today=int(due_rows.today or 0), this_week=int(due_rows.this_week or 0), later=int(due_rows.later or 0), mastered=int(due_rows.mastered or 0), ) # 5. 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용) sess_rows = ( await session.execute( select(StudyQuizSession) .where( StudyQuizSession.user_id == user.id, StudyQuizSession.study_topic_id == topic_id, StudyQuizSession.status == "done", StudyQuizSession.finished_at.is_not(None), ) .order_by(StudyQuizSession.finished_at.desc()) .limit(session_trend_limit) ) ).scalars().all() session_trend: list[StatsSessionTrendItem] = [] for s in sess_rows: total_n = len(s.question_ids or []) acc = round((s.correct_count / total_n) * 100) if total_n > 0 else 0 session_trend.append(StatsSessionTrendItem( id=s.id, finished_at=s.finished_at, total=total_n, correct_count=s.correct_count, wrong_count=s.wrong_count, unsure_count=s.unsure_count, accuracy=acc, newly_correct_count=s.newly_correct_count, relapsed_count=s.relapsed_count, recovered_count=s.recovered_count, )) # 6. 일별 풀이량 30일 (date 기준 — UTC, 시간대 차이는 Phase 5 후보) start_30d = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0) daily_rows = ( await session.execute( select( cast(StudyQuestionAttempt.answered_at, SQLDate).label("d"), func.count().label("cnt"), ) .where( StudyQuestionAttempt.user_id == user.id, StudyQuestionAttempt.study_topic_id == topic_id, StudyQuestionAttempt.answered_at >= start_30d, ) .group_by("d") .order_by("d") ) ).all() daily_attempts_30d = [StatsDailyAttempt(date=r.d, count=int(r.cnt)) for r in daily_rows] # 7. 과목별 약점 subj_rows = ( await session.execute( select( func.coalesce(StudyQuestion.subject, "(미분류)").label("subject"), func.count(StudyQuestion.id.distinct()).label("total"), func.count(StudyQuestionProgress.id.distinct()).filter( StudyQuestionProgress.last_outcome.is_not(None) ).label("attempted"), func.count(StudyQuestionProgress.id.distinct()).filter( StudyQuestionProgress.last_outcome == "correct" ).label("last_correct"), func.count(StudyQuestionProgress.id.distinct()).filter( and_( 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, ), ), ) ).label("pending_review"), func.count(StudyQuestionProgress.id.distinct()).filter( StudyQuestionProgress.pattern_state == "chronic_wrong" ).label("chronic"), ) .select_from(StudyQuestion) .outerjoin( StudyQuestionProgress, and_( StudyQuestionProgress.user_id == StudyQuestion.user_id, StudyQuestionProgress.study_topic_id == StudyQuestion.study_topic_id, StudyQuestionProgress.study_question_id == StudyQuestion.id, ), ) .where( StudyQuestion.user_id == user.id, StudyQuestion.study_topic_id == topic_id, StudyQuestion.deleted_at.is_(None), ) .group_by("subject") .order_by(func.count(StudyQuestion.id.distinct()).desc()) ) ).all() subject_breakdown = [ StatsSubjectBreakdown( subject=r.subject, total=int(r.total), attempted=int(r.attempted), last_correct=int(r.last_correct), accuracy=round((int(r.last_correct) / int(r.attempted)) * 100) if int(r.attempted) > 0 else 0, pending_review=int(r.pending_review), chronic=int(r.chronic), ) for r in subj_rows ] return StatsResponse( questions=StatsQuestions( total=total_q, attempted=attempted, unattempted=unattempted ), pattern_distribution=pattern_distribution, review_stage_distribution=review_stage_distribution, due=due, session_trend=session_trend, daily_attempts_30d=daily_attempts_30d, subject_breakdown=subject_breakdown, )