feat(study): 시험·회차·문항 관리 (PR-6)

기사시험 회차별 100문제 채워가기 시나리오. 문제 입력 페이지를 단순 폼에서
"회차 진행률 추적·재개" 도구로 보강.

데이터 모델 (migrations 195~197):
- study_topics: exam_round_size INT CHECK 1~300 (회차당 문항 수, NULL=미설정)
  + exam_subjects JSONB DEFAULT '[]' (과목 리스트, 입력 페이지 드롭다운 옵션)
- study_questions: exam_question_number SMALLINT CHECK >0 (회차 안 문항 번호)
- partial idx (study_topic_id, exam_round, exam_question_number) WHERE
  deleted_at IS NULL AND exam_round IS NOT NULL — 회차별 max+count 고속화

백엔드:
- POST /questions: exam_round 명시 + exam_question_number 미명시 시 서버가
  같은 토픽·회차의 max+1 자동 채움
- 신규 GET /api/study-topics/{id}/exam-rounds: 회차별 진행률 집계
  {exam_round_size, items: [{exam_round, question_count, max_question_number,
   next_question_number, is_complete}]}
- StudyTopic Create/Update/Response/Meta 에 exam_round_size·exam_subjects
- StudyQuestion Create/Update/Response 에 exam_question_number
- exam_question_number 변경은 embedding stale 트리거에서 제외 (의미 영향 없음)

프론트:
- 토픽 생성/편집 모달: "시험 정보" 섹션 (회차당 문항 수 + 과목 리스트
  +추가/제거 칩)
- /study/topics/[id]/exam-rounds 신규 페이지: 회차 카드 + 진행 바 +
  [N번부터 이어서] 버튼 + [새 회차 시작] 모달
- 통합뷰 문제 섹션 헤더에 [회차 보기] 진입점
- /questions/new 페이지 전면 개편:
  - 시험명 = topic.name 자동 prefill
  - 과목 드롭다운 (topic.exam_subjects + 기존 distinct, "직접 입력" 토글)
  - 회차 드롭다운 (기존 distinct + "새 회차")
  - 문항 번호 자동 (회차 선택 시 next_question_number, 새 회차 = 1)
  - 진행률 바 (현재/exam_round_size)
  - 출처/메모 자동 합성 "회차 N번" (수정 가능)
  - "저장 후 계속 입력" → 본문/보기/정답 reset, 회차 유지, 문항 +1
  - 회차 변경 감지 시 문항 번호 1로 reset
  - exam_round_size 도달 시 회차 강조 + "저장 후 계속 입력" 비활성
- query string ?exam_round=&start_qnum= 지원 (회차 목록에서 재개 진입)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 09:31:06 +09:00
parent 5b55274368
commit 8803e6a0fd
11 changed files with 719 additions and 62 deletions
+26
View File
@@ -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,
+94
View File
@@ -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,
)
+3
View File
@@ -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)
+5
View File
@@ -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
)
+107 -1
View File
@@ -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 @@
<Textarea label="설명 (선택)" bind:value={f_description} rows={2} placeholder="이 주제의 학습 목표, 시험 일정 등" />
<Select label="분류 (선택)" bind:value={f_study_type} options={STUDY_TYPE_OPTIONS} />
<TextInput label="색상 (선택, hex)" bind:value={f_color} placeholder="#3B82F6" />
<!-- PR-6: 시험 정보 -->
<div class="border-t border-default pt-3 mt-1">
<div class="text-xs font-semibold text-dim mb-2">시험 정보 (선택)</div>
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">회차당 문항 수 (1~300)</span>
<input type="number" min="1" max="300" bind:value={f_exam_round_size}
placeholder="예: 100"
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-dim">과목 리스트 (입력 시 드롭다운 옵션)</span>
<div class="flex gap-2">
<input type="text" bind:value={f_subject_input}
placeholder="예: 가스유체역학"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSubject(); } }}
class="flex-1 px-3 py-1.5 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<Button size="sm" variant="ghost" onclick={addSubject} icon={Plus}>추가</Button>
</div>
{#if f_exam_subjects.length > 0}
<div class="flex flex-wrap gap-1.5 mt-1">
{#each f_exam_subjects as s}
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-accent/10 border border-accent/30 text-xs text-text">
{s}
<button type="button" onclick={() => removeSubject(s)} class="text-dim hover:text-error" aria-label="제거">×</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="flex gap-2 justify-end">
<Button variant="ghost" onclick={() => (formOpen = false)} disabled={creating}>취소</Button>
<Button onclick={createTopic} loading={creating}>생성</Button>
@@ -263,6 +336,39 @@
<Textarea label="설명" bind:value={editing.description} rows={2} />
<Select label="분류" bind:value={editing.study_type} options={STUDY_TYPE_OPTIONS} />
<TextInput label="색상 (hex)" bind:value={editing.color} placeholder="#3B82F6" />
<!-- PR-6: 시험 정보 -->
<div class="border-t border-default pt-3 mt-1">
<div class="text-xs font-semibold text-dim mb-2">시험 정보</div>
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">회차당 문항 수 (1~300, 비우면 미설정)</span>
<input type="number" min="1" max="300" bind:value={editing.exam_round_size}
placeholder="예: 100"
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-dim">과목 리스트</span>
<div class="flex gap-2">
<input type="text" bind:value={edit_subject_input}
placeholder="예: 가스유체역학"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addEditSubject(); } }}
class="flex-1 px-3 py-1.5 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<Button size="sm" variant="ghost" onclick={addEditSubject} icon={Plus}>추가</Button>
</div>
{#if editing.exam_subjects.length > 0}
<div class="flex flex-wrap gap-1.5 mt-1">
{#each editing.exam_subjects as s}
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-accent/10 border border-accent/30 text-xs text-text">
{s}
<button type="button" onclick={() => removeEditSubject(s)} class="text-dim hover:text-error" aria-label="제거">×</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button variant="ghost" onclick={() => (editing = null)} disabled={savingEdit}>취소</Button>
@@ -13,6 +13,7 @@
import {
ArrowLeft, FolderKanban, PenLine, BookOpen, Plus, Trash2, ArrowRight, Languages,
ChevronRight, ChevronDown, FolderOpen, FolderPlus, HelpCircle, Edit, Play, CheckCircle2, XCircle,
ListChecks,
} from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
@@ -510,7 +511,8 @@
<HelpCircle size={14} class="text-accent" /> 문제
<span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span>
</h2>
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 flex-wrap">
<Button href={`/study/topics/${topicId}/exam-rounds`} size="sm" variant="ghost" icon={ListChecks}>회차 보기</Button>
<Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}> 문제</Button>
{#if (detail.sections.questions?.length ?? 0) > 0}
<Button href={`/study/topics/${topicId}/review`} size="sm" icon={Play}>복습 시작</Button>
@@ -0,0 +1,168 @@
<script>
/**
* /study/topics/[id]/exam-rounds — 회차 목록 + 진행률 (PR-6).
*
* 토픽의 exam_round_size 가 있으면 진행 바 + "N번부터 이어서" 동선.
* 새 회차 시작 모달 → /questions/new?exam_round=...&start_qnum=1 로 이동.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Plus, ArrowRight, CheckCircle2, ListChecks } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
let examRoundSize = $state(null);
let items = $state([]);
let loading = $state(true);
// 새 회차 시작 모달
let newRoundModal = $state(false);
let newRoundName = $state('');
async function load() {
loading = true;
try {
const [t, r] = await Promise.all([
api(`/study-topics/${topicId}`),
api(`/study-topics/${topicId}/exam-rounds`),
]);
topicName = t?.topic?.name ?? '';
examRoundSize = r.exam_round_size;
items = r.items;
} catch (err) {
addToast('error', err.detail || '회차 목록 로딩 실패');
} finally {
loading = false;
}
}
onMount(load);
function startNewRound() {
if (!newRoundName.trim()) {
addToast('error', '회차명을 입력하세요');
return;
}
const r = newRoundName.trim();
newRoundModal = false;
newRoundName = '';
const q = new URLSearchParams({ exam_round: r, start_qnum: '1' });
goto(`/study/topics/${topicId}/questions/new?${q}`);
}
function resumeRound(it) {
const q = new URLSearchParams({
exam_round: it.exam_round,
start_qnum: String(it.next_question_number ?? it.max_question_number + 1),
});
goto(`/study/topics/${topicId}/questions/new?${q}`);
}
function progressPercent(it) {
if (!examRoundSize) return 0;
return Math.min(100, Math.round((it.question_count / examRoundSize) * 100));
}
</script>
<svelte:head><title>회차 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-4xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium flex items-center gap-1.5"><ListChecks size={14} class="text-accent" /> 회차</span>
</div>
<header class="flex items-center justify-between mb-4 flex-wrap gap-2">
<div>
<h1 class="text-lg font-semibold text-text">{topicName} — 회차</h1>
<p class="text-xs text-dim mt-1">
{#if examRoundSize}회차당 <span class="text-text">{examRoundSize}</span>문항 기준{:else}회차당 문항 수 미설정 (주제 편집에서 설정){/if}
</p>
</div>
<Button onclick={() => (newRoundModal = true)} icon={Plus}>새 회차 시작</Button>
</header>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Array(3) as _}<Skeleton h="h-24" rounded="lg" />{/each}
</div>
{:else if items.length === 0}
<EmptyState
icon={ListChecks}
title="회차가 없습니다"
description="첫 회차(예: '2019년 1회')를 시작하면 여기에 진행률이 누적됩니다."
/>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each items as it (it.exam_round)}
<div class="p-4 rounded-lg border border-default bg-surface flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-text truncate">{it.exam_round}</span>
{#if it.is_complete}
<span class="text-[10px] text-success border border-success/40 rounded px-1.5 py-0.5 flex items-center gap-1">
<CheckCircle2 size={10} /> 완료
</span>
{/if}
<span class="ml-auto text-xs text-dim">
<span class="text-text">{it.question_count}</span>{#if examRoundSize}<span class="text-faint">/{examRoundSize}</span>{/if}
</span>
</div>
{#if examRoundSize}
<div class="h-1.5 bg-bg rounded-full overflow-hidden">
<div class="h-full bg-accent transition-all" style="width: {progressPercent(it)}%"></div>
</div>
{/if}
<div class="flex items-center justify-between mt-1">
<span class="text-[11px] text-dim">
{#if it.is_complete}모든 문항 입력 완료{:else}다음: <span class="text-text">{it.next_question_number}</span>{/if}
</span>
{#if !it.is_complete}
<Button size="sm" onclick={() => resumeRound(it)} icon={ArrowRight} iconPosition="right">
{it.next_question_number}번부터 이어서
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- 새 회차 모달 -->
{#if newRoundModal}
<button type="button" aria-label="모달 닫기" onclick={() => (newRoundModal = false)}
class="fixed inset-0 z-40 bg-black/40"></button>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-sm bg-surface rounded-lg border border-default shadow-xl p-4 pointer-events-auto">
<div class="text-sm font-semibold text-text mb-3">새 회차 시작</div>
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">회차명</span>
<input type="text" bind:value={newRoundName}
placeholder="예: 2019년 1회"
onkeydown={(e) => { if (e.key === 'Enter') startNewRound(); }}
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
<p class="text-[11px] text-dim mt-2">
같은 토픽 안에서 회차명은 자유 텍스트입니다. "2019년 1회" 같은 형태로 입력하면 진행률 추적이 가능합니다.
</p>
<div class="flex gap-2 justify-end mt-4">
<Button variant="ghost" onclick={() => (newRoundModal = false)}>취소</Button>
<Button onclick={startNewRound}>시작</Button>
</div>
</div>
</div>
{/if}
@@ -1,18 +1,23 @@
<script>
/**
* /study/topics/[id]/questions/new — 문제 입력 페이지.
* /study/topics/[id]/questions/new — 문제 입력 페이지 (PR-6 보강).
*
* 하루 100문제 입력 시나리오에 맞춰 빠른 반복 입력 UX:
* - "저장 후 계속 입력" → subject/scope/exam_name/exam_round 유지, 본문·보기·정답만 초기화
* - sessionStorage 캐시: 페이지 새로고침해도 분류 필드 유지
* - 입력 검증 실패 시 토스트
* 시험·회차·문항 자동 관리:
* - 시험명: topic.name 자동 prefill (수정 가능)
* - 과목: 드롭다운 (topic.exam_subjects + 기존 question.subject distinct + 직접 입력)
* - 회차: 드롭다운 (기존 distinct + 새 회차 입력). query string ?exam_round= prefill
* - 문항 번호: 회차 선택 시 next_question_number 자동, query ?start_qnum= prefill
* - 출처/메모: 비어있으면 자동 합성 "{exam_round} {N}번"
* - 저장 후 계속 입력: 본문/보기/정답 reset, 시험명/과목/회차 유지, 문항 번호 +1
* - 회차 변경 시 문항 번호 1로 reset
* - exam_round_size 도달 시 안내 + 회차 강조
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Save, Repeat } from 'lucide-svelte';
import { ArrowLeft, Save, Repeat, ListChecks, AlertCircle } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
@@ -20,6 +25,11 @@
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
let topicExamSubjects = $state([]); // study_topics.exam_subjects
let topicExamRoundSize = $state(null);
// 회차 진행률 캐시 (드롭다운 선택 시 next_question_number 조회용)
let examRounds = $state([]); // [{exam_round, max_question_number, next_question_number, is_complete}]
// 입력 필드
let q_text = $state('');
@@ -29,50 +39,153 @@
let c4 = $state('');
let correct = $state(1);
// persistent (sessionStorage 동기화)
let subject = $state('');
let scope = $state('');
let exam_name = $state('');
let exam_round = $state('');
// 시험·과목·회차 (저장 후 계속 입력 시 유지)
let f_exam_name = $state('');
let f_subject = $state(''); // dropdown 값. 빈 값 = 직접 입력 모드
let f_subject_custom = $state(''); // 직접 입력 모드 시 값
let f_subject_mode = $state('dropdown'); // 'dropdown' | 'custom'
let f_scope = $state('');
let f_exam_round = $state('');
let f_exam_round_mode = $state('select'); // 'select' | 'new'
let f_exam_round_new = $state('');
let f_qnum = $state(1); // 문항 번호
// 한 번 입력 후 유지 안 함
// 본문 자동 reset 안 되는 메타 (편의성 — 비워둠이 default)
let explanation = $state('');
let source_note = $state('');
let saving = $state(false);
let isCompleteRound = $state(false); // 회차 도달 여부
const STORAGE_KEY = $derived(`study_q_persist_${topicId}`);
const STORAGE_KEY = $derived(`study_q_persist_v2_${topicId}`);
function persist() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
f_exam_name, f_subject, f_subject_custom, f_subject_mode,
f_scope, f_exam_round, f_qnum,
}));
} catch {}
}
function loadPersist() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const p = JSON.parse(raw);
subject = p.subject ?? '';
scope = p.scope ?? '';
exam_name = p.exam_name ?? '';
exam_round = p.exam_round ?? '';
}
} catch {}
}
function savePersist() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ subject, scope, exam_name, exam_round }));
if (!raw) return;
const p = JSON.parse(raw);
f_exam_name = p.f_exam_name ?? f_exam_name;
f_subject = p.f_subject ?? '';
f_subject_custom = p.f_subject_custom ?? '';
f_subject_mode = p.f_subject_mode ?? 'dropdown';
f_scope = p.f_scope ?? '';
f_exam_round = p.f_exam_round ?? '';
f_qnum = Number(p.f_qnum) || 1;
} catch {}
}
async function loadTopic() {
// 같은 토픽의 기존 question.subject distinct (드롭다운 fallback)
let questionSubjects = $state([]);
async function loadTopicAndRounds() {
try {
const t = await api(`/study-topics/${topicId}`);
const [t, r] = await Promise.all([
api(`/study-topics/${topicId}`),
api(`/study-topics/${topicId}/exam-rounds`),
]);
topicName = t?.topic?.name ?? '';
} catch {}
topicExamSubjects = t?.topic?.exam_subjects ?? [];
topicExamRoundSize = t?.topic?.exam_round_size ?? null;
examRounds = r.items ?? [];
// exam_name 미설정 시 토픽명 prefill
if (!f_exam_name) f_exam_name = topicName;
// 기존 입력 question 의 subject distinct 도 가져옴
try {
const list = await api(`/study-topics/${topicId}/questions?page_size=200`);
const subjs = new Set();
for (const q of (list.items ?? [])) {
if (q.subject) subjs.add(q.subject);
}
questionSubjects = Array.from(subjs);
} catch {}
} catch (err) {
addToast('error', err.detail || '주제 로딩 실패');
}
}
onMount(() => {
// query string 처리 (회차 목록에서 ?exam_round=&start_qnum= 로 진입)
function applyQueryParams() {
const params = $page.url.searchParams;
const r = params.get('exam_round');
const sq = params.get('start_qnum');
if (r) {
f_exam_round = r;
f_exam_round_mode = 'select';
}
if (sq) {
const n = Number(sq);
if (Number.isFinite(n) && n > 0) f_qnum = n;
}
}
onMount(async () => {
loadPersist();
loadTopic();
applyQueryParams();
await loadTopicAndRounds();
refreshCompleteFlag();
});
// 드롭다운 옵션 합치기 (topic.exam_subjects + 기존 distinct, unique)
let subjectOptions = $derived(() => {
const set = new Set();
for (const s of topicExamSubjects) if (s) set.add(s);
for (const s of questionSubjects) if (s) set.add(s);
return Array.from(set);
});
// 회차 변경 감지 → 문항 번호 자동 reset
let lastExamRound = $state('');
$effect(() => {
if (f_exam_round && f_exam_round !== lastExamRound) {
// 기존 회차면 next_question_number, 아니면 1
const found = examRounds.find((r) => r.exam_round === f_exam_round);
if (found && found.next_question_number) {
f_qnum = found.next_question_number;
} else if (!found) {
f_qnum = 1;
}
lastExamRound = f_exam_round;
refreshCompleteFlag();
}
});
function refreshCompleteFlag() {
const found = examRounds.find((r) => r.exam_round === f_exam_round);
isCompleteRound = !!(found && found.is_complete);
}
function effectiveSubject() {
return f_subject_mode === 'custom' ? f_subject_custom.trim() : f_subject;
}
function autoSourceNote() {
if (source_note.trim()) return source_note.trim();
if (f_exam_round) return `${f_exam_round} ${f_qnum}번`;
return '';
}
function applyNewRound() {
if (!f_exam_round_new.trim()) {
addToast('error', '회차명을 입력하세요');
return;
}
f_exam_round = f_exam_round_new.trim();
f_exam_round_new = '';
f_exam_round_mode = 'select';
f_qnum = 1;
lastExamRound = f_exam_round;
refreshCompleteFlag();
}
function validate() {
if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; }
if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) {
@@ -81,20 +194,24 @@
if (![1, 2, 3, 4].includes(Number(correct))) {
addToast('error', '정답은 1~4 중 하나'); return false;
}
if (isCompleteRound) {
addToast('error', '이 회차는 이미 완료되었습니다. 다른 회차를 선택하거나 새 회차를 시작하세요.');
return false;
}
return true;
}
function clearForCont() {
q_text = ''; c1 = ''; c2 = ''; c3 = ''; c4 = ''; correct = 1;
explanation = ''; source_note = '';
// subject/scope/exam_name/exam_round 는 유지
}
async function save(continueAfter) {
if (!validate()) return;
saving = true;
savePersist();
persist();
try {
const subj = effectiveSubject();
const body = {
question_text: q_text.trim(),
choice_1: c1.trim(),
@@ -102,21 +219,25 @@
choice_3: c3.trim(),
choice_4: c4.trim(),
correct_choice: Number(correct),
subject: subject || null,
scope: scope || null,
exam_name: exam_name || null,
exam_round: exam_round || null,
subject: subj || null,
scope: f_scope || null,
exam_name: f_exam_name || null,
exam_round: f_exam_round || null,
exam_question_number: f_exam_round ? Number(f_qnum) : null,
explanation: explanation || null,
source_note: source_note || null,
source_note: autoSourceNote() || null,
};
await api(`/study-topics/${topicId}/questions`, {
method: 'POST',
body: JSON.stringify(body),
});
addToast('success', '문제 저장됨');
addToast('success', `문제 저장됨${f_exam_round ? ` (${f_exam_round} ${f_qnum})` : ''}`);
if (continueAfter) {
clearForCont();
// 본문 textarea 로 포커스 이동
f_qnum = Number(f_qnum) + 1;
// 회차 진행률 갱신 (도달 체크)
await refreshExamRounds();
persist();
setTimeout(() => document.getElementById('q-text')?.focus(), 0);
} else {
goto(`/study/topics/${topicId}`);
@@ -127,12 +248,28 @@
saving = false;
}
}
async function refreshExamRounds() {
try {
const r = await api(`/study-topics/${topicId}/exam-rounds`);
examRounds = r.items ?? [];
refreshCompleteFlag();
} catch {}
}
// 진행률 표시 (현재 회차)
let currentProgress = $derived(() => {
if (!f_exam_round) return null;
const found = examRounds.find((r) => r.exam_round === f_exam_round);
const before = found?.question_count ?? 0;
return { before, size: topicExamRoundSize };
});
</script>
<svelte:head><title>새 문제 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3 flex-wrap">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
@@ -140,18 +277,26 @@
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium">새 문제</span>
<a href={`/study/topics/${topicId}/exam-rounds`} class="ml-auto text-xs text-accent hover:underline flex items-center gap-1">
<ListChecks size={12} /> 회차 보기
</a>
</div>
<!-- 회차 도달 안내 -->
{#if isCompleteRound}
<div class="mb-3 p-3 rounded-lg border border-warning/40 bg-warning/5 text-xs text-text flex items-start gap-2">
<AlertCircle size={14} class="text-warning shrink-0 mt-0.5" />
<div>
<span class="font-semibold">{f_exam_round}</span> 회차는 {topicExamRoundSize}문항 모두 입력됨. 다른 회차를 선택하거나 "새 회차" 를 시작하세요.
</div>
</div>
{/if}
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<Textarea
label="문제 본문"
bind:value={q_text}
rows={3}
placeholder="예: 다음 중 가연성 가스의 폭발범위에 대한 설명으로 옳은 것은?"
id="q-text"
/>
<Textarea label="문제 본문" bind:value={q_text} rows={3}
placeholder="예: 다음 중 가연성 가스의 폭발범위에 대한 설명으로 옳은 것은?" id="q-text" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<TextInput label="① 1번 보기" bind:value={c1} />
@@ -164,27 +309,97 @@
<label class="text-xs text-dim">정답 번호</label>
<div class="flex items-center gap-2">
{#each [1, 2, 3, 4] as n}
<button
type="button"
onclick={() => (correct = n)}
<button type="button" onclick={() => (correct = n)}
class="px-4 py-2 rounded border text-sm font-medium transition-colors
{correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={correct === n}
>{n}</button>
aria-pressed={correct === n}>{n}</button>
{/each}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-default">
<TextInput label="과목 (유지)" bind:value={subject} placeholder="예: 연소공학" />
<TextInput label="범위 (유지)" bind:value={scope} placeholder="예: 폭발범위" />
<TextInput label="시험명 (유지)" bind:value={exam_name} placeholder="예: 가스기사" />
<TextInput label="회차 (유지)" bind:value={exam_round} placeholder="예: 2024년 1회" />
<!-- 시험·과목·회차·문항 번호 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-default">
<TextInput label="시험명 (자동)" bind:value={f_exam_name} placeholder="예: 가스기사" />
<!-- 과목 dropdown / 직접 입력 -->
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">과목</label>
{#if f_subject_mode === 'dropdown'}
<div class="flex gap-2">
<select bind:value={f_subject}
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent">
<option value="">— 선택 —</option>
{#each subjectOptions() as s}
<option value={s}>{s}</option>
{/each}
</select>
<button type="button" onclick={() => { f_subject_mode = 'custom'; f_subject_custom = f_subject; }}
class="text-[11px] text-accent hover:underline shrink-0">직접 입력</button>
</div>
{:else}
<div class="flex gap-2">
<input type="text" bind:value={f_subject_custom}
placeholder="과목 직접 입력"
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<button type="button" onclick={() => { f_subject_mode = 'dropdown'; f_subject = f_subject_custom; }}
class="text-[11px] text-dim hover:text-text shrink-0">목록에서 선택</button>
</div>
{/if}
</div>
<TextInput label="범위 (선택)" bind:value={f_scope} placeholder="예: 폭발범위" />
<!-- 회차 dropdown / 새 회차 -->
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">회차</label>
{#if f_exam_round_mode === 'select'}
<div class="flex gap-2">
<select bind:value={f_exam_round}
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent">
<option value="">— 선택 —</option>
{#each examRounds as r}
<option value={r.exam_round}>
{r.exam_round} ({r.question_count}{topicExamRoundSize ? `/${topicExamRoundSize}` : ''})
</option>
{/each}
</select>
<button type="button" onclick={() => (f_exam_round_mode = 'new')}
class="text-[11px] text-accent hover:underline shrink-0">새 회차</button>
</div>
{:else}
<div class="flex gap-2">
<input type="text" bind:value={f_exam_round_new}
placeholder="예: 2019년 1회"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyNewRound(); } }}
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<button type="button" onclick={applyNewRound} class="text-[11px] text-accent hover:underline shrink-0">확정</button>
<button type="button" onclick={() => (f_exam_round_mode = 'select')} class="text-[11px] text-dim hover:text-text shrink-0">취소</button>
</div>
{/if}
</div>
</div>
<!-- 문항 번호 + 진행률 -->
<div class="flex items-end gap-3 flex-wrap">
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">문항 번호</span>
<input type="number" min="1" bind:value={f_qnum}
class="w-28 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
{#if currentProgress() && currentProgress().size}
<div class="flex-1 min-w-32">
<div class="text-[11px] text-dim mb-1">진행률 {currentProgress().before}/{currentProgress().size}</div>
<div class="h-1.5 bg-bg rounded-full overflow-hidden">
<div class="h-full bg-accent transition-all"
style="width: {Math.min(100, (currentProgress().before / currentProgress().size) * 100)}%"></div>
</div>
</div>
{/if}
</div>
<div class="text-[11px] text-dim -mt-2">"유지" 표시 필드는 다음 입력에도 그대로 유지됩니다 (sessionStorage).</div>
<Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" />
<TextInput label="출처/메모 (선택)" bind:value={source_note} placeholder="예: 산업안전기사 2023 1회 기출 7번" />
<TextInput label="출처/메모 (자동: '회차 N번', 수정 가능)" bind:value={source_note}
placeholder={autoSourceNote() || "예: 산업안전기사 2023 1회 기출 7번"} />
</div>
{/snippet}
</Card>
@@ -193,7 +408,7 @@
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<div class="flex gap-2">
<Button onclick={() => save(false)} loading={saving} variant="ghost" icon={Save}>저장</Button>
<Button onclick={() => save(true)} loading={saving} icon={Repeat}>저장 계속 입력</Button>
<Button onclick={() => save(true)} loading={saving} icon={Repeat} disabled={isCompleteRound}>저장 후 계속 입력</Button>
</div>
</div>
</div>
+16
View File
@@ -0,0 +1,16 @@
-- 195_study_topic_exam_meta.sql (1/3)
-- 시험 메타: 회차당 문항 수 + 과목 리스트 (PR-6).
--
-- 기사시험은 시험마다 회차당 문항 수가 다름 (80/100/120 등). 회차별 진행률 추적·
-- 재개 UX 의 기반. 과목 리스트는 입력 페이지 드롭다운 옵션 (사용자가 시험 생성 시
-- 한 번에 등록).
--
-- CHECK 1~300: 0/음수/비정상 큰 값으로 진행률 UI 깨지는 것 차단. 기사시험 도메인
-- 에선 300 이면 충분.
-- exam_subjects DEFAULT '[]' — 빈 배열이면 입력 페이지가 기존 question.subject
-- distinct 만 사용.
ALTER TABLE study_topics
ADD COLUMN IF NOT EXISTS exam_round_size INTEGER
CHECK (exam_round_size IS NULL OR exam_round_size BETWEEN 1 AND 300),
ADD COLUMN IF NOT EXISTS exam_subjects JSONB NOT NULL DEFAULT '[]'::jsonb;
@@ -0,0 +1,14 @@
-- 196_study_questions_exam_qnum.sql (2/3)
-- 회차 안 문항 번호. 같은 (study_topic_id, exam_round) 묶음 안에서 1, 2, 3, ...
--
-- NULL 허용 — 기존 행은 NULL (마이그레이션 적용 전 입력 데이터).
-- CHECK > 0 — 0/음수 차단. 상한은 두지 않음 (회차 사이즈가 토픽별로 가변).
-- UNIQUE (study_topic_id, exam_round, exam_question_number) 는 두지 않음 — 중복
-- 입력 실수 시 사용자가 직접 수정하도록.
--
-- exam_question_number 변경은 의미 검색 영향 없음 → embedding stale 트리거에서
-- 제외 (study_questions.py PATCH 핸들러).
ALTER TABLE study_questions
ADD COLUMN IF NOT EXISTS exam_question_number SMALLINT
CHECK (exam_question_number IS NULL OR exam_question_number > 0);
@@ -0,0 +1,8 @@
-- 197_study_questions_exam_round_idx.sql (3/3)
-- 회차별 진행률 (max + count) 조회 고속화.
-- partial — 회차 미설정 / soft-deleted 행 제외해서 인덱스 부피 절약.
-- GET /api/study-topics/{id}/exam-rounds 의 GROUP BY exam_round 가 본 인덱스 활용.
CREATE INDEX IF NOT EXISTS idx_study_questions_topic_round_qnum
ON study_questions (study_topic_id, exam_round, exam_question_number)
WHERE deleted_at IS NULL AND exam_round IS NOT NULL;