feat: Add bulletin board system

- 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>
This commit is contained in:
AutonetSellCar Deploy
2026-01-10 01:34:41 +09:00
parent 04bec0d2c7
commit e0c1f4540b
17 changed files with 2630 additions and 2 deletions

524
backend/app/api/bulletin.py Normal file
View File

@@ -0,0 +1,524 @@
"""
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}