관리자부분 추가

This commit is contained in:
김종호 2025-09-19 16:28:03 +09:00
parent 1e7d742b58
commit 28dfc7ad69
14 changed files with 343 additions and 18 deletions

20
app/api/admin_auth.py Normal file
View File

@ -0,0 +1,20 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security_admin import create_admin_access_token, verify_password
from app.models.admin_user import AdminUser
router = APIRouter()
@router.post("/login")
def admin_login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
# username/password로만 사용 (scope 미사용)
admin = db.query(AdminUser).filter(AdminUser.username == form.username).first()
if not admin or not admin.is_active or not verify_password(form.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_admin_access_token(sub=admin.username, minutes=60)
admin.last_login_at = datetime.utcnow()
db.commit()
return {"access_token": token, "token_type": "bearer"}

27
app/api/admin_board.py Normal file
View File

@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security_admin import get_current_admin
from app.models.board import Board
from app.schemas.board import BoardCreate, BoardOut
from app.models.admin_user import AdminUser
router = APIRouter()
@router.get("", response_model=list[BoardOut])
def list_boards(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
return db.query(Board).order_by(Board.id.desc()).all()
@router.post("", response_model=BoardOut, status_code=201)
def create_board(payload: BoardCreate, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
if db.query(Board).filter(Board.code == payload.code).first():
raise HTTPException(409, "Board code already exists")
b = Board(**payload.model_dump())
db.add(b); db.commit(); db.refresh(b)
return b
@router.delete("/{board_id}", status_code=204)
def delete_board(board_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
b = db.query(Board).get(board_id)
if not b: return
db.delete(b); db.commit()

37
app/api/admin_members.py Normal file
View File

@ -0,0 +1,37 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from app.core.database import get_db
from app.core.security_admin import get_current_admin
from app.core.config import settings
from app.models.user import User # 기존 일반 사용자 모델
router = APIRouter()
class MemberOut(BaseModel):
id: int
username: str
email: EmailStr | None = None
created_at: datetime | None = None
is_active: bool | None = None
last_login_at: datetime | None = None
# Pydantic v2: ORM에서 읽기
model_config = {"from_attributes": True}
@router.get("", response_model=list[MemberOut])
def list_members(db: Session = Depends(get_db), _: User = Depends(get_current_admin)):
users = db.query(User).order_by(User.id.desc()).limit(200).all()
# 모델 필드가 없을 수 있는 값들은 getattr로 안전하게 매핑
result = []
for u in users:
result.append(MemberOut(
id=u.id,
username=getattr(u, "username", ""),
email=getattr(u, "email", None),
created_at=getattr(u, "created_at", None),
is_active=getattr(u, "is_active", None),
last_login_at=getattr(u, "last_login_at", None),
))
return result

50
app/api/admin_users.py Normal file
View File

@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security_admin import get_current_admin, hash_password
from app.models.admin_user import AdminUser
from app.schemas.admin_user import AdminUserCreate, AdminUserUpdate, AdminUserOut
router = APIRouter()
@router.get("", response_model=list[AdminUserOut])
def list_admin_users(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)):
return db.query(AdminUser).order_by(AdminUser.id.desc()).all()
@router.post("", response_model=AdminUserOut, status_code=201)
def create_admin_user(payload: AdminUserCreate, db: Session = Depends(get_db), me: AdminUser = Depends(get_current_admin)):
# 슈퍼관리자만 생성 허용
if not me.is_superadmin:
raise HTTPException(status_code=403, detail="Superadmin required")
if db.query(AdminUser).filter(AdminUser.username == payload.username).first():
raise HTTPException(status_code=409, detail="Username already exists")
user = AdminUser(
username=payload.username,
password_hash=hash_password(payload.password),
name=payload.name,
email=payload.email,
is_superadmin=payload.is_superadmin,
is_active=payload.is_active,
)
db.add(user); db.commit(); db.refresh(user)
return user
@router.patch("/{user_id}", response_model=AdminUserOut)
def update_admin_user(user_id: int, payload: AdminUserUpdate, db: Session = Depends(get_db), me: AdminUser = Depends(get_current_admin)):
user = db.query(AdminUser).get(user_id)
if not user: raise HTTPException(404, "Not found")
if payload.password: user.password_hash = hash_password(payload.password)
for f in ("name","email","is_active","is_superadmin"):
v = getattr(payload, f)
if v is not None:
setattr(user, f, v)
db.commit(); db.refresh(user)
return user
@router.delete("/{user_id}", status_code=204)
def delete_admin_user(user_id: int, db: Session = Depends(get_db), me: AdminUser = Depends(get_current_admin)):
if not me.is_superadmin:
raise HTTPException(403, "Superadmin required")
user = db.query(AdminUser).get(user_id)
if not user: return
db.delete(user); db.commit()

View File

@ -1,6 +1,8 @@
# app/core/config.py
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
database_url: str
@ -8,8 +10,13 @@ class Settings(BaseSettings):
algorithm: str
access_token_expire_minutes: int
class Config:
env_file = ".env"
admin_api_prefix: str = "/admin"
model_config = SettingsConfigDict(
env_file=".env",
env_ignore_case=True,
extra="ignore",
)
settings = Settings()

View File

@ -0,0 +1,38 @@
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.models.admin_user import AdminUser
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme_admin = OAuth2PasswordBearer(tokenUrl=f"{settings.admin_api_prefix}/auth/login")
def verify_password(plain, hashed): return pwd_ctx.verify(plain, hashed)
def hash_password(pw): return pwd_ctx.hash(pw)
def create_admin_access_token(sub: str, minutes: int = 60):
exp = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload = {"sub": sub, "role": "admin", "exp": exp}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def get_current_admin(
token: Annotated[str, Depends(oauth2_scheme_admin)],
db: Annotated[Session, Depends(get_db)],
) -> AdminUser:
cred_exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token")
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("role") != "admin":
raise cred_exc
username = payload.get("sub")
except JWTError:
raise cred_exc
user = db.query(AdminUser).filter(AdminUser.username == username, AdminUser.is_active == True).first()
if not user:
raise cred_exc
return user

View File

@ -1,33 +1,36 @@
#pp/main.py
from app.models import User, Character, HomeworkType, Friend, FriendRequest # 👈 명시적 import!
from app.models import User, Character, HomeworkType, Friend, FriendRequest
from fastapi import FastAPI, Request, Depends
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from app.core.deps import get_current_user
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer
from app.api import admin_auth, admin_users, admin_board
from app.api import admin_members
from app.core.config import settings
import traceback
from app.api import user, auth, character, homework, character_homework, dashboard, friend
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# app = FastAPI(
# title="숙제노기 API",
# description="마비노기 모바일 숙제 관리용 백엔드 API",
# version="0.1.0",
# docs_url="/docs",
# redoc_url=None,
# openapi_url="/openapi.json",
# root_path="/api"
# )
app = FastAPI(
docs_url=None,
title="숙제노기 API",
description="마비노기 모바일 숙제 관리용 백엔드 API",
version="0.1.0",
docs_url="/docs",
redoc_url=None,
openapi_url=None
openapi_url="/openapi.json",
root_path="/api"
)
# app = FastAPI(
# docs_url=None,
# redoc_url=None,
# openapi_url=None
# )
@app.get("/docs", include_in_schema=False)
def custom_docs(user=Depends(get_current_user)):
return get_swagger_ui_html(openapi_url="/openapi.json", title="Sukjenogi API Docs")
@ -58,8 +61,8 @@ origins = [
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
# allow_origins=["*"],
# allow_origins=origins,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -72,6 +75,10 @@ app.include_router(homework.router, prefix="/homeworks", tags=["Homeworks"])
app.include_router(character_homework.router, prefix="/characterHomework", tags=["Character Homeworks"])
app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])
app.include_router(friend.router, prefix="/friends", tags=["Friends"])
app.include_router(admin_auth.router, prefix=f"{settings.admin_api_prefix}/auth", tags=["admin-auth"])
app.include_router(admin_users.router, prefix=f"{settings.admin_api_prefix}/users", tags=["admin-users"])
app.include_router(admin_board.router, prefix=f"{settings.admin_api_prefix}/boards", tags=["admin-boards"])
app.include_router(admin_members.router, prefix=f"{settings.admin_api_prefix}/members",tags=["admin-members"])
@app.get("/")
def read_root():

View File

@ -2,4 +2,6 @@
from app.models.user import User
from app.models.character import Character
from app.models.homework import HomeworkType
from app.models.friend import Friend, FriendRequest
from app.models.friend import Friend, FriendRequest
from app.models.admin_user import AdminUser
from app.models.board import Board, BoardPost, BoardComment

18
app/models/admin_user.py Normal file
View File

@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String, Boolean, DateTime
from app.core.database import Base
class AdminUser(Base):
__tablename__ = "admin_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=True)
is_superadmin: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

41
app/models/board.py Normal file
View File

@ -0,0 +1,41 @@
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Integer, String, Boolean, DateTime, Text, ForeignKey
from app.core.database import Base
class Board(Base):
__tablename__ = "board"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) # 예: "notices", "free"
name: Mapped[str] = mapped_column(String(100), nullable=False) # 예: "공지사항"
description: Mapped[str | None] = mapped_column(String(255))
is_public: Mapped[bool] = mapped_column(Boolean, default=True) # 공개 여부
allow_comment: Mapped[bool] = mapped_column(Boolean, default=True) # 댓글 허용
use_attachment: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class BoardPost(Base):
__tablename__ = "board_post"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
board_id: Mapped[int] = mapped_column(ForeignKey("board.id", ondelete="CASCADE"), index=True, nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # 일반 유저 id(선택)
is_notice: Mapped[bool] = mapped_column(Boolean, default=False)
is_pinned: Mapped[bool] = mapped_column(Boolean, default=False)
view_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class BoardComment(Base):
__tablename__ = "board_comment"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
post_id: Mapped[int] = mapped_column(ForeignKey("board_post.id", ondelete="CASCADE"), index=True, nullable=False)
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

25
app/schemas/admin_user.py Normal file
View File

@ -0,0 +1,25 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
class AdminUserBase(BaseModel):
username: str
name: str
email: EmailStr | None = None
is_superadmin: bool = False
is_active: bool = True
class AdminUserCreate(AdminUserBase):
password: str
class AdminUserUpdate(BaseModel):
name: str | None = None
email: EmailStr | None = None
is_active: bool | None = None
is_superadmin: bool | None = None
password: str | None = None
class AdminUserOut(AdminUserBase):
id: int
last_login_at: datetime | None
created_at: datetime
updated_at: datetime

28
app/schemas/board.py Normal file
View File

@ -0,0 +1,28 @@
from pydantic import BaseModel
from datetime import datetime
class BoardCreate(BaseModel):
code: str
name: str
description: str | None = None
is_public: bool = True
allow_comment: bool = True
use_attachment: bool = True
class BoardOut(BoardCreate):
id: int
created_at: datetime
updated_at: datetime
class PostCreate(BaseModel):
title: str
content: str
is_notice: bool = False
is_pinned: bool = False
class PostOut(PostCreate):
id: int
board_id: int
view_count: int
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,24 @@
from sqlalchemy.orm import Session
from app.core.database import SessionLocal, Base, engine
from app.core.security_admin import hash_password
from app.models.admin_user import AdminUser
def main():
Base.metadata.create_all(bind=engine)
db: Session = SessionLocal()
if not db.query(AdminUser).filter(AdminUser.username=="admin").first():
user = AdminUser(
username="admin",
password_hash=hash_password("admin1234"),
name="관리자",
email=None,
is_superadmin=True,
is_active=True,
)
db.add(user); db.commit()
print("✅ admin / admin1234 created")
else:
print(" admin already exists")
if __name__ == "__main__":
main()

View File

@ -8,3 +8,4 @@ passlib[bcrypt]
python-dotenv
pydantic[email]
pydantic-settings
email-validator>=2.1