8803e6a0fd
기사시험 회차별 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>
89 lines
3.9 KiB
Python
89 lines
3.9 KiB
Python
"""study_topics / study_topic_documents 테이블 ORM — 학습 워크스페이스 1차 컨테이너
|
|
|
|
목적: 필기 세션(StudySession) 과 자료(documents) 를 한 학습 주제(예: 가스기사)
|
|
아래로 묶는 컨테이너. 향후 단어장/오디오/문제세트 같은 학습 자산이 같은
|
|
컨테이너 아래로 들어올 수 있도록 설계.
|
|
|
|
설계 원칙:
|
|
- documents.category(자료실 UI 축) 와 직교한 별도 분류 축. 자료실 facet/카테고리 미터치.
|
|
- StudySession.certification/subject/topic 컬럼은 보존, 본 컨테이너 와 직교 세부 메타.
|
|
- study_type 은 느슨한 분류. DB/Pydantic 강한 enum 미사용. 권장값: certification /
|
|
language / school / work / general (UI 드롭다운에서만 안내).
|
|
- soft delete (deleted_at). 동일 user_id+name 의 active 행만 partial unique index 로
|
|
중복 방지 — 삭제된 주제명 재생성 가능.
|
|
- 자산 다대다 매핑: 본 PR 은 documents 만 (study_topic_documents). 향후 자산 타입별
|
|
조인 테이블 추가 (study_topic_audio_assets 등). polymorphic 단일 테이블 금지.
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
class StudyTopic(Base):
|
|
__tablename__ = "study_topics"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
|
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text)
|
|
color: Mapped[str | None] = mapped_column(String(20))
|
|
|
|
# 느슨한 분류 (certification/language/school/work/general 권장)
|
|
study_type: Mapped[str | None] = mapped_column(String(40))
|
|
|
|
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
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
|
|
)
|
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
# 연관 — 세션 (1:N), 자료 매핑 (N:M), 문제 (1:N PR-2)
|
|
sessions: Mapped[list["StudySession"]] = relationship( # type: ignore[name-defined] # noqa: F821
|
|
"StudySession", back_populates="study_topic", lazy="noload"
|
|
)
|
|
document_links: Mapped[list["StudyTopicDocument"]] = relationship(
|
|
back_populates="topic",
|
|
cascade="all, delete-orphan",
|
|
order_by="StudyTopicDocument.sort_order",
|
|
lazy="noload",
|
|
)
|
|
questions: Mapped[list["StudyQuestion"]] = relationship( # type: ignore[name-defined] # noqa: F821
|
|
"StudyQuestion", back_populates="topic", lazy="noload"
|
|
)
|
|
|
|
|
|
class StudyTopicDocument(Base):
|
|
__tablename__ = "study_topic_documents"
|
|
|
|
study_topic_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
document_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
user_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=datetime.now, nullable=False
|
|
)
|
|
|
|
topic: Mapped["StudyTopic"] = relationship(back_populates="document_links")
|