관리자부분 추가
This commit is contained in:
parent
1e7d742b58
commit
28dfc7ad69
20
app/api/admin_auth.py
Normal file
20
app/api/admin_auth.py
Normal 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
27
app/api/admin_board.py
Normal 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
37
app/api/admin_members.py
Normal 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
50
app/api/admin_users.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
|
||||
38
app/core/security_admin.py
Normal file
38
app/core/security_admin.py
Normal 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
|
||||
37
app/main.py
37
app/main.py
@ -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():
|
||||
|
||||
@ -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
18
app/models/admin_user.py
Normal 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
41
app/models/board.py
Normal 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
25
app/schemas/admin_user.py
Normal 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
28
app/schemas/board.py
Normal 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
|
||||
24
app/scripts/create_admin.py
Normal file
24
app/scripts/create_admin.py
Normal 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()
|
||||
@ -8,3 +8,4 @@ passlib[bcrypt]
|
||||
python-dotenv
|
||||
pydantic[email]
|
||||
pydantic-settings
|
||||
email-validator>=2.1
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user