- Add BoardCategory and BoardPost models with multi-language support - Add bulletin API endpoints (CRUD, notice toggle, pin toggle) - Add board_enabled setting to control menu visibility - Create frontend board pages (list, detail, write, edit) - Create admin board management and category management pages - Update Header.tsx with conditional Board menu between Inquiry and Contact Us - Update admin settings with board_enabled toggle - Add Board menu to admin sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
525 lines
16 KiB
Python
525 lines
16 KiB
Python
"""
|
|
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}
|