diff --git a/app/api/admin_auth.py b/app/api/admin_auth.py new file mode 100644 index 0000000..bed247b --- /dev/null +++ b/app/api/admin_auth.py @@ -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"} diff --git a/app/api/admin_board.py b/app/api/admin_board.py new file mode 100644 index 0000000..74f2c79 --- /dev/null +++ b/app/api/admin_board.py @@ -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() diff --git a/app/api/admin_members.py b/app/api/admin_members.py new file mode 100644 index 0000000..f55736b --- /dev/null +++ b/app/api/admin_members.py @@ -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 diff --git a/app/api/admin_users.py b/app/api/admin_users.py new file mode 100644 index 0000000..b9d6a82 --- /dev/null +++ b/app/api/admin_users.py @@ -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() diff --git a/app/core/config.py b/app/core/config.py index 512725b..e2ad0fb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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() diff --git a/app/core/security_admin.py b/app/core/security_admin.py new file mode 100644 index 0000000..2e7f66c --- /dev/null +++ b/app/core/security_admin.py @@ -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 diff --git a/app/main.py b/app/main.py index 5007aee..9be9902 100644 --- a/app/main.py +++ b/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(): diff --git a/app/models/__init__.py b/app/models/__init__.py index 5c859e4..0b3c6b6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 \ No newline at end of file +from app.models.friend import Friend, FriendRequest +from app.models.admin_user import AdminUser +from app.models.board import Board, BoardPost, BoardComment \ No newline at end of file diff --git a/app/models/admin_user.py b/app/models/admin_user.py new file mode 100644 index 0000000..7dde015 --- /dev/null +++ b/app/models/admin_user.py @@ -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) diff --git a/app/models/board.py b/app/models/board.py new file mode 100644 index 0000000..1e7a892 --- /dev/null +++ b/app/models/board.py @@ -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) diff --git a/app/schemas/admin_user.py b/app/schemas/admin_user.py new file mode 100644 index 0000000..3404718 --- /dev/null +++ b/app/schemas/admin_user.py @@ -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 diff --git a/app/schemas/board.py b/app/schemas/board.py new file mode 100644 index 0000000..b37bf90 --- /dev/null +++ b/app/schemas/board.py @@ -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 diff --git a/app/scripts/create_admin.py b/app/scripts/create_admin.py new file mode 100644 index 0000000..dc28a73 --- /dev/null +++ b/app/scripts/create_admin.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 1418593..26a3630 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ passlib[bcrypt] python-dotenv pydantic[email] pydantic-settings +email-validator>=2.1