""" Bulletin Board API - 게시판 API """ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from sqlalchemy import func, desc, or_ from typing import Optional import math from ..database import get_db from ..models.bulletin import BoardCategory, BoardPost from ..models.user import User from ..schemas.bulletin import ( BoardCategoryCreate, BoardCategoryUpdate, BoardCategoryResponse, BoardCategoryListResponse, BoardPostCreate, BoardPostUpdate, BoardPostResponse, BoardPostListItem, BoardPostListResponse ) from .auth import get_current_user, get_current_user_optional router = APIRouter(prefix="/board", tags=["board"]) # ============ Category Endpoints ============ @router.get("/categories", response_model=BoardCategoryListResponse) def get_categories( include_inactive: bool = False, db: Session = Depends(get_db) ): """카테고리 목록 조회 (공개)""" query = db.query(BoardCategory) if not include_inactive: query = query.filter(BoardCategory.is_active == True) categories = query.order_by(BoardCategory.sort_order, BoardCategory.id).all() # 각 카테고리별 게시물 수 계산 result = [] for cat in categories: post_count = db.query(func.count(BoardPost.id)).filter( BoardPost.category_id == cat.id, BoardPost.is_published == True ).scalar() cat_dict = { "id": cat.id, "name": cat.name, "name_en": cat.name_en, "name_mn": cat.name_mn, "name_ru": cat.name_ru, "slug": cat.slug, "description": cat.description, "sort_order": cat.sort_order, "is_active": cat.is_active, "created_at": cat.created_at, "updated_at": cat.updated_at, "post_count": post_count } result.append(cat_dict) return BoardCategoryListResponse(categories=result, total=len(result)) @router.post("/categories", response_model=BoardCategoryResponse) def create_category( category: BoardCategoryCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """카테고리 생성 (관리자만)""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can create categories" ) # slug 중복 체크 existing = db.query(BoardCategory).filter(BoardCategory.slug == category.slug).first() if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Category slug already exists" ) db_category = BoardCategory(**category.model_dump()) db.add(db_category) db.commit() db.refresh(db_category) return {**db_category.__dict__, "post_count": 0} @router.put("/categories/{category_id}", response_model=BoardCategoryResponse) def update_category( category_id: int, category_update: BoardCategoryUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """카테고리 수정 (관리자만)""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can update categories" ) db_category = db.query(BoardCategory).filter(BoardCategory.id == category_id).first() if not db_category: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Category not found" ) # slug 중복 체크 (다른 카테고리) if category_update.slug: existing = db.query(BoardCategory).filter( BoardCategory.slug == category_update.slug, BoardCategory.id != category_id ).first() if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Category slug already exists" ) update_data = category_update.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(db_category, key, value) db.commit() db.refresh(db_category) post_count = db.query(func.count(BoardPost.id)).filter( BoardPost.category_id == category_id, BoardPost.is_published == True ).scalar() return {**db_category.__dict__, "post_count": post_count} @router.delete("/categories/{category_id}") def delete_category( category_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """카테고리 삭제 (관리자만)""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete categories" ) db_category = db.query(BoardCategory).filter(BoardCategory.id == category_id).first() if not db_category: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Category not found" ) # 해당 카테고리에 게시물이 있는지 확인 post_count = db.query(func.count(BoardPost.id)).filter( BoardPost.category_id == category_id ).scalar() if post_count > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Cannot delete category with {post_count} posts. Please delete or move posts first." ) db.delete(db_category) db.commit() return {"message": "Category deleted successfully"} # ============ Post Endpoints ============ @router.get("/posts", response_model=BoardPostListResponse) def get_posts( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), category_id: Optional[int] = None, search: Optional[str] = None, db: Session = Depends(get_db) ): """게시물 목록 조회 (공개)""" # 공지사항 (항상 상단에) notices_query = db.query(BoardPost).filter( BoardPost.is_published == True, BoardPost.is_notice == True ).order_by(desc(BoardPost.is_pinned), desc(BoardPost.created_at)) if category_id: notices_query = notices_query.filter(BoardPost.category_id == category_id) notices = notices_query.all() # 일반 게시물 query = db.query(BoardPost).filter( BoardPost.is_published == True, BoardPost.is_notice == False ) if category_id: query = query.filter(BoardPost.category_id == category_id) if search: query = query.filter( or_( BoardPost.title.ilike(f"%{search}%"), BoardPost.content.ilike(f"%{search}%") ) ) # Total count total = query.count() total_pages = math.ceil(total / page_size) if total > 0 else 1 # Pagination posts = query.order_by( desc(BoardPost.is_pinned), desc(BoardPost.created_at) ).offset((page - 1) * page_size).limit(page_size).all() # Format response def format_post(post: BoardPost) -> dict: return { "id": post.id, "title": post.title, "category_id": post.category_id, "category_name": post.category.name if post.category else None, "author_id": post.author_id, "author_name": post.author.name if post.author else None, "is_notice": post.is_notice, "is_pinned": post.is_pinned, "view_count": post.view_count, "created_at": post.created_at } return BoardPostListResponse( posts=[format_post(p) for p in posts], notices=[format_post(n) for n in notices], total=total, page=page, page_size=page_size, total_pages=total_pages ) @router.get("/posts/{post_id}", response_model=BoardPostResponse) def get_post( post_id: int, db: Session = Depends(get_db) ): """게시물 상세 조회 (공개)""" post = db.query(BoardPost).filter( BoardPost.id == post_id, BoardPost.is_published == True ).first() if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found" ) # 조회수 증가 post.view_count += 1 db.commit() db.refresh(post) return post @router.post("/posts", response_model=BoardPostResponse) def create_post( post_data: BoardPostCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """게시물 작성 (회원만)""" # 카테고리 존재 확인 category = db.query(BoardCategory).filter( BoardCategory.id == post_data.category_id, BoardCategory.is_active == True ).first() if not category: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid category" ) # 공지사항은 관리자만 작성 가능 is_notice = post_data.is_notice if is_notice and not current_user.is_admin: is_notice = False # 관리자가 아니면 공지사항 불가 db_post = BoardPost( title=post_data.title, content=post_data.content, category_id=post_data.category_id, author_id=current_user.id, is_notice=is_notice, is_pinned=is_notice # 공지사항은 자동으로 고정 ) db.add(db_post) db.commit() db.refresh(db_post) return db_post @router.put("/posts/{post_id}", response_model=BoardPostResponse) def update_post( post_id: int, post_update: BoardPostUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """게시물 수정 (작성자 또는 관리자)""" db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() if not db_post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found" ) # 권한 체크: 작성자 또는 관리자만 if db_post.author_id != current_user.id and not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to edit this post" ) # 카테고리 변경 시 존재 확인 if post_update.category_id: category = db.query(BoardCategory).filter( BoardCategory.id == post_update.category_id, BoardCategory.is_active == True ).first() if not category: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid category" ) update_data = post_update.model_dump(exclude_unset=True) # 공지사항/고정은 관리자만 변경 가능 if not current_user.is_admin: update_data.pop("is_notice", None) update_data.pop("is_pinned", None) update_data.pop("is_published", None) for key, value in update_data.items(): setattr(db_post, key, value) db.commit() db.refresh(db_post) return db_post @router.delete("/posts/{post_id}") def delete_post( post_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """게시물 삭제 (작성자 또는 관리자)""" db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() if not db_post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found" ) # 권한 체크: 작성자 또는 관리자만 if db_post.author_id != current_user.id and not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to delete this post" ) db.delete(db_post) db.commit() return {"message": "Post deleted successfully"} # ============ Admin Endpoints ============ @router.get("/admin/posts", response_model=BoardPostListResponse) def admin_get_posts( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), category_id: Optional[int] = None, is_notice: Optional[bool] = None, is_published: Optional[bool] = None, search: Optional[str] = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """관리자용 게시물 목록 (모든 게시물 포함)""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" ) query = db.query(BoardPost) if category_id: query = query.filter(BoardPost.category_id == category_id) if is_notice is not None: query = query.filter(BoardPost.is_notice == is_notice) if is_published is not None: query = query.filter(BoardPost.is_published == is_published) if search: query = query.filter( or_( BoardPost.title.ilike(f"%{search}%"), BoardPost.content.ilike(f"%{search}%") ) ) total = query.count() total_pages = math.ceil(total / page_size) if total > 0 else 1 posts = query.order_by( desc(BoardPost.is_notice), desc(BoardPost.is_pinned), desc(BoardPost.created_at) ).offset((page - 1) * page_size).limit(page_size).all() def format_post(post: BoardPost) -> dict: return { "id": post.id, "title": post.title, "category_id": post.category_id, "category_name": post.category.name if post.category else None, "author_id": post.author_id, "author_name": post.author.name if post.author else None, "is_notice": post.is_notice, "is_pinned": post.is_pinned, "view_count": post.view_count, "created_at": post.created_at } # 공지사항 분리 notices = [p for p in posts if p.is_notice] regular = [p for p in posts if not p.is_notice] return BoardPostListResponse( posts=[format_post(p) for p in regular], notices=[format_post(n) for n in notices], total=total, page=page, page_size=page_size, total_pages=total_pages ) @router.put("/admin/posts/{post_id}/toggle-notice") def toggle_notice( post_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """공지사항 토글 (관리자만)""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" ) db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() if not db_post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found" ) db_post.is_notice = not db_post.is_notice db_post.is_pinned = db_post.is_notice # 공지사항이면 자동 고정 db.commit() db.refresh(db_post) return {"is_notice": db_post.is_notice, "is_pinned": db_post.is_pinned} @router.put("/admin/posts/{post_id}/toggle-pin") def toggle_pin( post_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """상단 고정 토글 (관리자만)""" if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" ) db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() if not db_post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found" ) db_post.is_pinned = not db_post.is_pinned db.commit() return {"is_pinned": db_post.is_pinned}