diff --git a/app/api/friend.py b/app/api/friend.py new file mode 100644 index 0000000..fecce43 --- /dev/null +++ b/app/api/friend.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.deps import get_db, get_current_user +from app.schemas.friend import FriendRequestCreate, FriendRequestResponse, FriendResponse +from app.schemas.character import CharacterResponse +from app.services import friend_service +from app.models.user import User + +router = APIRouter() + + +@router.post("/request", response_model=FriendRequestResponse) +def send_request( + request_data: FriendRequestCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return friend_service.send_friend_request(db, current_user.id, request_data.to_user_email) + + +@router.get("/requests/received", response_model=list[FriendRequestResponse]) +def get_received_requests( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return friend_service.get_received_requests(db, current_user.id) + + +@router.get("/requests/sent", response_model=list[FriendRequestResponse]) +def get_sent_requests( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return friend_service.get_sent_requests(db, current_user.id) + + +@router.post("/requests/{request_id}/cancel") +def cancel_sent_request( + request_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + friend_service.cancel_sent_request(db, request_id, current_user.id) + return {"detail": "요청을 취소했습니다."} + + +@router.post("/requests/{request_id}/respond") +def respond_to_request( + request_id: int, + accept: bool, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + friend_service.respond_to_request(db, request_id, current_user.id, accept) + return {"detail": "요청을 처리했습니다."} + + +@router.get("/list", response_model=list[int]) +def get_friend_list( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return friend_service.get_friend_list(db, current_user.id) + +@router.get("/{friend_id}/characters", response_model=list[CharacterResponse]) +def get_friend_characters( + friend_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return friend_service.get_public_characters_of_friend(db, current_user.id, friend_id) + +@router.delete("/{friend_id}") +def delete_friend( + friend_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + friend_service.delete_friend(db, current_user.id, friend_id) + return {"detail": "친구가 삭제되었습니다."} diff --git a/app/api/user.py b/app/api/user.py index 68c5084..387dee4 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -1,16 +1,17 @@ # app/api/user.py -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi.logger import logger import traceback import sys from sqlalchemy.orm import Session -from app.schemas.user import UserCreate, UserResponse, PasswordUpdateRequest +from app.schemas.user import UserCreate, UserResponse, PasswordUpdateRequest, UserPublicInfoResponse, UserByCharacterResponse from app.crud.user import create_user from app.models.user import User from app.core.database import SessionLocal from app.core.deps import get_current_user from app.core.security import verify_password, get_password_hash +from app.services import user_service router = APIRouter() @@ -56,4 +57,20 @@ def update_password( except Exception as e: logger.error(f"❌ 비밀번호 변경 중 예외 발생: {e}") traceback.print_exc(file=sys.stdout) # ← 여기가 핵심 - raise \ No newline at end of file + raise + +@router.get("/public-info", response_model=UserPublicInfoResponse) +def get_public_info( + email: str = Query(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + return user_service.get_user_public_info(db, current_user.id, email) + +@router.get("/by-character", response_model=UserByCharacterResponse) +def get_by_character( + server: str, + name: str, + db: Session = Depends(get_db) +): + return user_service.get_user_by_character(db, server, name) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 74e480c..005e751 100644 --- a/app/main.py +++ b/app/main.py @@ -6,38 +6,39 @@ from app.core.deps import get_current_user from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer import traceback +from app.models import user, friend, character, homework -from app.api import user, auth, character, homework, character_homework, dashboard +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.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") - -@app.get("/openapi.json", include_in_schema=False) -def custom_openapi(user=Depends(get_current_user)): - return get_openapi( - title="Sukjenogi API", - version="0.2", - routes=app.routes - ) +# 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") +# +# @app.get("/openapi.json", include_in_schema=False) +# def custom_openapi(user=Depends(get_current_user)): +# return get_openapi( +# title="Sukjenogi API", +# version="0.2", +# routes=app.routes +# ) @app.middleware("http") async def log_exceptions_middleware(request: Request, call_next): @@ -57,7 +58,8 @@ origins = [ app.add_middleware( CORSMiddleware, - allow_origins=origins, + # allow_origins=origins, + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -69,6 +71,7 @@ app.include_router(character.router, prefix="/characters", tags=["Characters"]) 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.get("/") def read_root(): diff --git a/app/models/character.py b/app/models/character.py index 193b175..cbe3475 100644 --- a/app/models/character.py +++ b/app/models/character.py @@ -22,6 +22,8 @@ class Character(Base): order = Column(Integer, default=0) + is_public = Column(Boolean, default=False, nullable=False) + class CharacterHomework(Base): __tablename__ = "character_homeworks" diff --git a/app/models/friend.py b/app/models/friend.py new file mode 100644 index 0000000..bc8ef4d --- /dev/null +++ b/app/models/friend.py @@ -0,0 +1,43 @@ +from sqlalchemy import Column, Integer, Enum, ForeignKey, DateTime, CheckConstraint, UniqueConstraint +from sqlalchemy.orm import relationship +from datetime import datetime +from app.core.database import Base +import enum +from app.models.user import User + +class FriendRequestStatus(enum.Enum): + pending = "pending" + accepted = "accepted" + rejected = "rejected" + cancelled = "cancelled" + + +class FriendRequest(Base): + __tablename__ = "friend_requests" + + id = Column(Integer, primary_key=True, index=True) + from_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + to_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + status = Column(Enum(FriendRequestStatus), default=FriendRequestStatus.pending, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + from_user = relationship(User, foreign_keys=[from_user_id]) + to_user = relationship(User, foreign_keys=[to_user_id]) + + +class Friend(Base): + __tablename__ = "friends" + + id = Column(Integer, primary_key=True, index=True) + user_id_1 = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id_2 = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + __table_args__ = ( + UniqueConstraint("user_id_1", "user_id_2", name="unique_friend_pair"), + CheckConstraint("user_id_1 < user_id_2", name="check_user_order"), + ) + + user1 = relationship(User, foreign_keys=[user_id_1]) + user2 = relationship(User, foreign_keys=[user_id_2]) diff --git a/app/models/homework.py b/app/models/homework.py index e8506b6..0392317 100644 --- a/app/models/homework.py +++ b/app/models/homework.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Time, ForeignKey, DateTime +from sqlalchemy import Column, Integer, String, Time, ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship from datetime import time, datetime @@ -20,3 +20,5 @@ class HomeworkType(Base): assigned_characters = relationship("CharacterHomework", back_populates="homework_type", cascade="all, delete") order = Column(Integer, default=0) + + is_public = Column(Boolean, default=False, nullable=False) diff --git a/app/models/user.py b/app/models/user.py index 8e1984f..fe4e0e9 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,4 +1,5 @@ # app/models/user.py +from pydantic import BaseModel from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -15,4 +16,4 @@ class User(Base): updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) characters = relationship("Character", back_populates="user") - homework_types = relationship("HomeworkType", back_populates="user", cascade="all, delete") + homework_types = relationship("HomeworkType", back_populates="user", cascade="all, delete") \ No newline at end of file diff --git a/app/schemas/friend.py b/app/schemas/friend.py new file mode 100644 index 0000000..59b90b7 --- /dev/null +++ b/app/schemas/friend.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from datetime import datetime +from enum import Enum + + +class FriendRequestStatus(str, Enum): + pending = "pending" + accepted = "accepted" + rejected = "rejected" + cancelled = "cancelled" + + +class FriendRequestCreate(BaseModel): + to_user_email: str + + +class FriendRequestResponse(BaseModel): + id: int + from_user_id: int + to_user_id: int + status: FriendRequestStatus + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class FriendResponse(BaseModel): + id: int + user_id_1: int + user_id_2: int + created_at: datetime + + class Config: + orm_mode = True diff --git a/app/schemas/user.py b/app/schemas/user.py index 1b5605b..86e2f99 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -20,3 +20,19 @@ class PasswordUpdateRequest(BaseModel): class Config: orm_mode = True + + +class UserPublicInfoResponse(BaseModel): + id: int + email: str + is_friend: bool + request_sent: bool + request_received: bool + +class UserByCharacterResponse(BaseModel): + user_id: int + email: str + character_id: int + character_name: str + server: str + is_public: bool \ No newline at end of file diff --git a/app/services/friend_service.py b/app/services/friend_service.py new file mode 100644 index 0000000..466c663 --- /dev/null +++ b/app/services/friend_service.py @@ -0,0 +1,174 @@ +from sqlalchemy.orm import Session +from app.models.friend import FriendRequest, Friend, FriendRequestStatus +from app.models.user import User +from app.models.character import Character, CharacterHomework +from app.models.homework import HomeworkType +from fastapi import HTTPException, status +from datetime import datetime + + +def send_friend_request(db: Session, from_user_id: int, to_user_email: str): + to_user = db.query(User).filter(User.email == to_user_email).first() + if not to_user: + raise HTTPException(status_code=404, detail="해당 이메일의 유저를 찾을 수 없습니다.") + + if to_user.id == from_user_id: + raise HTTPException(status_code=400, detail="자기 자신에게 친구 요청을 보낼 수 없습니다.") + + # 이미 친구인지 확인 + user_ids = sorted([from_user_id, to_user.id]) + existing_friend = db.query(Friend).filter( + Friend.user_id_1 == user_ids[0], + Friend.user_id_2 == user_ids[1] + ).first() + if existing_friend: + raise HTTPException(status_code=400, detail="이미 친구 상태입니다.") + + # 이미 pending 요청이 있는지 확인 + existing_request = db.query(FriendRequest).filter( + FriendRequest.from_user_id == from_user_id, + FriendRequest.to_user_id == to_user.id, + FriendRequest.status == FriendRequestStatus.pending + ).first() + if existing_request: + raise HTTPException(status_code=400, detail="이미 친구 요청을 보낸 상태입니다.") + + friend_request = FriendRequest( + from_user_id=from_user_id, + to_user_id=to_user.id, + ) + db.add(friend_request) + db.commit() + db.refresh(friend_request) + return friend_request + + +def get_received_requests(db: Session, user_id: int): + return db.query(FriendRequest).filter( + FriendRequest.to_user_id == user_id, + FriendRequest.status == FriendRequestStatus.pending + ).all() + + +def get_sent_requests(db: Session, user_id: int): + return db.query(FriendRequest).filter( + FriendRequest.from_user_id == user_id, + FriendRequest.status == FriendRequestStatus.pending + ).all() + + +def cancel_sent_request(db: Session, request_id: int, user_id: int): + request = db.query(FriendRequest).filter( + FriendRequest.id == request_id, + FriendRequest.from_user_id == user_id + ).first() + + if not request: + raise HTTPException(status_code=404, detail="요청을 찾을 수 없습니다.") + + request.status = FriendRequestStatus.cancelled + request.updated_at = datetime.utcnow() + db.commit() + + +def respond_to_request(db: Session, request_id: int, user_id: int, accept: bool): + request = db.query(FriendRequest).filter( + FriendRequest.id == request_id, + FriendRequest.to_user_id == user_id, + FriendRequest.status == FriendRequestStatus.pending + ).first() + + if not request: + raise HTTPException(status_code=404, detail="요청을 찾을 수 없습니다.") + + request.status = FriendRequestStatus.accepted if accept else FriendRequestStatus.rejected + request.updated_at = datetime.utcnow() + + # 친구 수락 시 friends 테이블에도 저장 + if accept: + user_ids = sorted([request.from_user_id, request.to_user_id]) + friend = Friend( + user_id_1=user_ids[0], + user_id_2=user_ids[1] + ) + db.add(friend) + + db.commit() + + +def get_friend_list(db: Session, user_id: int): + friends = db.query(Friend).filter( + (Friend.user_id_1 == user_id) | (Friend.user_id_2 == user_id) + ).all() + + result = [] + for f in friends: + friend_id = f.user_id_2 if f.user_id_1 == user_id else f.user_id_1 + result.append(friend_id) + + return result + +def get_public_characters_of_friend(db: Session, current_user_id: int, friend_id: int): + # 친구 관계 확인 + user_ids = sorted([current_user_id, friend_id]) + is_friend = db.query(Friend).filter( + Friend.user_id_1 == user_ids[0], + Friend.user_id_2 == user_ids[1] + ).first() + + if not is_friend: + raise HTTPException(status_code=403, detail="친구가 아닙니다.") + + # 공개된 캐릭터 목록 + characters = db.query(Character).filter( + Character.user_id == friend_id, + Character.is_public == True + ).all() + + return characters + +def get_public_homeworks_of_friend_character( + db: Session, + current_user_id: int, + friend_id: int, + character_id: int +): + # 1. 친구인지 확인 + user_ids = sorted([current_user_id, friend_id]) + is_friend = db.query(Friend).filter( + Friend.user_id_1 == user_ids[0], + Friend.user_id_2 == user_ids[1] + ).first() + if not is_friend: + raise HTTPException(status_code=403, detail="친구가 아닙니다.") + + # 2. 해당 캐릭터가 공개인지 확인 + character = db.query(Character).filter( + Character.id == character_id, + Character.user_id == friend_id, + Character.is_public == True + ).first() + if not character: + raise HTTPException(status_code=404, detail="공개된 캐릭터를 찾을 수 없습니다.") + + # 3. 공개된 숙제만 조회 + results = db.query(HomeworkType).join(CharacterHomework).filter( + CharacterHomework.character_id == character_id, + HomeworkType.is_public == True + ).all() + + return results + +def delete_friend(db: Session, user_id: int, friend_id: int): + user_ids = sorted([user_id, friend_id]) + + friend = db.query(Friend).filter( + Friend.user_id_1 == user_ids[0], + Friend.user_id_2 == user_ids[1] + ).first() + + if not friend: + raise HTTPException(status_code=404, detail="친구 관계가 존재하지 않습니다.") + + db.delete(friend) + db.commit() diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..f989084 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,68 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.models.user import User +from app.models.friend import Friend, FriendRequest, FriendRequestStatus +from app.models.character import Character + +def get_user_public_info( + db: Session, + current_user_id: int, + target_email: str +): + target_user = db.query(User).filter(User.email == target_email).first() + if not target_user: + raise HTTPException(status_code=404, detail="해당 유저를 찾을 수 없습니다.") + + if target_user.id == current_user_id: + raise HTTPException(status_code=400, detail="자기 자신은 검색할 수 없습니다.") + + user_ids = sorted([current_user_id, target_user.id]) + is_friend = db.query(Friend).filter( + Friend.user_id_1 == user_ids[0], + Friend.user_id_2 == user_ids[1] + ).first() is not None + + request_sent = db.query(FriendRequest).filter( + FriendRequest.from_user_id == current_user_id, + FriendRequest.to_user_id == target_user.id, + FriendRequest.status == FriendRequestStatus.pending + ).first() is not None + + request_received = db.query(FriendRequest).filter( + FriendRequest.from_user_id == target_user.id, + FriendRequest.to_user_id == current_user_id, + FriendRequest.status == FriendRequestStatus.pending + ).first() is not None + + return { + "id": target_user.id, + "email": target_user.email, + "is_friend": is_friend, + "request_sent": request_sent, + "request_received": request_received, + } + +def get_user_by_character( + db: Session, + server: str, + name: str +): + character = db.query(Character).filter( + Character.server == server, + Character.name == name + ).first() + + if not character: + raise HTTPException(status_code=404, detail="캐릭터를 찾을 수 없습니다.") + + user = db.query(User).filter(User.id == character.user_id).first() + + return { + "user_id": user.id, + "email": user.email, + "character_id": character.id, + "character_name": character.name, + "server": character.server, + "is_public": character.is_public + } \ No newline at end of file