From 63ed4d81e595e84d849c3ad3a591e020f5415cbf Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 28 Apr 2026 07:06:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20study=5Ftopics=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 필기 세션과 자료(library document)를 한 학습 주제(예: 가스기사) 아래로 묶는 1차 컨테이너. 향후 단어장/오디오/문제세트 등 학습 자산이 같은 묶음으로 들어올 수 있도록 응답 구조(sections + stats)를 dict 기반으로 설계. 데이터 모델 (migrations 179~185): - study_topics: user_id × name partial unique (active 행만), soft delete - study_sessions.study_topic_id: 1:N nullable FK (ON DELETE SET NULL) - study_topic_documents: 자료 N:M 매핑 (user_id 반정규화로 권한 격리) 설계 원칙: - documents.category(자료실 UI 축)와 직교 → 자료실 facet/카테고리 미터치 - StudySession.certification/subject/topic 보존 (세부 메타로 계속 사용) - study_type은 느슨한 분류 (강한 enum 미사용, jlpt_n3 등 확장 여지) - polymorphic study_topic_items 영구 금지 → 자산 타입별 조인 테이블 추가 방식 API: /api/study-topics CRUD + /by-document/{id} + 자료/세션 매핑 엔드포인트. 프론트: /study/topics 목록 + /study/topics/[id] 통합 뷰(필기·자료 두 트랙) + write 폼에 워크스페이스 드롭다운 + study hub 진입 카드. 후속 PR-2 어학 UX, PR-3 오디오 자산, PR-4 AI retrieval scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_sessions.py | 27 + app/api/study_topics.py | 629 ++++++++++++++++++ app/main.py | 2 + app/models/study_session.py | 10 + app/models/study_topic.py | 80 +++ frontend/src/routes/study/+page.svelte | 14 +- frontend/src/routes/study/topics/+page.svelte | 272 ++++++++ .../src/routes/study/topics/[id]/+page.svelte | 361 ++++++++++ frontend/src/routes/study/write/+page.svelte | 23 +- migrations/179_study_topics.sql | 28 + migrations/180_study_topics_uq_active.sql | 8 + migrations/181_study_topics_idx_user.sql | 6 + migrations/182_study_sessions_topic_fk.sql | 8 + migrations/183_study_sessions_topic_idx.sql | 6 + migrations/184_study_topic_documents.sql | 19 + .../185_study_topic_documents_idx_doc.sql | 7 + 16 files changed, 1498 insertions(+), 2 deletions(-) create mode 100644 app/api/study_topics.py create mode 100644 app/models/study_topic.py create mode 100644 frontend/src/routes/study/topics/+page.svelte create mode 100644 frontend/src/routes/study/topics/[id]/+page.svelte create mode 100644 migrations/179_study_topics.sql create mode 100644 migrations/180_study_topics_uq_active.sql create mode 100644 migrations/181_study_topics_idx_user.sql create mode 100644 migrations/182_study_sessions_topic_fk.sql create mode 100644 migrations/183_study_sessions_topic_idx.sql create mode 100644 migrations/184_study_topic_documents.sql create mode 100644 migrations/185_study_topic_documents_idx_doc.sql diff --git a/app/api/study_sessions.py b/app/api/study_sessions.py index fadb393..06f9eaf 100644 --- a/app/api/study_sessions.py +++ b/app/api/study_sessions.py @@ -146,6 +146,8 @@ class StudySessionCreate(BaseModel): canvas_width: int | None = None canvas_height: int | None = None strokes_json: dict[str, Any] | None = None + # 학습 워크스페이스 묶음. 미지정 시 미분류. + study_topic_id: int | None = None class StudySessionUpdate(BaseModel): @@ -171,6 +173,8 @@ class StudySessionUpdate(BaseModel): user_corrected_text: str | None = None review_state: str | None = None next_review_at: datetime | None = None + # 주제 재할당 (NULL 로 분리도 가능) + study_topic_id: int | None = None class StudySessionResponse(BaseModel): @@ -202,6 +206,7 @@ class StudySessionResponse(BaseModel): last_quiz_at: datetime | None correct_count: int incorrect_count: int + study_topic_id: int | None = None assets: list[StudySessionAssetResponse] created_at: datetime updated_at: datetime @@ -244,6 +249,7 @@ def _to_session_response(sess: StudySession) -> StudySessionResponse: last_quiz_at=sess.last_quiz_at, correct_count=sess.correct_count, incorrect_count=sess.incorrect_count, + study_topic_id=sess.study_topic_id, assets=[ StudySessionAssetResponse.model_validate(a) for a in (sess.assets or []) ], @@ -284,6 +290,14 @@ async def create_study_session( """ _validate_create_payload(body) + # study_topic_id 가 주어지면 소유 검증 (다른 사용자의 주제로 매핑 차단) + if body.study_topic_id is not None: + from models.study_topic import StudyTopic as _Topic + + topic = await session.get(_Topic, body.study_topic_id) + if topic is None or topic.user_id != user.id or topic.deleted_at is not None: + raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다") + sess = StudySession( user_id=user.id, study_type=body.study_type, @@ -302,6 +316,7 @@ async def create_study_session( canvas_width=body.canvas_width, canvas_height=body.canvas_height, strokes_json=body.strokes_json, + study_topic_id=body.study_topic_id, ) session.add(sess) await session.flush() @@ -326,6 +341,7 @@ async def list_study_sessions( asset_type: str | None = Query(None, description="이 asset_type 보유 세션만"), mode: str | None = Query(None), due_before: datetime | None = Query(None, description="next_review_at <= due_before"), + study_topic_id: int | None = Query(None, description="학습 워크스페이스(주제) id"), order: str = Query("created_at"), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), @@ -361,6 +377,8 @@ async def list_study_sessions( base = base.where(StudySession.mode == mode) if due_before is not None: base = base.where(StudySession.next_review_at <= due_before) + if study_topic_id is not None: + base = base.where(StudySession.study_topic_id == study_topic_id) # assets join filter — EXISTS 서브쿼리 if document_id is not None or asset_type is not None: @@ -625,6 +643,14 @@ async def update_study_session( raise HTTPException(status_code=422, detail="review_state 값이 올바르지 않습니다") sess.review_state = body.review_state + # study_topic_id 변경 시 소유 검증 + if "study_topic_id" in fields_set and body.study_topic_id is not None: + from models.study_topic import StudyTopic as _Topic + + topic = await session.get(_Topic, body.study_topic_id) + if topic is None or topic.user_id != user.id or topic.deleted_at is not None: + raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다") + # 단순 매핑 필드 (검증 불필요) SIMPLE_FIELDS = { "certification", "language_code", "learning_level", "subject", "topic", @@ -632,6 +658,7 @@ async def update_study_session( "target_count", "repetition_count", "canvas_width", "canvas_height", "strokes_json", "ocr_text", "user_corrected_text", "next_review_at", + "study_topic_id", } for fname in SIMPLE_FIELDS & fields_set: setattr(sess, fname, getattr(body, fname)) diff --git a/app/api/study_topics.py b/app/api/study_topics.py new file mode 100644 index 0000000..9035423 --- /dev/null +++ b/app/api/study_topics.py @@ -0,0 +1,629 @@ +"""학습 워크스페이스(study_topic) API — 필기 세션 + 자료(library document) 묶음 컨테이너 + +핵심 개념: + - study_topic 은 단순 폴더/태그가 아니라 학습 자산 1차 컨테이너 (학습 워크스페이스). + - 향후 단어장/오디오/문제 세트 같은 자산이 같은 컨테이너 아래로 들어올 수 있도록 + 응답 구조를 sections + stats 형태로 설계 (현재는 sessions / documents 두 키만). + - documents.category(자료실 UI 축) 와 직교한 별도 분류 축. 자료실 facet/카테고리는 미터치. + - StudySession.certification/subject/topic 은 보존 — 본 컨테이너 와 직교한 세부 메타. + +설계 제약: + - study_type 은 강한 enum 미사용. VALID_STUDY_TYPES 는 단순 권장값 (DB/Pydantic 강제 안 함). + - soft delete (deleted_at). active 행끼리만 (user_id, name) partial unique. + - 권한: 모든 쿼리에 user_id 강제. 다른 사용자 자료를 묶지 못함. + - 문서 매핑은 N:M (study_topic_documents). 세션 매핑은 1:N (study_sessions.study_topic_id). + - polymorphic 단일 study_topic_items 테이블은 만들지 않는다 (영구 금지). +""" + +import logging +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.database import get_session +from models.document import Document +from models.study_session import StudySession +from models.study_topic import StudyTopic, StudyTopicDocument +from models.user import User + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ─── 권장값 (강제 enum 아님, UI 안내용) ─── + +RECOMMENDED_STUDY_TYPES: set[str] = { + "certification", "language", "school", "work", "general", +} + + +# ─── Pydantic 스키마 ─── + + +class StudyTopicCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + description: str | None = None + color: str | None = Field(default=None, max_length=20) + study_type: str | None = Field(default=None, max_length=40) + sort_order: int = 0 + + +class StudyTopicUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = None + color: str | None = Field(default=None, max_length=20) + study_type: str | None = Field(default=None, max_length=40) + sort_order: int | None = None + + +class StudyTopicResponse(BaseModel): + """주제 목록 응답 — 집계 카운트 포함.""" + + id: int + name: str + description: str | None + color: str | None + study_type: str | None + sort_order: int + session_count: int = 0 + document_count: int = 0 + created_at: datetime + updated_at: datetime + + +class StudyTopicListResponse(BaseModel): + items: list[StudyTopicResponse] + total: int + + +class StudyTopicSessionSummary(BaseModel): + """상세 뷰의 세션 카드 페이로드 — 통합 뷰 렌더용 최소 필드.""" + + id: int + study_type: str + certification: str | None + language_code: str | None + learning_level: str | None + subject: str | None + topic: str | None + mode: str + repetition_count: int + review_state: str | None + created_at: datetime + updated_at: datetime + + +class StudyTopicDocumentSummary(BaseModel): + """상세 뷰의 자료 카드 페이로드.""" + + id: int + title: str | None + file_format: str + file_type: str + category: str | None + ai_domain: str | None + importance: str | None + sort_order: int + linked_at: datetime + + +class StudyTopicSections(BaseModel): + """확장 친화 dict — 향후 audio_assets / vocab_decks / question_sets 키 추가 가능.""" + + sessions: list[StudyTopicSessionSummary] + documents: list[StudyTopicDocumentSummary] + + +class StudyTopicStats(BaseModel): + """자산 카운트 — 0 으로 미리 노출해서 후속 PR 에서 필드만 채움.""" + + session_count: int + document_count: int + audio_count: int = 0 + vocab_count: int = 0 + question_set_count: int = 0 + + +class StudyTopicMeta(BaseModel): + id: int + name: str + description: str | None + color: str | None + study_type: str | None + sort_order: int + created_at: datetime + updated_at: datetime + + +class StudyTopicDetailResponse(BaseModel): + topic: StudyTopicMeta + sections: StudyTopicSections + stats: StudyTopicStats + + +class StudyTopicDocumentLinkRequest(BaseModel): + document_ids: list[int] = Field(min_length=1, max_length=100) + + +class StudyTopicDocumentLinkResponse(BaseModel): + linked: list[int] + skipped_existing: list[int] + skipped_not_found: list[int] + + +# ─── Helpers ─── + + +def _verify_topic_ownership(topic: StudyTopic | None, user: User) -> StudyTopic: + """소유자 검증 + soft-deleted 행 차단. mismatch 도 404 (정보 누설 방지).""" + if topic is None or topic.user_id != user.id or topic.deleted_at is not None: + raise HTTPException(status_code=404, detail="학습 주제를 찾을 수 없습니다") + return topic + + +def _meta_from_topic(t: StudyTopic) -> StudyTopicMeta: + return StudyTopicMeta( + id=t.id, + name=t.name, + description=t.description, + color=t.color, + study_type=t.study_type, + sort_order=t.sort_order, + created_at=t.created_at, + updated_at=t.updated_at, + ) + + +# ─── 엔드포인트 ─── + + +@router.get("/", response_model=StudyTopicListResponse) +async def list_study_topics( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """사용자의 active 주제 목록. 세션 수·자료 수 집계 포함.""" + + # active 주제 + 세션 수 (LEFT JOIN study_sessions) + sess_count_sub = ( + select( + StudySession.study_topic_id.label("topic_id"), + func.count().label("c"), + ) + .where(StudySession.user_id == user.id) + .where(StudySession.study_topic_id.is_not(None)) + .group_by(StudySession.study_topic_id) + .subquery() + ) + + doc_count_sub = ( + select( + StudyTopicDocument.study_topic_id.label("topic_id"), + func.count().label("c"), + ) + .where(StudyTopicDocument.user_id == user.id) + .group_by(StudyTopicDocument.study_topic_id) + .subquery() + ) + + base = ( + select( + StudyTopic, + func.coalesce(sess_count_sub.c.c, 0).label("session_count"), + func.coalesce(doc_count_sub.c.c, 0).label("document_count"), + ) + .outerjoin(sess_count_sub, sess_count_sub.c.topic_id == StudyTopic.id) + .outerjoin(doc_count_sub, doc_count_sub.c.topic_id == StudyTopic.id) + .where(StudyTopic.user_id == user.id, StudyTopic.deleted_at.is_(None)) + .order_by(StudyTopic.sort_order.asc(), StudyTopic.id.desc()) + ) + + total_query = select(func.count()).select_from( + select(StudyTopic.id) + .where(StudyTopic.user_id == user.id, StudyTopic.deleted_at.is_(None)) + .subquery() + ) + total = (await session.execute(total_query)).scalar() or 0 + + rows = (await session.execute(base.offset(offset).limit(limit))).all() + + items = [ + StudyTopicResponse( + id=t.id, + name=t.name, + description=t.description, + color=t.color, + study_type=t.study_type, + sort_order=t.sort_order, + session_count=int(sc), + document_count=int(dc), + created_at=t.created_at, + updated_at=t.updated_at, + ) + for t, sc, dc in rows + ] + return StudyTopicListResponse(items=items, total=total) + + +@router.get("/by-document/{document_id}", response_model=list[StudyTopicMeta]) +async def list_topics_for_document( + document_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """이 자료가 매핑된 active 주제 목록. + /study/sources 카드의 "이 자료가 속한 주제" 배지·컨텍스트 메뉴용. + """ + rows = ( + await session.execute( + select(StudyTopic) + .join(StudyTopicDocument, StudyTopicDocument.study_topic_id == StudyTopic.id) + .where( + StudyTopicDocument.document_id == document_id, + StudyTopicDocument.user_id == user.id, + StudyTopic.deleted_at.is_(None), + ) + .order_by(StudyTopic.sort_order.asc(), StudyTopic.id.desc()) + ) + ).scalars().all() + return [_meta_from_topic(t) for t in rows] + + +@router.post("/", response_model=StudyTopicResponse, status_code=201) +async def create_study_topic( + body: StudyTopicCreate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """주제 생성. 같은 이름의 active 주제가 이미 있으면 409. + soft-deleted 동명 주제는 partial unique index 에서 빠지므로 신규 생성 가능. + """ + topic = StudyTopic( + user_id=user.id, + name=body.name.strip(), + description=body.description, + color=body.color, + study_type=body.study_type, + sort_order=body.sort_order, + ) + session.add(topic) + try: + await session.flush() + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="이미 같은 이름의 학습 주제가 있습니다") + + return StudyTopicResponse( + id=topic.id, + name=topic.name, + description=topic.description, + color=topic.color, + study_type=topic.study_type, + sort_order=topic.sort_order, + session_count=0, + document_count=0, + created_at=topic.created_at, + updated_at=topic.updated_at, + ) + + +@router.get("/{topic_id}", response_model=StudyTopicDetailResponse) +async def get_study_topic( + topic_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """통합 뷰: 주제 메타 + 세션 목록 + 자료 목록 + stats. + 응답 형태는 후속 PR(오디오/단어장/문제세트 추가) 에서 sections / stats 키만 늘리면 되도록 dict 구조. + """ + topic = await session.get(StudyTopic, topic_id) + topic = _verify_topic_ownership(topic, user) + + # 세션 목록 — 최근순 + sess_rows = ( + await session.execute( + select(StudySession) + .where( + StudySession.user_id == user.id, + StudySession.study_topic_id == topic_id, + ) + .order_by(StudySession.created_at.desc(), StudySession.id.desc()) + ) + ).scalars().all() + + sessions_payload = [ + StudyTopicSessionSummary( + id=s.id, + study_type=s.study_type, + certification=s.certification, + language_code=s.language_code, + learning_level=s.learning_level, + subject=s.subject, + topic=s.topic, + mode=s.mode, + repetition_count=s.repetition_count, + review_state=s.review_state, + created_at=s.created_at, + updated_at=s.updated_at, + ) + for s in sess_rows + ] + + # 자료 목록 — 매핑 sort_order → created_at desc + doc_rows = ( + await session.execute( + select(Document, StudyTopicDocument) + .join( + StudyTopicDocument, + and_( + StudyTopicDocument.document_id == Document.id, + StudyTopicDocument.study_topic_id == topic_id, + StudyTopicDocument.user_id == user.id, + ), + ) + .where(Document.deleted_at.is_(None)) + .order_by( + StudyTopicDocument.sort_order.asc(), + StudyTopicDocument.created_at.desc(), + ) + ) + ).all() + + documents_payload = [ + StudyTopicDocumentSummary( + id=d.id, + title=d.title, + file_format=d.file_format, + file_type=str(d.file_type) if d.file_type is not None else "immutable", + category=str(d.category) if d.category is not None else None, + ai_domain=d.ai_domain, + importance=d.importance, + sort_order=link.sort_order, + linked_at=link.created_at, + ) + for d, link in doc_rows + ] + + return StudyTopicDetailResponse( + topic=_meta_from_topic(topic), + sections=StudyTopicSections( + sessions=sessions_payload, + documents=documents_payload, + ), + stats=StudyTopicStats( + session_count=len(sessions_payload), + document_count=len(documents_payload), + # 후속 PR 에서 채움 + audio_count=0, + vocab_count=0, + question_set_count=0, + ), + ) + + +@router.patch("/{topic_id}", response_model=StudyTopicResponse) +async def update_study_topic( + topic_id: int, + body: StudyTopicUpdate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + topic = await session.get(StudyTopic, topic_id) + topic = _verify_topic_ownership(topic, user) + + fields_set = body.model_fields_set + if "name" in fields_set and body.name is not None: + topic.name = body.name.strip() + for fname in {"description", "color", "study_type", "sort_order"} & fields_set: + setattr(topic, fname, getattr(body, fname)) + + topic.updated_at = datetime.now(timezone.utc) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="이미 같은 이름의 학습 주제가 있습니다") + + # 응답용 카운트 별도 조회 + sc = (await session.execute( + select(func.count()) + .select_from(StudySession) + .where(StudySession.user_id == user.id, StudySession.study_topic_id == topic.id) + )).scalar() or 0 + dc = (await session.execute( + select(func.count()) + .select_from(StudyTopicDocument) + .where(StudyTopicDocument.study_topic_id == topic.id) + )).scalar() or 0 + + return StudyTopicResponse( + id=topic.id, + name=topic.name, + description=topic.description, + color=topic.color, + study_type=topic.study_type, + sort_order=topic.sort_order, + session_count=int(sc), + document_count=int(dc), + created_at=topic.created_at, + updated_at=topic.updated_at, + ) + + +@router.delete("/{topic_id}", status_code=204) +async def delete_study_topic( + topic_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """주제 soft delete. 세션의 study_topic_id 는 SET NULL, 문서 매핑은 cascade 제거. + 같은 이름 재생성을 위해 partial unique index 가 deleted_at IS NULL 인 행만 본다. + """ + topic = await session.get(StudyTopic, topic_id) + topic = _verify_topic_ownership(topic, user) + + # 세션 FK SET NULL — 명시적으로 (DB ON DELETE SET NULL 은 hard delete 시에만 동작) + await session.execute( + update(StudySession) + .where( + StudySession.user_id == user.id, + StudySession.study_topic_id == topic_id, + ) + .values(study_topic_id=None) + ) + # 문서 매핑 명시 제거 (soft delete 라 FK CASCADE 안 탐) + await session.execute( + delete(StudyTopicDocument).where( + StudyTopicDocument.study_topic_id == topic_id, + ) + ) + + topic.deleted_at = datetime.now(timezone.utc) + await session.commit() + + +# ─── 자료(문서) 매핑 ─── + + +@router.post( + "/{topic_id}/documents", + response_model=StudyTopicDocumentLinkResponse, + status_code=201, +) +async def link_documents_to_topic( + topic_id: int, + body: StudyTopicDocumentLinkRequest, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """자료 N개를 주제에 일괄 매핑. 이미 연결된 건 skipped_existing 으로 보고.""" + topic = await session.get(StudyTopic, topic_id) + topic = _verify_topic_ownership(topic, user) + + requested = list(dict.fromkeys(body.document_ids)) # 중복 제거 + 순서 보존 + + # 존재 + 미삭제 documents 만 통과 (single-user 시스템: documents.user_id 부재 가능) + valid_rows = ( + await session.execute( + select(Document.id).where( + Document.id.in_(requested), + Document.deleted_at.is_(None), + ) + ) + ).scalars().all() + valid_set = set(valid_rows) + not_found = [d for d in requested if d not in valid_set] + + # 이미 매핑된 것 + existing_rows = ( + await session.execute( + select(StudyTopicDocument.document_id).where( + StudyTopicDocument.study_topic_id == topic_id, + StudyTopicDocument.document_id.in_(valid_set), + ) + ) + ).scalars().all() + existing_set = set(existing_rows) + to_link = [d for d in requested if d in valid_set and d not in existing_set] + + # 기존 max sort_order 다음부터 + max_sort = ( + await session.execute( + select(func.coalesce(func.max(StudyTopicDocument.sort_order), -1)) + .where(StudyTopicDocument.study_topic_id == topic_id) + ) + ).scalar() or -1 + + for i, doc_id in enumerate(to_link, start=1): + session.add(StudyTopicDocument( + study_topic_id=topic_id, + document_id=doc_id, + user_id=user.id, + sort_order=int(max_sort) + i, + )) + + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException(status_code=409, detail="자료 매핑 중 충돌이 발생했습니다") + + return StudyTopicDocumentLinkResponse( + linked=to_link, + skipped_existing=sorted(existing_set), + skipped_not_found=not_found, + ) + + +@router.delete("/{topic_id}/documents/{document_id}", status_code=204) +async def unlink_document_from_topic( + topic_id: int, + document_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + result = await session.execute( + delete(StudyTopicDocument).where( + StudyTopicDocument.study_topic_id == topic_id, + StudyTopicDocument.document_id == document_id, + StudyTopicDocument.user_id == user.id, + ) + ) + await session.commit() + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="매핑을 찾을 수 없습니다") + + +# ─── 세션 매핑 ─── + + +@router.post("/{topic_id}/sessions/{session_id}", status_code=204) +async def attach_session_to_topic( + topic_id: int, + session_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """기존 세션을 주제에 연결. 다른 주제에 이미 연결돼 있으면 새 주제로 갱신 (1:N).""" + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + sess = await session.get(StudySession, session_id) + if sess is None or sess.user_id != user.id: + raise HTTPException(status_code=404, detail="학습 세션을 찾을 수 없습니다") + sess.study_topic_id = topic_id + sess.updated_at = datetime.now(timezone.utc) + await session.commit() + + +@router.delete("/{topic_id}/sessions/{session_id}", status_code=204) +async def detach_session_from_topic( + topic_id: int, + session_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """세션 분리. 현재 study_topic_id 가 topic_id 가 아니면 404.""" + topic = await session.get(StudyTopic, topic_id) + _verify_topic_ownership(topic, user) + + sess = await session.get(StudySession, session_id) + if sess is None or sess.user_id != user.id: + raise HTTPException(status_code=404, detail="학습 세션을 찾을 수 없습니다") + if sess.study_topic_id != topic_id: + raise HTTPException(status_code=404, detail="해당 주제에 연결된 세션이 아닙니다") + sess.study_topic_id = None + sess.updated_at = datetime.now(timezone.utc) + await session.commit() diff --git a/app/main.py b/app/main.py index c1055ad..4b124ba 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from api.news import router as news_router from api.search import router as search_router from api.setup import router as setup_router from api.study_sessions import router as study_sessions_router +from api.study_topics import router as study_topics_router from api.video import router as video_router from core.config import settings from core.database import async_session, engine, init_db @@ -115,6 +116,7 @@ app.include_router(digest_router, prefix="/api/digest", tags=["digest"]) app.include_router(audio_router, prefix="/api/audio", tags=["audio"]) app.include_router(video_router, prefix="/api/video", tags=["video"]) app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"]) +app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"]) # TODO: Phase 5에서 추가 # app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"]) diff --git a/app/models/study_session.py b/app/models/study_session.py index 12bf23d..ad3ee61 100644 --- a/app/models/study_session.py +++ b/app/models/study_session.py @@ -87,6 +87,11 @@ class StudySession(Base): correct_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) incorrect_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + # 학습 워크스페이스(study_topic) 1:N. NULL 허용 — 미분류 세션이 정상 상태. + study_topic_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("study_topics.id", ondelete="SET NULL") + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, nullable=False ) @@ -101,6 +106,11 @@ class StudySession(Base): order_by="StudySessionAsset.sort_order", ) + # 연관 학습 워크스페이스 + study_topic: Mapped["StudyTopic | None"] = relationship( + "StudyTopic", back_populates="sessions", lazy="noload" + ) + class StudySessionAsset(Base): __tablename__ = "study_session_assets" diff --git a/app/models/study_topic.py b/app/models/study_topic.py new file mode 100644 index 0000000..26e4ada --- /dev/null +++ b/app/models/study_topic.py @@ -0,0 +1,80 @@ +"""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.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) + + 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) + 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", + ) + + +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") diff --git a/frontend/src/routes/study/+page.svelte b/frontend/src/routes/study/+page.svelte index c61394a..84644a2 100644 --- a/frontend/src/routes/study/+page.svelte +++ b/frontend/src/routes/study/+page.svelte @@ -1,7 +1,8 @@
@@ -12,6 +13,17 @@

학습 자료 회독 / 손글씨 필사 세션 / (예정) 퀴즈·복습.

+ +
+ +

주제로 보기

+
+

"가스기사" 같은 학습 주제 아래에 필기 세션과 자료를 함께 묶어 본다. 한 주제 안에서 필기·자료를 한눈에.

+
+
+ /** + * /study/topics — 학습 워크스페이스(주제) 목록. + * + * 한 주제(예: 가스기사) 아래에 필기 세션과 자료(library document)를 묶어 본다. + * 1차 컨테이너로 향후 단어장/오디오/문제세트 같은 학습 자산이 같은 묶음으로 들어올 예정. + * + * - 카드 그리드 (세션 수 / 자료 수 표시) + * - 새 주제 생성 인라인 폼 + * - 주제 클릭 시 /study/topics/[id] 통합 뷰로 이동 + */ + import { onMount } from 'svelte'; + import { goto } from '$app/navigation'; + import { api } from '$lib/api'; + import { addToast } from '$lib/stores/toast'; + import { Plus, ArrowLeft, FolderKanban, Trash2, Pencil } 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'; + import TextInput from '$lib/components/ui/TextInput.svelte'; + import Select from '$lib/components/ui/Select.svelte'; + import Textarea from '$lib/components/ui/Textarea.svelte'; + + const STUDY_TYPE_OPTIONS = [ + { value: '', label: '미지정' }, + { value: 'certification', label: '자격증 (certification)' }, + { value: 'language', label: '어학 (language)' }, + { value: 'school', label: '학교 (school)' }, + { value: 'work', label: '업무 (work)' }, + { value: 'general', label: '일반 (general)' }, + ]; + + let topics = $state([]); + let total = $state(0); + let loading = $state(true); + + // 생성 폼 + let formOpen = $state(false); + let f_name = $state(''); + let f_description = $state(''); + let f_color = $state(''); + let f_study_type = $state(''); + let creating = $state(false); + + // 편집 모달 + let editing = $state(null); // {id, name, description, color, study_type} + let savingEdit = $state(false); + + async function load() { + loading = true; + try { + const res = await api('/study-topics/?limit=200'); + topics = res.items; + total = res.total; + } catch (err) { + addToast('error', err.detail || '학습 주제 로딩 실패'); + } finally { + loading = false; + } + } + + onMount(load); + + async function createTopic() { + if (!f_name.trim()) { + addToast('error', '주제 이름을 입력하세요'); + return; + } + creating = true; + try { + const body = { + name: f_name.trim(), + description: f_description || null, + color: f_color || null, + study_type: f_study_type || null, + }; + const t = await api('/study-topics/', { + method: 'POST', + body: JSON.stringify(body), + }); + topics = [t, ...topics]; + total += 1; + addToast('success', '주제 생성됨'); + formOpen = false; + f_name = ''; + f_description = ''; + f_color = ''; + f_study_type = ''; + } catch (err) { + addToast('error', err.detail || '주제 생성 실패'); + } finally { + creating = false; + } + } + + function startEdit(t) { + editing = { + id: t.id, + name: t.name, + description: t.description ?? '', + color: t.color ?? '', + study_type: t.study_type ?? '', + }; + } + + async function saveEdit() { + if (!editing) return; + if (!editing.name.trim()) { + addToast('error', '주제 이름을 입력하세요'); + return; + } + savingEdit = true; + try { + const updated = await api(`/study-topics/${editing.id}`, { + method: 'PATCH', + body: JSON.stringify({ + name: editing.name.trim(), + description: editing.description || null, + color: editing.color || null, + study_type: editing.study_type || null, + }), + }); + topics = topics.map((x) => (x.id === updated.id ? updated : x)); + addToast('success', '저장됨'); + editing = null; + } catch (err) { + addToast('error', err.detail || '저장 실패'); + } finally { + savingEdit = false; + } + } + + async function removeTopic(t) { + if (!confirm(`"${t.name}" 주제를 삭제합니다.\n연결된 자료 매핑은 해제되고 세션은 미분류 상태로 남습니다.`)) return; + try { + await api(`/study-topics/${t.id}`, { method: 'DELETE' }); + topics = topics.filter((x) => x.id !== t.id); + total -= 1; + addToast('success', '삭제됨'); + } catch (err) { + addToast('error', err.detail || '삭제 실패'); + } + } + + function fmtDate(s) { + return new Date(s).toLocaleDateString('ko-KR', { dateStyle: 'medium' }); + } + + +주제로 보기 — 공부 + +
+
+ + 공부 + + / + + 주제로 보기 + +
+ +
+

학습 주제

+

한 주제 아래에 필기 세션과 자료를 묶어 보고 진도 관리. 향후 단어장·오디오·문제세트도 같은 묶음으로 연결됩니다.

+
+ + + + {#snippet children()} +
+ {#if !formOpen} + + {:else} +
+
새 학습 주제
+ +