63ed4d81e5
필기 세션과 자료(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) <noreply@anthropic.com>
630 lines
20 KiB
Python
630 lines
20 KiB
Python
"""학습 워크스페이스(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()
|