diff --git a/app/api/study_questions.py b/app/api/study_questions.py
index 2ae5d93..8590272 100644
--- a/app/api/study_questions.py
+++ b/app/api/study_questions.py
@@ -67,6 +67,8 @@ class StudyQuestionCreate(BaseModel):
scope: str | None = Field(default=None, max_length=200)
exam_name: str | None = Field(default=None, max_length=120)
exam_round: str | None = Field(default=None, max_length=120)
+ # PR-6: 회차 안 문항 번호. 미명시 + exam_round 명시 시 서버가 max+1 자동 채움.
+ exam_question_number: int | None = Field(default=None, ge=1)
explanation: str | None = None
source_note: str | None = None
is_active: bool = True
@@ -83,6 +85,7 @@ class StudyQuestionUpdate(BaseModel):
scope: str | None = Field(default=None, max_length=200)
exam_name: str | None = Field(default=None, max_length=120)
exam_round: str | None = Field(default=None, max_length=120)
+ exam_question_number: int | None = Field(default=None, ge=1)
explanation: str | None = None
source_note: str | None = None
is_active: bool | None = None
@@ -112,6 +115,8 @@ class StudyQuestionResponse(BaseModel):
explanation: str | None
source_note: str | None
is_active: bool
+ # PR-6: 회차 안 문항 번호
+ exam_question_number: int | None = None
# PR-3: AI 풀이 상태 (편집 화면에서 사용). 본문은 별도 GET /ai-explanation 으로
ai_explanation_status: str = "none"
ai_explanation_generated_at: datetime | None = None
@@ -378,6 +383,22 @@ async def create_question_in_topic(
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
+ # PR-6: exam_question_number 미명시 + exam_round 명시 시 서버가 max+1 자동 채움.
+ # 개인 사용 환경 race 부담 적어 단순 SELECT max 사용. 동시 입력 빈번해지면 향후
+ # SELECT FOR UPDATE 또는 토픽 단위 advisory lock 으로 강화 검토.
+ qnum = body.exam_question_number
+ if qnum is None and body.exam_round:
+ max_row = await session.execute(
+ select(func.coalesce(func.max(StudyQuestion.exam_question_number), 0))
+ .where(
+ StudyQuestion.user_id == user.id,
+ StudyQuestion.study_topic_id == topic_id,
+ StudyQuestion.exam_round == body.exam_round,
+ StudyQuestion.deleted_at.is_(None),
+ )
+ )
+ qnum = int(max_row.scalar() or 0) + 1
+
q = StudyQuestion(
user_id=user.id,
study_topic_id=topic_id,
@@ -391,6 +412,7 @@ async def create_question_in_topic(
scope=body.scope,
exam_name=body.exam_name,
exam_round=body.exam_round,
+ exam_question_number=qnum,
explanation=body.explanation,
source_note=body.source_note,
is_active=body.is_active,
@@ -416,6 +438,7 @@ async def create_question_in_topic(
explanation=q.explanation,
source_note=q.source_note,
is_active=q.is_active,
+ exam_question_number=q.exam_question_number,
ai_explanation_status=q.ai_explanation_status,
ai_explanation_generated_at=q.ai_explanation_generated_at,
ai_explanation_model=q.ai_explanation_model,
@@ -610,6 +633,7 @@ async def get_question(
explanation=q.explanation,
source_note=q.source_note,
is_active=q.is_active,
+ exam_question_number=q.exam_question_number,
ai_explanation_status=q.ai_explanation_status,
ai_explanation_generated_at=q.ai_explanation_generated_at,
ai_explanation_model=q.ai_explanation_model,
@@ -635,6 +659,7 @@ async def update_question(
SIMPLE_FIELDS = {
"question_text", "choice_1", "choice_2", "choice_3", "choice_4",
"correct_choice", "subject", "scope", "exam_name", "exam_round",
+ "exam_question_number",
"explanation", "source_note", "is_active",
}
for fname in SIMPLE_FIELDS & fields_set:
@@ -676,6 +701,7 @@ async def update_question(
explanation=q.explanation,
source_note=q.source_note,
is_active=q.is_active,
+ exam_question_number=q.exam_question_number,
ai_explanation_status=q.ai_explanation_status,
ai_explanation_generated_at=q.ai_explanation_generated_at,
ai_explanation_model=q.ai_explanation_model,
diff --git a/app/api/study_topics.py b/app/api/study_topics.py
index f994655..1c90bae 100644
--- a/app/api/study_topics.py
+++ b/app/api/study_topics.py
@@ -53,6 +53,9 @@ class StudyTopicCreate(BaseModel):
color: str | None = Field(default=None, max_length=20)
study_type: str | None = Field(default=None, max_length=40)
sort_order: int = 0
+ # PR-6: 시험 메타
+ exam_round_size: int | None = Field(default=None, ge=1, le=300)
+ exam_subjects: list[str] = []
class StudyTopicUpdate(BaseModel):
@@ -61,6 +64,9 @@ class StudyTopicUpdate(BaseModel):
color: str | None = Field(default=None, max_length=20)
study_type: str | None = Field(default=None, max_length=40)
sort_order: int | None = None
+ # PR-6: 시험 메타
+ exam_round_size: int | None = Field(default=None, ge=1, le=300)
+ exam_subjects: list[str] | None = None
class StudyTopicResponse(BaseModel):
@@ -75,6 +81,9 @@ class StudyTopicResponse(BaseModel):
session_count: int = 0
document_count: int = 0
question_count: int = 0
+ # PR-6: 시험 메타
+ exam_round_size: int | None = None
+ exam_subjects: list[str] = []
created_at: datetime
updated_at: datetime
@@ -165,6 +174,9 @@ class StudyTopicMeta(BaseModel):
color: str | None
study_type: str | None
sort_order: int
+ # PR-6: 시험 메타
+ exam_round_size: int | None = None
+ exam_subjects: list[str] = []
created_at: datetime
updated_at: datetime
@@ -218,6 +230,8 @@ def _meta_from_topic(t: StudyTopic) -> StudyTopicMeta:
color=t.color,
study_type=t.study_type,
sort_order=t.sort_order,
+ exam_round_size=t.exam_round_size,
+ exam_subjects=t.exam_subjects or [],
created_at=t.created_at,
updated_at=t.updated_at,
)
@@ -303,6 +317,8 @@ async def list_study_topics(
session_count=int(sc),
document_count=int(dc),
question_count=int(qc),
+ exam_round_size=t.exam_round_size,
+ exam_subjects=t.exam_subjects or [],
created_at=t.created_at,
updated_at=t.updated_at,
)
@@ -311,6 +327,72 @@ async def list_study_topics(
return StudyTopicListResponse(items=items, total=total)
+class ExamRoundProgress(BaseModel):
+ exam_round: str
+ question_count: int
+ max_question_number: int # 0 = exam_question_number 가 모두 NULL
+ next_question_number: int | None # 도달 시 None
+ is_complete: bool
+
+
+class ExamRoundsResponse(BaseModel):
+ exam_round_size: int | None # 토픽 메타. NULL = 미설정
+ items: list[ExamRoundProgress]
+
+
+@router.get("/{topic_id}/exam-rounds", response_model=ExamRoundsResponse)
+async def list_exam_rounds(
+ topic_id: int,
+ user: Annotated[User, Depends(get_current_user)],
+ session: Annotated[AsyncSession, Depends(get_session)],
+):
+ """토픽 안 회차별 진행률 집계. 진행률 표시 + 재개 UX 용.
+
+ is_complete = (exam_round_size IS NOT NULL AND question_count >= exam_round_size).
+ next_question_number = max + 1 (도달 시 NULL).
+ """
+ topic = await session.get(StudyTopic, topic_id)
+ _verify_topic_ownership(topic, user)
+
+ from models.study_question import StudyQuestion as _SQ
+
+ rows = (
+ await session.execute(
+ select(
+ _SQ.exam_round,
+ func.count().label("question_count"),
+ func.coalesce(func.max(_SQ.exam_question_number), 0).label("max_qnum"),
+ )
+ .where(
+ _SQ.user_id == user.id,
+ _SQ.study_topic_id == topic_id,
+ _SQ.deleted_at.is_(None),
+ _SQ.exam_round.is_not(None),
+ )
+ .group_by(_SQ.exam_round)
+ .order_by(_SQ.exam_round.desc())
+ )
+ ).all()
+
+ size = topic.exam_round_size
+ items: list[ExamRoundProgress] = []
+ for r in rows:
+ cnt = int(r.question_count)
+ mx = int(r.max_qnum)
+ is_complete = bool(size is not None and cnt >= size)
+ # 도달했으면 next None, 아니면 max+1 (max=0 이면 1번부터)
+ next_n = None if is_complete else (mx + 1 if mx > 0 else 1)
+ items.append(ExamRoundProgress(
+ exam_round=r.exam_round,
+ question_count=cnt,
+ max_question_number=mx,
+ next_question_number=next_n,
+ is_complete=is_complete,
+ ))
+
+ return ExamRoundsResponse(exam_round_size=size, items=items)
+
+
@router.get("/by-document/{document_id}", response_model=list[StudyTopicMeta])
async def list_topics_for_document(
document_id: int,
@@ -351,6 +433,8 @@ async def create_study_topic(
color=body.color,
study_type=body.study_type,
sort_order=body.sort_order,
+ exam_round_size=body.exam_round_size,
+ exam_subjects=body.exam_subjects or [],
)
session.add(topic)
try:
@@ -369,6 +453,9 @@ async def create_study_topic(
sort_order=topic.sort_order,
session_count=0,
document_count=0,
+ question_count=0,
+ exam_round_size=topic.exam_round_size,
+ exam_subjects=topic.exam_subjects or [],
created_at=topic.created_at,
updated_at=topic.updated_at,
)
@@ -570,6 +657,11 @@ async def update_study_topic(
topic.name = body.name.strip()
for fname in {"description", "color", "study_type", "sort_order"} & fields_set:
setattr(topic, fname, getattr(body, fname))
+ # PR-6: 시험 메타. exam_subjects 가 None 이면 (PATCH 미명시) 기존 유지, 빈 배열은 명시적 클리어
+ if "exam_round_size" in fields_set:
+ topic.exam_round_size = body.exam_round_size
+ if "exam_subjects" in fields_set and body.exam_subjects is not None:
+ topic.exam_subjects = body.exam_subjects
topic.updated_at = datetime.now(timezone.utc)
try:
@@ -610,6 +702,8 @@ async def update_study_topic(
session_count=int(sc),
document_count=int(dc),
question_count=int(qc),
+ exam_round_size=topic.exam_round_size,
+ exam_subjects=topic.exam_subjects or [],
created_at=topic.created_at,
updated_at=topic.updated_at,
)
diff --git a/app/models/study_question.py b/app/models/study_question.py
index e930a9e..cdb2ad9 100644
--- a/app/models/study_question.py
+++ b/app/models/study_question.py
@@ -43,6 +43,9 @@ class StudyQuestion(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ # PR-6: 회차 안 문항 번호 (1~exam_round_size). NULL 허용 — 기존 행 + 회차 미설정 입력
+ exam_question_number: Mapped[int | None] = mapped_column(SmallInteger)
+
# PR-3: AI 풀이 캐시 (수동 트리거)
# status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값)
ai_explanation: Mapped[str | None] = mapped_column(Text)
diff --git a/app/models/study_topic.py b/app/models/study_topic.py
index 18d25d8..6f5777e 100644
--- a/app/models/study_topic.py
+++ b/app/models/study_topic.py
@@ -18,6 +18,7 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base
@@ -40,6 +41,10 @@ class StudyTopic(Base):
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ # PR-6: 시험 메타 (회차당 문항 수 + 과목 리스트)
+ exam_round_size: Mapped[int | None] = mapped_column(Integer)
+ exam_subjects: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
+
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False
)
diff --git a/frontend/src/routes/study/topics/+page.svelte b/frontend/src/routes/study/topics/+page.svelte
index 0e09bb4..40d16bd 100644
--- a/frontend/src/routes/study/topics/+page.svelte
+++ b/frontend/src/routes/study/topics/+page.svelte
@@ -41,11 +41,38 @@
let f_description = $state('');
let f_color = $state('');
let f_study_type = $state('');
+ // PR-6: 시험 메타
+ let f_exam_round_size = $state(''); // 빈 값 = 미설정
+ let f_exam_subjects = $state([]); // 문자열 배열
+ let f_subject_input = $state('');
let creating = $state(false);
+ function addSubject() {
+ const v = f_subject_input.trim();
+ if (!v) return;
+ if (!f_exam_subjects.includes(v)) f_exam_subjects = [...f_exam_subjects, v];
+ f_subject_input = '';
+ }
+ function removeSubject(s) {
+ f_exam_subjects = f_exam_subjects.filter((x) => x !== s);
+ }
+
// 편집 모달
- let editing = $state(null); // {id, name, description, color, study_type}
+ let editing = $state(null); // {id, name, description, color, study_type, exam_round_size, exam_subjects}
let savingEdit = $state(false);
+ let edit_subject_input = $state('');
+
+ function addEditSubject() {
+ if (!editing) return;
+ const v = edit_subject_input.trim();
+ if (!v) return;
+ if (!editing.exam_subjects.includes(v)) editing.exam_subjects = [...editing.exam_subjects, v];
+ edit_subject_input = '';
+ }
+ function removeEditSubject(s) {
+ if (!editing) return;
+ editing.exam_subjects = editing.exam_subjects.filter((x) => x !== s);
+ }
async function load() {
loading = true;
@@ -74,6 +101,8 @@
description: f_description || null,
color: f_color || null,
study_type: f_study_type || null,
+ exam_round_size: f_exam_round_size ? Number(f_exam_round_size) : null,
+ exam_subjects: f_exam_subjects,
};
const t = await api('/study-topics/', {
method: 'POST',
@@ -87,6 +116,9 @@
f_description = '';
f_color = '';
f_study_type = '';
+ f_exam_round_size = '';
+ f_exam_subjects = [];
+ f_subject_input = '';
} catch (err) {
addToast('error', err.detail || '주제 생성 실패');
} finally {
@@ -101,7 +133,10 @@
description: t.description ?? '',
color: t.color ?? '',
study_type: t.study_type ?? '',
+ exam_round_size: t.exam_round_size ?? '',
+ exam_subjects: [...(t.exam_subjects ?? [])],
};
+ edit_subject_input = '';
}
async function saveEdit() {
@@ -119,6 +154,10 @@
description: editing.description || null,
color: editing.color || null,
study_type: editing.study_type || null,
+ exam_round_size: editing.exam_round_size === '' || editing.exam_round_size == null
+ ? null
+ : Number(editing.exam_round_size),
+ exam_subjects: editing.exam_subjects ?? [],
}),
});
topics = topics.map((x) => (x.id === updated.id ? updated : x));
@@ -179,6 +218,40 @@
+ {#if examRoundSize}회차당 {examRoundSize}문항 기준{:else}회차당 문항 수 미설정 (주제 편집에서 설정){/if} +
++ 같은 토픽 안에서 회차명은 자유 텍스트입니다. "2019년 1회" 같은 형태로 입력하면 진행률 추적이 가능합니다. +
+