diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 09d40a6..f804cf3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,41 +1,151 @@ -name: Code Quality Checks +name: CI on: push: - branches: - - develop - pull_request: -jobs: - ci: - runs-on: ubuntu-latest +jobs: + static-analysis: # mypy, black, ruff 등 정적 분석 + runs-on: ubuntu-22.04 # 실제 프로덕션에서는 모든 버전을 고정하는 것이 좋다. + # 예기치 못하게 버전이 올라가서 장애나는 것을 막기 위해 steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Check out the codes + uses: actions/checkout@v2 + + - name: Setup python environment + id: setup-python + uses: actions/setup-python@v2 + with: + python-version: "3.12" - - name: Set up Python - uses: actions/setup-python@v4 + - name: Cache Poetry + id: cache-poetry + uses: actions/cache@v4 with: - python-version: '3.12' + key: poetry-1.8.5 + path: ~/.local/ # poetry 는 ~/.local 에 설치되므로, 이 디렉터리를 통째로 캐시할 것입니다. - name: Install Poetry + if: steps.cache-poetry.outputs.cache-hit != 'true' run: | - curl -sSL https://install.python-poetry.org | python3 - - echo "${HOME}/.local/bin" >> $GITHUB_PATH + curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5 + + - name: Register Poetry bin + run: echo "${HOME}/.poetry/bin" >> $GITHUB_PATH + + - name: Cache dependencies + id: cache-venv + uses: actions/cache@v4 + with: + key: python-${{ steps.setup-python.outputs.python-version }}-poetry-lock-${{ hashFiles('poetry.lock') }}-toml-${{ hashFiles('pyproject.toml') }}-poetry-1.8.5 + path: /home/runner/.cache/pypoetry/virtualenvs/ + + - name: Install dependencies + if: steps.cache-venv.outputs.cache-hit != 'true' + run: poetry install --no-root + + - name: Run Black + run: poetry run black . --check + + - name: Run Ruff + run: | + poetry run ruff check --select I + poetry run ruff check + + - name: Run Mypy + run: poetry run mypy . + + test: # 전체 테스트 실행한다. + runs-on: ubuntu-22.04 + + services: + redis: + image: redis:7.2-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: 3306 + MYSQL_USER: root + MYSQL_PASSWORD: password + MYSQL_DATABASE: tellingme_local + REDIS_HOST: localhost - - name: Install Packages & Libraries + steps: + - name: Check out the codes + uses: actions/checkout@v2 + + - name: Setup python environment + id: setup-python + uses: actions/setup-python@v2 + with: + python-version: "3.12" + + - name: Cache Poetry + id: cache-poetry + uses: actions/cache@v4 + with: + key: poetry-1.8.5 + path: ~/.local/ # poetry 는 ~/.local 에 설치되므로, 이 디렉터리를 통째로 캐시할 것입니다. + + - name: Install Poetry + if: steps.cache-poetry.outputs.cache-hit != 'true' run: | - poetry install + curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5 + + - name: Register Poetry bin + run: echo "${HOME}/.poetry/bin" >> $GITHUB_PATH - - name: Run isort (Import sorting) + - name: Cache dependencies + id: cache-venv + uses: actions/cache@v4 + with: + key: python-${{ steps.setup-python.outputs.python-version }}-poetry-lock-${{ hashFiles('poetry.lock') }}-toml-${{ hashFiles('pyproject.toml') }}-poetry-1.8.5 + path: /home/runner/.cache/pypoetry/virtualenvs/ + + - name: Install dependencies + if: steps.cache-venv.outputs.cache-hit != 'true' + run: poetry install --no-root + + - name: Set timezone to KST run: | - poetry run isort . --check --diff + sudo rm /etc/localtime + sudo ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime - - name: Run black (Code formatting) + - name: Start Mysql run: | - poetry run black . --check + sudo systemctl start mysql + mysql -e "use mysql; FLUSH PRIVILEGES; ALTER USER '${{ env.MYSQL_USER }}'@'localhost' IDENTIFIED BY '${{ env.MYSQL_PASSWORD }}';" -uroot -proot + mysql -e 'CREATE DATABASE ${{ env.MYSQL_DATABASE }};' -u${{ env.MYSQL_USER }} -p${{ env.MYSQL_PASSWORD }} - - name: Run Mypy + - name: Run tests run: | - poetry run mypy . + poetry run coverage run -m pytest . + poetry run coverage report -m + + +# deploy: +# runs-on: ubuntu-24.04 +# needs: [test, static-analysis] +# if: github.ref == 'refs/heads/main' +# steps: +# - name: Check out the codes +# uses: actions/checkout@v3 +# +# - name: deploy staging +# env: +# PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY_STAGING }} +# HOSTNAME: ${{ secrets.SSH_HOST_STAGING }} +# USER_NAME: ${{ secrets.USER_NAME_STAGING }} +# +# # staging 서버의 .bashrc 에 gunicorn_reload 가 정의되어 있습니다. gunicorn master 에게 HUP 를 줘서 worker 를 재시작합니다. +# run: | +# echo "$PRIVATE_KEY" > private_key && chmod 600 private_key +# ssh -o StrictHostKeyChecking=no -t -i private_key ${USER_NAME}@${HOSTNAME} "bash -i -c 'gunicorn_reload'" + +# todo : CD 작성하기 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2733f73..249a9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,151 @@ +/etc +/htmlcov +/uploads + +# Ignore .env.local files +.env +.env.* + +# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,windows,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,macos,windows,linux + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -57,6 +205,7 @@ cover/ # Django stuff: *.log +*.log.* local_settings.py db.sqlite3 db.sqlite3-journal @@ -106,10 +255,8 @@ ipython_config.py #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +# https://pdm.fming.dev/#use-with-ide .pdm.toml -.pdm-python -.pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -122,7 +269,7 @@ celerybeat.pid *.sage.py # Environments -src/.env.local +.env.* .venv env/ venv/ @@ -159,4 +306,47 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,windows,linux +/app/.env.local + +# JetBrains 관련 설정 파일 무시 .idea/ +/app/migrations/ diff --git a/Dockerfile b/Dockerfile index ff0034c..126f0d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,52 +1,17 @@ -# 1. 기본 Python 이미지를 지정합니다. -FROM python:3.12-slim +# Redis 공식 이미지 사용 +FROM redis:7.2-alpine -# 2. 작업 디렉토리를 설정합니다. -WORKDIR /app -ENV PYTHONPATH="/app/src" +# 포트 오픈 +EXPOSE 6379 -# 3. 필수 패키지를 설치합니다. -RUN apt-get update && apt-get install -y \ - curl \ - git \ - build-essential \ - libssl-dev \ - zlib1g-dev \ - libbz2-dev \ - libreadline-dev \ - libsqlite3-dev \ - wget \ - llvm \ - libncurses5-dev \ - libncursesw5-dev \ - xz-utils \ - tk-dev \ - libffi-dev \ - liblzma-dev \ - python3-openssl \ - default-libmysqlclient-dev \ - libmariadb-dev-compat \ - pkg-config \ - && rm -rf /var/lib/apt/lists/* -# 4. Poetry 설치 -RUN curl -sSL https://install.python-poetry.org | python3 - +# 기본 명령 실행 (기본 설정 사용 시) +CMD ["redis-server"] +# 커스텀 설정 사용 시 아래 명령어 사용 +# CMD ["redis-server", "/usr/local/etc/redis/redis.conf"] -# 5. Poetry 환경 설정 -ENV PATH="/root/.local/bin:$PATH" +# 빌드 명령어 +# docker build -t my-redis . -# 6. 프로젝트 파일 복사 및 의존성 설치 -COPY pyproject.toml poetry.lock /app/ -RUN /bin/bash -c "source ~/.bashrc" -RUN /bin/bash -c "poetry config virtualenvs.create false" -RUN /bin/bash -c "poetry install --no-root" - -# 7. 프로젝트 소스 코드 복사 -COPY . /app - -# 8. ENTRYPOINT 설정 -RUN chmod +x ./scripts/start_app.sh -ENTRYPOINT ["/bin/bash", "./scripts/start_app.sh"] - -# 9. Gunicorn이 8000 포트에서 수신하도록 EXPOSE -EXPOSE 8000 \ No newline at end of file +# 컨테이너 실행 명령어 +# docker run -d -p 6379:6379 --name redis-server my-redis diff --git a/src/main.py b/app/__init__.py similarity index 69% rename from src/main.py rename to app/__init__.py index 7c23be5..b1d6d96 100644 --- a/src/main.py +++ b/app/__init__.py @@ -2,7 +2,7 @@ from fastapi import FastAPI -from common.post_construct import post_construct +from app.common.post_construct import post_construct logging.basicConfig(level=logging.DEBUG) @@ -16,9 +16,3 @@ @app.get("/health_check") def health_check() -> dict[str, str]: return {"message": "Hello World"} - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/app/__init__.py b/app/apis/__init__.py similarity index 100% rename from src/app/__init__.py rename to app/apis/__init__.py diff --git a/src/app/v2/__init__.py b/app/apis/v2/__init__.py similarity index 100% rename from src/app/v2/__init__.py rename to app/apis/v2/__init__.py diff --git a/app/apis/v2/badge_router.py b/app/apis/v2/badge_router.py new file mode 100644 index 0000000..a2ce83d --- /dev/null +++ b/app/apis/v2/badge_router.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, status + +from app.dtos.badge.badges_response import BadgesResponse +from app.services.badge_service import BadgeService + +badge_router = APIRouter(prefix="/user/badge", tags=["Badge"]) + + +@badge_router.get( + "", + response_model=BadgesResponse, + status_code=status.HTTP_200_OK, +) +async def api_get_user_badges(user_id: str) -> BadgesResponse: + return BadgesResponse( + code=status.HTTP_200_OK, + message="보유 뱃지 정보 조회", + data=await BadgeService.get_badges_with_details_by_user_id(user_id), + ) diff --git a/app/apis/v2/cheese_router.py b/app/apis/v2/cheese_router.py new file mode 100644 index 0000000..b7922eb --- /dev/null +++ b/app/apis/v2/cheese_router.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, status + +from app.dtos.cheese.cheese_response import CheeseResponse, TotalCheeseAmount +from app.models.cheese_manager import CheeseManager +from app.services.user_service import UserService + +cheese_router = APIRouter(prefix="/cheese", tags=["Cheese"]) + + +@cheese_router.get("", response_model=CheeseResponse, status_code=status.HTTP_200_OK) +async def api_get_cheese_balance(user_id: str) -> CheeseResponse: + user = await UserService.get_user_info(user_id=user_id) + cheese_amount = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=user.cheese_manager_id) + return CheeseResponse( + code=status.HTTP_200_OK, + message="총 치즈 갯수 조회", + data=TotalCheeseAmount(cheeseBalance=cheese_amount), + ) diff --git a/app/apis/v2/color_router.py b/app/apis/v2/color_router.py new file mode 100644 index 0000000..a4f1aaf --- /dev/null +++ b/app/apis/v2/color_router.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, status + +from app.dtos.color.colors_response import ColorsResponse +from app.services.color_service import ColorService + +color_router = APIRouter(prefix="/user/color", tags=["Color"]) + + +@color_router.get( + "", + response_model=ColorsResponse, + status_code=status.HTTP_200_OK, +) +async def api_get_user_colors(user_id: str) -> ColorsResponse: + return ColorsResponse( + code=status.HTTP_200_OK, + message="보유 색상 정보 조회", + data=await ColorService.get_colors_with_details_by_user_id(user_id=user_id), + ) diff --git a/app/apis/v2/emotion_router.py b/app/apis/v2/emotion_router.py new file mode 100644 index 0000000..b0e609d --- /dev/null +++ b/app/apis/v2/emotion_router.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, status + +from app.dtos.emotion.emotions_response import EmotionsResponse +from app.services.emotion_service import EmotionService + +emotion_router = APIRouter(prefix="/user/emotion", tags=["Emotion"]) + + +@emotion_router.get( + "", + response_model=EmotionsResponse, + status_code=status.HTTP_200_OK, +) +async def api_get_user_emotions(user_id: str) -> EmotionsResponse: + return EmotionsResponse( + data=await EmotionService.mapping_emotion_list(user_id=user_id), + code=status.HTTP_200_OK, + message="보유 감정 정보 조회", + ) diff --git a/src/app/v2/missions/router.py b/app/apis/v2/mission_router.py similarity index 57% rename from src/app/v2/missions/router.py rename to app/apis/v2/mission_router.py index da468cd..fb4648f 100644 --- a/src/app/v2/missions/router.py +++ b/app/apis/v2/mission_router.py @@ -2,18 +2,18 @@ from fastapi import APIRouter, Depends -from app.v2.missions.services.mission_service import MissionService -from core.configs.celery_settings import process_mission_in_background +from app.core.configs.celery_settings import process_mission_in_background +from app.services.mission_service import MissionService -router = APIRouter(prefix="/mission", tags=["Mission"]) +mission_router = APIRouter(prefix="/mission", tags=["Mission"]) -@router.get("") +@mission_router.get("") async def mission_handler(user_id: str) -> None: process_mission_in_background.delay(user_id) -@router.get("/direct") +@mission_router.get("/check") async def mission_handler_direct( user_id: str, mission_service: MissionService = Depends(), @@ -24,3 +24,6 @@ async def mission_handler_direct( "message": "success", "data": True, } + + +# todo : mission refactoring 및 purchase 삭제 및 테스트 코드 작성 diff --git a/app/apis/v2/mobile_router.py b/app/apis/v2/mobile_router.py new file mode 100644 index 0000000..5cded0d --- /dev/null +++ b/app/apis/v2/mobile_router.py @@ -0,0 +1,85 @@ +import asyncio + +from fastapi import APIRouter, status + +from app.dtos.mobile.data_dto import DataDTO +from app.dtos.mobile.mypage_response import MyPageResponse +from app.dtos.mobile.teller_card_response import TellerCardResponse +from app.dtos.mobile.user_profile_with_level_dto import UserProfileWithLevelDTO +from app.dtos.user.user_info_dto import UserInfoDTO +from app.dtos.user.user_profile_dto import UserProfileDTO +from app.models.cheese_manager import CheeseManager +from app.services.answer_service import AnswerService +from app.services.badge_service import BadgeService +from app.services.color_service import ColorService +from app.services.level_service import LevelService +from app.services.teller_card_service import TellerCardService +from app.services.user_service import UserService + +mobile_router = APIRouter(prefix="/mobiles", tags=["모바일 화면용 컨트롤러"]) + + +@mobile_router.get( + "/tellercard", + response_model=TellerCardResponse, + status_code=status.HTTP_200_OK, +) +async def mobile_teller_card_handler(user_id: str) -> TellerCardResponse: + + badges_task = BadgeService.get_badges_with_details_by_user_id(user_id) + colors_task = ColorService.get_colors_with_details_by_user_id(user_id) + level_info_task = LevelService.get_level_info_add_answer_days(user_id) + teller_card_task = TellerCardService.get_teller_card(user_id) + user_info_task = UserService.get_user_info(user_id) + record_answer_task = AnswerService.get_answer_record(user_id) + + badges, colors, level_info, teller_card, user, record_count = await asyncio.gather( + badges_task, colors_task, level_info_task, teller_card_task, user_info_task, record_answer_task + ) + cheese_amount = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=user.cheese_manager_id) + user_info = UserInfoDTO(nickname=user.nickname, cheeseBalance=cheese_amount, tellerCard=teller_card) + + data = DataDTO(badges=badges, colors=colors, userInfo=user_info, levelInfo=level_info, recordCount=record_count) + + return TellerCardResponse( + code=status.HTTP_200_OK, + data=data, + message="teller_card ui page", + ) + + +@mobile_router.get( + "/mypage", + response_model=MyPageResponse, + status_code=status.HTTP_200_OK, +) +async def mobile_my_page_handler(user_id: str) -> MyPageResponse: + + user, answer_count, badge_count, teller_card, level = await asyncio.gather( + UserService.get_user_profile(user_id=user_id), + AnswerService.get_answer_count(user_id=user_id), + BadgeService.get_badge_count(user_id=user_id), + TellerCardService.get_teller_card(user_id=user_id), + LevelService.get_level_info_add_answer_days(user_id), + ) + + cheese_amount = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=user.cheese_manager_id) + + user_profile_data = UserProfileWithLevelDTO( + userProfile=UserProfileDTO( + nickname=user.nickname, + cheeseBalance=cheese_amount, + badgeCode=teller_card.badgeCode, + badgeCount=badge_count, + answerCount=answer_count, + premium=user.is_premium, + allowNotification=user.allow_notification, + ), + level=level, + ) + + return MyPageResponse( + code=status.HTTP_200_OK, + message="mypage ui page", + data=user_profile_data, + ) diff --git a/app/apis/v2/payment_router.py b/app/apis/v2/payment_router.py new file mode 100644 index 0000000..a6196c9 --- /dev/null +++ b/app/apis/v2/payment_router.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, status + +from app.dtos.payment.payment_request import PaymentRequest +from app.dtos.payment.payment_response import PaymentResponse, ProductDTO +from app.services.payment_service import PaymentService + +payment_router = APIRouter(prefix="/payment", tags=["Payment"]) + + +@payment_router.post( + "", + response_model=PaymentResponse, + status_code=status.HTTP_200_OK, +) +async def process_payment(payment_request: PaymentRequest) -> PaymentResponse: + return PaymentResponse( + code=status.HTTP_200_OK, + data=ProductDTO( + product_code=await PaymentService.process_cheese_payment( + product_code=payment_request.productCode, user_id=payment_request.user_id + ), + ), + message="Payment successful", + ) diff --git a/app/apis/v2/purchase_router.py b/app/apis/v2/purchase_router.py new file mode 100644 index 0000000..0e5e188 --- /dev/null +++ b/app/apis/v2/purchase_router.py @@ -0,0 +1,50 @@ +# from typing import Any +# +# from fastapi import APIRouter, Depends, status +# +# from app.dtos.purchase.purchase_dto import PurchaseResponseDTO +# from app.dtos.purchase.requests import ReceiptRequestDTO +# from app.services.purchase_service import PurchaseService +# +# purchase_router = APIRouter(prefix="/purchase", tags=["Purchase"]) +# +# +# @purchase_router.post( +# "/apple", +# status_code=status.HTTP_200_OK, +# response_model=PurchaseResponseDTO, +# summary="apple 결제 api", +# description="apple 결제 api", +# ) +# async def process_receipt( +# receipt: ReceiptRequestDTO, +# purchase_service: PurchaseService = Depends(), +# ) -> PurchaseResponseDTO: +# return await purchase_service.process_apple_purchase(receipt_data=receipt.receiptData, user_id=receipt.user_id) +# +# +# @purchase_router.post("/receipt-test") +# async def receipt_test( +# receipt: ReceiptRequestDTO, +# purchase_service: PurchaseService = Depends(), +# ) -> dict[str, Any]: +# data = await purchase_service._validate_apple_receipt(receipt_data=receipt.receiptData) +# return { +# "code": 200, +# "data": data, +# "message": "정상처리되었습니다", +# } +# +# +# @purchase_router.get("/renew-test") +# async def renew_test( +# purchase_service: PurchaseService = Depends(), +# ) -> None: +# return await purchase_service.process_subscriptions_renewal() +# +# +# @purchase_router.get("/expired-test") +# async def expired_test( +# purchase_service: PurchaseService = Depends(), +# ) -> None: +# await purchase_service.expire_subscriptions() diff --git a/app/apis/v2/teller_card_router.py b/app/apis/v2/teller_card_router.py new file mode 100644 index 0000000..9d72985 --- /dev/null +++ b/app/apis/v2/teller_card_router.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, status + +from app.dtos.teller_card.teller_card_request import TellerCardRequest +from app.dtos.teller_card.teller_card_response import TellerCardResponse +from app.services.teller_card_service import TellerCardService + +teller_card_router = APIRouter(prefix="/tellercard", tags=["TellerCard"]) + + +@teller_card_router.post( + "", + response_model=TellerCardResponse, + status_code=status.HTTP_200_OK, +) +async def patch_teller_card_handler( + teller_card_request: TellerCardRequest, +) -> TellerCardResponse: + return TellerCardResponse( + code=status.HTTP_200_OK, + message="success", + data=await TellerCardService.patch_teller_card( + user_id=teller_card_request.user_id, + badge_code=teller_card_request.badgeCode, + color_code=teller_card_request.colorCode, + ), + ) diff --git a/app/celery_worker.py b/app/celery_worker.py new file mode 100644 index 0000000..7972d69 --- /dev/null +++ b/app/celery_worker.py @@ -0,0 +1,3 @@ +from app.core.configs.celery_settings import celery_app + +__all__ = ("celery_app",) diff --git a/src/app/v2/answers/__init__.py b/app/common/__init__.py similarity index 100% rename from src/app/v2/answers/__init__.py rename to app/common/__init__.py diff --git a/src/app/v2/answers/dtos/__init__.py b/app/common/constants/__init__.py similarity index 100% rename from src/app/v2/answers/dtos/__init__.py rename to app/common/constants/__init__.py diff --git a/app/common/constants/badge_code_list.py b/app/common/constants/badge_code_list.py new file mode 100644 index 0000000..68828da --- /dev/null +++ b/app/common/constants/badge_code_list.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class BadgeCodeList(str, Enum): + AGAIN_001 = "BG_AGAIN_001" + CHRISTMAS_2024 = "BG_CHRISTMAS_2024" + FIRST = "BG_FIRST" + MUCH_001 = "BG_MUCH_001" + NEW = "BG_NEW" + NIGHT_001 = "BG_NIGHT_001" + SAVE_001 = "BG_SAVE_001" diff --git a/src/app/v2/cheese_managers/models/cheese_status.py b/app/common/constants/cheese_status.py similarity index 100% rename from src/app/v2/cheese_managers/models/cheese_status.py rename to app/common/constants/cheese_status.py diff --git a/app/common/constants/color_code_list.py b/app/common/constants/color_code_list.py new file mode 100644 index 0000000..a50b1e1 --- /dev/null +++ b/app/common/constants/color_code_list.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class ColorCodeList(str, Enum): + CL_BLUE_001 = "CL_BLUE_001" + CL_DEFAULT = "CL_DEFAULT" + CL_GREEN_001 = "CL_GREEN_001" + CL_NAVY_001 = "CL_NAVY_001" + CL_ORANGE_001 = "CL_ORANGE_001" + CL_PINK_001 = "CL_PINK_001" + CL_PURPLE_001 = "CL_PURPLE_001" + CL_RED_001 = "CL_RED_001" + CL_YELLOW_001 = "CL_YELLOW_001" diff --git a/app/common/constants/emotion_dict.py b/app/common/constants/emotion_dict.py new file mode 100644 index 0000000..3ca72a0 --- /dev/null +++ b/app/common/constants/emotion_dict.py @@ -0,0 +1,14 @@ +EMOTION_DICT = { + "EM_HAPPY": 1, + "EM_PROUD": 2, + "EM_OKAY": 3, + "EM_TIRED": 4, + "EM_SAD": 5, + "EM_ANGRY": 6, + "EM_EXCITED": 7, + "EM_FUN": 8, + "EM_RELAXED": 9, + "EM_APATHETIC": 10, + "EM_LONELY": 11, + "EM_COMPLEX": 12, +} diff --git a/app/common/constants/item_category.py b/app/common/constants/item_category.py new file mode 100644 index 0000000..eafea5c --- /dev/null +++ b/app/common/constants/item_category.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class ItemCategory(str, Enum): + BADGE = "BADGE" + COLOR = "COLOR" + EMOTION = "EMOTION" + SUBSCRIPTION = "SUBSCRIPTION" + CHEESE = "CHEESE" + POINT = "POINT" diff --git a/app/common/constants/mission_condition.py b/app/common/constants/mission_condition.py new file mode 100644 index 0000000..991839a --- /dev/null +++ b/app/common/constants/mission_condition.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class MS(str, Enum): + BADGE = "MS_BADGE" + BADGE_POST_FIRST = "MS_BADGE_POST_FIRST" + BADGE_POST_280_CHAR = "MS_BADGE_POST_280_CHAR" + BADGE_POST_CONSECUTIVE_7 = "MS_BADGE_POST_CONSECUTIVE_7" + BADGE_POST_EARLY_3 = "MS_BADGE_POST_EARLY_3" + BADGE_CHEESE_TOTAL_50 = "MS_BADGE_CHEESE_TOTAL_50" + BADGE_CHRISTMAS = "MS_BADGE_CHRISTMAS" + + DAILY = "MS_DAILY" + DAILY_LIKE_3_PER_DAY = "MS_DAILY_LIKE_3_PER_DAY" + DAILY_POST_GENERAL = "MS_DAILY_POST_GENERAL" + + LV_UP = "MS_LV_UP" diff --git a/app/common/constants/product_code_list.py b/app/common/constants/product_code_list.py new file mode 100644 index 0000000..504da32 --- /dev/null +++ b/app/common/constants/product_code_list.py @@ -0,0 +1,21 @@ +from enum import Enum + + +class ProductCodeList(str, Enum): + PD_CL_PURPLE_001 = "PD_CL_PURPLE_001" + PD_CL_NAVY_001 = "PD_CL_NAVY_001" + PD_CL_PINK_001 = "PD_CL_PINK_001" + PD_CL_YELLOW_001 = "PD_CL_YELLOW_001" + PD_CL_GREEN_001 = "PD_CL_GREEN_001" + PD_EM_EXCITED = "PD_EM_EXCITED" + PD_EM_FUN = "PD_EM_FUN" + PD_EM_RELAXED = "PD_EM_RELAXED" + PD_EM_APATHETIC = "PD_EM_APATHETIC" + PD_EM_LONELY = "PD_EM_LONELY" + PD_EM_COMPLEX = "PD_EM_COMPLEX" + PD_PLUS_MONTH_1_KR = "PD_PLUS_MONTH_1_KR" + PD_PLUS_YEAR_1_KR = "PD_PLUS_YEAR_1_KR" + + # 뱃지 구매 테스트용 + PD_BG_CHRISTMAS_2024 = "PD_BG_CHRISTMAS_2024" + PD_TEST = "PD_TEST" diff --git a/app/common/constants/purchase_status.py b/app/common/constants/purchase_status.py new file mode 100644 index 0000000..3f071e4 --- /dev/null +++ b/app/common/constants/purchase_status.py @@ -0,0 +1,21 @@ +# from enum import Enum +# +# +# class PurchaseStatus(Enum): +# AVAILABLE = "AVAILABLE" +# CONSUMED = "CONSUMED" +# EXPIRED = "EXPIRED" +# REFUNDED = "REFUNDED" +# CANCELED = "CANCELED" +# +# +# class SubscriptionStatus(Enum): +# ACTIVE = "ACTIVE" +# EXPIRED = "EXPIRED" +# CANCELED = "CANCELED" +# +# +# purchase_mapping = { +# "tellingme.plus.oneMonth": "PD_PLUS_MONTH_1_KR", +# "tellingme.plus.oneYear": "PD_PLUS_YEAR_1_KR", +# } diff --git a/app/common/constants/reward_type.py b/app/common/constants/reward_type.py new file mode 100644 index 0000000..abb8e0d --- /dev/null +++ b/app/common/constants/reward_type.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class RewardType(str, Enum): + DAILY_MISSION = "DAILY_MISSION" + LEVEL_UP = "LEVEL_UP" + BADGE_MISSION = "BADGE_MISSION" diff --git a/src/app/v2/answers/models/__init__.py b/app/common/exceptions/__init__.py similarity index 100% rename from src/app/v2/answers/models/__init__.py rename to app/common/exceptions/__init__.py diff --git a/src/common/exceptions/custom_exception.py b/app/common/exceptions/custom_exception.py similarity index 87% rename from src/common/exceptions/custom_exception.py rename to app/common/exceptions/custom_exception.py index 48e6fb3..c3a5070 100644 --- a/src/common/exceptions/custom_exception.py +++ b/app/common/exceptions/custom_exception.py @@ -1,6 +1,6 @@ from typing import Any -from common.exceptions.error_code import ErrorCode +from app.common.exceptions.error_code import ErrorCode class CustomException(Exception): diff --git a/src/common/exceptions/error_code.py b/app/common/exceptions/error_code.py similarity index 73% rename from src/common/exceptions/error_code.py rename to app/common/exceptions/error_code.py index 22afa5a..72f2690 100644 --- a/src/common/exceptions/error_code.py +++ b/app/common/exceptions/error_code.py @@ -2,17 +2,21 @@ class ErrorCode(Enum): + # 400 Bad Request + INVALID_TRANSACTION_CURRENCY = (4001, "결제에 유효하지 않은 거래 통화입니다.") NOT_ENOUGH_CHEESE = (4003, "치즈가 부족하여 구매를 진행할 수 없습니다.") INVALID_ITEM_CATEGORY = (4004, "치즈 결제에 유효하지 않은 아이템 카테고리입니다.") - INVALID_TRANSACTION_CURRENCY = (4001, "결제에 유효하지 않은 거래 통화입니다.") DUPLICATE_PURCHASE = (4005, "이미 소유한 제품입니다.") + INVALID_BADGE_CODE = (4006, "유효하지 않은 뱃지 코드입니다") + INVALID_COLOR_CODE = (4006, "유효하지 않은 컬러 코드입니다") # 404 Not Found - NO_INVENTORY_FOR_PRODUCT = (4041, "이 상품에 대한 재고가 없습니다.") PRODUCT_NOT_FOUND = (4042, "해당 상품을 찾을 수 없습니다.") - NO_VALID_RECEIPT = (4006, "유효한 영수증이 없습니다.") + # 500 server Error + NO_INVENTORY_FOR_PRODUCT = (5001, "데이터베이스에 해당 상품에 대한 재고가 없습니다.") + def __init__(self, code: int, message: str) -> None: self._code = code self._message = message diff --git a/src/app/v2/answers/querys/__init__.py b/app/common/handlers/__init__.py similarity index 100% rename from src/app/v2/answers/querys/__init__.py rename to app/common/handlers/__init__.py diff --git a/src/common/handlers/exception_handler.py b/app/common/handlers/exception_handler.py similarity index 92% rename from src/common/handlers/exception_handler.py rename to app/common/handlers/exception_handler.py index 84313d0..d8f13e2 100644 --- a/src/common/handlers/exception_handler.py +++ b/app/common/handlers/exception_handler.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, Request from starlette.responses import JSONResponse -from common.exceptions.custom_exception import CustomException +from app.common.exceptions.custom_exception import CustomException def attach_exception_handlers(app: FastAPI) -> None: diff --git a/app/common/handlers/router_handler.py b/app/common/handlers/router_handler.py new file mode 100644 index 0000000..c9d5fd6 --- /dev/null +++ b/app/common/handlers/router_handler.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI + +from app.apis.v2.badge_router import badge_router as badge_router +from app.apis.v2.cheese_router import cheese_router as cheese_router +from app.apis.v2.color_router import color_router as color_router +from app.apis.v2.emotion_router import emotion_router as emotion_router +from app.apis.v2.mission_router import mission_router as mission_router +from app.apis.v2.mobile_router import mobile_router as mobile_router +from app.apis.v2.payment_router import payment_router as payment_router +from app.apis.v2.teller_card_router import teller_card_router as teller_card_router + + +def attach_router_handlers(app: FastAPI) -> None: + app.include_router(router=mobile_router, prefix="/api/v2") + app.include_router(router=badge_router, prefix="/api/v2") + app.include_router(router=color_router, prefix="/api/v2") + app.include_router(router=teller_card_router, prefix="/api/v2") + app.include_router(router=payment_router, prefix="/api/v2") + app.include_router(router=mission_router, prefix="/api/v2") + app.include_router(router=cheese_router, prefix="/api/v2") + app.include_router(router=emotion_router, prefix="/api/v2") diff --git a/app/common/post_construct.py b/app/common/post_construct.py new file mode 100644 index 0000000..9c3dce5 --- /dev/null +++ b/app/common/post_construct.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from app.common.handlers.exception_handler import attach_exception_handlers +from app.common.handlers.router_handler import attach_router_handlers +from app.common.utils.scheduler import start_scheduler +from app.core.database.tortoise_database_settings import database_initialize + + +def post_construct(app: FastAPI) -> None: + attach_router_handlers(app) + attach_exception_handlers(app) + database_initialize(app) + start_scheduler() diff --git a/src/app/v2/answers/services/__init__.py b/app/common/tasks/__init__.py similarity index 100% rename from src/app/v2/answers/services/__init__.py rename to app/common/tasks/__init__.py diff --git a/app/common/tasks/mission_task.py b/app/common/tasks/mission_task.py new file mode 100644 index 0000000..a443ddb --- /dev/null +++ b/app/common/tasks/mission_task.py @@ -0,0 +1,6 @@ +from app.services.mission_service import MissionService + + +async def mission_reset_task() -> None: + mission_service = MissionService() + await mission_service.reset_mission() diff --git a/app/common/tasks/renew_subscription_task.py b/app/common/tasks/renew_subscription_task.py new file mode 100644 index 0000000..dfec321 --- /dev/null +++ b/app/common/tasks/renew_subscription_task.py @@ -0,0 +1,11 @@ +# from app.services.purchase_service import PurchaseService +# +# +# async def renew_subscription_task() -> None: +# purchase_service = PurchaseService() +# await purchase_service.process_subscriptions_renewal() +# +# +# async def expire_subscription_task() -> None: +# purchase_service = PurchaseService() +# await purchase_service.expire_subscriptions() diff --git a/src/app/v2/badges/__init__.py b/app/common/utils/__init__.py similarity index 100% rename from src/app/v2/badges/__init__.py rename to app/common/utils/__init__.py diff --git a/src/common/utils/get_user_id.py b/app/common/utils/get_user_id.py similarity index 100% rename from src/common/utils/get_user_id.py rename to app/common/utils/get_user_id.py diff --git a/src/common/utils/query_executor.py b/app/common/utils/query_executor.py similarity index 73% rename from src/common/utils/query_executor.py rename to app/common/utils/query_executor.py index cb10261..653d579 100644 --- a/src/common/utils/query_executor.py +++ b/app/common/utils/query_executor.py @@ -1,5 +1,4 @@ -from datetime import datetime -from typing import Any, Union +from typing import Any from tortoise import Tortoise @@ -20,6 +19,7 @@ async def execute_query( :param fetch_type: "single"일 경우 단일 값을 반환하고, "multiple"일 경우 여러 값을 반환 :return: 단일 값 또는 여러 값(딕셔너리 리스트) """ + connection = Tortoise.get_connection("default") if isinstance(values, tuple): @@ -35,3 +35,14 @@ async def execute_query( elif fetch_type == "multiple": return result return 0 if fetch_type == "single" else [] + + @staticmethod + async def execute_write_query(query: str, values: Any = ()) -> None: + connection = Tortoise.get_connection("default") + + if isinstance(values, tuple): + processed_values = tuple(v[0] if isinstance(v, tuple) else v for v in values) + else: + processed_values = (values,) + + await connection.execute_query(query, processed_values) # type: ignore diff --git a/src/common/utils/query_formatter.py b/app/common/utils/query_formatter.py similarity index 100% rename from src/common/utils/query_formatter.py rename to app/common/utils/query_formatter.py diff --git a/src/common/utils/scheduler.py b/app/common/utils/scheduler.py similarity index 92% rename from src/common/utils/scheduler.py rename to app/common/utils/scheduler.py index 130a969..a94fb5c 100644 --- a/src/common/utils/scheduler.py +++ b/app/common/utils/scheduler.py @@ -3,7 +3,7 @@ from apscheduler.jobstores.redis import RedisJobStore # type: ignore from apscheduler.schedulers.background import BackgroundScheduler # type: ignore -from core.configs.celery_settings import celery_app +from app.core.configs.celery_settings import celery_app logger = logging.getLogger(__name__) diff --git a/src/app/v2/badges/dtos/__init__.py b/app/core/__init__.py similarity index 100% rename from src/app/v2/badges/dtos/__init__.py rename to app/core/__init__.py diff --git a/src/core/configs/__init__.py b/app/core/configs/__init__.py similarity index 61% rename from src/core/configs/__init__.py rename to app/core/configs/__init__.py index 8110387..3a65399 100644 --- a/src/core/configs/__init__.py +++ b/app/core/configs/__init__.py @@ -1,4 +1,4 @@ -from core.configs.base_settings import Settings +from app.core.configs.base_settings import Settings def get_settings() -> Settings: diff --git a/src/core/configs/base_settings.py b/app/core/configs/base_settings.py similarity index 65% rename from src/core/configs/base_settings.py rename to app/core/configs/base_settings.py index 247b705..aa97238 100644 --- a/src/core/configs/base_settings.py +++ b/app/core/configs/base_settings.py @@ -1,5 +1,6 @@ import os from enum import StrEnum +from zoneinfo import ZoneInfo from pydantic_settings import BaseSettings @@ -16,7 +17,7 @@ class Settings(BaseSettings): DB_PORT: int = 3306 DB_USER: str = "root" DB_PASSWORD: str = "password" - DB_NAME: str = "database_name" + DB_NAME: str = "tellingme_local" DB_TIMEZONE: str = "Asia/Seoul" DB_CHARSET: str = "utf8mb4" APPLE_URL: str = "https://sandbox.itunes.apple.com/verifyReceipt" @@ -25,3 +26,11 @@ class Settings(BaseSettings): class Config: env_file = f".env.{os.getenv('ENV', 'local')}" env_file_encoding = "utf-8" + + @property + def database_url(self) -> str: + return f"mysql+asyncmy://{self.DB_USER}:{self.DB_PASSWORD}" f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + @property + def db_zoneinfo(self) -> ZoneInfo: + return ZoneInfo(self.DB_TIMEZONE) diff --git a/src/core/configs/celery_settings.py b/app/core/configs/celery_settings.py similarity index 87% rename from src/core/configs/celery_settings.py rename to app/core/configs/celery_settings.py index cf66aaa..b5d8df5 100644 --- a/src/core/configs/celery_settings.py +++ b/app/core/configs/celery_settings.py @@ -4,9 +4,9 @@ from celery import Celery from tortoise import Tortoise -from app.v2.missions.services.mission_service import MissionService -from common.tasks.mission_task import mission_reset_task -from core.database.database_settings import TORTOISE_ORM +from app.common.tasks.mission_task import mission_reset_task +from app.core.database.tortoise_database_settings import TORTOISE_ORM +from app.services.mission_service import MissionService celery_app = Celery( "telling-me-celery", @@ -53,7 +53,7 @@ async def execute_async_mission_task(user_id: str) -> None: async def initialize_celery() -> None: logger = logging.getLogger(__name__) - logger.info(f"Current path: 여기") + logger.info("Current path: 여기") logging.basicConfig(level=logging.DEBUG) db_client_logger = logging.getLogger("tortoise.db_client") db_client_logger.setLevel(logging.DEBUG) diff --git a/src/app/v2/badges/models/__init__.py b/app/core/database/__init__.py similarity index 100% rename from src/app/v2/badges/models/__init__.py rename to app/core/database/__init__.py diff --git a/app/core/database/sql_alchemy_db_settings.py b/app/core/database/sql_alchemy_db_settings.py new file mode 100644 index 0000000..a8ed00c --- /dev/null +++ b/app/core/database/sql_alchemy_db_settings.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.configs import settings + +_async_engine = create_async_engine( + settings.database_url, + pool_recycle=3600, + echo=False, +) +_AsyncSessionFactory = async_sessionmaker( + autocommit=False, + autoflush=False, + expire_on_commit=False, + bind=_async_engine, +) + + +def get_async_session_with_alchemy() -> AsyncSession: + return _AsyncSessionFactory() diff --git a/src/core/database/database_settings.py b/app/core/database/tortoise_database_settings.py similarity index 68% rename from src/core/database/database_settings.py rename to app/core/database/tortoise_database_settings.py index 98477c4..dca05a6 100644 --- a/src/core/database/database_settings.py +++ b/app/core/database/tortoise_database_settings.py @@ -2,23 +2,27 @@ from tortoise import Tortoise from tortoise.contrib.fastapi import register_tortoise -from core.configs import settings +from app.core.configs import settings TORTOISE_APP_MODELS = [ - "app.v2.questions.models.question", - "app.v2.users.models.user", - "app.v2.users.models.refresh_token", - "app.v2.badges.models.badge", - "app.v2.colors.models.color", - "app.v2.answers.models.answer", - "app.v2.teller_cards.models.teller_card", - "app.v2.levels.models.level", - "app.v2.cheese_managers.models.cheese_manager", - "app.v2.items.models.item", - "app.v2.missions.models.mission", - "app.v2.likes.models.like", - "app.v2.emotions.models.emotion", - "app.v2.purchases.models.purchase_history", + "app.models.user", + "app.models.refresh_token", + "app.models.badge", + "app.models.badge_inventory", + "app.models.color", + "app.models.color_inventory", + "app.models.emotion", + "app.models.emotion_inventory", + "app.models.answer", + "app.models.teller_card", + "app.models.level", + "app.models.level_inventory", + "app.models.cheese_manager", + "app.models.item", + "app.models.mission", + "app.models.mission_inventory", + "app.models.like", + "app.models.notice", ] TORTOISE_ORM = { @@ -52,7 +56,7 @@ def database_initialize(app: FastAPI) -> None: Tortoise.init_models(TORTOISE_APP_MODELS, "models") register_tortoise( - app, + app=app, config=TORTOISE_ORM, generate_schemas=False, add_exception_handlers=True, diff --git a/src/app/v2/badges/querys/__init__.py b/app/dtos/__init__.py similarity index 100% rename from src/app/v2/badges/querys/__init__.py rename to app/dtos/__init__.py diff --git a/src/app/v2/badges/services/__init__.py b/app/dtos/answer/__init__.py similarity index 100% rename from src/app/v2/badges/services/__init__.py rename to app/dtos/answer/__init__.py diff --git a/app/dtos/answer/answer_data.py b/app/dtos/answer/answer_data.py new file mode 100644 index 0000000..eaf5ed3 --- /dev/null +++ b/app/dtos/answer/answer_data.py @@ -0,0 +1,20 @@ +import dataclasses +from datetime import date, datetime + + +@dataclasses.dataclass(frozen=True) +class AnswerData: + answer_id: int + content: str + created_time: datetime + date: date + emotion: int + is_premium: bool + is_public: bool + modified_time: datetime + user_id: bytes + is_blind: bool + like_count: int + is_spare: bool + blind_ended_at: datetime | None = None + blind_started_at: datetime | None = None diff --git a/src/app/v2/cheese_managers/__init__.py b/app/dtos/badge/__init__.py similarity index 100% rename from src/app/v2/cheese_managers/__init__.py rename to app/dtos/badge/__init__.py diff --git a/app/dtos/badge/badge_data.py b/app/dtos/badge/badge_data.py new file mode 100644 index 0000000..5731095 --- /dev/null +++ b/app/dtos/badge/badge_data.py @@ -0,0 +1,9 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class BadgeData: + badge_code: str + badge_name: str + badge_condition: str + badge_middle_name: str diff --git a/app/dtos/badge/badge_dto.py b/app/dtos/badge/badge_dto.py new file mode 100644 index 0000000..9554766 --- /dev/null +++ b/app/dtos/badge/badge_dto.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class BadgeDTO(BaseModel): + model_config = FROZEN_CONFIG + + badgeCode: str + badgeName: str + badgeMiddleName: str + badgeCondition: str diff --git a/app/dtos/badge/badges_response.py b/app/dtos/badge/badges_response.py new file mode 100644 index 0000000..55252ab --- /dev/null +++ b/app/dtos/badge/badges_response.py @@ -0,0 +1,9 @@ +from app.dtos.badge.badge_dto import BadgeDTO +from app.dtos.base_response import BaseResponseDTO +from app.dtos.frozen_config import FROZEN_CONFIG + + +class BadgesResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: list[BadgeDTO] diff --git a/app/dtos/base_response.py b/app/dtos/base_response.py new file mode 100644 index 0000000..0e0b986 --- /dev/null +++ b/app/dtos/base_response.py @@ -0,0 +1,14 @@ +from typing import Any + +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +# 공통 응답 모델 정의 +class BaseResponseDTO(BaseModel): + model_config = FROZEN_CONFIG + + code: int + message: str + data: Any | None = None diff --git a/src/app/v2/cheese_managers/dtos/__init__.py b/app/dtos/cheese/__init__.py similarity index 100% rename from src/app/v2/cheese_managers/dtos/__init__.py rename to app/dtos/cheese/__init__.py diff --git a/app/dtos/cheese/cheese_response.py b/app/dtos/cheese/cheese_response.py new file mode 100644 index 0000000..3d3b2a8 --- /dev/null +++ b/app/dtos/cheese/cheese_response.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from app.dtos.base_response import BaseResponseDTO +from app.dtos.frozen_config import FROZEN_CONFIG + + +class TotalCheeseAmount(BaseModel): + model_config = FROZEN_CONFIG + + cheeseBalance: int + + +class CheeseResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: TotalCheeseAmount diff --git a/src/app/v2/cheese_managers/models/__init__.py b/app/dtos/color/__init__.py similarity index 100% rename from src/app/v2/cheese_managers/models/__init__.py rename to app/dtos/color/__init__.py diff --git a/app/dtos/color/color_data.py b/app/dtos/color/color_data.py new file mode 100644 index 0000000..a58c37e --- /dev/null +++ b/app/dtos/color/color_data.py @@ -0,0 +1,8 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class ColorData: + color_code: str + color_name: str + color_hex_code: str diff --git a/app/dtos/color/color_dto.py b/app/dtos/color/color_dto.py new file mode 100644 index 0000000..1adfc41 --- /dev/null +++ b/app/dtos/color/color_dto.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class ColorDTO(BaseModel): + model_config = FROZEN_CONFIG + + colorCode: str + colorName: str + colorHexCode: str diff --git a/app/dtos/color/colors_response.py b/app/dtos/color/colors_response.py new file mode 100644 index 0000000..8538905 --- /dev/null +++ b/app/dtos/color/colors_response.py @@ -0,0 +1,9 @@ +from app.dtos.base_response import BaseResponseDTO +from app.dtos.color.color_dto import ColorDTO +from app.dtos.frozen_config import FROZEN_CONFIG + + +class ColorsResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: list[ColorDTO] diff --git a/src/app/v2/cheese_managers/querys/__init__.py b/app/dtos/emotion/__init__.py similarity index 100% rename from src/app/v2/cheese_managers/querys/__init__.py rename to app/dtos/emotion/__init__.py diff --git a/app/dtos/emotion/emotion_data.py b/app/dtos/emotion/emotion_data.py new file mode 100644 index 0000000..178eeb6 --- /dev/null +++ b/app/dtos/emotion/emotion_data.py @@ -0,0 +1,7 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class EmotionData: + emotion_code: str + emotion_name: str diff --git a/app/dtos/emotion/emotion_dto.py b/app/dtos/emotion/emotion_dto.py new file mode 100644 index 0000000..207fa83 --- /dev/null +++ b/app/dtos/emotion/emotion_dto.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class EmotionDTO(BaseModel): + model_config = FROZEN_CONFIG + + emotionList: list[int] diff --git a/app/dtos/emotion/emotions_response.py b/app/dtos/emotion/emotions_response.py new file mode 100644 index 0000000..fc31fb0 --- /dev/null +++ b/app/dtos/emotion/emotions_response.py @@ -0,0 +1,9 @@ +from app.dtos.base_response import BaseResponseDTO +from app.dtos.emotion.emotion_dto import EmotionDTO +from app.dtos.frozen_config import FROZEN_CONFIG + + +class EmotionsResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: EmotionDTO diff --git a/app/dtos/frozen_config.py b/app/dtos/frozen_config.py new file mode 100644 index 0000000..cebe046 --- /dev/null +++ b/app/dtos/frozen_config.py @@ -0,0 +1,3 @@ +from pydantic import ConfigDict + +FROZEN_CONFIG = ConfigDict(frozen=True) diff --git a/src/app/v2/cheese_managers/services/__init__.py b/app/dtos/level/__init__.py similarity index 100% rename from src/app/v2/cheese_managers/services/__init__.py rename to app/dtos/level/__init__.py diff --git a/app/dtos/level/level_data.py b/app/dtos/level/level_data.py new file mode 100644 index 0000000..ead8b79 --- /dev/null +++ b/app/dtos/level/level_data.py @@ -0,0 +1,8 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class LevelData: + level_exp: int + level_level: int + required_exp: int diff --git a/app/dtos/level/level_dto.py b/app/dtos/level/level_dto.py new file mode 100644 index 0000000..c099628 --- /dev/null +++ b/app/dtos/level/level_dto.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class LevelDTO(BaseModel): + model_config = FROZEN_CONFIG + + level: int + currentExp: int + requiredExp: int diff --git a/app/dtos/level/level_info_dto.py b/app/dtos/level/level_info_dto.py new file mode 100644 index 0000000..79e79bb --- /dev/null +++ b/app/dtos/level/level_info_dto.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.level.level_dto import LevelDTO + + +class LevelInfoDTO(BaseModel): + model_config = FROZEN_CONFIG + + levelDto: LevelDTO + daysToLevelUp: int diff --git a/src/app/v2/colors/__init__.py b/app/dtos/mission/__init__.py similarity index 100% rename from src/app/v2/colors/__init__.py rename to app/dtos/mission/__init__.py diff --git a/app/dtos/mission/mission_data.py b/app/dtos/mission/mission_data.py new file mode 100644 index 0000000..1a45c30 --- /dev/null +++ b/app/dtos/mission/mission_data.py @@ -0,0 +1,15 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class MissionData: + user_mission_id: int + is_completed: bool + mission_code: str + progress_count: int + + @staticmethod + def to_bool(val: str) -> bool: + if isinstance(val, bytes): + return bool(int.from_bytes(val, byteorder="big")) + return bool(val) diff --git a/app/dtos/mission/mission_dto.py b/app/dtos/mission/mission_dto.py new file mode 100644 index 0000000..1968431 --- /dev/null +++ b/app/dtos/mission/mission_dto.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class UserMissionDTO(BaseModel): + + user_mission_id: int + is_completed: bool + mission_code: str + progress_count: int diff --git a/app/dtos/mission/reward_dto.py b/app/dtos/mission/reward_dto.py new file mode 100644 index 0000000..c3f8415 --- /dev/null +++ b/app/dtos/mission/reward_dto.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class RewardDTO(BaseModel): + model_config = FROZEN_CONFIG + + total_cheese: int + total_exp: int + badge_code: str | None = None + badge_full_name: str | None = None diff --git a/src/app/v2/colors/dtos/__init__.py b/app/dtos/mobile/__init__.py similarity index 100% rename from src/app/v2/colors/dtos/__init__.py rename to app/dtos/mobile/__init__.py diff --git a/app/dtos/mobile/data_dto.py b/app/dtos/mobile/data_dto.py new file mode 100644 index 0000000..0eed6fb --- /dev/null +++ b/app/dtos/mobile/data_dto.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from app.dtos.badge.badge_dto import BadgeDTO +from app.dtos.color.color_dto import ColorDTO +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.level.level_info_dto import LevelInfoDTO +from app.dtos.user.user_info_dto import UserInfoDTO + + +class DataDTO(BaseModel): + model_config = FROZEN_CONFIG + + badges: list[BadgeDTO] + colors: list[ColorDTO] + userInfo: UserInfoDTO + levelInfo: LevelInfoDTO + recordCount: int = 0 diff --git a/app/dtos/mobile/mypage_response.py b/app/dtos/mobile/mypage_response.py new file mode 100644 index 0000000..dfdfb19 --- /dev/null +++ b/app/dtos/mobile/mypage_response.py @@ -0,0 +1,9 @@ +from app.dtos.base_response import BaseResponseDTO +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.mobile.user_profile_with_level_dto import UserProfileWithLevelDTO + + +class MyPageResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: UserProfileWithLevelDTO diff --git a/app/dtos/mobile/teller_card_response.py b/app/dtos/mobile/teller_card_response.py new file mode 100644 index 0000000..f1e3b5b --- /dev/null +++ b/app/dtos/mobile/teller_card_response.py @@ -0,0 +1,10 @@ +from app.dtos.base_response import BaseResponseDTO +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.mobile.data_dto import DataDTO + + +# 최종 응답 DTO +class TellerCardResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: DataDTO diff --git a/app/dtos/mobile/user_profile_with_level_dto.py b/app/dtos/mobile/user_profile_with_level_dto.py new file mode 100644 index 0000000..38da123 --- /dev/null +++ b/app/dtos/mobile/user_profile_with_level_dto.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.level.level_info_dto import LevelInfoDTO +from app.dtos.user.user_profile_dto import UserProfileDTO + + +class UserProfileWithLevelDTO(BaseModel): + model_config = FROZEN_CONFIG + + userProfile: UserProfileDTO + level: LevelInfoDTO diff --git a/src/app/v2/colors/models/__init__.py b/app/dtos/payment/__init__.py similarity index 100% rename from src/app/v2/colors/models/__init__.py rename to app/dtos/payment/__init__.py diff --git a/app/dtos/payment/payment_request.py b/app/dtos/payment/payment_request.py new file mode 100644 index 0000000..4fa3ec6 --- /dev/null +++ b/app/dtos/payment/payment_request.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class PaymentRequest(BaseModel): + model_config = FROZEN_CONFIG + + user_id: str + productCode: str diff --git a/app/dtos/payment/payment_response.py b/app/dtos/payment/payment_response.py new file mode 100644 index 0000000..9c2bd24 --- /dev/null +++ b/app/dtos/payment/payment_response.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from app.dtos.base_response import BaseResponseDTO +from app.dtos.frozen_config import FROZEN_CONFIG + + +class ProductDTO(BaseModel): + model_config = FROZEN_CONFIG + + product_code: str + + +class PaymentResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: ProductDTO diff --git a/src/app/v2/colors/querys/__init__.py b/app/dtos/purchase/__init__.py similarity index 100% rename from src/app/v2/colors/querys/__init__.py rename to app/dtos/purchase/__init__.py diff --git a/app/dtos/purchase/purchase_dto.py b/app/dtos/purchase/purchase_dto.py new file mode 100644 index 0000000..63747a7 --- /dev/null +++ b/app/dtos/purchase/purchase_dto.py @@ -0,0 +1,64 @@ +# from typing import Any, Optional +# +# from pydantic import BaseModel +# +# from app.models.purchase_status import purchase_mapping +# +# +# class ReceiptInfoDTO(BaseModel): +# transaction_id: str +# original_transaction_id: str +# expires_date_ms: int +# purchase_date_ms: int +# product_code: str +# product_code_two: str +# quantity: int +# cancellation_date_ms: Optional[int] = None +# +# @classmethod +# def build(cls, latest_receipt_info: dict[str, Any]) -> "ReceiptInfoDTO": +# transaction_id = latest_receipt_info["transaction_id"] +# original_transaction_id = latest_receipt_info["original_transaction_id"] +# expires_date_ms = int(latest_receipt_info.get("expires_date_ms", 0)) +# purchase_date_ms = int(latest_receipt_info.get("purchase_date_ms", 0)) +# product_code = purchase_mapping.get(latest_receipt_info["product_id"], latest_receipt_info["product_id"]) +# product_code_two = latest_receipt_info["product_id"] +# quantity = int(latest_receipt_info.get("quantity", 1)) +# cancellation_date_ms = latest_receipt_info.get("cancellation_date_ms") # 환불일 (밀리초) +# +# return cls( +# transaction_id=transaction_id, +# original_transaction_id=original_transaction_id, +# expires_date_ms=expires_date_ms, +# purchase_date_ms=purchase_date_ms, +# product_code=product_code, +# product_code_two=product_code_two, +# quantity=quantity, +# cancellation_date_ms=cancellation_date_ms, +# ) +# +# +# class PurchaseDTO(BaseModel): +# productCode: str +# isPremium: bool +# +# @classmethod +# def build(cls, product_code: str, is_premium: bool) -> "PurchaseDTO": +# return cls( +# productCode=product_code, +# isPremium=is_premium, +# ) +# +# +# class PurchaseResponseDTO(BaseModel): +# message: str +# data: PurchaseDTO +# code: int +# +# @classmethod +# def build(cls, is_premium: bool, product_code: str) -> "PurchaseResponseDTO": +# return cls( +# code=200, +# message="Purchase successful.", +# data=PurchaseDTO.build(product_code=product_code, is_premium=is_premium), +# ) diff --git a/app/dtos/purchase/requests.py b/app/dtos/purchase/requests.py new file mode 100644 index 0000000..0a7c927 --- /dev/null +++ b/app/dtos/purchase/requests.py @@ -0,0 +1,11 @@ +# from pydantic import BaseModel +# +# +# class ReceiptRequestDTO(BaseModel): +# receiptData: str +# user_id: str +# +# +# class PurchaseRequest(BaseModel): +# user_id: str +# product_code: str diff --git a/src/app/v2/colors/services/__init__.py b/app/dtos/teller_card/__init__.py similarity index 100% rename from src/app/v2/colors/services/__init__.py rename to app/dtos/teller_card/__init__.py diff --git a/app/dtos/teller_card/teller_card_data.py b/app/dtos/teller_card/teller_card_data.py new file mode 100644 index 0000000..dafa8ba --- /dev/null +++ b/app/dtos/teller_card/teller_card_data.py @@ -0,0 +1,9 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class TellerCardData: + activate_color_code: str + activate_badge_code: str + badge_name: str + badge_middle_name: str diff --git a/app/dtos/teller_card/teller_card_dto.py b/app/dtos/teller_card/teller_card_dto.py new file mode 100644 index 0000000..bd8c53e --- /dev/null +++ b/app/dtos/teller_card/teller_card_dto.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class TellerCardDTO(BaseModel): + model_config = FROZEN_CONFIG + + colorCode: str + badgeCode: str + badgeName: str + badgeMiddleName: str diff --git a/app/dtos/teller_card/teller_card_request.py b/app/dtos/teller_card/teller_card_request.py new file mode 100644 index 0000000..f431891 --- /dev/null +++ b/app/dtos/teller_card/teller_card_request.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class TellerCardRequest(BaseModel): + model_config = FROZEN_CONFIG + + user_id: str + colorCode: str | None = None + badgeCode: str | None = None diff --git a/app/dtos/teller_card/teller_card_response.py b/app/dtos/teller_card/teller_card_response.py new file mode 100644 index 0000000..a757d75 --- /dev/null +++ b/app/dtos/teller_card/teller_card_response.py @@ -0,0 +1,9 @@ +from app.dtos.base_response import BaseResponseDTO +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.teller_card.teller_card_dto import TellerCardDTO + + +class TellerCardResponse(BaseResponseDTO): + model_config = FROZEN_CONFIG + + data: TellerCardDTO diff --git a/src/app/v2/emotions/__init__.py b/app/dtos/user/__init__.py similarity index 100% rename from src/app/v2/emotions/__init__.py rename to app/dtos/user/__init__.py diff --git a/app/dtos/user/user_data.py b/app/dtos/user/user_data.py new file mode 100644 index 0000000..f247983 --- /dev/null +++ b/app/dtos/user/user_data.py @@ -0,0 +1,7 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class UserData: + nickname: str + cheese_manager_id: int diff --git a/app/dtos/user/user_dto.py b/app/dtos/user/user_dto.py new file mode 100644 index 0000000..0df2955 --- /dev/null +++ b/app/dtos/user/user_dto.py @@ -0,0 +1,14 @@ +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class UserProfileData: + user_id: str + nickname: str + profile_url: str + is_premium: bool + user_status: bool + cheese_manager_id: int + teller_card_id: int + level_id: int + allow_notification: bool diff --git a/app/dtos/user/user_info_dto.py b/app/dtos/user/user_info_dto.py new file mode 100644 index 0000000..7de8b18 --- /dev/null +++ b/app/dtos/user/user_info_dto.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG +from app.dtos.teller_card.teller_card_dto import TellerCardDTO + + +class UserInfoDTO(BaseModel): + model_config = FROZEN_CONFIG + + nickname: str + cheeseBalance: int + tellerCard: TellerCardDTO diff --git a/app/dtos/user/user_profile_dto.py b/app/dtos/user/user_profile_dto.py new file mode 100644 index 0000000..0bf60b4 --- /dev/null +++ b/app/dtos/user/user_profile_dto.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + +from app.dtos.frozen_config import FROZEN_CONFIG + + +class UserProfileDTO(BaseModel): + model_config = FROZEN_CONFIG + + nickname: str + badgeCode: str + cheeseBalance: int + badgeCount: int + answerCount: int + premium: bool + allowNotification: bool diff --git a/src/app/v2/emotions/dtos/__init__.py b/app/log/__init__.py similarity index 100% rename from src/app/v2/emotions/dtos/__init__.py rename to app/log/__init__.py diff --git a/src/app/v2/emotions/models/__init__.py b/app/models/__init__.py similarity index 100% rename from src/app/v2/emotions/models/__init__.py rename to app/models/__init__.py diff --git a/app/models/answer.py b/app/models/answer.py new file mode 100644 index 0000000..7ff386e --- /dev/null +++ b/app/models/answer.py @@ -0,0 +1,96 @@ +from datetime import datetime + +from tortoise import fields +from tortoise.models import Model + +from app.common.utils.query_executor import QueryExecutor +from app.core.configs import settings +from app.dtos.answer.answer_data import AnswerData +from app.queries.answer_query import ( + SELECT_ANSWER_BY_USER_UUID_QUERY, + SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY, + SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY_V2, + SELECT_MOST_RECENT_ANSWER_BY_USER_UUID_QUERY, +) + + +class Answer(Model): + answer_id = fields.BigIntField(primary_key=True) + user_id = fields.BinaryField(max_length=16, null=True) + content = fields.TextField(null=False) + created_time = fields.DatetimeField(null=True) + date = fields.DateField(null=False) + emotion = fields.IntField(null=False) + is_premium = fields.BooleanField(null=False) + is_public = fields.BooleanField(null=False) + modified_time = fields.DatetimeField(null=True) + is_blind = fields.BooleanField(null=False) + blind_ended_at = fields.DatetimeField(null=True) + blind_started_at = fields.DatetimeField(null=True) + like_count = fields.IntField(null=False, default=0) + is_spare = fields.BooleanField(null=False) + + class Meta: + table = "answer" + + @classmethod + async def create_answer( + cls, + user_id: str, + content: str, + date: str, + emotion: int = 1, + like_count: int = 0, + is_premium: bool = False, + is_public: bool = False, + is_blind: bool = False, + is_spare: bool = False, + ) -> None: + created_time = datetime.now(settings.db_zoneinfo).strftime("%Y-%m-%d %H:%M:%S") + + query = """ + INSERT INTO answer ( + user_id, content, date, emotion, + is_premium, is_public, is_blind, is_spare, + created_time, like_count + ) + VALUES ( + UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, + %s, %s, %s, %s, + %s, %s + ); + """ + + await QueryExecutor.execute_write_query( + query, + (user_id, content, date, emotion, is_premium, is_public, is_blind, is_spare, created_time, like_count), + ) + + # 기존 get_answer_count_by_user_id 메서드 + @classmethod + async def get_answer_count_by_user_id(cls, user_id: str) -> int: + query = SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return int(result.get("answer_count", 0) if result else 0) + + @classmethod + async def get_answer_count_by_user_id_v2(cls, user_id: str) -> int: + query = SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY_V2 + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return int(result.get("answer_count", 0) if result else 0) + + @classmethod + async def get_all_by_user_id(cls, user_id: str, start_date: datetime, end_date: datetime) -> list[AnswerData]: + query = SELECT_ANSWER_BY_USER_UUID_QUERY + values = (user_id, start_date, end_date) + results = await QueryExecutor.execute_query(query, values=values, fetch_type="multiple") + return [AnswerData(**row) for row in results] + + @classmethod + async def get_most_recent_answer_by_user_id(cls, user_id: str) -> AnswerData | None: + query = SELECT_MOST_RECENT_ANSWER_BY_USER_UUID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return AnswerData(**result) if result else None diff --git a/app/models/badge.py b/app/models/badge.py new file mode 100644 index 0000000..25ec452 --- /dev/null +++ b/app/models/badge.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from tortoise import fields +from tortoise.models import Model + +from app.common.utils.query_executor import QueryExecutor +from app.dtos.badge.badge_data import BadgeData +from app.queries.badge_query import ( + INSERT_BADGE_CODE_FOR_USER_QUERY, + SELECT_BADGE_BY_USER_UUID_QUERY, + SELECT_BADGE_COUNT_BY_USER_UUID_QUERY, +) + + +class Badge(Model): + badge_id = fields.BigIntField(primary_key=True) + badge_code = fields.CharField(max_length=255) + user_id = fields.BinaryField(max_length=16, null=True) + + class Meta: + table = "badge" + + @classmethod + async def get_badge_count_by_user_id(cls, user_id: str) -> int: + query = SELECT_BADGE_COUNT_BY_USER_UUID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return int(result.get("badge_count", 0) if result else 0) + + @classmethod + async def get_badges_with_details_by_user_id(cls, user_id: str) -> list[BadgeData]: + query = SELECT_BADGE_BY_USER_UUID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="multiple") + return [BadgeData(**row) for row in result] + + @classmethod + async def create_by_user_id(cls, user_id: str, badge_code: str) -> None: + query = INSERT_BADGE_CODE_FOR_USER_QUERY + values = (badge_code, user_id) + await QueryExecutor.execute_query(query, values=values) diff --git a/app/models/badge_inventory.py b/app/models/badge_inventory.py new file mode 100644 index 0000000..184ac75 --- /dev/null +++ b/app/models/badge_inventory.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from tortoise import Model, fields +from tortoise.transactions import in_transaction + + +class BadgeInventory(Model): + badge_code = fields.CharField(max_length=255, primary_key=True) + badge_name = fields.CharField(max_length=255, null=True) + badge_condition = fields.CharField(max_length=255, null=True) + badge_middle_name = fields.CharField(max_length=255, null=True) + + class Meta: + table = "badge_inventory" + + @property + def badge_full_name(self) -> str: + return f"{self.badge_middle_name} {self.badge_name}" + + @classmethod + async def create_bulk(cls) -> None: + badges = [ + cls( + badge_code="BG_AGAIN_001", + badge_condition="연속으로 7일 작성했어요!", + badge_middle_name="또 오셨네요,", + badge_name="단골 텔러", + ), + cls( + badge_code="BG_CHRISTMAS_2024", + badge_condition="2024 크리스마스 한정판 배지예요!", + badge_middle_name="흰 눈 사이에서,", + badge_name="화이트 크리스마스", + ), + cls( + badge_code="BG_FIRST", + badge_condition="첫 글을 작성했어요!", + badge_middle_name="낯선 길에 첫 발자국,", + badge_name="탐험가 텔러", + ), + cls( + badge_code="BG_MUCH_001", + badge_condition="280자 이상 글을 1회 작성했어요!", + badge_middle_name="내 이야길 들어봐,", + badge_name="투머치 토커", + ), + cls( + badge_code="BG_NEW", + badge_condition="회원가입 시 기본 제공", + badge_middle_name="아직은 낯설어요,", + badge_name="미스터리 방문객", + ), + cls( + badge_code="BG_NIGHT_001", + badge_condition="새벽 시간에 글 3회 작성했어요!", + badge_middle_name="다들 꿈꿀 때 글을 썼지,", + badge_name="올빼미 텔러", + ), + cls( + badge_code="BG_SAVE_001", + badge_condition="치즈 50개를 모았어요!", + badge_middle_name="치즈를 모아모아,", + badge_name="나는야 저축왕", + ), + ] + async with in_transaction(): + await cls.bulk_create(badges) + + @classmethod + async def get_by_badge_code(cls, badge_code: str) -> BadgeInventory: + return await cls.get(badge_code=badge_code) diff --git a/app/models/cheese_manager.py b/app/models/cheese_manager.py new file mode 100644 index 0000000..fc6d71a --- /dev/null +++ b/app/models/cheese_manager.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import cast + +from tortoise import fields +from tortoise.expressions import Q +from tortoise.fields import ForeignKeyRelation +from tortoise.functions import Sum +from tortoise.models import Model + +from app.common.constants.cheese_status import CheeseStatus +from app.common.utils.query_executor import QueryExecutor + + +class CheeseManager(Model): + """ + Cheese Manager 치즈 관련 작업을 하는 모델 겸 서비스 + """ + + cheese_manager_id = fields.BigIntField(primary_key=True) + + class Meta: + table = "cheese_manager" + + @classmethod + async def create_cheese_manager(cls) -> CheeseManager: + query = "INSERT INTO cheese_manager VALUES (DEFAULT);" + await QueryExecutor.execute_write_query(query) + + result = await cls.all().order_by("-cheese_manager_id").first() + if result is None: + raise RuntimeError("CheeseManager creation failed unexpectedly.") + return result + + @staticmethod + async def get_total_cheese_amount_by_manager(cheese_manager_id: int) -> int: + return await CheeseHistory.get_total_amount_by_manager(cheese_manager_id) + + @classmethod + async def use_cheese(cls, cheese_manager_id: int, amount: int) -> None: + remaining = amount + + using_cheeses = await CheeseHistory.get_using_cheeses(cheese_manager_id) + for cheese in using_cheeses: + if cheese.current_amount >= remaining: + cheese.current_amount -= remaining + if cheese.current_amount == 0: + cheese.status = CheeseStatus.ALREADY_USED + await cheese.save() + return + else: + remaining -= cheese.current_amount + cheese.current_amount = 0 + cheese.status = CheeseStatus.ALREADY_USED + await cheese.save() + + can_use_cheeses = await CheeseHistory.get_can_use_cheeses(cheese_manager_id) + + for cheese in can_use_cheeses: + if cheese.current_amount >= remaining: + cheese.current_amount -= remaining + cheese.status = CheeseStatus.USING if cheese.current_amount > 0 else CheeseStatus.ALREADY_USED + await cheese.save() + return + else: + remaining -= cheese.current_amount + cheese.current_amount = 0 + cheese.status = CheeseStatus.ALREADY_USED + await cheese.save() + + @staticmethod + async def add_cheese(cheese_manager_id: int, amount: int) -> None: + await CheeseHistory.create( + status=CheeseStatus.CAN_USE, + current_amount=amount, + starting_amount=amount, + cheese_manager_id=cheese_manager_id, + ) + + +class CheeseHistory(Model): + cheese_history_id = fields.BigIntField(primary_key=True) + status = fields.CharEnumField(CheeseStatus, max_length=50, null=True) + current_amount = fields.IntField() + starting_amount = fields.IntField() + cheese_manager: ForeignKeyRelation[CheeseManager] = fields.ForeignKeyField( + "models.CheeseManager", + related_name="histories", + on_delete=fields.CASCADE, + ) + + class Meta: + table = "cheese_history" + + @classmethod + async def get_total_amount_by_manager(cls, manager_id: int) -> int: + result = cast( + list[int | None], + await ( + cls.filter( + Q(status=CheeseStatus.CAN_USE) | Q(status=CheeseStatus.USING), + cheese_manager_id=manager_id, + ) + .annotate(total=Sum("current_amount")) + .values_list("total", flat=True) + ), + ) + + return result[0] if result and result[0] is not None else 0 + + @classmethod + async def get_using_cheeses(cls, manager_id: int) -> list[CheeseHistory]: + return await cls.filter(status=CheeseStatus.USING, cheese_manager_id=manager_id).order_by("cheese_history_id") + + @classmethod + async def get_can_use_cheeses(cls, manager_id: int) -> list[CheeseHistory]: + return await cls.filter(status=CheeseStatus.CAN_USE, cheese_manager_id=manager_id).order_by("cheese_history_id") diff --git a/app/models/color.py b/app/models/color.py new file mode 100644 index 0000000..a215320 --- /dev/null +++ b/app/models/color.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta, timezone + +from tortoise import fields +from tortoise.models import Model + +from app.common.utils.query_executor import QueryExecutor +from app.dtos.color.color_data import ColorData +from app.queries.color_query import ( + INSERT_COLOR_CODE_FOR_USER_QUERY, + SELECT_COLOR_BY_USER_UUID_QUERY, +) + + +class Color(Model): + color_id = fields.BigIntField(primary_key=True) + color_code = fields.CharField(max_length=255, null=True) + user_id = fields.BinaryField(max_length=16, null=True) + + class Meta: + table = "color" + + @classmethod + async def create_default_by_user_id(cls, user_id: str) -> None: + color_codes = ["CL_DEFAULT", "CL_BLUE_001", "CL_ORANGE_001"] + now_kst = datetime.now(timezone(timedelta(hours=9))) + + if now_kst >= datetime(2024, 12, 28, 6, 0, 0, tzinfo=timezone(timedelta(hours=9))): + color_codes.append("CL_RED_001") + + values_placeholders = ", ".join(["(%s, UNHEX(REPLACE(%s, '-', '')))"] * len(color_codes)) + values = [] + + for code in color_codes: + values.extend([code, user_id]) + + query = f"INSERT INTO color (color_code, user_id) VALUES {values_placeholders}" + + await QueryExecutor.execute_write_query(query, tuple(values)) + + @classmethod + async def create_by_user_id(cls, user_id: str, color_code: str) -> None: + query = INSERT_COLOR_CODE_FOR_USER_QUERY + values = (color_code, user_id) + await QueryExecutor.execute_query(query, values=values, fetch_type="single") + + @classmethod + async def get_colors_with_details_by_user_id(cls, user_id: str) -> list[ColorData]: + query = SELECT_COLOR_BY_USER_UUID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="multiple") + return [ColorData(**row) for row in result] diff --git a/app/models/color_inventory.py b/app/models/color_inventory.py new file mode 100644 index 0000000..0f57c07 --- /dev/null +++ b/app/models/color_inventory.py @@ -0,0 +1,34 @@ +from tortoise import Model, fields +from tortoise.transactions import in_transaction + +from app.dtos.color.color_data import ColorData + + +class ColorInventory(Model): + color_code = fields.CharField(max_length=255, primary_key=True) + color_name = fields.CharField(max_length=255, null=True) + color_hex_code = fields.CharField(max_length=255, null=True) + + class Meta: + table = "color_inventory" # 테이블 이름을 명시 + + @classmethod + async def create_bulk(cls) -> None: + colors = [ + cls(color_code="CL_BLUE_001", color_hex_code="#229DF6", color_name="Blue_1"), + cls(color_code="CL_DEFAULT", color_hex_code="#1EDCC5", color_name="Default"), + cls(color_code="CL_GREEN_001", color_hex_code="#80E252", color_name="Green_1"), + cls(color_code="CL_NAVY_001", color_hex_code="#7075FF", color_name="Navy_1"), + cls(color_code="CL_ORANGE_001", color_hex_code="#FFA216", color_name="Orange_1"), + cls(color_code="CL_PINK_001", color_hex_code="#FC6CA0", color_name="Pink_1"), + cls(color_code="CL_PURPLE_001", color_hex_code="#8C56FF", color_name="Purple_1"), + cls(color_code="CL_RED_001", color_hex_code="#ED3639", color_name="Red_1"), + cls(color_code="CL_YELLOW_001", color_hex_code="#FFC543", color_name="Yellow_1"), + ] + async with in_transaction(): + await cls.bulk_create(colors) + + @classmethod + async def get_color_inventory(cls) -> list[ColorData]: + result = await cls.all().values("color_code", "color_name", "color_hex_code") + return [ColorData(**row) for row in result] diff --git a/app/models/emotion.py b/app/models/emotion.py new file mode 100644 index 0000000..748fb99 --- /dev/null +++ b/app/models/emotion.py @@ -0,0 +1,44 @@ +from tortoise import fields, models + +from app.common.utils.query_executor import QueryExecutor +from app.dtos.emotion.emotion_data import EmotionData +from app.queries.emotion_query import ( + INSERT_EMOTION_CODE_FOR_USER_QUERY, + SELECT_EMOTION_CODE_BY_USER_UUID_QUERY, +) + + +class Emotion(models.Model): + emotion_id = fields.BigIntField(primary_key=True) + emotion_code = fields.CharField(max_length=255) + user_id = fields.BinaryField(max_length=16, null=True) + + class Meta: + table = "emotion" + + @classmethod + async def create_default_emotions_by_user_id(cls, user_id: str) -> None: + emotion_codes = ["EM_HAPPY", "EM_PROUD", "EM_OKAY", "EM_TIRED", "EM_SAD", "EM_ANGRY"] + + values_placeholders = ", ".join(["(%s, UNHEX(REPLACE(%s, '-', '')))"] * len(emotion_codes)) + values = [] + + for code in emotion_codes: + values.extend([code, user_id]) + + query = f"INSERT INTO emotion (emotion_code, user_id) VALUES {values_placeholders}" + + await QueryExecutor.execute_write_query(query, tuple(values)) + + @classmethod + async def create_by_user_id(cls, user_id: str, emotion_code: str) -> None: + query = INSERT_EMOTION_CODE_FOR_USER_QUERY + values = (emotion_code, user_id) + await QueryExecutor.execute_query(query, values=values) + + @classmethod + async def get_emotions_with_details_by_user_id(cls, user_id: str) -> list[EmotionData]: + query = SELECT_EMOTION_CODE_BY_USER_UUID_QUERY + values = user_id + result = await QueryExecutor.execute_query(query, values=values, fetch_type="multiple") + return [EmotionData(**row) for row in result] diff --git a/app/models/emotion_inventory.py b/app/models/emotion_inventory.py new file mode 100644 index 0000000..3f3f149 --- /dev/null +++ b/app/models/emotion_inventory.py @@ -0,0 +1,37 @@ +from tortoise import fields, models +from tortoise.transactions import in_transaction + +from app.dtos.emotion.emotion_data import EmotionData + + +class EmotionInventory(models.Model): + emotion_inventory_id = fields.BigIntField(primary_key=True) + emotion_code = fields.CharField(max_length=255, unique=True) + emotion_name = fields.CharField(max_length=255) + + class Meta: + table = "emotion_inventory" + + @classmethod + async def create_bulk(cls) -> None: + emotions = [ + cls(emotion_code="EM_HAPPY", emotion_name="행복해요"), + cls(emotion_code="EM_PROUD", emotion_name="뿌듯해요"), + cls(emotion_code="EM_OKAY", emotion_name="그저 그래요"), + cls(emotion_code="EM_TIRED", emotion_name="피곤해요"), + cls(emotion_code="EM_SAD", emotion_name="슬퍼요"), + cls(emotion_code="EM_ANGRY", emotion_name="화나요"), + cls(emotion_code="EM_EXCITED", emotion_name="설레요"), + cls(emotion_code="EM_FUN", emotion_name="신나요"), + cls(emotion_code="EM_RELAXED", emotion_name="편안해요"), + cls(emotion_code="EM_APATHETIC", emotion_name="무기력해요"), + cls(emotion_code="EM_LONELY", emotion_name="외로워요"), + cls(emotion_code="EM_COMPLEX", emotion_name="복잡해요"), + ] + async with in_transaction(): + await cls.bulk_create(emotions) + + @classmethod + async def get_emotion_inventory(cls) -> list[EmotionData]: + result = await cls.all().values("emotion_code", "emotion_name") + return [EmotionData(**row) for row in result] diff --git a/app/models/item.py b/app/models/item.py new file mode 100644 index 0000000..0bb0f82 --- /dev/null +++ b/app/models/item.py @@ -0,0 +1,247 @@ +from tortoise import fields +from tortoise.fields import ForeignKeyRelation +from tortoise.models import Model +from tortoise.transactions import in_transaction + + +class ItemInventory(Model): + item_id = fields.BigIntField(primary_key=True) + item_category = fields.CharField(max_length=255, null=True) + item_code = fields.CharField(max_length=255, null=True) + + class Meta: + table = "item_inventory" + + @classmethod + async def create_bulk(cls) -> None: + items_data = [ + (1, "BADGE", "BG_AGAIN_001"), + (2, "BADGE", "BG_CHRISTMAS_2024"), + (3, "BADGE", "BG_FIRST"), + (4, "BADGE", "BG_MUCH_001"), + (5, "BADGE", "BG_NEW"), + (6, "BADGE", "BG_NIGHT_001"), + (7, "BADGE", "BG_SAVE_001"), + (16, "COLOR", "CL_BLUE_001"), + (17, "COLOR", "CL_DEFAULT"), + (18, "COLOR", "CL_GREEN_001"), + (19, "COLOR", "CL_NAVY_001"), + (20, "COLOR", "CL_ORANGE_001"), + (21, "COLOR", "CL_PINK_001"), + (22, "COLOR", "CL_PURPLE_001"), + (23, "COLOR", "CL_RED_001"), + (24, "COLOR", "CL_YELLOW_001"), + (25, "EMOTION", "EM_HAPPY"), + (26, "EMOTION", "EM_PROUD"), + (27, "EMOTION", "EM_OKAY"), + (28, "EMOTION", "EM_TIRED"), + (29, "EMOTION", "EM_SAD"), + (30, "EMOTION", "EM_ANGRY"), + (31, "EMOTION", "EM_EXCITED"), + (32, "EMOTION", "EM_FUN"), + (33, "EMOTION", "EM_RELAXED"), + (34, "EMOTION", "EM_APATHETIC"), + (35, "EMOTION", "EM_LONELY"), + (36, "EMOTION", "EM_COMPLEX"), + (37, "SUBSCRIPTION", "PLUS_MONTH_1"), + (38, "SUBSCRIPTION", "PLUS_YEAR_1"), + (39, "CHEESE", "CHEESE"), + (40, "POINT", "POINT"), + ] + + async with in_transaction(): + await cls.bulk_create( + [cls(item_id=item_id, item_category=category, item_code=code) for item_id, category, code in items_data] + ) + + +class ProductInventory(Model): + product_id = fields.BigIntField(primary_key=True) + price = fields.FloatField(null=True) + product_category = fields.CharField(max_length=255, null=True) + product_code = fields.CharField(max_length=255, null=True) + transaction_currency = fields.CharField(max_length=255, null=True) + + class Meta: + table = "product_inventory" + + @classmethod + async def create_bulk(cls) -> None: + products_data = [ + (1, 20, "CONSUMABLE", "PD_CL_PURPLE_001", "CHEESE"), + (2, 20, "CONSUMABLE", "PD_CL_NAVY_001", "CHEESE"), + (3, 20, "CONSUMABLE", "PD_CL_PINK_001", "CHEESE"), + (4, 20, "CONSUMABLE", "PD_CL_YELLOW_001", "CHEESE"), + (5, 20, "CONSUMABLE", "PD_CL_GREEN_001", "CHEESE"), + (6, 33, "CONSUMABLE", "PD_EM_EXCITED", "CHEESE"), + (7, 33, "CONSUMABLE", "PD_EM_FUN", "CHEESE"), + (8, 33, "CONSUMABLE", "PD_EM_RELAXED", "CHEESE"), + (9, 33, "CONSUMABLE", "PD_EM_APATHETIC", "CHEESE"), + (10, 33, "CONSUMABLE", "PD_EM_LONELY", "CHEESE"), + (11, 33, "CONSUMABLE", "PD_EM_COMPLEX", "CHEESE"), + (12, 990, "SUBSCRIPTION", "PD_PLUS_MONTH_1_KR", "KRW"), + (13, 9900, "SUBSCRIPTION", "PD_PLUS_YEAR_1_KR", "KRW"), + (14, 33, "CONSUMABLE", "PD_BG_CHRISTMAS_2024", "CHEESE"), + (15, 0, "CONSUMABLE", "PD_TEST", "CHEESE"), + ] + + async with in_transaction(): + await cls.bulk_create( + [ + cls( + product_id=pid, + price=price, + product_category=category, + product_code=code, + transaction_currency=currency, + ) + for pid, price, category, code, currency in products_data + ] + ) + + +class ItemInventoryProductInventory(Model): + item_inventory_product_inventory_id = fields.BigIntField(primary_key=True) + quantity = fields.IntField() + item_inventory: ForeignKeyRelation[ItemInventory] = fields.ForeignKeyField( + "models.ItemInventory", related_name="product_inventories" + ) + product_inventory: ForeignKeyRelation[ProductInventory] = fields.ForeignKeyField( + "models.ProductInventory", related_name="item_inventories" + ) + item_measurement = fields.CharField(max_length=255, null=True) + + class Meta: + table = "item_inventory_product_inventory" + + @classmethod + async def create_bulk(cls) -> None: + mapping_data = [ + (1, 1, 22, 1, "UNIT"), + (2, 1, 19, 2, "UNIT"), + (3, 1, 21, 3, "UNIT"), + (4, 1, 24, 4, "UNIT"), + (5, 1, 18, 5, "UNIT"), + (6, 1, 31, 6, "UNIT"), + (7, 1, 32, 7, "UNIT"), + (8, 1, 33, 8, "UNIT"), + (9, 1, 34, 9, "UNIT"), + (10, 1, 35, 10, "UNIT"), + (11, 1, 36, 11, "UNIT"), + (12, 1, 37, 12, "MONTH"), + (13, 1, 38, 13, "YEAR"), + (14, 1, 2, 14, "UNIT"), + ] + + async with in_transaction(): + await cls.bulk_create( + [ + cls( + item_inventory_product_inventory_id=id_, + quantity=quantity, + item_inventory_id=item_id, + product_inventory_id=product_id, + item_measurement=measurement, + ) + for id_, quantity, item_id, product_id, measurement in mapping_data + ] + ) + + +class RewardInventory(Model): + reward_inventory_id = fields.BigIntField(primary_key=True) + item_code = fields.CharField(max_length=255, null=True) + reward_code = fields.CharField(max_length=255, null=True) + reward_description = fields.CharField(max_length=255, null=True) + reward_name = fields.CharField(max_length=255, null=True) + + item_inventories = fields.ReverseRelation["ItemInventoryRewardInventory"] + + class Meta: + table = "reward_inventory" + + @classmethod + async def create_bulk(cls) -> None: + rewards_data = [ + (1, "RW_LV_000", "레벨업 보상"), + (2, "RW_FIRST_POST", "첫 글 작성 보상"), + (3, "RW_LONG_POST", "280자 이상의 글을 작성 보상"), + (4, "RW_CONSECUTIVE_7", "연속 7일 글 작성 보상"), + (5, "RW_EARLY_MORNING", "오전 12시~6시에 3개의 글 작성 보상"), + (6, "RW_LIKE_3_DAY", "하루 좋아요 3개"), + (7, "RW_CHEESE_50", "누적 치즈 50개를 획득하세요"), + (8, "RW_REGISTRATION", "회원가입 보상"), + (9, "RW_CHRISTMAS", "크리스마스 시즌에 접속하여 뱃지를 받으세요"), + (10, "RW_POST_2_5", "글 작성 보상 2~5개"), + (11, "RW_POST_GENERAL", "글 작성 보상"), + ] + + async with in_transaction(): + await cls.bulk_create( + [ + cls( + reward_inventory_id=reward_id, + reward_code=code, + reward_description=desc, + reward_name=None, + item_code=None, + ) + for reward_id, code, desc in rewards_data + ] + ) + + +class ItemInventoryRewardInventory(Model): + item_inventory_reward_invnetory_id = fields.BigIntField(primary_key=True) + quantity = fields.IntField() + item_inventory: ForeignKeyRelation[ItemInventory] = fields.ForeignKeyField( + "models.ItemInventory", + related_name="reward_inventories", + on_delete=fields.CASCADE, + db_column="item_inventory_id", + ) + reward_inventory: ForeignKeyRelation[RewardInventory] = fields.ForeignKeyField( + "models.RewardInventory", + related_name="item_inventories", + on_delete=fields.CASCADE, + db_column="reward_inventory_id", + ) + item_measurement = fields.CharField(max_length=255, null=True) + + class Meta: + table = "item_inventory_reward_inventory" + + @classmethod + async def create_bulk(cls) -> None: + data = [ + (1, 1, 39, 1, "UNIT"), + (2, 1, 39, 2, "UNIT"), + (3, 1, 3, 2, "UNIT"), + (4, 1, 4, 3, "UNIT"), + (5, 1, 1, 4, "UNIT"), + (6, 1, 6, 5, "UNIT"), + (7, 1, 40, 6, "UNIT"), + (8, 1, 7, 7, "UNIT"), + (9, 1, 5, 8, "UNIT"), + (10, 1, 2, 9, "UNIT"), + (11, 1, 23, 9, "UNIT"), + (12, 5, 40, 10, "UNIT"), + (13, 5, 39, 3, "UNIT"), + (14, 5, 39, 4, "UNIT"), + (15, 5, 39, 5, "UNIT"), + (16, 10, 39, 7, "UNIT"), + ] + + async with in_transaction(): + await cls.bulk_create( + [ + cls( + item_inventory_reward_invnetory_id=pk, + quantity=qty, + item_inventory_id=item_id, + reward_inventory_id=reward_id, + item_measurement=measure, + ) + for pk, qty, item_id, reward_id, measure in data + ] + ) diff --git a/src/app/v2/levels/models/level.py b/app/models/level.py similarity index 51% rename from src/app/v2/levels/models/level.py rename to app/models/level.py index 8081d0f..54612ee 100644 --- a/src/app/v2/levels/models/level.py +++ b/app/models/level.py @@ -1,14 +1,16 @@ -from typing import Any - from tortoise import fields from tortoise.models import Model -from app.v2.levels.querys.level_query import SELECT_USER_LEVEL_AND_REQUIRED_EXP_QUERY, UPDATE_USER_LEVEL_AND_EXP_QUERY -from common.utils.query_executor import QueryExecutor +from app.common.utils.query_executor import QueryExecutor +from app.dtos.level.level_data import LevelData +from app.queries.level_query import ( + SELECT_USER_LEVEL_AND_REQUIRED_EXP_QUERY, + UPDATE_USER_LEVEL_AND_EXP_QUERY, +) class Level(Model): - level_id = fields.BigIntField(pk=True) + level_id = fields.BigIntField(primary_key=True) user_exp = fields.IntField() user_level = fields.IntField() @@ -16,19 +18,14 @@ class Meta: table = "level" @classmethod - async def get_level_info(cls, user_id: str) -> Any: + async def get_level_info(cls, user_id: str) -> LevelData: query = SELECT_USER_LEVEL_AND_REQUIRED_EXP_QUERY value = user_id - return await QueryExecutor.execute_query(query, values=(value,), fetch_type="single") + result = await QueryExecutor.execute_query(query, values=(value,), fetch_type="single") + return LevelData(**result) @classmethod async def update_level_and_exp(cls, user_id: str, new_level: int, new_exp: int) -> None: query = UPDATE_USER_LEVEL_AND_EXP_QUERY values = (new_level, new_exp, user_id) await QueryExecutor.execute_query(query, values=values, fetch_type="single") - - -class LevelInventory(Model): - level_inventory_id = fields.BigIntField(pk=True) - level = fields.IntField(null=True) - required_exp = fields.IntField(null=True) diff --git a/app/models/level_inventory.py b/app/models/level_inventory.py new file mode 100644 index 0000000..79d295d --- /dev/null +++ b/app/models/level_inventory.py @@ -0,0 +1,41 @@ +from tortoise import Model, fields +from tortoise.transactions import in_transaction + + +class LevelInventory(Model): + level_inventory_id = fields.BigIntField(primary_key=True) + level = fields.IntField(null=True) + required_exp = fields.IntField(null=True) + + class Meta: + table = "level_inventory" + + @classmethod + async def create_bulk(cls) -> None: + levels = [ + cls(level=1, required_exp=15), + cls(level=2, required_exp=20), + cls(level=3, required_exp=20), + cls(level=4, required_exp=20), + cls(level=5, required_exp=20), + cls(level=6, required_exp=30), + cls(level=7, required_exp=30), + cls(level=8, required_exp=30), + cls(level=9, required_exp=30), + cls(level=10, required_exp=30), + cls(level=11, required_exp=30), + cls(level=12, required_exp=30), + cls(level=13, required_exp=30), + cls(level=14, required_exp=30), + cls(level=15, required_exp=30), + cls(level=16, required_exp=30), + cls(level=17, required_exp=30), + cls(level=18, required_exp=30), + cls(level=19, required_exp=30), + cls(level=20, required_exp=30), + cls(level=21, required_exp=30), + cls(level=22, required_exp=30), + ] + + async with in_transaction(): + await cls.bulk_create(levels) diff --git a/app/models/like.py b/app/models/like.py new file mode 100644 index 0000000..9ba8c44 --- /dev/null +++ b/app/models/like.py @@ -0,0 +1,25 @@ +from tortoise import fields +from tortoise.models import Model + +from app.common.utils.query_executor import QueryExecutor +from app.queries.like_query import SELECT_UNIQUE_LIKES_COUNT_BY_USER_TODAY_QUERY + + +class Like(Model): + likes_id = fields.BigIntField(primary_key=True) + answer_id = fields.BinaryField(null=True) + user_id = fields.BinaryField(max_length=16, null=True) + created_time = fields.DatetimeField(null=True) + modified_time = fields.DatetimeField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "likes" + + @staticmethod + async def get_unique_likes_today(user_id: str) -> int: + query = SELECT_UNIQUE_LIKES_COUNT_BY_USER_TODAY_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return int(result.get("unique_likes", 0) if result else 0) diff --git a/app/models/mission.py b/app/models/mission.py new file mode 100644 index 0000000..57223dd --- /dev/null +++ b/app/models/mission.py @@ -0,0 +1,71 @@ +from tortoise import fields +from tortoise.models import Model + +from app.common.utils.query_executor import QueryExecutor +from app.dtos.mission.mission_data import MissionData +from app.queries.mission_query import SELECT_USER_MISSIONS_QUERY, UPDATE_USER_MISSION_PROGRESS_QUERY + + +class UserMission(Model): + user_mission_id = fields.BigIntField(primary_key=True) + is_completed = fields.BooleanField(default=False) + mission_code = fields.CharField(max_length=255) + progress_count = fields.IntField(default=0) + user_id = fields.BinaryField(max_length=16) + + class Meta: + table = "user_mission" + + @classmethod + async def create_default_missions_by_user_id(cls, user_id: str) -> None: + mission_codes = [ + "MS_LV_UP", + "MS_BADGE_POST_FIRST", + "MS_BADGE_POST_280_CHAR", + "MS_BADGE_POST_CONSECUTIVE_7", + "MS_BADGE_POST_EARLY_3", + "MS_DAILY_LIKE_3_PER_DAY", + "MS_BADGE_CHEESE_TOTAL_50", + "MS_BADGE_CHRISTMAS", + "MS_DAILY_POST_GENERAL", + ] + + values_placeholders = ", ".join(["(%s, UNHEX(REPLACE(%s, '-', '')), %s, %s)"] * len(mission_codes)) + values = [] + + for code in mission_codes: + values.extend([code, user_id, False, 0]) + + query = f""" + INSERT INTO user_mission (mission_code, user_id, is_completed, progress_count) + VALUES {values_placeholders} + """ + + await QueryExecutor.execute_write_query(query, tuple(values)) + + @classmethod + async def get_user_missions_by_condition_type(cls, user_id: str) -> list[MissionData]: + query = SELECT_USER_MISSIONS_QUERY + values = (user_id,) + results = await QueryExecutor.execute_query(query, values=values, fetch_type="multiple") + return [ + MissionData( + user_mission_id=row.get("user_mission_id", 0), + mission_code=row.get("mission_code", ""), + progress_count=row.get("progress_count", 0), + is_completed=MissionData.to_bool(row.get("is_completed")), + ) + for row in results + ] + + @classmethod + async def update_user_mission_progress( + cls, + user_id: str, + mission_code: str, + new_progress_count: int, + is_completed: bool, + ) -> None: + query = UPDATE_USER_MISSION_PROGRESS_QUERY + values = (new_progress_count, int(is_completed), user_id, mission_code) + await QueryExecutor.execute_query(query, values=values, fetch_type="single") diff --git a/app/models/mission_inventory.py b/app/models/mission_inventory.py new file mode 100644 index 0000000..ea32c6e --- /dev/null +++ b/app/models/mission_inventory.py @@ -0,0 +1,71 @@ +from tortoise import Model, fields +from tortoise.transactions import in_transaction + + +class MissionInventory(Model): + mission_inventory_id = fields.BigIntField(primary_key=True) + condition_type = fields.CharField(max_length=255) + mission_code = fields.CharField(max_length=255) + mission_description = fields.CharField(max_length=255) + mission_name = fields.CharField(max_length=255) + reward_code = fields.CharField(max_length=255) + target_count = fields.IntField() + + class Meta: + table = "mission_inventory" + + @classmethod + async def create_bulk(cls) -> None: + data = [ + (1, "하", "MS_LV_UP", "XP를 누적하여 다음 레벨에 도달했어요!", "레벨 업 달성", "RW_LV_000", 1), + (2, "하", "MS_BADGE_POST_FIRST", "첫 글을 첫 글을 작성했어요!", "첫 글 작성", "RW_FIRST_POST", 1), + (3, "하", "MS_BADGE_POST_280_CHAR", "280자 이상 글을 1회 작성했어요!", "긴 글 작성", "RW_LONG_POST", 1), + ( + 4, + "하", + "MS_BADGE_POST_CONSECUTIVE_7", + "연속으로 7일 작성했어요!", + "연속 7일 글 작성", + "RW_CONSECUTIVE_7", + 1, + ), + ( + 5, + "하", + "MS_BADGE_POST_EARLY_3", + "새벽 시간에 글 3회 작성했어요!", + "이른 아침 작가", + "RW_EARLY_MORNING", + 3, + ), + ( + 6, + "하", + "MS_DAILY_LIKE_3_PER_DAY", + "하루에 다른 글 3개에 좋아요를 누르세요", + "하루 좋아요 3개", + "RW_LIKE_3_DAY", + 1, + ), + (7, "중", "MS_BADGE_CHEESE_TOTAL_50", "치즈 50개를 모았어요!", "치즈 수집가", "RW_CHEESE_50", 1), + (8, "하", "MS_BADGE_REGISTRATION", "회원가입 보상이에요!", "환영 뱃지", "RW_REGISTRATION", 1), + (9, "하", "MS_BADGE_CHRISTMAS", "2024 크리스마스 한정판 배지예요!", "크리스마스 뱃지", "RW_CHRISTMAS", 1), + (10, "하", "MS_SINGLE_POST_2_5", "2~5회의 기록을 작성했어요!", "2~5 글 작성", "RW_POST_2_5", 1), + (11, "하", "MS_DAILY_POST_GENERAL", "기록을 작성했어요!", "일반 작성 보상", "RW_POST_GENERAL", 1), + ] + + async with in_transaction(): + await cls.bulk_create( + [ + cls( + mission_inventory_id=mid, + condition_type=ctype, + mission_code=mcode, + mission_description=desc, + mission_name=name, + reward_code=rcode, + target_count=target, + ) + for mid, ctype, mcode, desc, name, rcode, target in data + ] + ) diff --git a/src/app/v2/notices/models/notice.py b/app/models/notice.py similarity index 83% rename from src/app/v2/notices/models/notice.py rename to app/models/notice.py index f189219..7be0a48 100644 --- a/src/app/v2/notices/models/notice.py +++ b/app/models/notice.py @@ -1,29 +1,25 @@ from typing import Optional from tortoise import fields -from tortoise.fields import ForeignKeyRelation from tortoise.models import Model -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor +from app.common.utils.query_executor import QueryExecutor class Notice(Model): - notice_id = fields.BigIntField(pk=True) - title = fields.CharField(max_length=255, null=False) + notice_id = fields.BigIntField(primary_key=True) + answer_id = fields.BigIntField(null=True) content = fields.TextField(null=True) - is_read = fields.BooleanField(default=False) created_at = fields.DatetimeField(auto_now_add=True) - link = fields.CharField(max_length=255, null=True) is_internal = fields.BooleanField(default=False) - answer_id = fields.BigIntField(null=True) + is_read = fields.BooleanField(default=False) + link = fields.CharField(max_length=255, null=True) + title = fields.CharField(max_length=255, null=False) + user_id = fields.BinaryField(max_length=16, null=True) date = fields.DateField(null=True) + badge_code = fields.CharField(max_length=255, null=True) reward_type = fields.CharField(max_length=255, null=True) - user: ForeignKeyRelation[User] = fields.ForeignKeyField( - "models.User", related_name="notices", on_delete=fields.CASCADE - ) - class Meta: table = "notice" diff --git a/app/models/purchase_history.py b/app/models/purchase_history.py new file mode 100644 index 0000000..4f78435 --- /dev/null +++ b/app/models/purchase_history.py @@ -0,0 +1,166 @@ +# from datetime import datetime +# from typing import Optional +# +# from tortoise import fields +# from tortoise.fields import ForeignKeyRelation +# from tortoise.models import Model +# +# from app.common.utils.query_executor import QueryExecutor +# from app.models.user import User +# +# +# class Subscription(Model): +# subscription_id = fields.BigIntField(primary_key=True, description="Primary key for the Subscription") +# product_code = fields.CharField(max_length=255, null=False, description="Product code of the subscription") +# status = fields.CharField(max_length=255, null=False, description="Status of the subscription") +# current_transaction_id = fields.CharField(max_length=255, null=False, description="Current transaction ID") +# expires_date = fields.DatetimeField(null=False, description="Expiration date of the subscription") +# created_at = fields.DatetimeField(auto_now_add=True, description="When the subscription was created") +# updated_at = fields.DatetimeField(auto_now=True, description="Last updated timestamp") +# +# user: ForeignKeyRelation["User"] = fields.ForeignKeyField( +# "models.User", +# related_name="subscriptions", +# on_delete=fields.CASCADE, +# description="User linked to the subscription", +# ) +# purchase_histories = fields.ReverseRelation["PurchaseHistory"] +# +# class Meta: +# table = "subscription" +# +# @classmethod +# async def get_subscription_by_user_id_and_product_code( +# cls, user_id: str, product_code: str +# ) -> Optional["Subscription"]: +# query = """ +# SELECT * FROM subscription +# WHERE user_id = UNHEX(REPLACE(%s, '-', '')) AND product_code = %s +# LIMIT 1; +# """ +# values = (user_id, product_code) +# +# result = await QueryExecutor.execute_query(query, values=values, fetch_type="single") +# +# if result: +# return cls(**result) +# return None +# +# @classmethod +# async def create_or_update_subscription( +# cls, +# user_id: str, +# product_code: str, +# transaction_id: str, +# expires_date_ms: int, +# status: str, +# ) -> "Subscription": +# query = """ +# INSERT INTO subscription (user_id, product_code, status, current_transaction_id, expires_date) +# VALUES (UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, FROM_UNIXTIME(%s / 1000)) +# ON DUPLICATE KEY UPDATE +# current_transaction_id = VALUES(current_transaction_id), +# expires_date = VALUES(expires_date), +# status = VALUES(status); +# """ +# values = (user_id, product_code, status, transaction_id, expires_date_ms) +# +# await QueryExecutor.execute_query(query, values=values, fetch_type="none") +# +# return cls( +# user_id=user_id, +# product_code=product_code, +# status=status, +# current_transaction_id=transaction_id, +# expires_date=datetime.fromtimestamp(expires_date_ms / 1000), +# ) +# +# @classmethod +# async def update_subscription( +# cls, user_id: str, product_code: str, transaction_id: str, expires_date_ms: int +# ) -> None: +# query = """ +# UPDATE subscription +# SET current_transaction_id = %s, +# expires_date = FROM_UNIXTIME(%s / 1000), +# status = %s +# WHERE user_id = UNHEX(REPLACE(%s, '-', '')) +# AND product_code = %s; +# """ +# values = (transaction_id, expires_date_ms, "active", user_id, product_code) +# +# await QueryExecutor.execute_query(query, values=values, fetch_type="single") +# +# +# class PurchaseHistory(Model): +# purchase_history_id = fields.BigIntField(primary_key=True, description="Primary key for the Purchase History") +# product_code = fields.CharField(max_length=255, null=False, description="Product code of the purchase") +# transaction_id = fields.CharField(max_length=255, unique=True, null=False, description="Transaction ID") +# original_transaction_id = fields.CharField(max_length=255, null=True, description="Original transaction ID") +# status = fields.CharField(max_length=255, null=False, description="Purchase status") +# expires_date = fields.DatetimeField(null=True, description="Expiration date of the purchase") +# purchase_date = fields.DatetimeField(null=False, description="Date of the purchase") +# quantity = fields.IntField(default=1, description="Quantity of items purchased") +# receipt_data = fields.TextField(null=True, description="Raw receipt data from Apple") +# created_at = fields.DatetimeField(auto_now_add=True, description="When the purchase was made") +# updated_at = fields.DatetimeField(auto_now=True, description="Last updated timestamp") +# +# user: ForeignKeyRelation["User"] = fields.ForeignKeyField( +# "models.User", +# related_name="purchase_histories", +# on_delete=fields.CASCADE, +# description="User linked to the purchase", +# ) +# +# subscription: Optional[ForeignKeyRelation["Subscription"]] = fields.ForeignKeyField( +# "models.Subscription", +# related_name="purchase_histories", +# null=True, +# on_delete=fields.SET_NULL, +# description="Linked subscription", +# ) +# +# class Meta: +# table = "purchase_history" +# +# @classmethod +# async def create_purchase_history( +# cls, +# user_id: str, +# subscription_id: Optional[int], +# product_code: str, +# transaction_id: str, +# original_transaction_id: str, +# status: str, +# expires_date_ms: Optional[int], +# purchase_date_ms: int, +# receipt_data: str, +# quantity: int = 1, +# ) -> None: +# query = """ +# INSERT INTO purchase_history ( +# user_id, subscription_id, product_code, transaction_id, +# original_transaction_id, status, expires_date, purchase_date, +# quantity, receipt_data, created_at, updated_at +# ) +# VALUES ( +# UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, +# %s, %s, FROM_UNIXTIME(%s / 1000), FROM_UNIXTIME(%s / 1000), +# %s, %s, NOW(), NOW() +# ); +# """ +# +# values = ( +# user_id, +# subscription_id, +# product_code, +# transaction_id, +# original_transaction_id, +# status, +# expires_date_ms, +# purchase_date_ms, +# quantity, +# receipt_data, +# ) +# +# await QueryExecutor.execute_query(query, values=values, fetch_type="single") diff --git a/src/app/v2/users/models/refresh_token.py b/app/models/refresh_token.py similarity index 59% rename from src/app/v2/users/models/refresh_token.py rename to app/models/refresh_token.py index 15d5e03..133a4da 100644 --- a/src/app/v2/users/models/refresh_token.py +++ b/app/models/refresh_token.py @@ -3,10 +3,10 @@ class RefreshToken(Model): - refresh_token_id = fields.BigIntField(pk=True) + refresh_token_id = fields.BigIntField(primary_key=True) access_token = fields.CharField(max_length=255) refresh_token = fields.CharField(max_length=255) - user_id = fields.BinaryField(max_length=16) # foreign key로 사용되지는 않지만 binary field로 선언 + user_id = fields.BinaryField(max_length=16) class Meta: table = "refresh_token" diff --git a/src/app/v2/teller_cards/models/teller_card.py b/app/models/teller_card.py similarity index 62% rename from src/app/v2/teller_cards/models/teller_card.py rename to app/models/teller_card.py index 7314021..767a48d 100644 --- a/src/app/v2/teller_cards/models/teller_card.py +++ b/app/models/teller_card.py @@ -1,17 +1,16 @@ -from typing import Any, Optional - from tortoise import fields from tortoise.models import Model -from app.v2.teller_cards.querys.teller_card_query import ( +from app.common.utils.query_executor import QueryExecutor +from app.dtos.teller_card.teller_card_data import TellerCardData +from app.queries.teller_card_query import ( PATCH_TELLER_CARD_QUERY, SELECT_TELLER_CARD_INFO_BY_USER_UUID_QUERY, ) -from common.utils.query_executor import QueryExecutor class TellerCard(Model): - teller_card_id = fields.BigIntField(pk=True) + teller_card_id = fields.BigIntField(primary_key=True) activate_badge_code = fields.CharField(max_length=255, null=True) activate_color_code = fields.CharField(max_length=255, null=True) @@ -19,14 +18,15 @@ class Meta: table = "teller_card" @classmethod - async def get_teller_card_info_by_user_id(cls, user_id: str) -> Any: # type ignore + async def get_teller_card_info_by_user_id(cls, user_id: str) -> TellerCardData: query = SELECT_TELLER_CARD_INFO_BY_USER_UUID_QUERY value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") # type ignore + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return TellerCardData(**result) @classmethod async def patch_teller_card_info_by_user_id( - cls, user_id: str, badge_code: Optional[str] = None, color_code: Optional[str] = None + cls, user_id: str, badge_code: str | None = None, color_code: str | None = None ) -> None: query = PATCH_TELLER_CARD_QUERY values = (badge_code, color_code, user_id) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..3c53b9d --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional + +from tortoise import Tortoise, fields +from tortoise.fields import ForeignKeyRelation +from tortoise.models import Model + +from app.common.utils.query_executor import QueryExecutor +from app.core.configs import settings +from app.dtos.user.user_data import UserData +from app.dtos.user.user_dto import UserProfileData +from app.models.refresh_token import RefreshToken +from app.queries.user_query import ( + SELECT_USER_INFO_BY_USER_UUID_QUERY, + SELECT_USER_PROFILE_BY_USER_ID_QUERY, +) + + +class User(Model): + id = fields.BigIntField(primary_key=True) # Auto Increment Primary Key + user_id = fields.BinaryField(max_length=16, description="UUID PK in binary form") + allow_notification = fields.BinaryField(null=True) + birth_date = fields.CharField(max_length=8, null=True) + created_time = fields.DatetimeField(auto_now_add=True) + gender = fields.CharField(max_length=16, null=True) + job = fields.IntField() + mbti = fields.CharField(max_length=8, null=True) + nickname = fields.CharField(max_length=16) + purpose = fields.CharField(max_length=16) + push_token = fields.CharField(max_length=255, null=True) + social_id = fields.CharField(max_length=255) + social_login_type = fields.CharField(max_length=16) + user_status = fields.BinaryField() + withdraw_period = fields.DatetimeField(null=True) + refresh_token: Optional[ForeignKeyRelation[RefreshToken]] = fields.ForeignKeyField( + "models.RefreshToken", + related_name="users", + db_column="refresh_token_id", + null=True, + ) + is_premium = fields.BinaryField() + profile_url = fields.CharField( + max_length=255, + default="https://miro.medium.com/v2/resize:fit:1400/format:webp/1*dh7Xy5tFvRj7n2wf1UweAw.png", + ) + premium_started_at = fields.DatetimeField(null=True) + cheese_manager_id = fields.BigIntField(null=True) + teller_card_id = fields.BigIntField(null=True) + level_id = fields.BigIntField(null=True) + + class Meta: + table = "user" + + @classmethod + async def create_user( + cls, + social_id: str, + social_login_type: str, + nickname: str, + purpose: str, + job: int, + cheese_manager_id: int, + teller_card_id: int, + level_id: int, + allow_notification: bool = False, + birth_date: str | None = None, + gender: str = "female", + mbti: str | None = None, + push_token: str | None = None, + refresh_token: RefreshToken | None = None, + is_premium: bool = False, + profile_url: str | None = "", + ) -> str: + + user_id = str(uuid.uuid4()) + created_time = datetime.now(settings.db_zoneinfo).strftime("%Y-%m-%d %H:%M:%S") + allow_notification_byte = b"\x01" if allow_notification else b"\x00" + is_premium_byte = b"\x01" if is_premium else b"\x00" + user_status_byte = b"\x01" # 항상 TRUE로 설정한 부분 + + query = """ + INSERT INTO user ( + user_id, social_id, social_login_type, nickname, purpose, job, + cheese_manager_id, teller_card_id, level_id, + allow_notification, birth_date, gender, mbti, + push_token, refresh_token_id, is_premium, profile_url, user_status, created_time + ) + VALUES ( + UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, %s, %s, + %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s + ); + """ + + await QueryExecutor.execute_write_query( + query, + ( + user_id, + social_id, + social_login_type, + nickname, + purpose, + job, + cheese_manager_id, + teller_card_id, + level_id, + allow_notification_byte, + birth_date, + gender, + mbti, + push_token, + refresh_token, + is_premium_byte, + profile_url, + user_status_byte, + created_time, + ), + ) + return user_id + + @classmethod + async def get_user_profile_by_user_id(cls, user_id: str) -> UserProfileData: + query = SELECT_USER_PROFILE_BY_USER_ID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return UserProfileData( + user_id=result.get("user_id", ""), + nickname=result.get("nickname", ""), + profile_url=result.get("profile_url", ""), + is_premium=result.get("is_premium") != b"\x00", + user_status=result.get("user_status") != b"\x00", + cheese_manager_id=result.get("cheese_manager_id", 0), + teller_card_id=result.get("teller_card_id", 0), + level_id=result.get("level_id", 0), + allow_notification=result.get("allow_notification") != b"\x00", + ) + + @classmethod + async def get_user_info_by_user_id(cls, user_id: str) -> UserData: + query = SELECT_USER_INFO_BY_USER_UUID_QUERY + value = user_id + result = await QueryExecutor.execute_query(query, values=value, fetch_type="single") + return UserData(**result) + + @classmethod + def format_user_id(cls, user_id_bytes: bytes) -> str: + return str(uuid.UUID(bytes=user_id_bytes)) + + @classmethod + def format_user_ids(cls, user_ids: list[bytes]) -> str: + return ", ".join([f"UNHEX(REPLACE('{str(uuid.UUID(bytes=user_id))}', '-', ''))" for user_id in user_ids]) + + @classmethod + async def bulk_update_is_premium(cls, user_ids: list[bytes]) -> None: + query = f""" + UPDATE user + SET is_premium = FALSE + WHERE user_id IN ({cls.format_user_ids(user_ids)}); + """ + await Tortoise.get_connection("default").execute_query(query) + + @staticmethod + def uuid_str_to_bytes(s: str) -> bytes: + return uuid.UUID(s).bytes + + @staticmethod + def uuid_bytes_to_str(b: bytes) -> str: + return str(uuid.UUID(bytes=b)) diff --git a/src/app/v2/emotions/querys/__init__.py b/app/queries/__init__.py similarity index 100% rename from src/app/v2/emotions/querys/__init__.py rename to app/queries/__init__.py diff --git a/src/app/v2/answers/querys/answer_query.py b/app/queries/answer_query.py similarity index 83% rename from src/app/v2/answers/querys/answer_query.py rename to app/queries/answer_query.py index ff5c6f8..5f5b138 100644 --- a/src/app/v2/answers/querys/answer_query.py +++ b/app/queries/answer_query.py @@ -1,10 +1,10 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY +from app.queries.user_query import USER_ID_QUERY SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY = f"SELECT COUNT(*) as answer_count FROM answer WHERE {USER_ID_QUERY}" SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY_V2 = f""" - SELECT COUNT(*) as answer_count - FROM answer + SELECT COUNT(*) as answer_count + FROM answer WHERE {USER_ID_QUERY} AND created_time >= '2024-12-16 00:00:00' """ diff --git a/src/app/v2/badges/querys/badge_query.py b/app/queries/badge_query.py similarity index 74% rename from src/app/v2/badges/querys/badge_query.py rename to app/queries/badge_query.py index 2dae63e..ae3364d 100644 --- a/src/app/v2/badges/querys/badge_query.py +++ b/app/queries/badge_query.py @@ -1,4 +1,4 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY +from app.queries.user_query import USER_ID_QUERY SELECT_BADGE_COUNT_BY_USER_UUID_QUERY = f""" SELECT COUNT(*) as badge_count @@ -7,7 +7,7 @@ """ SELECT_BADGE_BY_USER_UUID_QUERY = f""" - SELECT + SELECT b.badge_code, bi.badge_name, bi.badge_condition, @@ -17,11 +17,6 @@ WHERE {USER_ID_QUERY} """ -SELECT_BADGE_CODE_BY_USER_UUID_QUERY = f""" - SELECT badge_code - FROM badge - WHERE {USER_ID_QUERY} -""" INSERT_BADGE_CODE_FOR_USER_QUERY = f""" INSERT INTO badge (badge_code, user_id) SELECT %s, user_id diff --git a/src/app/v2/colors/querys/color_query.py b/app/queries/color_query.py similarity index 80% rename from src/app/v2/colors/querys/color_query.py rename to app/queries/color_query.py index 0121d8d..bd425dd 100644 --- a/src/app/v2/colors/querys/color_query.py +++ b/app/queries/color_query.py @@ -1,8 +1,8 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY +from app.queries.user_query import USER_ID_QUERY SELECT_COLOR_CODE_BY_USER_UUID_QUERY = f""" - SELECT color_code - FROM color + SELECT color_code + FROM color WHERE {USER_ID_QUERY} """ @@ -14,7 +14,7 @@ """ SELECT_COLOR_BY_USER_UUID_QUERY = f""" - SELECT + SELECT c.color_code, ci.color_name, ci.color_hex_code diff --git a/src/app/v2/emotions/querys/emotion_query.py b/app/queries/emotion_query.py similarity index 57% rename from src/app/v2/emotions/querys/emotion_query.py rename to app/queries/emotion_query.py index 813d727..5dc14a0 100644 --- a/src/app/v2/emotions/querys/emotion_query.py +++ b/app/queries/emotion_query.py @@ -1,8 +1,10 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY +from app.queries.user_query import USER_ID_QUERY SELECT_EMOTION_CODE_BY_USER_UUID_QUERY = f""" - SELECT emotion_code - FROM emotion + SELECT e.emotion_code, ei.emotion_name + FROM emotion e + JOIN emotion_inventory ei + ON e.emotion_code = ei.emotion_code WHERE {USER_ID_QUERY} """ diff --git a/src/app/v2/levels/querys/level_query.py b/app/queries/level_query.py similarity index 82% rename from src/app/v2/levels/querys/level_query.py rename to app/queries/level_query.py index e92b0fe..28d4d91 100644 --- a/src/app/v2/levels/querys/level_query.py +++ b/app/queries/level_query.py @@ -1,12 +1,12 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY +from app.queries.user_query import USER_ID_QUERY SELECT_USER_LEVEL_AND_EXP_BY_USER_UUID_QUERY = f""" - SELECT - l.user_exp AS level_exp, + SELECT + l.user_exp AS level_exp, l.user_level AS level_level - FROM + FROM user u - JOIN + JOIN level l ON u.level_id = l.level_id WHERE {USER_ID_QUERY} """ @@ -28,17 +28,17 @@ """ SELECT_USER_LEVEL_AND_REQUIRED_EXP_QUERY = f""" - SELECT + SELECT l.user_exp AS level_exp, l.user_level AS level_level, li.required_exp AS required_exp - FROM + FROM user u - JOIN + JOIN level l ON u.level_id = l.level_id - JOIN + JOIN level_inventory li ON l.user_level = li.level - WHERE + WHERE {USER_ID_QUERY} LIMIT 1; """ diff --git a/src/app/v2/likes/querys/like_query.py b/app/queries/like_query.py similarity index 100% rename from src/app/v2/likes/querys/like_query.py rename to app/queries/like_query.py diff --git a/src/app/v2/missions/querys/mission_query.py b/app/queries/mission_query.py similarity index 87% rename from src/app/v2/missions/querys/mission_query.py rename to app/queries/mission_query.py index 40f5671..1a57e43 100644 --- a/src/app/v2/missions/querys/mission_query.py +++ b/app/queries/mission_query.py @@ -1,5 +1,3 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY - SELECT_USER_MISSIONS_QUERY = """ SELECT um.* FROM user_mission um diff --git a/src/app/v2/teller_cards/querys/teller_card_query.py b/app/queries/teller_card_query.py similarity index 87% rename from src/app/v2/teller_cards/querys/teller_card_query.py rename to app/queries/teller_card_query.py index 7e64673..74e5001 100644 --- a/src/app/v2/teller_cards/querys/teller_card_query.py +++ b/app/queries/teller_card_query.py @@ -1,7 +1,7 @@ -from app.v2.users.querys.user_query import USER_ID_QUERY +from app.queries.user_query import USER_ID_QUERY SELECT_TELLER_CARD_INFO_BY_USER_UUID_QUERY = f""" - SELECT + SELECT tc.activate_badge_code, bi.badge_name, bi.badge_middle_name, @@ -9,16 +9,16 @@ FROM teller_card tc JOIN badge_inventory bi ON tc.activate_badge_code = bi.badge_code WHERE tc.teller_card_id = ( - SELECT u.teller_card_id - FROM user u + SELECT u.teller_card_id + FROM user u WHERE {USER_ID_QUERY} ) """ PATCH_TELLER_CARD_QUERY = """ UPDATE teller_card - SET - activate_badge_code = COALESCE(%s, activate_badge_code), + SET + activate_badge_code = COALESCE(%s, activate_badge_code), activate_color_code = COALESCE(%s, activate_color_code) WHERE teller_card_id = ( SELECT u.teller_card_id diff --git a/src/app/v2/users/querys/user_query.py b/app/queries/user_query.py similarity index 77% rename from src/app/v2/users/querys/user_query.py rename to app/queries/user_query.py index 9966e95..18d0ce8 100644 --- a/src/app/v2/users/querys/user_query.py +++ b/app/queries/user_query.py @@ -4,17 +4,21 @@ SELECT_USER_PROFILE_BY_USER_ID_QUERY = f""" - SELECT + SELECT u.nickname, + u.profile_url, u.is_premium, + u.user_status, u.cheese_manager_id, + u.teller_card_id, + u.level_id, u.allow_notification FROM user u WHERE {USER_ID_QUERY} """ SELECT_USER_INFO_BY_USER_UUID_QUERY = f""" - SELECT + SELECT u.nickname, u.cheese_manager_id FROM user u @@ -27,10 +31,3 @@ FROM user u WHERE {USER_ID_QUERY} """ - -UPDATE_PREMIUM_STATUS_QUERY = f""" - UPDATE `user` - SET `is_premium` = %s, - `premium_started_at` = %s - WHERE {USER_ID_QUERY} - """ diff --git a/src/app/v2/emotions/services/__init__.py b/app/services/__init__.py similarity index 100% rename from src/app/v2/emotions/services/__init__.py rename to app/services/__init__.py diff --git a/app/services/answer_service.py b/app/services/answer_service.py new file mode 100644 index 0000000..d73adab --- /dev/null +++ b/app/services/answer_service.py @@ -0,0 +1,60 @@ +from datetime import datetime, timedelta + +from app.core.configs import settings +from app.models.answer import Answer + + +class AnswerService: + + @classmethod + async def create_answer( + cls, + user_id: str, + content: str, + date: str, + like_count: int = 0, + ) -> None: + await Answer.create_answer( + user_id=user_id, + content=content, + date=date, + like_count=like_count, + ) + + @classmethod + async def get_answer_count(cls, user_id: str) -> int: + """ + 과거부터 현재까지 총 답변 수 + """ + return await Answer.get_answer_count_by_user_id(user_id=user_id) + + @classmethod + async def get_answer_record(cls, user_id: str) -> int: + now = datetime.now(settings.db_zoneinfo) + + if now.hour < 6: + now -= timedelta(days=1) + + end_date = now + start_date = end_date - timedelta(days=100) + + all_answers = await Answer.get_all_by_user_id(user_id, start_date, end_date) + + record = 0 + target_date = end_date + + if all_answers: + for answer in all_answers: + answer_date = answer.date + + if answer_date == target_date.date(): # 날짜만 비교 + record += 1 + target_date = target_date - timedelta(days=1) + else: + break + + return record + + @classmethod + async def calculate_consecutive_answer_points(cls, user_id: str) -> int: + return min(await cls.get_answer_record(user_id=user_id), 10) diff --git a/app/services/badge_service.py b/app/services/badge_service.py new file mode 100644 index 0000000..1ab1f62 --- /dev/null +++ b/app/services/badge_service.py @@ -0,0 +1,31 @@ +from app.dtos.badge.badge_dto import BadgeDTO +from app.models.badge import Badge +from app.models.badge_inventory import BadgeInventory + + +class BadgeService: + + @classmethod + async def create_badge(cls, user_id: str, badge_code: str) -> None: + await Badge.create_by_user_id(user_id=user_id, badge_code=badge_code) + + @classmethod + async def create_badge_inventory(cls) -> None: + await BadgeInventory.create_bulk() + + @classmethod + async def get_badges_with_details_by_user_id(cls, user_id: str) -> list[BadgeDTO]: + badges = await Badge.get_badges_with_details_by_user_id(user_id=user_id) + return [ + BadgeDTO( + badgeCode=badge.badge_code, + badgeName=badge.badge_name, + badgeCondition=badge.badge_condition, + badgeMiddleName=badge.badge_middle_name, + ) + for badge in badges + ] + + @classmethod + async def get_badge_count(cls, user_id: str) -> int: + return await Badge.get_badge_count_by_user_id(user_id=user_id) diff --git a/app/services/color_service.py b/app/services/color_service.py new file mode 100644 index 0000000..8f865ba --- /dev/null +++ b/app/services/color_service.py @@ -0,0 +1,29 @@ +from app.dtos.color.color_dto import ColorDTO +from app.models.color import Color +from app.models.color_inventory import ColorInventory +from app.models.user import User + + +class ColorService: + + @classmethod + async def create_color_inventory(cls) -> None: + await ColorInventory.create_bulk() + + @classmethod + async def get_colors_with_details_by_user_id(cls, user_id: str) -> list[ColorDTO]: + user = await User.get_user_profile_by_user_id(user_id=user_id) + + if user.is_premium: + colors = await ColorInventory.get_color_inventory() + else: + colors = await Color.get_colors_with_details_by_user_id(user_id=user_id) + + return [ + ColorDTO( + colorCode=color.color_code, + colorName=color.color_name, + colorHexCode=color.color_hex_code, + ) + for color in colors + ] diff --git a/app/services/emotion_service.py b/app/services/emotion_service.py new file mode 100644 index 0000000..7ec593b --- /dev/null +++ b/app/services/emotion_service.py @@ -0,0 +1,30 @@ +from app.common.constants.emotion_dict import EMOTION_DICT +from app.dtos.emotion.emotion_data import EmotionData +from app.dtos.emotion.emotion_dto import EmotionDTO +from app.models.emotion import Emotion +from app.models.emotion_inventory import EmotionInventory +from app.models.user import User + + +class EmotionService: + + @classmethod + async def create_emotion_inventory(cls) -> None: + await EmotionInventory.create_bulk() + + @classmethod + async def mapping_emotion_list(cls, user_id: str) -> EmotionDTO: + user = await User.get_user_profile_by_user_id(user_id=user_id) + + if user.is_premium: + emotions = await EmotionInventory.get_emotion_inventory() + else: + emotions = await Emotion.get_emotions_with_details_by_user_id(user_id=user_id) + + return EmotionDTO(emotionList=await cls.get_mapped_emotions(emotions)) + + @classmethod + async def get_mapped_emotions(cls, emotions: list[EmotionData]) -> list[int]: + return [ + value for value in (EMOTION_DICT.get(emotion.emotion_code) for emotion in emotions) if value is not None + ] diff --git a/app/services/item_service.py b/app/services/item_service.py new file mode 100644 index 0000000..b06523d --- /dev/null +++ b/app/services/item_service.py @@ -0,0 +1,29 @@ +from app.models.item import ( + ItemInventory, + ItemInventoryProductInventory, + ItemInventoryRewardInventory, + ProductInventory, + RewardInventory, +) + + +class ItemService: + @classmethod + async def create_item_inventory(cls) -> None: + await ItemInventory.create_bulk() + + @classmethod + async def create_product_inventory(cls) -> None: + await ProductInventory.create_bulk() + + @classmethod + async def create_link_item_product(cls) -> None: + await ItemInventoryProductInventory.create_bulk() + + @classmethod + async def create_reward_inventory(cls) -> None: + await RewardInventory.create_bulk() + + @classmethod + async def create_link_item_reward(cls) -> None: + await ItemInventoryRewardInventory.create_bulk() diff --git a/src/app/v2/levels/services/level_service.py b/app/services/level_service.py similarity index 61% rename from src/app/v2/levels/services/level_service.py rename to app/services/level_service.py index a723d00..26357ba 100644 --- a/src/app/v2/levels/services/level_service.py +++ b/app/services/level_service.py @@ -1,46 +1,44 @@ -from fastapi import HTTPException - -from app.v2.answers.services.answer_service import AnswerService -from app.v2.levels.dtos.level_dto import LevelDTO, LevelInfoDTO -from app.v2.levels.models.level import Level +from app.dtos.level.level_dto import LevelDTO +from app.dtos.level.level_info_dto import LevelInfoDTO +from app.models.answer import Answer +from app.models.level import Level +from app.models.level_inventory import LevelInventory +from app.services.answer_service import AnswerService class LevelService: @classmethod - async def get_level_info(cls, user_id: str) -> LevelDTO: - level_data = await Level.get_level_info(user_id=user_id) - if level_data is None: - raise HTTPException(status_code=404, detail="Level info not found") - return LevelDTO.builder(level=level_data) + async def create_level_inventory(cls) -> None: + await LevelInventory.create_bulk() @classmethod - async def get_level_info_add_answer_days(cls, user_id: str) -> LevelInfoDTO: - level_dto = await cls.get_level_info(user_id=user_id) + async def get_level(cls, user_id: str) -> LevelDTO: + level = await Level.get_level_info(user_id=user_id) + return LevelDTO(level=level.level_level, requiredExp=level.required_exp, currentExp=level.level_exp) - if level_dto.requiredExp is None: - raise ValueError("Required experience cannot be None") + @classmethod + async def get_level_info_add_answer_days(cls, user_id: str) -> LevelInfoDTO: + level_dto = await cls.get_level(user_id=user_id) needs_to_level_up = await cls.calculate_days_to_level_up( user_id=user_id, current_exp=level_dto.currentExp, required_exp=level_dto.requiredExp, ) - return LevelInfoDTO.builder( - level_dto=await cls.get_level_info(user_id=user_id), - days_to_level_up=needs_to_level_up, + + return LevelInfoDTO( + levelDto=await cls.get_level(user_id=user_id), + daysToLevelUp=needs_to_level_up, ) @classmethod async def level_up(cls, user_id: str) -> int: - level_dto = await cls.get_level_info(user_id=user_id) + level_dto = await cls.get_level(user_id=user_id) level = level_dto.level current_exp = level_dto.currentExp required_exp = level_dto.requiredExp - if current_exp is None or required_exp is None: - raise ValueError("Experience values cannot be None") - if current_exp >= required_exp: new_exp = current_exp - required_exp new_level = level + 1 @@ -51,7 +49,7 @@ async def level_up(cls, user_id: str) -> int: @classmethod async def add_exp(cls, user_id: str, exp: int) -> None: - level_dto = await cls.get_level_info(user_id=user_id) + level_dto = await cls.get_level(user_id=user_id) current_exp = level_dto.currentExp new_exp = current_exp + exp @@ -63,7 +61,7 @@ async def calculate_days_to_level_up(cls, user_id: str, current_exp: int, requir remaining_exp = required_exp - current_exp days_needed = 0 - answer_count = await AnswerService.get_answer_count_v2(user_id=user_id) + 1 + answer_count = await Answer.get_answer_count_by_user_id_v2(user_id=user_id) + 1 bonus_points = await AnswerService.calculate_consecutive_answer_points(user_id=user_id) while remaining_exp > 0: diff --git a/src/app/v2/missions/services/mission_service.py b/app/services/mission_service.py similarity index 63% rename from src/app/v2/missions/services/mission_service.py rename to app/services/mission_service.py index 039df56..0ffedc3 100644 --- a/src/app/v2/missions/services/mission_service.py +++ b/app/services/mission_service.py @@ -1,31 +1,55 @@ import asyncio -from datetime import date, datetime, timedelta, timezone -from typing import Optional +from datetime import datetime, timedelta, timezone -import pytz -from fastapi import HTTPException +from fastapi import HTTPException, status from tortoise.exceptions import DoesNotExist from tortoise.transactions import atomic -from app.v2.answers.services.answer_service import AnswerService -from app.v2.badges.services.badge_service import BadgeService -from app.v2.cheese_managers.services.cheese_service import CheeseService -from app.v2.colors.services.color_service import ColorService -from app.v2.items.models.item import ItemInventory, ItemInventoryRewardInventory, RewardInventory -from app.v2.levels.services.level_service import LevelService -from app.v2.likes.models.like import Like -from app.v2.missions.dtos.mission_dto import UserMissionDTO -from app.v2.missions.dtos.reward_dto import RewardDTO -from app.v2.missions.models.mission import MissionInventory, UserMission -from app.v2.notices.services.notice_service import NoticeService -from app.v2.users.services.user_service import UserService +from app.common.constants.item_category import ItemCategory +from app.common.constants.mission_condition import MS +from app.common.constants.reward_type import RewardType +from app.core.configs import settings +from app.dtos.mission.mission_dto import UserMissionDTO +from app.dtos.mission.reward_dto import RewardDTO +from app.models.answer import Answer +from app.models.badge import Badge +from app.models.badge_inventory import BadgeInventory +from app.models.cheese_manager import CheeseManager +from app.models.color import Color +from app.models.item import ItemInventory, ItemInventoryRewardInventory, RewardInventory +from app.models.like import Like +from app.models.mission import UserMission +from app.models.mission_inventory import MissionInventory +from app.models.user import User +from app.services.answer_service import AnswerService +from app.services.level_service import LevelService +from app.services.notice_service import NoticeService class MissionService: + + @staticmethod + async def create_mission_inventory() -> None: + await MissionInventory.create_bulk() + + @staticmethod + async def reset_mission() -> None: + await UserMission.filter( + mission_code__in=["MS_LV_UP", "MS_DAILY_LIKE_3_PER_DAY", "MS_DAILY_POST_GENERAL"] + ).update(is_completed=False, progress_count=0) + @staticmethod async def get_user_missions(user_id: str) -> list[UserMissionDTO]: - user_mission_raw = await UserMission.get_user_missions_by_condition_type(user_id) - return [UserMissionDTO.builder(user_mission) for user_mission in user_mission_raw] + user_missions = await UserMission.get_user_missions_by_condition_type(user_id) + return [ + UserMissionDTO( + user_mission_id=user_mission.user_mission_id, + is_completed=user_mission.is_completed, + mission_code=user_mission.mission_code, + progress_count=user_mission.progress_count, + ) + for user_mission in user_missions + ] @staticmethod async def _update_user_mission_progress( @@ -45,12 +69,12 @@ async def _update_user_mission_progress( async def update_mission_progress(self, user_id: str) -> None: user, user_missions, missions = await asyncio.gather( - UserService.get_user_info(user_id=user_id), + User.get_user_info_by_user_id(user_id=user_id), self.get_user_missions(user_id=user_id), MissionInventory.all(), ) - cheese_manager_id: int = user["cheese_manager_id"] + cheese_manager_id = user.cheese_manager_id mission_dict = {mission.mission_code: mission for mission in missions} badge_missions, lv_up_mission, daily_missions = await self._classify_missions(user_missions) @@ -63,14 +87,6 @@ async def update_mission_progress(self, user_id: str) -> None: if lv_up_mission[0]: await self._process_mission(lv_up_mission[0], mission_dict, cheese_manager_id, user_id) - async def _classify_missions( - self, user_missions: list[UserMissionDTO] - ) -> tuple[list[UserMissionDTO], list[UserMissionDTO], list[UserMissionDTO]]: - badge_missions = [mission for mission in user_missions if mission.mission_code.startswith("MS_BADGE")] - lv_up_mission = [mission for mission in user_missions if mission.mission_code == "MS_LV_UP"] - daily_missions = [mission for mission in user_missions if mission.mission_code.startswith("MS_DAILY")] - return badge_missions, lv_up_mission, daily_missions - async def _process_mission( self, user_mission: UserMissionDTO, @@ -108,51 +124,54 @@ async def _handle_mission_reward( reward_code: str, cheese_manager_id: int, ) -> None: - if mission_code == "MS_DAILY_POST_GENERAL": + if mission_code == MS.DAILY_POST_GENERAL: await self.reward_daily_post(user_id=user_id, cheese_manager_id=cheese_manager_id) - elif mission_code == "MS_LV_UP": + elif mission_code == MS.LV_UP: await self.reward_level_up_mission( user_id=user_id, cheese_manager_id=cheese_manager_id, reward_code=reward_code ) - elif mission_code.startswith("MS_BADGE"): + elif mission_code.startswith(MS.BADGE): await self.reward_badge_mission( user_id=user_id, cheese_manager_id=cheese_manager_id, reward_code=reward_code ) - else: - await self.reward_mission( - user_id=user_id, - cheese_manager_id=cheese_manager_id, - reward_code=reward_code, - ) async def evaluate_mission_condition(self, user_id: str, mission_code: str) -> int: - if mission_code == "MS_BADGE_POST_FIRST" and await self.check_first_post(user_id): + if mission_code == MS.BADGE_POST_FIRST and await self.check_first_post(user_id): return 1 - elif mission_code == "MS_BADGE_POST_280_CHAR" and await self.check_long_answer(user_id): + elif mission_code == MS.BADGE_POST_280_CHAR and await self.check_long_answer(user_id): return 1 - elif mission_code == "MS_BADGE_POST_CONSECUTIVE_7" and await self.check_consecutive_days(user_id): + elif mission_code == MS.BADGE_POST_CONSECUTIVE_7 and await self.check_consecutive_days(user_id): return 1 - elif mission_code == "MS_BADGE_POST_EARLY_3" and await self.check_early_morning_posts(user_id): + elif mission_code == MS.BADGE_POST_EARLY_3 and await self.check_early_morning_posts(user_id): return 1 - elif mission_code == "MS_BADGE_CHEESE_TOTAL_50" and await self.check_cheese_total(user_id): + elif mission_code == MS.BADGE_CHEESE_TOTAL_50 and await self.check_cheese_total(user_id): return 1 - elif mission_code == "MS_BADGE_CHRISTMAS" and await self.check_christmas_period(): + elif mission_code == MS.BADGE_CHRISTMAS and await self.check_christmas_period(): return 1 - elif mission_code == "MS_DAILY_LIKE_3_PER_DAY" and await self.check_three_likes_different_posts(user_id): + elif mission_code == MS.DAILY_LIKE_3_PER_DAY and await self.check_three_likes_different_posts(user_id): return 1 - elif mission_code == f"MS_LV_UP" and await LevelService.level_up(user_id=user_id): + elif mission_code == MS.DAILY_POST_GENERAL and await self.check_daily_post(user_id): return 1 - elif mission_code == "MS_DAILY_POST_GENERAL" and await self.check_daily_post(user_id): + elif mission_code == MS.LV_UP and await LevelService.level_up(user_id=user_id): return 1 return 0 + @staticmethod + async def _classify_missions( + user_missions: list[UserMissionDTO], + ) -> tuple[list[UserMissionDTO], list[UserMissionDTO], list[UserMissionDTO]]: + badge_missions = [mission for mission in user_missions if mission.mission_code.startswith(MS.BADGE)] + lv_up_mission = [mission for mission in user_missions if mission.mission_code == MS.LV_UP] + daily_missions = [mission for mission in user_missions if mission.mission_code.startswith(MS.DAILY)] + return badge_missions, lv_up_mission, daily_missions + @staticmethod async def check_first_post(user_id: str) -> bool: - return await AnswerService.get_answer_count_v2(user_id=user_id) > 0 + return await Answer.get_answer_count_by_user_id_v2(user_id=user_id) > 0 @staticmethod async def get_answer_count(user_id: str) -> int: - return await AnswerService.get_answer_count_v2(user_id=user_id) + return await Answer.get_answer_count_by_user_id_v2(user_id=user_id) @staticmethod async def check_post_count_range(answer_count: int, min_count: int, max_count: int) -> bool: @@ -160,8 +179,8 @@ async def check_post_count_range(answer_count: int, min_count: int, max_count: i @staticmethod async def check_long_answer(user_id: str) -> bool: - recent_answer = await AnswerService.get_most_recent_answer(user_id=user_id) - return len(recent_answer["content"]) >= 280 if recent_answer else False + recent_answer = await Answer.get_most_recent_answer_by_user_id(user_id=user_id) + return len(recent_answer.content) >= 280 if recent_answer else False @staticmethod async def check_consecutive_days(user_id: str) -> bool: @@ -170,16 +189,13 @@ async def check_consecutive_days(user_id: str) -> bool: @staticmethod async def check_early_morning_posts(user_id: str) -> bool: - recent_answer = await AnswerService.get_most_recent_answer(user_id=user_id) - if recent_answer: - answer_time = recent_answer.get("created_time") - return 0 <= answer_time.hour <= 5 if isinstance(answer_time, datetime) else False - return False + recent_answer = await Answer.get_most_recent_answer_by_user_id(user_id=user_id) + return 0 <= recent_answer.created_time.hour <= 6 if recent_answer else False @staticmethod async def check_cheese_total(user_id: str) -> bool: - user = await UserService.get_user_info(user_id=user_id) - cheese_amount = await CheeseService.get_cheese_balance(user["cheese_manager_id"]) + user = await User.get_user_info_by_user_id(user_id=user_id) + cheese_amount = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=user.cheese_manager_id) return cheese_amount >= 50 @@ -195,21 +211,15 @@ async def check_christmas_period() -> bool: @staticmethod async def check_three_likes_different_posts(user_id: str) -> bool: - like_raw = await Like.get_unique_likes_today(user_id) - like_count: int = like_raw.get("unique_likes", 0) + like_count = await Like.get_unique_likes_today(user_id) return like_count >= 3 @staticmethod async def check_daily_post(user_id: str) -> bool: - seoul_tz = pytz.timezone("Asia/Seoul") - now = datetime.now(seoul_tz) - + now = datetime.now(settings.db_zoneinfo) current_date = (now - timedelta(days=1)).date() if now.hour < 6 else now.date() - - answer = await AnswerService.get_most_recent_answer(user_id=user_id) - answer_date = answer.get("date") - - return answer_date == current_date # type: ignore + answer = await Answer.get_most_recent_answer_by_user_id(user_id=user_id) + return answer.date == current_date if answer else False @staticmethod async def validate_reward(reward_code: str): # type: ignore @@ -217,14 +227,14 @@ async def validate_reward(reward_code: str): # type: ignore reward = await RewardInventory.filter(reward_code=reward_code).prefetch_related("item_inventories").first() if not reward: - raise HTTPException(status_code=404, detail="Reward not found.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reward not found.") item_inventory_rewards = reward.item_inventories return item_inventory_rewards except DoesNotExist: - raise HTTPException(status_code=404, detail="Reward not found.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reward not found.") async def process_reward( self, @@ -240,29 +250,27 @@ async def process_reward( item: ItemInventory = await item_inventory_reward.item_inventory quantity = item_inventory_reward.quantity - if item.item_category == "BADGE": + if item.item_category == ItemCategory.BADGE: for _ in range(quantity): - await BadgeService.add_badge(user_id=user_id, badge_code=item.item_code) - badge = await BadgeService.get_badge_info_by_badge_code(badge_code=item.item_code) + await Badge.create_by_user_id(user_id=user_id, badge_code=item.item_code) + badge = await BadgeInventory.get_by_badge_code(badge_code=item.item_code) badge_info.append(badge) - elif item.item_category == "COLOR": + elif item.item_category == ItemCategory.COLOR: for _ in range(quantity): - await ColorService.add_color(user_id=user_id, color_code=item.item_code) - elif item.item_category == "CHEESE": + await Color.create_by_user_id(user_id=user_id, color_code=item.item_code) + elif item.item_category == ItemCategory.CHEESE: total_cheese += quantity - await CheeseService.add_cheese(cheese_manager_id=cheese_manager_id, amount=quantity) - elif item.item_category == "POINT": - total_exp += quantity - await LevelService.add_exp(user_id=user_id, exp=quantity) - else: - raise ValueError(f"Invalid item category for reward: {item.item_category}") + await CheeseManager.add_cheese(cheese_manager_id=cheese_manager_id, amount=quantity) badge_full_name = badge_info[0].badge_full_name if badge_info else None badge_code = badge_info[0].badge_code if badge_info else None - return await RewardDTO.build( - total_cheese=total_cheese, total_exp=total_exp, badge_full_name=badge_full_name, badge_code=badge_code + return RewardDTO( + total_cheese=total_cheese, + total_exp=total_exp, + badge_full_name=badge_full_name, + badge_code=badge_code, ) async def reward_daily_post(self, user_id: str, cheese_manager_id: int) -> None: @@ -275,7 +283,7 @@ async def reward_daily_post(self, user_id: str, cheese_manager_id: int) -> None: # 3. 보상 알림 생성 await self._create_reward_notice( user_id=user_id, - reward_type="DAILY_MISSION", + reward_type=RewardType.DAILY_MISSION, total_exp=exp, total_cheese=cheese, ) @@ -327,7 +335,7 @@ async def _add_exp_and_cheese(user_id: str, cheese_manager_id: int, exp: int, ch await LevelService.add_exp(user_id=user_id, exp=exp) # 치즈 추가 - await CheeseService.add_cheese(cheese_manager_id=cheese_manager_id, amount=cheese) + await CheeseManager.add_cheese(cheese_manager_id=cheese_manager_id, amount=cheese) @staticmethod async def _create_reward_notice( @@ -335,11 +343,11 @@ async def _create_reward_notice( reward_type: str, total_exp: int, total_cheese: int, - badge_full_name: Optional[str] = None, - badge_code: Optional[str] = None, - level_up: Optional[bool] = False, - nickname: Optional[str] = None, - new_level: Optional[int] = None, + badge_full_name: str | None = None, + badge_code: str | None = None, + level_up: bool = False, + nickname: str | None = None, + new_level: int | None = None, ) -> None: await NoticeService.create_reward_notice( user_id=user_id, @@ -361,14 +369,14 @@ async def reward_level_up_mission(self, user_id: str, cheese_manager_id: int, re cheese_manager_id=cheese_manager_id, ) level_info = await LevelService.get_level_info_add_answer_days(user_id) - user_info = await UserService.get_user_profile(user_id=user_id) + user_profile = await User.get_user_profile_by_user_id(user_id=user_id) - nickname = user_info.nickname + nickname = user_profile.nickname level = level_info.levelDto.level await self._create_reward_notice( user_id=user_id, - reward_type="LEVEL_UP", + reward_type=RewardType.LEVEL_UP, total_exp=reward_dto.total_exp, total_cheese=reward_dto.total_cheese, level_up=True, @@ -385,23 +393,9 @@ async def reward_badge_mission(self, user_id: str, cheese_manager_id: int, rewar ) await self._create_reward_notice( user_id=user_id, - reward_type="BADGE_MISSION", + reward_type=RewardType.BADGE_MISSION, total_exp=reward_dto.total_exp, total_cheese=reward_dto.total_cheese, badge_code=reward_dto.badge_code, badge_full_name=reward_dto.badge_full_name, ) - - async def reward_mission(self, user_id: str, cheese_manager_id: int, reward_code: str) -> None: - item_inventory_rewards = await self.validate_reward(reward_code=reward_code) - reward_dto = await self.process_reward( - item_inventory_rewards=item_inventory_rewards, - user_id=user_id, - cheese_manager_id=cheese_manager_id, - ) - await self._create_reward_notice( - user_id=user_id, - reward_type="DAILY_MISSION", - total_exp=reward_dto.total_exp, - total_cheese=reward_dto.total_cheese, - ) diff --git a/src/app/v2/notices/services/notice_service.py b/app/services/notice_service.py similarity index 77% rename from src/app/v2/notices/services/notice_service.py rename to app/services/notice_service.py index 5c94902..f244f2a 100644 --- a/src/app/v2/notices/services/notice_service.py +++ b/app/services/notice_service.py @@ -1,7 +1,4 @@ -from typing import Optional - -from app.v2.badges.models.badge import BadgeInventory -from app.v2.notices.models.notice import Notice +from app.models.notice import Notice class NoticeService: @@ -13,7 +10,7 @@ async def create_notice( title: str, reward_type: str, content: str, - badge_code: Optional[str] = None, + badge_code: str | None = None, ) -> None: return await Notice.create_notice( title=title, @@ -32,15 +29,12 @@ async def create_reward_notice( reward_type: str, total_cheese: int = 0, total_exp: int = 0, - badge_full_name: Optional[str] = None, - badge_code: Optional[str] = None, - level_up: Optional[bool] = False, - nickname: Optional[str] = None, - new_level: Optional[int] = None, + badge_full_name: str | None = None, + badge_code: str | None = None, + level_up: bool = False, + nickname: str | None = None, + new_level: int | None = None, ) -> None: - if not badge_code and not level_up and total_cheese == 0 and total_exp == 0: - return - # 1. 제목 생성 title = cls.create_title( badge_full_name=badge_full_name, @@ -67,10 +61,10 @@ async def create_reward_notice( @classmethod def create_title( cls, - badge_full_name: Optional[str] = None, - level_up: Optional[bool] = False, - nickname: Optional[str] = None, - new_level: Optional[int] = None, + level_up: bool = False, + badge_full_name: str | None = None, + nickname: str | None = None, + new_level: int | None = None, ) -> str: if level_up and nickname and new_level is not None: return f"{nickname} LV{new_level}로 레벨업!" @@ -80,7 +74,7 @@ def create_title( @classmethod def create_reward_message( - cls, total_cheese: int, total_exp: int, badge_full_name: Optional[str] = None, level_up: Optional[bool] = False + cls, total_cheese: int, total_exp: int, badge_full_name: str | None = None, level_up: bool = False ) -> str: if level_up: return f"선물로 치즈 {total_cheese}개를 드릴게요!" if total_cheese > 0 else "레벨업을 축하드립니다!" diff --git a/src/app/v2/payments/services/payment_service.py b/app/services/payment_service.py similarity index 52% rename from src/app/v2/payments/services/payment_service.py rename to app/services/payment_service.py index b03d68c..485889f 100644 --- a/src/app/v2/payments/services/payment_service.py +++ b/app/services/payment_service.py @@ -1,19 +1,20 @@ -from fastapi import HTTPException from tortoise.exceptions import DoesNotExist, IntegrityError from tortoise.transactions import atomic -from app.v2.badges.services.badge_service import BadgeService -from app.v2.cheese_managers.models.cheese_manager import CheeseManager -from app.v2.colors.services.color_service import ColorService -from app.v2.emotions.services.emotion_service import EmotionService -from app.v2.items.models.item import ItemInventory, ItemInventoryProductInventory, ProductInventory -from common.exceptions.custom_exception import CustomException -from common.exceptions.error_code import ErrorCode +from app.common.constants.item_category import ItemCategory +from app.common.exceptions.custom_exception import CustomException +from app.common.exceptions.error_code import ErrorCode +from app.models.badge import Badge +from app.models.cheese_manager import CheeseManager +from app.models.color import Color +from app.models.emotion import Emotion +from app.models.item import ItemInventory, ItemInventoryProductInventory, ProductInventory +from app.models.user import User class PaymentService: @staticmethod - async def validate_payment( + async def _validate_payment( product_code: str, ) -> tuple[ProductInventory, list[ItemInventoryProductInventory]]: try: @@ -38,38 +39,40 @@ async def validate_payment( @atomic() async def process_cheese_payment( cls, - product: ProductInventory, - item_inventory_products: list[ItemInventoryProductInventory], + product_code: str, user_id: str, - cheese_manager_id: int, - ) -> None: - total_cheese = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=cheese_manager_id) + ) -> str: + # 1. 제품 코드 검증 + product, item_inventory_products = await cls._validate_payment(product_code) + + # 2. 유저 정보 조회 및 치즈 잔액 조회 + user = await User.get_user_info_by_user_id(user_id=user_id) + total_cheese = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=user.cheese_manager_id) + # 3. 치즈 결제 진행 total_required_cheese = product.price if total_cheese < total_required_cheese: raise CustomException(ErrorCode.NOT_ENOUGH_CHEESE) - try: - await CheeseManager.use_cheese(cheese_manager_id, int(total_required_cheese)) - except ValueError as e: - raise CustomException(ErrorCode.NOT_ENOUGH_CHEESE) + await CheeseManager.use_cheese(user.cheese_manager_id, int(total_required_cheese)) + # 4. 아이템 부여 try: for item_inventory_product in item_inventory_products: item: ItemInventory = await item_inventory_product.item_inventory quantity = item_inventory_product.quantity - if item.item_category == "BADGE": + if item.item_category == ItemCategory.BADGE: for _ in range(quantity): - await BadgeService.add_badge(user_id=user_id, badge_code=item.item_code) - elif item.item_category == "COLOR": + await Badge.create_by_user_id(user_id=user_id, badge_code=item.item_code) + elif item.item_category == ItemCategory.COLOR: for _ in range(quantity): - await ColorService.add_color(user_id=user_id, color_code=item.item_code) - elif item.item_category == "EMOTION": + await Color.create_by_user_id(user_id=user_id, color_code=item.item_code) + elif item.item_category == ItemCategory.EMOTION: for _ in range(quantity): - await EmotionService.add_emotion(user_id=user_id, emotion_code=item.item_code) - else: - raise CustomException(ErrorCode.INVALID_ITEM_CATEGORY) + await Emotion.create_by_user_id(user_id=user_id, emotion_code=item.item_code) + + return product_code except IntegrityError: raise CustomException(ErrorCode.DUPLICATE_PURCHASE) diff --git a/app/services/purchase_service.py b/app/services/purchase_service.py new file mode 100644 index 0000000..dc9c642 --- /dev/null +++ b/app/services/purchase_service.py @@ -0,0 +1,309 @@ +# import time +# import uuid +# from datetime import date, datetime, timedelta, timezone +# from typing import Any, Optional, cast +# +# import httpx +# from fastapi import HTTPException +# from tortoise.exceptions import DoesNotExist +# from tortoise.transactions import atomic +# +# from app.common.exceptions.custom_exception import CustomException +# from app.common.exceptions.error_code import ErrorCode +# from app.core.configs import settings +# from app.dtos.purchase.purchase_dto import PurchaseResponseDTO, ReceiptInfoDTO +# from app.models.item import ItemInventory, ItemInventoryProductInventory, ProductInventory +# from app.models.purchase_history import PurchaseHistory, Subscription +# from app.models.purchase_status import PurchaseStatus, SubscriptionStatus +# from app.models.user import User +# from app.services.user_service import UserService +# +# +# class PurchaseService: +# @atomic() +# async def process_apple_purchase(self, receipt_data: str, user_id: str) -> PurchaseResponseDTO: +# response = await self._validate_apple_receipt(receipt_data=receipt_data) +# +# latest_receipt_info = self._extract_latest_receipt_info(response) +# +# if latest_receipt_info is None: +# raise CustomException(ErrorCode.NO_VALID_RECEIPT) +# +# receipt_info = await self._parse_receipt_info(latest_receipt_info) +# +# subscription_status = self.get_subscription_status(receipt_info) +# +# await self._create_or_update_subscription( +# user_id=user_id, +# product_code=receipt_info.product_code, +# transaction_id=receipt_info.transaction_id, +# expires_date_ms=receipt_info.expires_date_ms, +# status=subscription_status, +# ) +# +# subscription = await self._get_subscription(user_id, receipt_info.product_code) +# +# if subscription is None: +# raise DoesNotExist("Subscription not found") +# +# item_inventory_products = await self._validate_purchase(product_code=receipt_info.product_code) +# +# await self._process_purchase( +# user_id=user_id, +# item_inventory_products=item_inventory_products, +# status=subscription_status, +# ) +# +# user = await User.get_user_profile_by_user_id(user_id=user_id) +# +# return PurchaseResponseDTO.build(is_premium=user.is_premium, product_code=receipt_info.product_code_two) +# +# @staticmethod +# def _extract_latest_receipt_info(response: dict[str, Any]) -> dict[str, Any] | None: +# latest_receipt_info = response.get("latest_receipt_info") +# +# if isinstance(latest_receipt_info, list) and latest_receipt_info: +# return latest_receipt_info[0] or {} +# return None +# +# async def _validate_apple_receipt(self, receipt_data: str) -> dict[str, Any]: +# url = settings.APPLE_URL +# payload = self.create_receipt_validation_payload(receipt_data) +# response = await self.send_receipt_validation_request(url, payload) +# return await self.parse_apple_response(response) +# +# @staticmethod +# def create_receipt_validation_payload(receipt_data: str) -> dict[str, Any]: +# return { +# "receipt-data": receipt_data, +# "password": settings.APPLE_SHARED_SECRET, +# } +# +# @staticmethod +# async def send_receipt_validation_request(url: str, payload: dict[str, Any]) -> httpx.Response: +# async with httpx.AsyncClient() as client: +# response = await client.post(url, json=payload) +# return response +# +# @staticmethod +# async def parse_apple_response(response: httpx.Response) -> dict[str, Any]: +# if response.status_code == 200: +# return cast(dict[str, Any], response.json()) +# else: +# raise HTTPException(status_code=500, detail="Failed to connect to Apple server") +# +# @staticmethod +# async def _create_or_update_subscription( +# user_id: str, +# product_code: str, +# transaction_id: str, +# expires_date_ms: int, +# status: str, +# ) -> None: +# await Subscription.create_or_update_subscription( +# user_id=user_id, +# product_code=product_code, +# transaction_id=transaction_id, +# expires_date_ms=expires_date_ms, +# status=status, +# ) +# +# @staticmethod +# def get_subscription_status(receipt: ReceiptInfoDTO) -> str: +# current_time_ms = int(time.time() * 1000) +# +# if receipt.cancellation_date_ms: +# return SubscriptionStatus.CANCELED.value +# +# if receipt.expires_date_ms < current_time_ms: +# return SubscriptionStatus.EXPIRED.value +# +# return SubscriptionStatus.ACTIVE.value +# +# @staticmethod +# def get_purchase_status(cancellation_date_ms: Optional[int]) -> str: +# if cancellation_date_ms: +# return PurchaseStatus.REFUNDED.value +# return PurchaseStatus.AVAILABLE.value +# +# @staticmethod +# async def _get_subscription(user_id: str, product_code: str) -> Subscription | None: +# return await Subscription.get_subscription_by_user_id_and_product_code( +# user_id=user_id, product_code=product_code +# ) +# +# @staticmethod +# async def _create_purchase_history( +# user_id: str, +# subscription: Subscription, +# product_code: str, +# transaction_id: str, +# original_transaction_id: str, +# status: str, +# expires_date_ms: int, +# purchase_date_ms: int, +# quantity: int, +# receipt_data: str, +# ) -> None: +# await PurchaseHistory.create_purchase_history( +# user_id=user_id, +# subscription_id=subscription.subscription_id if subscription else None, +# product_code=product_code, +# transaction_id=transaction_id, +# original_transaction_id=original_transaction_id, +# status=status, +# expires_date_ms=expires_date_ms, +# purchase_date_ms=purchase_date_ms, +# quantity=quantity, +# receipt_data=receipt_data, +# ) +# +# @staticmethod +# async def _validate_purchase( +# product_code: str, +# ) -> list[ItemInventoryProductInventory]: +# try: +# product = await ProductInventory.get(product_code=product_code) +# +# if product.transaction_currency not in ["KRW", "CHEESE"]: +# raise HTTPException(status_code=400, detail="Invalid transaction currency for purchase.") +# +# item_inventory_products = await ItemInventoryProductInventory.filter( +# product_inventory_id=product.product_id +# ).all() +# +# if not item_inventory_products: +# raise HTTPException(status_code=404, detail="No inventory found for this product.") +# return item_inventory_products +# except DoesNotExist: +# raise HTTPException(status_code=404, detail="Product not found.") +# +# @classmethod +# async def _process_purchase( +# cls, +# item_inventory_products: list[ItemInventoryProductInventory], +# user_id: str, +# status: str = "ACTIVE", +# # cheese_manager_id: int, +# ) -> None: +# for item_inventory_product in item_inventory_products: +# item: ItemInventory = await item_inventory_product.item_inventory +# # quantity = item_inventory_product.quantity +# +# if item.item_category == "SUBSCRIPTION": +# if status == SubscriptionStatus.ACTIVE.value: +# await UserService.set_is_premium(user_id=user_id, is_premium=True) +# if status == SubscriptionStatus.CANCELED.value or status == SubscriptionStatus.EXPIRED.value: +# await UserService.set_is_premium(user_id=user_id, is_premium=False) +# # elif item.item_category == "CHEESE": +# # await CheeseService.add_cheese(cheese_manager_id=cheese_manager_id, amount=quantity) +# else: +# raise ValueError(f"Invalid item category for purchase: {item.item_category}") +# +# async def renew_subscription(self, subscription: Subscription) -> None: +# +# purchase_history = await PurchaseHistory.filter(transaction_id=subscription.current_transaction_id).first() +# +# if not purchase_history: +# return +# +# response = await self._validate_apple_receipt(receipt_data=purchase_history.receipt_data) +# latest_receipt_info = self._extract_latest_receipt_info(response) +# +# if latest_receipt_info is None: +# raise CustomException(ErrorCode.NO_VALID_RECEIPT) +# +# receipt_data = await self._parse_receipt_info(latest_receipt_info) +# +# purchase_status = self.get_purchase_status(receipt_data.cancellation_date_ms) +# +# if not await self._check_auto_renewal(response.get("pending_renewal_info", [])): +# return +# +# await self._update_subscription_expiration( +# subscription=subscription, +# expires_date_ms=receipt_data.expires_date_ms, +# transaction_id=receipt_data.transaction_id, +# ) +# +# await self._create_purchase_history( +# user_id=str(uuid.UUID(bytes=subscription.user.user_id)), # type: ignore +# subscription=subscription, +# product_code=receipt_data.product_code, +# transaction_id=receipt_data.transaction_id, +# original_transaction_id=receipt_data.original_transaction_id, +# status=purchase_status, +# expires_date_ms=receipt_data.expires_date_ms, +# purchase_date_ms=receipt_data.purchase_date_ms, +# quantity=receipt_data.quantity, +# receipt_data=purchase_history.receipt_data, +# ) +# +# @staticmethod +# async def get_subscriptions_to_renew(today: datetime) -> list[Subscription]: +# return ( +# await Subscription.filter(expires_date__lte=today + timedelta(days=1), status="ACTIVE") +# .select_related("user") +# .all() +# ) +# +# @atomic() +# async def process_subscriptions_renewal(self) -> None: +# today = datetime.now(timezone.utc) + timedelta(hours=9) +# subscriptions_to_renew = await self.get_subscriptions_to_renew(today) +# +# for subscription in subscriptions_to_renew: +# await self.renew_subscription(subscription) +# +# @staticmethod +# async def _update_subscription_expiration( +# subscription: Subscription, expires_date_ms: int, transaction_id: str +# ) -> None: +# new_expires_date = datetime.fromtimestamp(expires_date_ms / 1000) +# subscription.expires_date = new_expires_date +# subscription.current_transaction_id = transaction_id +# await subscription.save() +# +# @staticmethod +# async def _parse_receipt_info(latest_receipt_info: dict[str, Any]) -> ReceiptInfoDTO: +# return ReceiptInfoDTO.build(latest_receipt_info) +# +# @staticmethod +# async def _check_auto_renewal(pending_renewal_info: list[dict[str, Any]]) -> bool: +# if pending_renewal_info: +# auto_renew_status = pending_renewal_info[0].get("auto_renew_status") +# expiration_intent = pending_renewal_info[0].get("expiration_intent") +# +# if auto_renew_status == "0" or expiration_intent == "1": +# return False +# return True +# +# @staticmethod +# async def get_expired_subscriptions(today: date) -> list[Subscription]: +# return ( +# await Subscription.filter(status=SubscriptionStatus.ACTIVE.value, expires_date__lt=today) +# .select_related("user") +# .all() +# ) +# +# @staticmethod +# async def update_subscription_status(expired_subscriptions: list[Subscription]) -> None: +# for subscription in expired_subscriptions: +# subscription.status = SubscriptionStatus.EXPIRED.value +# await Subscription.bulk_update(expired_subscriptions, fields=["status"]) +# +# @staticmethod +# async def update_user_premium_status(expired_subscriptions: list[Subscription]) -> None: +# user_ids = [subscription.user.user_id for subscription in expired_subscriptions] +# if user_ids: +# await User.bulk_update_is_premium(user_ids) # type: ignore +# +# @atomic() +# async def expire_subscriptions(self) -> None: +# today = date.today() +# +# expired_subscriptions = await self.get_expired_subscriptions(today) +# +# if expired_subscriptions: +# await self.update_subscription_status(expired_subscriptions) +# await self.update_user_premium_status(expired_subscriptions) diff --git a/app/services/teller_card_service.py b/app/services/teller_card_service.py new file mode 100644 index 0000000..8c5b5e0 --- /dev/null +++ b/app/services/teller_card_service.py @@ -0,0 +1,42 @@ +from app.common.exceptions.custom_exception import CustomException +from app.common.exceptions.error_code import ErrorCode +from app.dtos.teller_card.teller_card_dto import TellerCardDTO +from app.models.badge_inventory import BadgeInventory +from app.models.color_inventory import ColorInventory +from app.models.teller_card import TellerCard + + +class TellerCardService: + @classmethod + async def get_teller_card(cls, user_id: str) -> TellerCardDTO: + teller_card = await TellerCard.get_teller_card_info_by_user_id(user_id=user_id) + return TellerCardDTO( + badgeCode=teller_card.activate_badge_code, + badgeName=teller_card.badge_name, + badgeMiddleName=teller_card.badge_middle_name, + colorCode=teller_card.activate_color_code, + ) + + @classmethod + async def patch_teller_card( + cls, user_id: str, badge_code: str | None = None, color_code: str | None = None + ) -> TellerCardDTO: + await cls._validate_teller_card(badge_code=badge_code, color_code=color_code) + + await TellerCard.patch_teller_card_info_by_user_id( + user_id=user_id, badge_code=badge_code, color_code=color_code + ) + + return await cls.get_teller_card(user_id=user_id) + + @classmethod + async def _validate_teller_card(cls, badge_code: str | None, color_code: str | None) -> None: + badge_code_list = await BadgeInventory.all().values("badge_code") + color_code_list = await ColorInventory.all().values("color_code") + badge_codes = [badge["badge_code"] for badge in badge_code_list] + color_codes = [color["color_code"] for color in color_code_list] + + if badge_code and badge_code not in badge_codes: + raise CustomException(ErrorCode.INVALID_BADGE_CODE) + if color_code and color_code not in color_codes: + raise CustomException(ErrorCode.INVALID_COLOR_CODE) diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..c305d31 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,41 @@ +from app.dtos.user.user_data import UserData +from app.dtos.user.user_dto import UserProfileData +from app.models.cheese_manager import CheeseManager +from app.models.color import Color +from app.models.emotion import Emotion +from app.models.level import Level +from app.models.mission import UserMission +from app.models.teller_card import TellerCard +from app.models.user import User + + +class UserService: + + @staticmethod + async def create_user(user_name: str, is_premium: bool = False) -> str: + cheese_manager = await CheeseManager.create_cheese_manager() + teller_card = await TellerCard.create(activate_badge_code="BG_NEW", activate_color_code="CL_DEFAULT") + level = await Level.create(user_exp=0, user_level=1) + user_id = await User.create_user( + social_id="kakao_456", + social_login_type="kakao", + nickname=user_name, + purpose="test", + job=1, + cheese_manager_id=cheese_manager.cheese_manager_id, + teller_card_id=teller_card.teller_card_id, + level_id=level.level_id, + is_premium=is_premium, + ) + await Color.create_default_by_user_id(user_id=user_id) + await Emotion.create_default_emotions_by_user_id(user_id=user_id) + await UserMission.create_default_missions_by_user_id(user_id=user_id) + return user_id + + @staticmethod + async def get_user_info(user_id: str) -> UserData: + return await User.get_user_info_by_user_id(user_id=user_id) + + @classmethod + async def get_user_profile(cls, user_id: str) -> UserProfileData: + return await User.get_user_profile_by_user_id(user_id=user_id) diff --git a/src/app/v2/items/__init__.py b/app/tests/__init__.py similarity index 100% rename from src/app/v2/items/__init__.py rename to app/tests/__init__.py diff --git a/src/app/v2/items/dtos/__init__.py b/app/tests/apis/__init__.py similarity index 100% rename from src/app/v2/items/dtos/__init__.py rename to app/tests/apis/__init__.py diff --git a/src/app/v2/items/models/__init__.py b/app/tests/apis/v2/__init__.py similarity index 100% rename from src/app/v2/items/models/__init__.py rename to app/tests/apis/v2/__init__.py diff --git a/app/tests/apis/v2/test_badge_router.py b/app/tests/apis/v2/test_badge_router.py new file mode 100644 index 0000000..8650787 --- /dev/null +++ b/app/tests/apis/v2/test_badge_router.py @@ -0,0 +1,35 @@ +import asyncio + +from fastapi import status + +from app.common.constants.badge_code_list import BadgeCodeList +from app.tests.mothers.badge_mother import BadgeMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_get_badges(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + badge_mother = BadgeMother() + + user_id = await user_mother.create_user() + await asyncio.gather( + badge_mother.create_badge_inventory(), badge_mother.create_badge(user_id=user_id, badge_code=BadgeCodeList.NEW) + ) + + # When + response = await telling_me_client.get_badges(user_id=user_id) + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "보유 뱃지 정보 조회" + assert data[0]["badgeCode"] == BadgeCodeList.NEW + assert data[0]["badgeName"] == "미스터리 방문객" + assert data[0]["badgeMiddleName"] == "아직은 낯설어요," + assert data[0]["badgeCondition"] == "회원가입 시 기본 제공" diff --git a/app/tests/apis/v2/test_cheese_router.py b/app/tests/apis/v2/test_cheese_router.py new file mode 100644 index 0000000..29f2809 --- /dev/null +++ b/app/tests/apis/v2/test_cheese_router.py @@ -0,0 +1,25 @@ +from fastapi import status + +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_get_cheese_amount(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + + user_id = await user_mother.create_user() + await user_mother.add_cheese(user_id=user_id, amount=(cheese_amount := 100)) + + # When + response = await telling_me_client.get_cheese_amount(user_id=user_id) + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "총 치즈 갯수 조회" + assert data["cheeseBalance"] == cheese_amount diff --git a/app/tests/apis/v2/test_color_router.py b/app/tests/apis/v2/test_color_router.py new file mode 100644 index 0000000..c2d6140 --- /dev/null +++ b/app/tests/apis/v2/test_color_router.py @@ -0,0 +1,71 @@ +from fastapi import status + +from app.common.constants.color_code_list import ColorCodeList +from app.tests.mothers.color_mother import ColorMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_get_colors_with_normal_user(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + + user_id = await user_mother.create_user() + await color_mother.create_color_inventory() + + # 기본 색상 + expected_colors = [ + {"colorCode": ColorCodeList.CL_BLUE_001, "colorName": "Blue_1", "colorHexCode": "#229DF6"}, + {"colorCode": ColorCodeList.CL_DEFAULT, "colorName": "Default", "colorHexCode": "#1EDCC5"}, + {"colorCode": ColorCodeList.CL_ORANGE_001, "colorName": "Orange_1", "colorHexCode": "#FFA216"}, + {"colorCode": ColorCodeList.CL_RED_001, "colorName": "Red_1", "colorHexCode": "#ED3639"}, + ] + + # When + response = await telling_me_client.get_colors(user_id=user_id) + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "보유 색상 정보 조회" + assert sorted(data, key=lambda x: x["colorCode"]) == sorted(expected_colors, key=lambda x: x["colorCode"]) + + +async def test_get_colors_with_premium_user(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + + user_id = await user_mother.create_user(is_premium=True) + await color_mother.create_color_inventory() + + # 기본 색상 + expected_colors = [ + {"colorCode": "CL_BLUE_001", "colorName": "Blue_1", "colorHexCode": "#229DF6"}, + {"colorCode": "CL_DEFAULT", "colorName": "Default", "colorHexCode": "#1EDCC5"}, + {"colorCode": "CL_GREEN_001", "colorName": "Green_1", "colorHexCode": "#80E252"}, + {"colorCode": "CL_NAVY_001", "colorName": "Navy_1", "colorHexCode": "#7075FF"}, + {"colorCode": "CL_ORANGE_001", "colorName": "Orange_1", "colorHexCode": "#FFA216"}, + {"colorCode": "CL_PINK_001", "colorName": "Pink_1", "colorHexCode": "#FC6CA0"}, + {"colorCode": "CL_PURPLE_001", "colorName": "Purple_1", "colorHexCode": "#8C56FF"}, + {"colorCode": "CL_RED_001", "colorName": "Red_1", "colorHexCode": "#ED3639"}, + {"colorCode": "CL_YELLOW_001", "colorName": "Yellow_1", "colorHexCode": "#FFC543"}, + ] + + # When + response = await telling_me_client.get_colors(user_id=user_id) + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "보유 색상 정보 조회" + assert sorted(data, key=lambda x: x["colorCode"]) == sorted(expected_colors, key=lambda x: x["colorCode"]) diff --git a/app/tests/apis/v2/test_emotion_router.py b/app/tests/apis/v2/test_emotion_router.py new file mode 100644 index 0000000..36aedf7 --- /dev/null +++ b/app/tests/apis/v2/test_emotion_router.py @@ -0,0 +1,66 @@ +from fastapi import status + +from app.common.constants.emotion_dict import EMOTION_DICT +from app.tests.mothers.emotion_mother import EmotionMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_get_emotions_with_normal_user( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + emotion_mother = EmotionMother() + + user_id = await user_mother.create_user() + await emotion_mother.create_emotion_inventory() + + # 기본 감정 + expected_emotion_ids = [ + EMOTION_DICT["EM_HAPPY"], + EMOTION_DICT["EM_PROUD"], + EMOTION_DICT["EM_OKAY"], + EMOTION_DICT["EM_TIRED"], + EMOTION_DICT["EM_SAD"], + EMOTION_DICT["EM_ANGRY"], + ] + + # When + response = await telling_me_client.get_emotions(user_id=user_id) + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "보유 감정 정보 조회" + assert sorted(data["emotionList"]) == sorted(expected_emotion_ids) + + +async def test_get_emotions_with_premium_user( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + emotion_mother = EmotionMother() + + user_id = await user_mother.create_user(is_premium=True) + await emotion_mother.create_emotion_inventory() + + # 기본 감정 + expected_emotion_ids = list(EMOTION_DICT.values()) + # When + response = await telling_me_client.get_emotions(user_id=user_id) + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "보유 감정 정보 조회" + assert sorted(data["emotionList"]) == sorted(expected_emotion_ids) diff --git a/app/tests/apis/v2/test_mission_router.py b/app/tests/apis/v2/test_mission_router.py new file mode 100644 index 0000000..8b3a342 --- /dev/null +++ b/app/tests/apis/v2/test_mission_router.py @@ -0,0 +1,286 @@ +import asyncio +from datetime import datetime + +import time_machine + +from app.common.constants.badge_code_list import BadgeCodeList +from app.common.constants.color_code_list import ColorCodeList +from app.core.configs import settings +from app.tests.mothers.answer_mother import AnswerMother +from app.tests.mothers.badge_mother import BadgeMother +from app.tests.mothers.color_mother import ColorMother +from app.tests.mothers.emotion_mother import EmotionMother +from app.tests.mothers.item_mother import ItemMother +from app.tests.mothers.mission_mother import MissionMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_mission_check_first_post(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + answer_mother = AnswerMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=0), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-13"), + ) + + # When + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await telling_me_client.check_mission(user_id=user_id) + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + badge_codes = [badge["badgeCode"] for badge in response.json()["data"]["badges"]] + level_info = response.json()["data"]["levelInfo"]["levelDto"] + + # Then 첫 글 작성 시 Badge code First + 경험치 11 ( 첫 글 작성 10 + 기본 1 ) + assert BadgeCodeList.FIRST in badge_codes + assert level_info["currentExp"] == 11 + + +async def test_mission_check_long_post(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + answer_mother = AnswerMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=0), + answer_mother.create_answer( + user_id=user_id, content="이것은 텔러가 작성한 280자 이상의 긴 답변입니다. " * 10, date="2025-06-13" + ), + ) + + # When + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await telling_me_client.check_mission(user_id=user_id) + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + badge_codes = [badge["badgeCode"] for badge in response.json()["data"]["badges"]] + + # Then 280자 이상 글 작성 시 뱃지 지급 + assert BadgeCodeList.MUCH_001 in badge_codes + + +async def test_mission_check_consecutive_7_days( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + answer_mother = AnswerMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=0), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-07"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-08"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-09"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-10"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-11"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-12"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-13"), + ) + + # When + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await telling_me_client.check_mission(user_id=user_id) + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + badge_codes = [badge["badgeCode"] for badge in response.json()["data"]["badges"]] + + # Then 연속 7일 글 작성 시 Badge code AGAIN + assert BadgeCodeList.AGAIN_001 in badge_codes + + +async def test_mission_check_cheese_50(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=50), + ) + + # When + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await telling_me_client.check_mission(user_id=user_id) + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + badge_codes = [badge["badgeCode"] for badge in response.json()["data"]["badges"]] + + # Then 총 치즈 누적량 50 이상 + assert BadgeCodeList.SAVE_001 in badge_codes + + +async def test_mission_christmas(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + with time_machine.travel(datetime(2024, 12, 25, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=50), + ) + + # When + with time_machine.travel(datetime(2024, 12, 25, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await telling_me_client.check_mission(user_id=user_id) + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + badge_codes = [badge["badgeCode"] for badge in response.json()["data"]["badges"]] + color_codes = [badge["colorCode"] for badge in response.json()["data"]["colors"]] + + # Then 2024년 크리스마스 기간에 접속 했을 시 뱃지 및 색상 지급 + assert BadgeCodeList.CHRISTMAS_2024 in badge_codes + assert ColorCodeList.CL_RED_001 in color_codes + + +async def test_mission_post_2_5(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + answer_mother = AnswerMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=50), + ) + + # When + with time_machine.travel(datetime(2025, 6, 11, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-11") + await telling_me_client.check_mission(user_id=user_id) + await mission_mother.reset_mission() + + with time_machine.travel(datetime(2025, 6, 12, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-12") + await telling_me_client.check_mission(user_id=user_id) + await mission_mother.reset_mission() + + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-13") + await telling_me_client.check_mission(user_id=user_id) + await mission_mother.reset_mission() + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + level_info = response.json()["data"]["levelInfo"]["levelDto"] + + # Then 첫 날 경험치 11 ( 첫 글 작성 10 + 기본 1 ) + 둘쨰날 경험치 7 ( 2~5 작성 시 5pt + 연속 작성 2pt ) + 셋째날 경험치 8 ( 2~5 작성 시 5pt + 연속 작성 3pt ) + assert level_info["level"] == 2 + assert level_info["currentExp"] == 11 + + +async def test_mission_level_up(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + answer_mother = AnswerMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + mission_mother = MissionMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + mission_mother.create_mission_inventory(), + item_mother.create_item_inventory_and_reward_inventory(), + user_mother.add_cheese(user_id=user_id, amount=0), + ) + + # When + with time_machine.travel(datetime(2025, 6, 12, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-12") + await telling_me_client.check_mission(user_id=user_id) + await mission_mother.reset_mission() + + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + await answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-13") + await telling_me_client.check_mission(user_id=user_id) + await mission_mother.reset_mission() + + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + cheese_balance = response.json()["data"]["userInfo"]["cheeseBalance"] + level_info = response.json()["data"]["levelInfo"]["levelDto"] + + # Then 첫 날 경험치 11 ( 첫 글 작성 10 + 기본 1 ) + 둘쨰날 경험치 7 ( 2~5 작성 시 5pt + 연속 작성 2pt ) + assert level_info["level"] == 2 + assert level_info["currentExp"] == 3 + assert cheese_balance == 1 + 2 # 첫 글 보상 치즈 10 + 레벨업 보상 치즈 1 + 연속 작성 2일 보상 치즈 2개 diff --git a/app/tests/apis/v2/test_mobile_router.py b/app/tests/apis/v2/test_mobile_router.py new file mode 100644 index 0000000..e355819 --- /dev/null +++ b/app/tests/apis/v2/test_mobile_router.py @@ -0,0 +1,132 @@ +import asyncio +from datetime import datetime + +import time_machine +from fastapi import status + +from app.common.constants.badge_code_list import BadgeCodeList +from app.common.constants.color_code_list import ColorCodeList +from app.core.configs import settings +from app.tests.mothers.answer_mother import AnswerMother +from app.tests.mothers.badge_mother import BadgeMother +from app.tests.mothers.color_mother import ColorMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_get_mobile_teller_card(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + answer_mother = AnswerMother() + badge_mother = BadgeMother() + color_mother = ColorMother() + + user_id = await user_mother.create_user(user_name=(user_name := "telling me user")) + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-10"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-12"), + answer_mother.create_answer(user_id=user_id, content="-", date="2025-06-13"), + user_mother.add_cheese(user_id=user_id, amount=(cheese_amount := 100)), + badge_mother.create_badge(user_id=user_id, badge_code=BadgeCodeList.FIRST), + ) + + excepted_badges = [ + { + "badgeCode": BadgeCodeList.FIRST, + "badgeName": "탐험가 텔러", + "badgeMiddleName": "낯선 길에 첫 발자국,", + "badgeCondition": "첫 글을 작성했어요!", + } + ] + expected_colors = [ + {"colorCode": ColorCodeList.CL_BLUE_001, "colorName": "Blue_1", "colorHexCode": "#229DF6"}, + {"colorCode": ColorCodeList.CL_DEFAULT, "colorName": "Default", "colorHexCode": "#1EDCC5"}, + {"colorCode": ColorCodeList.CL_ORANGE_001, "colorName": "Orange_1", "colorHexCode": "#FFA216"}, + {"colorCode": ColorCodeList.CL_RED_001, "colorName": "Red_1", "colorHexCode": "#ED3639"}, + ] + + # When + with time_machine.travel(datetime(2025, 6, 13, 1, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + response_before_6am = await telling_me_client.get_mobile_teller_card(user_id=user_id) + with time_machine.travel(datetime(2025, 6, 13, 10, 0, 0, tzinfo=settings.db_zoneinfo), tick=False): + # record는 연속 작성일을 의미한다. 6시 이전은 전날로 인식하여 time machine으로 시간 고정 + response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + + code = response.json()["code"] + message = response.json()["message"] + data = response.json()["data"] + badges = data["badges"] + colors = data["colors"] + user_info = data["userInfo"] + level_info = data["levelInfo"] + after_6am_record_count = data["recordCount"] + before_6am_record_count = response_before_6am.json()["data"]["recordCount"] + + # Then user 정보 및 보유 색상 및 뱃지 조회 + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "teller_card ui page" + + assert user_info["nickname"] == user_name + assert user_info["cheeseBalance"] == cheese_amount + assert user_info["tellerCard"]["colorCode"] == ColorCodeList.CL_DEFAULT + assert user_info["tellerCard"]["badgeCode"] == BadgeCodeList.NEW + + assert level_info["levelDto"]["level"] == 1 + assert level_info["levelDto"]["currentExp"] == 0 + assert level_info["levelDto"]["requiredExp"] == 15 + assert after_6am_record_count == 2 + assert before_6am_record_count == 1 + + assert sorted(badges, key=lambda x: x["badgeCode"]) == sorted(excepted_badges, key=lambda x: x["badgeCode"]) + assert sorted(colors, key=lambda x: x["colorCode"]) == sorted(expected_colors, key=lambda x: x["colorCode"]) + + +async def test_get_mobile_mypage(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + badge_mother = BadgeMother() + color_mother = ColorMother() + + user_id = await user_mother.create_user(user_name=(user_name := "telling me user"), is_premium=(is_premium := True)) + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + user_mother.add_cheese(user_id=user_id, amount=(cheese_amount := 100)), + badge_mother.create_badge(user_id=user_id, badge_code=BadgeCodeList.FIRST), + ) + + # When + + response = await telling_me_client.get_mobile_my_page(user_id=user_id) + code = response.json()["code"] + message = response.json()["message"] + data = response.json()["data"] + + user_profile = data["userProfile"] + level = data["level"]["levelDto"] + + # Then user 정보 및 보유 색상 및 뱃지 조회 + assert response.status_code == status.HTTP_200_OK + + assert code == response.status_code + assert message == "mypage ui page" + + assert user_profile["nickname"] == user_name + assert user_profile["cheeseBalance"] == cheese_amount + assert user_profile["badgeCode"] == BadgeCodeList.NEW + assert user_profile["badgeCount"] == 1 + assert user_profile["answerCount"] == 0 + assert user_profile["premium"] == is_premium + assert not user_profile["allowNotification"] + + assert level["level"] == 1 + assert level["currentExp"] == 0 + assert level["requiredExp"] == 15 diff --git a/app/tests/apis/v2/test_payment_router.py b/app/tests/apis/v2/test_payment_router.py new file mode 100644 index 0000000..692d29a --- /dev/null +++ b/app/tests/apis/v2/test_payment_router.py @@ -0,0 +1,263 @@ +import asyncio +from unittest.mock import patch + +from fastapi import status +from tortoise.exceptions import IntegrityError + +from app.common.constants.product_code_list import ProductCodeList +from app.common.exceptions.error_code import ErrorCode +from app.dtos.payment.payment_request import PaymentRequest +from app.tests.mothers.badge_mother import BadgeMother +from app.tests.mothers.color_mother import ColorMother +from app.tests.mothers.emotion_mother import EmotionMother +from app.tests.mothers.item_mother import ItemMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_payment_happy_case(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + item_mother.create_item_inventory_and_product_inventory(), + user_mother.add_cheese(user_id=user_id, amount=100), + ) + + payment_badge = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_BG_CHRISTMAS_2024) + payment_color = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_CL_GREEN_001) + payment_emotion = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_EM_LONELY) + + # When + payment_badge_response, payment_color_response, payment_emotion_response = await asyncio.gather( + telling_me_client.payment_product(payment_request=payment_badge), + telling_me_client.payment_product(payment_request=payment_color), + telling_me_client.payment_product(payment_request=payment_emotion), + ) + + badge_response_code = payment_badge_response.json()["code"] + badge_response_data = payment_badge_response.json()["data"] + badge_response_message = payment_badge_response.json()["message"] + + color_response_code = payment_color_response.json()["code"] + color_response_data = payment_color_response.json()["data"] + color_response_message = payment_color_response.json()["message"] + + emotion_response_code = payment_emotion_response.json()["code"] + emotion_response_data = payment_emotion_response.json()["data"] + emotion_response_message = payment_emotion_response.json()["message"] + + # Then + assert payment_badge_response.status_code == status.HTTP_200_OK + assert payment_color_response.status_code == status.HTTP_200_OK + assert payment_emotion_response.status_code == status.HTTP_200_OK + + assert badge_response_code == payment_badge_response.status_code + assert color_response_code == payment_color_response.status_code + assert emotion_response_code == payment_emotion_response.status_code + + assert badge_response_message == "Payment successful" + assert color_response_message == "Payment successful" + assert emotion_response_message == "Payment successful" + + assert badge_response_data["product_code"] == ProductCodeList.PD_BG_CHRISTMAS_2024 + assert color_response_data["product_code"] == ProductCodeList.PD_CL_GREEN_001 + assert emotion_response_data["product_code"] == ProductCodeList.PD_EM_LONELY + + +async def test_duplicate_payment_case(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + item_mother.create_item_inventory_and_product_inventory(), + user_mother.add_cheese(user_id=user_id, amount=100), + ) + + payment_request = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_BG_CHRISTMAS_2024) + + # When + with patch("app.models.badge.Badge.create_by_user_id", side_effect=IntegrityError("mock integrity error")): + response = await telling_me_client.payment_product(payment_request=payment_request) + + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert code == ErrorCode.DUPLICATE_PURCHASE.code + assert message == ErrorCode.DUPLICATE_PURCHASE.message + assert data is None + + +async def test_payment_when_cheese_is_insufficient( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + item_mother.create_item_inventory_and_product_inventory(), + user_mother.add_cheese(user_id=user_id, amount=0), + ) + + payment_request = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_BG_CHRISTMAS_2024) + + # When + response = await telling_me_client.payment_product(payment_request=payment_request) + + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert code == ErrorCode.NOT_ENOUGH_CHEESE.code + assert message == ErrorCode.NOT_ENOUGH_CHEESE.message + assert data is None + + +async def test_payment_when_not_cheese_payment( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + item_mother.create_item_inventory_and_product_inventory(), + user_mother.add_cheese(user_id=user_id, amount=100), + ) + + # 현금 구매 제품 + payment_request = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_PLUS_MONTH_1_KR) + + # When + response = await telling_me_client.payment_product(payment_request=payment_request) + + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + assert code == ErrorCode.INVALID_TRANSACTION_CURRENCY.code + assert message == ErrorCode.INVALID_TRANSACTION_CURRENCY.message + assert data is None + + +async def test_payment_invalid_product_code(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + item_mother.create_item_inventory_and_product_inventory(), + user_mother.add_cheese(user_id=user_id, amount=100), + ) + + payment_request = PaymentRequest(user_id=user_id, productCode="invalid_product_code") + + # When + response = await telling_me_client.payment_product(payment_request=payment_request) + + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + assert code == ErrorCode.PRODUCT_NOT_FOUND.code + assert message == ErrorCode.PRODUCT_NOT_FOUND.message + assert data is None + + +async def test_payment_invalid_item_category( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + color_mother = ColorMother() + badge_mother = BadgeMother() + emotion_mother = EmotionMother() + item_mother = ItemMother() + + user_id = await user_mother.create_user(user_name="telling me user") + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + emotion_mother.create_emotion_inventory(), + item_mother.create_item_inventory_and_product_inventory(), + user_mother.add_cheese(user_id=user_id, amount=100), + ) + + payment_request = PaymentRequest(user_id=user_id, productCode=ProductCodeList.PD_TEST) + + # When + response = await telling_me_client.payment_product(payment_request=payment_request) + + code = response.json()["code"] + data = response.json()["data"] + message = response.json()["message"] + + # Then + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + assert code == ErrorCode.NO_INVENTORY_FOR_PRODUCT.code + assert message == ErrorCode.NO_INVENTORY_FOR_PRODUCT.message + assert data is None diff --git a/app/tests/apis/v2/test_teller_card_router.py b/app/tests/apis/v2/test_teller_card_router.py new file mode 100644 index 0000000..c344144 --- /dev/null +++ b/app/tests/apis/v2/test_teller_card_router.py @@ -0,0 +1,97 @@ +import asyncio + +from fastapi import status + +from app.common.constants.badge_code_list import BadgeCodeList +from app.common.constants.color_code_list import ColorCodeList +from app.common.exceptions.error_code import ErrorCode +from app.dtos.teller_card.teller_card_request import TellerCardRequest +from app.tests.mothers.badge_mother import BadgeMother +from app.tests.mothers.color_mother import ColorMother +from app.tests.mothers.user_mother import UserMother +from app.tests.telling_me_client import TellingMeClient + + +async def test_update_teller_card(telling_me_client: TellingMeClient, init_tortoise_connection: None) -> None: + # Given + user_mother = UserMother() + badge_mother = BadgeMother() + color_mother = ColorMother() + + user_id = await user_mother.create_user() + + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + badge_mother.create_badge(user_id=user_id, badge_code=BadgeCodeList.FIRST), + ) + + teller_card_request = TellerCardRequest( + user_id=user_id, + badgeCode=BadgeCodeList.FIRST, + colorCode=ColorCodeList.CL_RED_001, + ) + + # When + before_update_response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + before_user_data = before_update_response.json()["data"]["userInfo"]["tellerCard"] + + update_response = await telling_me_client.update_teller_card(teller_card_request=teller_card_request) + + after_update_response = await telling_me_client.get_mobile_teller_card(user_id=user_id) + after_user_data = after_update_response.json()["data"]["userInfo"]["tellerCard"] + + # Then + assert before_update_response.status_code == status.HTTP_200_OK + assert update_response.status_code == status.HTTP_200_OK + assert after_update_response.status_code == status.HTTP_200_OK + + assert before_user_data["colorCode"] == ColorCodeList.CL_DEFAULT + assert before_user_data["badgeCode"] == BadgeCodeList.NEW + + assert after_user_data["colorCode"] == ColorCodeList.CL_RED_001 + assert after_user_data["badgeCode"] == BadgeCodeList.FIRST + + +async def test_update_teller_card_with_invalid_code( + telling_me_client: TellingMeClient, init_tortoise_connection: None +) -> None: + # Given + user_mother = UserMother() + badge_mother = BadgeMother() + color_mother = ColorMother() + + user_id = await user_mother.create_user() + await asyncio.gather( + user_mother.create_level_inventory(), + badge_mother.create_badge_inventory(), + color_mother.create_color_inventory(), + badge_mother.create_badge(user_id=user_id, badge_code=BadgeCodeList.FIRST), + ) + + invalid_badge_code_request = TellerCardRequest( + user_id=user_id, + badgeCode="invalid_badge_code", + colorCode=ColorCodeList.CL_RED_001, + ) + invalid_color_code_request = TellerCardRequest( + user_id=user_id, + badgeCode=BadgeCodeList.FIRST, + colorCode="invalid_color_code", + ) + + # When + invalid_badge_code_response = await telling_me_client.update_teller_card( + teller_card_request=invalid_badge_code_request + ) + invalid_color_code_response = await telling_me_client.update_teller_card( + teller_card_request=invalid_color_code_request + ) + + # Then + assert invalid_badge_code_response.status_code == status.HTTP_400_BAD_REQUEST + assert invalid_color_code_response.status_code == status.HTTP_400_BAD_REQUEST + + assert invalid_badge_code_response.json()["message"] == ErrorCode.INVALID_BADGE_CODE.message + assert invalid_color_code_response.json()["message"] == ErrorCode.INVALID_COLOR_CODE.message diff --git a/app/tests/fixtures.py b/app/tests/fixtures.py new file mode 100644 index 0000000..3e48389 --- /dev/null +++ b/app/tests/fixtures.py @@ -0,0 +1,44 @@ +from typing import AsyncGenerator +from unittest.mock import Mock, patch + +import httpx +import pytest +from _pytest.fixtures import FixtureRequest +from tortoise import Tortoise +from tortoise.contrib.test import finalizer, initializer + +from app import app +from app.core.configs import settings +from app.core.database.tortoise_database_settings import TORTOISE_APP_MODELS +from app.tests.telling_me_client import TellingMeClient +from app.tests.utils.db_utils import reset_inventory_tables +from app.tests.utils.test_db_config import TEST_BASE_URL, get_test_db_config + + +@pytest.fixture(scope="session", autouse=True) +def initialize(request: FixtureRequest) -> None: + with patch("tortoise.contrib.test.getDBConfig", Mock(return_value=get_test_db_config())): + initializer(modules=TORTOISE_APP_MODELS, loop=None) + request.addfinalizer(finalizer) + + +@pytest.fixture() +async def init_tortoise_connection() -> AsyncGenerator[None, None]: + """ + tortoise orm 테스트 시 initializer에서는 테이블 생성 후 connection을 전부 삭제한다. + Tortoise.get_connection("default") or atomic()을 사용 시 연결을 찾을 수 없어 에러 발생 + 이런 오류를 해결하기 위해서 직접 connection을 생성 + """ + await Tortoise.init( + db_url=f"mysql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/test", + modules={"models": TORTOISE_APP_MODELS}, + ) + await reset_inventory_tables() + yield + await Tortoise.close_connections() + + +@pytest.fixture() +async def telling_me_client() -> AsyncGenerator[TellingMeClient, None]: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url=TEST_BASE_URL) as client: + yield TellingMeClient(client) diff --git a/src/app/v2/items/repositorys/__init__.py b/app/tests/mothers/__init__.py similarity index 100% rename from src/app/v2/items/repositorys/__init__.py rename to app/tests/mothers/__init__.py diff --git a/app/tests/mothers/answer_mother.py b/app/tests/mothers/answer_mother.py new file mode 100644 index 0000000..5bcff63 --- /dev/null +++ b/app/tests/mothers/answer_mother.py @@ -0,0 +1,14 @@ +from app.services.answer_service import AnswerService + + +class AnswerMother: + + @staticmethod + async def create_answer( + user_id: str, + date: str, + content: str = "-", + likes: int = 0, + ) -> None: + answer_service = AnswerService() + await answer_service.create_answer(user_id=user_id, content=content, date=date, like_count=likes) diff --git a/app/tests/mothers/badge_mother.py b/app/tests/mothers/badge_mother.py new file mode 100644 index 0000000..40ec1d9 --- /dev/null +++ b/app/tests/mothers/badge_mother.py @@ -0,0 +1,14 @@ +from app.services.badge_service import BadgeService + + +class BadgeMother: + + @staticmethod + async def create_badge(user_id: str, badge_code: str) -> None: + badge_service = BadgeService() + await badge_service.create_badge(user_id=user_id, badge_code=badge_code) + + @staticmethod + async def create_badge_inventory() -> None: + badge_service = BadgeService() + await badge_service.create_badge_inventory() diff --git a/app/tests/mothers/color_mother.py b/app/tests/mothers/color_mother.py new file mode 100644 index 0000000..c764aab --- /dev/null +++ b/app/tests/mothers/color_mother.py @@ -0,0 +1,9 @@ +from app.services.color_service import ColorService + + +class ColorMother: + + @staticmethod + async def create_color_inventory() -> None: + color_service = ColorService() + await color_service.create_color_inventory() diff --git a/app/tests/mothers/emotion_mother.py b/app/tests/mothers/emotion_mother.py new file mode 100644 index 0000000..afe1120 --- /dev/null +++ b/app/tests/mothers/emotion_mother.py @@ -0,0 +1,9 @@ +from app.services.emotion_service import EmotionService + + +class EmotionMother: + + @staticmethod + async def create_emotion_inventory() -> None: + emotion_service = EmotionService() + await emotion_service.create_emotion_inventory() diff --git a/app/tests/mothers/item_mother.py b/app/tests/mothers/item_mother.py new file mode 100644 index 0000000..23cfd91 --- /dev/null +++ b/app/tests/mothers/item_mother.py @@ -0,0 +1,24 @@ +import asyncio + +from app.services.item_service import ItemService + + +class ItemMother: + + @staticmethod + async def create_item_inventory_and_product_inventory() -> None: + item_service = ItemService() + await asyncio.gather( + item_service.create_item_inventory(), + item_service.create_product_inventory(), + ) + await item_service.create_link_item_product() + + @staticmethod + async def create_item_inventory_and_reward_inventory() -> None: + item_service = ItemService() + await asyncio.gather( + item_service.create_item_inventory(), + item_service.create_reward_inventory(), + ) + await item_service.create_link_item_reward() diff --git a/app/tests/mothers/mission_mother.py b/app/tests/mothers/mission_mother.py new file mode 100644 index 0000000..63bff79 --- /dev/null +++ b/app/tests/mothers/mission_mother.py @@ -0,0 +1,14 @@ +from app.services.mission_service import MissionService + + +class MissionMother: + + @staticmethod + async def create_mission_inventory() -> None: + mission_service = MissionService() + await mission_service.create_mission_inventory() + + @staticmethod + async def reset_mission() -> None: + mission_service = MissionService() + await mission_service.reset_mission() diff --git a/app/tests/mothers/user_mother.py b/app/tests/mothers/user_mother.py new file mode 100644 index 0000000..0ce94ff --- /dev/null +++ b/app/tests/mothers/user_mother.py @@ -0,0 +1,25 @@ +from app.models.cheese_manager import CheeseManager +from app.services.level_service import LevelService +from app.services.user_service import UserService + + +class UserMother: + + @staticmethod + async def create_user( + user_name: str = "test_user", + is_premium: bool = False, + ) -> str: + user_service = UserService() + new_user_id = await user_service.create_user(user_name=user_name, is_premium=is_premium) + return new_user_id + + @staticmethod + async def add_cheese(user_id: str, amount: int) -> None: + user = await UserService.get_user_info(user_id=user_id) + await CheeseManager.add_cheese(cheese_manager_id=user.cheese_manager_id, amount=amount) + + @staticmethod + async def create_level_inventory() -> None: + level_service = LevelService() + await level_service.create_level_inventory() diff --git a/app/tests/telling_me_client.py b/app/tests/telling_me_client.py new file mode 100644 index 0000000..d4d1763 --- /dev/null +++ b/app/tests/telling_me_client.py @@ -0,0 +1,103 @@ +import httpx + +from app.dtos.payment.payment_request import PaymentRequest +from app.dtos.teller_card.teller_card_request import TellerCardRequest + + +class TellingMeClient: + def __init__(self, httpx_client: httpx.AsyncClient): + """ + 테스트 중 API 호출은 본 클래스를 통합니다. + 모든 public 메소드들은 알파벳 순으로 정렬합시다. + """ + self._client = httpx_client + + async def get_mobile_my_page(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/mobiles/mypage", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def get_mobile_teller_card(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/mobiles/tellercard", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def get_badges(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/user/badge", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def get_colors(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/user/color", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def get_emotions(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/user/emotion", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def get_cheese_amount(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/cheese", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def check_mission(self, user_id: str) -> httpx.Response: + return await self._client.get( + "/api/v2/mission/check", + params={ + key: value + for key, value in { + "user_id": user_id, + }.items() + if value is not None + }, + ) + + async def update_teller_card(self, teller_card_request: TellerCardRequest) -> httpx.Response: + return await self._client.post("/api/v2/tellercard", json=teller_card_request.model_dump()) + + async def payment_product(self, payment_request: PaymentRequest) -> httpx.Response: + return await self._client.post("/api/v2/payment", json=payment_request.model_dump()) diff --git a/app/tests/test_simple.py b/app/tests/test_simple.py new file mode 100644 index 0000000..b9df410 --- /dev/null +++ b/app/tests/test_simple.py @@ -0,0 +1,2 @@ +def test_simple() -> None: + assert True diff --git a/src/app/v2/items/services/__init__.py b/app/tests/utils/__init__.py similarity index 100% rename from src/app/v2/items/services/__init__.py rename to app/tests/utils/__init__.py diff --git a/app/tests/utils/db_utils.py b/app/tests/utils/db_utils.py new file mode 100644 index 0000000..4e73c63 --- /dev/null +++ b/app/tests/utils/db_utils.py @@ -0,0 +1,31 @@ +from app.models.badge_inventory import BadgeInventory +from app.models.color_inventory import ColorInventory +from app.models.emotion_inventory import EmotionInventory +from app.models.item import ( + ItemInventory, + ItemInventoryProductInventory, + ItemInventoryRewardInventory, + ProductInventory, + RewardInventory, +) +from app.models.level_inventory import LevelInventory +from app.models.mission_inventory import MissionInventory + + +async def reset_inventory_tables() -> None: + """ + 인벤토리 관련 테이블 초기화 함수 + 테스트 시 중복 데이터로 인한 충돌 방지를 위해 사용 + """ + await BadgeInventory.all().delete() + await ColorInventory.all().delete() + await EmotionInventory.all().delete() + await LevelInventory.all().delete() + await MissionInventory.all().delete() + + await ItemInventoryProductInventory.all().delete() + await ItemInventoryRewardInventory.all().delete() + + await ItemInventory.all().delete() + await ProductInventory.all().delete() + await RewardInventory.all().delete() diff --git a/app/tests/utils/test_db_config.py b/app/tests/utils/test_db_config.py new file mode 100644 index 0000000..5918f7d --- /dev/null +++ b/app/tests/utils/test_db_config.py @@ -0,0 +1,21 @@ +from typing import Any + +from tortoise.backends.base.config_generator import generate_config + +from app.core.configs import settings +from app.core.database.tortoise_database_settings import TORTOISE_APP_MODELS + +TEST_BASE_URL = "http://test" +TEST_DB_LABEL = "models" +TEST_DB_TZ = "Asia/Seoul" + + +def get_test_db_config() -> dict[Any, Any]: + config = generate_config( + db_url=f"mysql://{settings.DB_USER}:{settings.DB_PASSWORD}@{settings.DB_HOST}:{settings.DB_PORT}/test", + app_modules={TEST_DB_LABEL: TORTOISE_APP_MODELS}, + connection_label=TEST_DB_LABEL, + testing=True, + ) + config["timezone"] = TEST_DB_TZ + return config diff --git a/asgi.py b/asgi.py new file mode 100644 index 0000000..3919967 --- /dev/null +++ b/asgi.py @@ -0,0 +1,6 @@ +from app import app + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..89ecd25 --- /dev/null +++ b/conftest.py @@ -0,0 +1,22 @@ +from typing import Any + +import pytest +from pytest_asyncio import is_async_test + +pytest_plugins = ("app.tests.fixtures",) + + +def pytest_collection_modifyitems(items: Any) -> None: + """ + https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/run_session_tests_in_same_loop.html + pyproject.toml 의 + [tool.pytest.ini_options] + asyncio_default_fixture_loop_scope = "session" + + 설정은 fixture 의 scope 만 변경한다...! 이 설정만 하면 다른 모든 test 함수는 session scope 의 loop 를 쓰지 않는다. + 이 함수야말로 모든 테스트가 같은 루프를 쓰게 해준다. (아니 왜 이렇게 만들었나...) + """ + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) diff --git a/poetry.lock b/poetry.lock index f522317..cf8cca2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "aiomysql" @@ -1519,6 +1519,33 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.11.12" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.11.12-py3-none-linux_armv6l.whl", hash = "sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc"}, + {file = "ruff-0.11.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3"}, + {file = "ruff-0.11.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be"}, + {file = "ruff-0.11.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd"}, + {file = "ruff-0.11.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef"}, + {file = "ruff-0.11.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5"}, + {file = "ruff-0.11.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02"}, + {file = "ruff-0.11.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c"}, + {file = "ruff-0.11.12-py3-none-win32.whl", hash = "sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6"}, + {file = "ruff-0.11.12-py3-none-win_amd64.whl", hash = "sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832"}, + {file = "ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5"}, + {file = "ruff-0.11.12.tar.gz", hash = "sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603"}, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1664,6 +1691,74 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "time-machine" +version = "2.16.0" +description = "Travel through time in your tests." +optional = false +python-versions = ">=3.9" +files = [ + {file = "time_machine-2.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09531af59fdfb39bfd24d28bd1e837eff5a5d98318509a31b6cfd57d27801e52"}, + {file = "time_machine-2.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:92d0b0f3c49f34dd76eb462f0afdc61ed1cb318c06c46d03e99b44ebb489bdad"}, + {file = "time_machine-2.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c29616e18e2349a8766d5b6817920fc74e39c00fa375d202231e9d525a1b882"}, + {file = "time_machine-2.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1ceb6035a64cb00650e3ab203cf3faffac18576a3f3125c24df468b784077c7"}, + {file = "time_machine-2.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64c205ea37b8c4ba232645335fc3b75bc2d03ce30f0a34649e36cae85652ee96"}, + {file = "time_machine-2.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dfe92412bd11104c4f0fb2da68653e6c45b41f7217319a83a8b66ed4f20148b3"}, + {file = "time_machine-2.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d5fe7a6284e3dce87ae13a25029c53542dd27a28d151f3ef362ec4dd9c3e45fd"}, + {file = "time_machine-2.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0fca3025266d88d1b48be162a43b7c2d91c81cc5b3bee9f01194678ffb9969a"}, + {file = "time_machine-2.16.0-cp310-cp310-win32.whl", hash = "sha256:4149e17018af07a5756a1df84aea71e6e178598c358c860c6bfec42170fa7970"}, + {file = "time_machine-2.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:01bc257e9418980a4922de94775be42a966e1a082fb01a1635917f9afc7b84ca"}, + {file = "time_machine-2.16.0-cp310-cp310-win_arm64.whl", hash = "sha256:6895e3e84119594ab12847c928f619d40ae9cedd0755515dc154a5b5dc6edd9f"}, + {file = "time_machine-2.16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8f936566ef9f09136a3d5db305961ef6d897b76b240c9ff4199144aed6dd4fe5"}, + {file = "time_machine-2.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5886e23ede3478ca2a3e0a641f5d09dd784dfa9e48c96e8e5e31fc4fe77b6dc0"}, + {file = "time_machine-2.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76caf539fa4941e1817b7c482c87c65c52a1903fea761e84525955c6106fafb"}, + {file = "time_machine-2.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:298aa423e07c8b21b991782f01d7749c871c792319c2af3e9755f9ab49033212"}, + {file = "time_machine-2.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391ae9c484736850bb44ef125cbad52fe2d1b69e42c95dc88c43af8ead2cc7"}, + {file = "time_machine-2.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:503e7ff507c2089699d91885fc5b9c8ff16774a7b6aff48b4dcee0c0a0685b61"}, + {file = "time_machine-2.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eee7b0fc4fbab2c6585ea17606c6548be83919c70deea0865409fe9fc2d8cdce"}, + {file = "time_machine-2.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9db5e5b3ccdadaafa5730c2f9db44c38b013234c9ad01f87738907e19bdba268"}, + {file = "time_machine-2.16.0-cp311-cp311-win32.whl", hash = "sha256:2552f0767bc10c9d668f108fef9b487809cdeb772439ce932e74136365c69baf"}, + {file = "time_machine-2.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:12474fcdbc475aa6fe5275fe7224e685c5b9777f5939647f35980e9614ae7558"}, + {file = "time_machine-2.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:ac2df0fa564356384515ed62cb6679f33f1f529435b16b0ec0f88414635dbe39"}, + {file = "time_machine-2.16.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:84788f4d62a8b1bf5e499bb9b0e23ceceea21c415ad6030be6267ce3d639842f"}, + {file = "time_machine-2.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:15ec236b6571730236a193d9d6c11d472432fc6ab54e85eac1c16d98ddcd71bf"}, + {file = "time_machine-2.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cedc989717c8b44a3881ac3d68ab5a95820448796c550de6a2149ed1525157f0"}, + {file = "time_machine-2.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d26d79de1c63a8c6586c75967e09b0ff306aa7e944a1eaddb74595c9b1839ca"}, + {file = "time_machine-2.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:317b68b56a9c3731e0cf8886e0f94230727159e375988b36c60edce0ddbcb44a"}, + {file = "time_machine-2.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:43e1e18279759897be3293a255d53e6b1cb0364b69d9591d0b80c51e461c94b0"}, + {file = "time_machine-2.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e43adb22def972a29d2b147999b56897116085777a0fea182fd93ee45730611e"}, + {file = "time_machine-2.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0c766bea27a0600e36806d628ebc4b47178b12fcdfb6c24dc0a566a9c06bfe7f"}, + {file = "time_machine-2.16.0-cp312-cp312-win32.whl", hash = "sha256:6dae82ab647d107817e013db82223e20a9853fa88543fec853ae326382d03c2e"}, + {file = "time_machine-2.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:265462c77dc9576267c3c7f20707780a171a9fdbac93ac22e608c309efd68c33"}, + {file = "time_machine-2.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:ef768e14768eebe3bb1196c0dece8e14c1c6991605721214a0c3c68cf77eb216"}, + {file = "time_machine-2.16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7751bf745d54e9e8b358c0afa332815da9b8a6194b26d0fd62876ab6c4d5c9c0"}, + {file = "time_machine-2.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1784edf173ca840ba154de6eed000b5727f65ab92972c2f88cec5c4d6349c5f2"}, + {file = "time_machine-2.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f5876a5682ce1f517e55d7ace2383432627889f6f7e338b961f99d684fd9e8d"}, + {file = "time_machine-2.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:806672529a2e255cd901f244c9033767dc1fa53466d0d3e3e49565a1572a64fe"}, + {file = "time_machine-2.16.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:667b150fedb54acdca2a4bea5bf6da837b43e6dd12857301b48191f8803ba93f"}, + {file = "time_machine-2.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:da3ae1028af240c0c46c79adf9c1acffecc6ed1701f2863b8132f5ceae6ae4b5"}, + {file = "time_machine-2.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:520a814ea1b2706c89ab260a54023033d3015abef25c77873b83e3d7c1fafbb2"}, + {file = "time_machine-2.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8243664438bb468408b29c6865958662d75e51f79c91842d2794fa22629eb697"}, + {file = "time_machine-2.16.0-cp313-cp313-win32.whl", hash = "sha256:32d445ce20d25c60ab92153c073942b0bac9815bfbfd152ce3dcc225d15ce988"}, + {file = "time_machine-2.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:f6927dda86425f97ffda36131f297b1a601c64a6ee6838bfa0e6d3149c2f0d9f"}, + {file = "time_machine-2.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:4d3843143c46dddca6491a954bbd0abfd435681512ac343169560e9bab504129"}, + {file = "time_machine-2.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:23c5283c01b4f80b7dfbc88f3d8088c06c301b94b7c35366be498c2d7b308549"}, + {file = "time_machine-2.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ac95ae4529d7d85b251f9cf0f961a8a408ba285875811268f469d824a3b0b15a"}, + {file = "time_machine-2.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfb76674db946a74f0ca6e3b81caa8265e35dafe9b7005c7d2b8dd5bbd3825cf"}, + {file = "time_machine-2.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0b6ff3ccde9b16bbc694a2b5facf2d8890554f3135ff626ed1429e270e3cc4f"}, + {file = "time_machine-2.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1906ec6e26e6b803cd6aab28d420c87285b9c209ff2a69f82d12f82278f78bb"}, + {file = "time_machine-2.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e46bd09c944ec7a20868abd2b83d7d7abdaf427775e9df3089b9226a122b340f"}, + {file = "time_machine-2.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cac3e2b4101db296b150cb665e5461c03621e6ede6117fc9d5048c0ec96d6e7c"}, + {file = "time_machine-2.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e0dcc97cfec12ae306e3036746e7631cc7ef65c31889f7264c25217d4938367"}, + {file = "time_machine-2.16.0-cp39-cp39-win32.whl", hash = "sha256:c761d32d0c5d1fe5b71ac502e1bd5edec4598a7fc6f607b9b906b98e911148ce"}, + {file = "time_machine-2.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:ddfab1c622342f2945942c5c2d6be327656980e8f2d2b2ce0c022d0aa3711361"}, + {file = "time_machine-2.16.0-cp39-cp39-win_arm64.whl", hash = "sha256:2e08a4015d5d1aab2cb46c780e85b33efcd5cbe880bb363b282a6972e617b8bb"}, + {file = "time_machine-2.16.0.tar.gz", hash = "sha256:4a99acc273d2f98add23a89b94d4dd9e14969c01214c8514bfa78e4e9364c7e2"}, +] + +[package.dependencies] +python-dateutil = "*" + [[package]] name = "tortoise-orm" version = "0.21.7" @@ -2031,4 +2126,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "42b1f2c02091e30e76d806b1415db897e5feb02b3daf41250bbc809f76e05ea2" +content-hash = "ee677ff6b453248aadd43bea161451ce9d18a7889e3ecf490ff38a6a291c7749" diff --git a/pyproject.toml b/pyproject.toml index 02211f6..e1dc5ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,25 +30,39 @@ pytest-asyncio = "^0.24.0" mypy-extensions = "^1.0.0" coverage = "^7.6.8" celery-stubs = "^0.1.3" +ruff = "^0.11.12" +time-machine = "^2.16.0" [tool.mypy] -files = "src" +python_version = "3.12" strict = true +plugins = [ + "pydantic.mypy", + "sqlalchemy.ext.mypy.plugin" +] +[tool.black] +line-length = 120 + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = [ + "E501", # Line too long +] -#disallow_untyped_calls = true # 타입이 없는 함수 호출 금지 -#disallow_untyped_defs = true # 타입이 없는 함수 정의 금지 -#ignore_missing_imports = true # 누락된 import 무시 +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" -[tool.black] -line-length = 120 - - -[tool.isort] -profile = "black" -line_length = 120 +[tool.coverage.run] +concurrency = ["greenlet"] +[tool.coverage.report] +fail_under = 92 diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index a2ccfbb..706a602 100644 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -1,7 +1,7 @@ cat deploy-prod.sh #!/bin/bash -# src 디렉토리로 이동 +# apis 디렉토리로 이동 cd "$(dirname "$0")/../src" || exit # .env.prod 파일 확인 및 로드 diff --git a/scripts/start_app.sh b/scripts/start_app.sh index 87ae737..7b51ca6 100755 --- a/scripts/start_app.sh +++ b/scripts/start_app.sh @@ -7,6 +7,6 @@ poetry install --no-root # -k uvicorn.workers.UvicornWorker: Uvicorn 워커를 사용하여 FastAPI를 실행 # -w: 워커 수를 지정 (CPU 코어 수에 맞춰 조정) # -b: 바인딩할 주소 및 포트 -# src.main:app -> src 디렉토리 내의 main.py 파일에서 "app" 객체를 가리킴 (FastAPI 인스턴스) +# apis.main:apis -> apis 디렉토리 내의 __init__.py 파일에서 "apis" 객체를 가리킴 (FastAPI 인스턴스) exec gunicorn -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 main:app \ No newline at end of file diff --git a/src/app/v2/answers/dtos/answer_dto.py b/src/app/v2/answers/dtos/answer_dto.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/answers/models/answer.py b/src/app/v2/answers/models/answer.py deleted file mode 100644 index 98188f3..0000000 --- a/src/app/v2/answers/models/answer.py +++ /dev/null @@ -1,65 +0,0 @@ -from datetime import datetime -from typing import Any - -from tortoise import fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.answers.querys.answer_query import ( - SELECT_ANSWER_BY_USER_UUID_QUERY, - SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY, - SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY_V2, - SELECT_MOST_RECENT_ANSWER_BY_USER_UUID_QUERY, -) -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class Answer(Model): - answer_id = fields.BigIntField(pk=True) - content = fields.TextField(null=False) - created_time = fields.DatetimeField(null=True) - date = fields.DateField(null=False) - emotion = fields.IntField(null=False) - is_premium = fields.BooleanField(null=False) - is_public = fields.BooleanField(null=False) - modified_time = fields.DatetimeField(null=True) - is_blind = fields.BooleanField(null=False) - blind_ended_at = fields.DatetimeField(null=True) - blind_started_at = fields.DatetimeField(null=True) - like_count = fields.IntField(null=False, default=0) - is_spare = fields.BooleanField(null=False) - created_at = fields.DatetimeField(auto_now_add=True) - updated_at = fields.DatetimeField(auto_now=True) - - user: ForeignKeyRelation[User] = fields.ForeignKeyField( - "models.User", related_name="answers", on_delete=fields.CASCADE - ) - - class Meta: - table = "answer" - - # 기존 get_answer_count_by_user_id 메서드 - @classmethod - async def get_answer_count_by_user_id(cls, user_id: str) -> Any: - query = SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") - - @classmethod - async def get_answer_count_by_user_id_v2(cls, user_id: str) -> Any: - query = SELECT_ANSWER_COUNT_BY_USER_UUID_QUERY_V2 - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") - - @classmethod - async def find_all_by_user(cls, user_id: str, start_date: datetime, end_date: datetime) -> Any: - query = SELECT_ANSWER_BY_USER_UUID_QUERY - values = (user_id, start_date, end_date) - return await QueryExecutor.execute_query(query, values=values, fetch_type="multiple") - - @classmethod - async def get_most_recent_answer_by_user_id(cls, user_id: str) -> Any: - query = SELECT_MOST_RECENT_ANSWER_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") diff --git a/src/app/v2/answers/router.py b/src/app/v2/answers/router.py deleted file mode 100644 index 821fb95..0000000 --- a/src/app/v2/answers/router.py +++ /dev/null @@ -1,21 +0,0 @@ -from fastapi import APIRouter - -from app.v2.levels.services.level_service import LevelService - -router = APIRouter(prefix="/answer", tags=["Test용"]) - - -# FastAPI 비동기 뷰 - - -@router.get("/level-up") -async def level_up_handler() -> int: - user_id = "180a4e40-62f8-46be-b1eb-e7e3dd91cddf" - result = await LevelService.level_up(user_id=user_id) - return result - - -@router.get("/add-exp") -async def add_exp_handler() -> None: - user_id = "180a4e40-62f8-46be-b1eb-e7e3dd91cddf" - await LevelService.add_exp(user_id=user_id, exp=100) diff --git a/src/app/v2/answers/services/answer_service.py b/src/app/v2/answers/services/answer_service.py deleted file mode 100644 index 2d30db9..0000000 --- a/src/app/v2/answers/services/answer_service.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import datetime, timedelta -from typing import Any - -import pytz - -from app.v2.answers.models.answer import Answer - - -class AnswerService: - @classmethod - async def get_answer_count(cls, user_id: str) -> int: - """ - 과거부터 현재까지 총 답변 수 - """ - answer_count_raw = await Answer.get_answer_count_by_user_id(user_id=user_id) - if answer_count_raw is None: - return 0 - return int(answer_count_raw.get("answer_count", 0)) - - @classmethod - async def get_answer_count_v2(cls, user_id: str) -> int: - """ - v2 이후 총 답변 수 - """ - answer_count_raw = await Answer.get_answer_count_by_user_id_v2(user_id=user_id) - if answer_count_raw is None: - return 0 - return int(answer_count_raw.get("answer_count", 0)) - - @classmethod - async def get_answer_record(cls, user_id: str) -> int: - - seoul_tz = pytz.timezone("Asia/Seoul") - now = datetime.now(seoul_tz) - - if now.hour < 6: - now -= timedelta(days=1) - - end_date = now - start_date = end_date - timedelta(days=100) - - all_answers = await Answer.find_all_by_user(user_id, start_date, end_date) - - record = 0 - target_date = end_date - - if all_answers: - for answer in all_answers: - answer_date = answer["date"] - - if answer_date == target_date.date(): # 날짜만 비교 - record += 1 - target_date = target_date - timedelta(days=1) - else: - break - - return record - - @classmethod - async def calculate_consecutive_answer_points(cls, user_id: str) -> int: - return min(await cls.get_answer_record(user_id=user_id), 10) - - @classmethod - async def get_most_recent_answer(cls, user_id: str) -> Any: - answer = await Answer.get_most_recent_answer_by_user_id(user_id=user_id) - if answer == 0: - return {} - return answer diff --git a/src/app/v2/badges/dtos/badge_dto.py b/src/app/v2/badges/dtos/badge_dto.py deleted file mode 100644 index 30772de..0000000 --- a/src/app/v2/badges/dtos/badge_dto.py +++ /dev/null @@ -1,25 +0,0 @@ -from pydantic import BaseModel - - -class BadgeCodeDTO(BaseModel): - badgeCode: str - - @classmethod - def builder(cls, badge_raw: dict[str, str]) -> "BadgeCodeDTO": - return cls(badgeCode=badge_raw.get("badge_code", "")) - - -class BadgeDTO(BaseModel): - badgeCode: str - badgeName: str - badgeMiddleName: str - badgeCondition: str - - @classmethod - def builder(cls, badge_raw: dict[str, str]) -> "BadgeDTO": - return cls( - badgeCode=badge_raw.get("badge_code", ""), - badgeName=badge_raw.get("badge_name", ""), - badgeMiddleName=badge_raw.get("badge_middle_name", ""), - badgeCondition=badge_raw.get("badge_condition", ""), - ) diff --git a/src/app/v2/badges/dtos/response.py b/src/app/v2/badges/dtos/response.py deleted file mode 100644 index efba691..0000000 --- a/src/app/v2/badges/dtos/response.py +++ /dev/null @@ -1,6 +0,0 @@ -from app.v2.badges.dtos.badge_dto import BadgeDTO -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class BadgeListResponseDTO(BaseResponseDTO): - data: list[BadgeDTO] diff --git a/src/app/v2/badges/models/badge.py b/src/app/v2/badges/models/badge.py deleted file mode 100644 index 3de92a8..0000000 --- a/src/app/v2/badges/models/badge.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Any - -from tortoise import fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.badges.querys.badge_query import ( - INSERT_BADGE_CODE_FOR_USER_QUERY, - SELECT_BADGE_BY_USER_UUID_QUERY, - SELECT_BADGE_CODE_BY_USER_UUID_QUERY, - SELECT_BADGE_COUNT_BY_USER_UUID_QUERY, -) -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class Badge(Model): - badge_id = fields.BigIntField(pk=True) - badge_code = fields.CharField(max_length=255) - user: ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="badges") - - class Meta: - table = "badge" - - @classmethod - async def get_badge_count_by_user_id(cls, user_id: str) -> Any: - query = SELECT_BADGE_COUNT_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") - - @classmethod - async def get_badges_with_details_by_user_id(cls, user_id: str) -> Any: - query = SELECT_BADGE_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="multiple") - - @classmethod - async def get_badge_codes_by_user_id(cls, user_id: str) -> Any: - query = SELECT_BADGE_CODE_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="multiple") - - @classmethod - async def add_badge(cls, user_id: str, badge_code: str) -> None: - query = INSERT_BADGE_CODE_FOR_USER_QUERY - values = (badge_code, user_id) - await QueryExecutor.execute_query(query, values=values) - - -class BadgeInventory(Model): - badge_code = fields.CharField(max_length=255, primary_key=True) - badge_name = fields.CharField(max_length=255, null=True) - badge_condition = fields.CharField(max_length=255, null=True) - badge_middle_name = fields.CharField(max_length=255, null=True) - - class Meta: - table = "badge_inventory" - - @property - def badge_full_name(self) -> str: - return f"{self.badge_middle_name} {self.badge_name}" diff --git a/src/app/v2/badges/router.py b/src/app/v2/badges/router.py deleted file mode 100644 index a3ec584..0000000 --- a/src/app/v2/badges/router.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import APIRouter, status - -from app.v2.badges.dtos.response import BadgeListResponseDTO -from app.v2.badges.services.badge_service import BadgeService - -router = APIRouter(prefix="/user/badge", tags=["Badge"]) - - -@router.get( - "", - response_model=BadgeListResponseDTO, - status_code=status.HTTP_200_OK, -) -async def get_user_badge_handler(user_id: str) -> BadgeListResponseDTO: - - badges = await BadgeService.get_badges_with_details_by_user_id(user_id) - - return BadgeListResponseDTO( - code=status.HTTP_200_OK, - message="보유 뱃지 정보 조회", - data=badges, - ) diff --git a/src/app/v2/badges/services/badge_service.py b/src/app/v2/badges/services/badge_service.py deleted file mode 100644 index e611757..0000000 --- a/src/app/v2/badges/services/badge_service.py +++ /dev/null @@ -1,29 +0,0 @@ -from app.v2.badges.dtos.badge_dto import BadgeCodeDTO, BadgeDTO -from app.v2.badges.models.badge import Badge, BadgeInventory - - -class BadgeService: - @classmethod - async def get_badges(cls, user_id: str) -> list[BadgeCodeDTO]: - badges_raw = await Badge.get_badge_codes_by_user_id(user_id=user_id) - return [BadgeCodeDTO.builder(badge) for badge in badges_raw] - - @classmethod - async def add_badge(cls, user_id: str, badge_code: str) -> None: - await Badge.add_badge(user_id=user_id, badge_code=badge_code) - - @classmethod - async def get_badges_with_details_by_user_id(cls, user_id: str) -> list[BadgeDTO]: - badges_raw = await Badge.get_badges_with_details_by_user_id(user_id=user_id) - return [BadgeDTO.builder(badge) for badge in badges_raw] - - @classmethod - async def get_badge_count(cls, user_id: str) -> int: - badge_count_raw = await Badge.get_badge_count_by_user_id(user_id=user_id) - if badge_count_raw is None: - return 0 - return int(badge_count_raw.get("badge_count", 0)) - - @classmethod - async def get_badge_info_by_badge_code(cls, badge_code: str) -> BadgeInventory: - return await BadgeInventory.get(badge_code=badge_code) diff --git a/src/app/v2/cheese_managers/dtos/cheese_dto.py b/src/app/v2/cheese_managers/dtos/cheese_dto.py deleted file mode 100644 index 83dc866..0000000 --- a/src/app/v2/cheese_managers/dtos/cheese_dto.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Optional, TypedDict - -from pydantic import BaseModel - -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class CheeseAmountResult(TypedDict): - total_cheese_amount: Optional[int] - - -class CheeseDTO(BaseModel): - cheeseBalance: int - - @classmethod - def builder(cls, cheese_balance: int) -> "CheeseDTO": - return cls(cheeseBalance=cheese_balance) - - -class CheeseResponseDTO(BaseResponseDTO): - data: CheeseDTO - - @classmethod - def builder(cls, cheese_balance: int) -> "CheeseResponseDTO": - return cls( - code=200, - message="success", - data=CheeseDTO.builder(cheese_balance=cheese_balance), - ) diff --git a/src/app/v2/cheese_managers/models/cheese_manager.py b/src/app/v2/cheese_managers/models/cheese_manager.py deleted file mode 100644 index ea7dbfd..0000000 --- a/src/app/v2/cheese_managers/models/cheese_manager.py +++ /dev/null @@ -1,96 +0,0 @@ -from typing import Any - -from tortoise import fields -from tortoise.expressions import Q -from tortoise.fields import ForeignKeyRelation -from tortoise.functions import Sum -from tortoise.models import Model - -from app.v2.cheese_managers.models.cheese_status import CheeseStatus - - -class CheeseManager(Model): - cheese_manager_id = fields.BigIntField(pk=True) # BIGINT auto_increment equivalent - - class Meta: - table = "cheese_manager" # Database table name - - @staticmethod - async def get_total_cheese_amount_by_manager(cheese_manager_id: int) -> int: - result: list[dict[str, Any]] = ( - await CheeseHistory.filter( - Q(status=CheeseStatus.CAN_USE) | Q(status=CheeseStatus.USING), - cheese_manager_id=cheese_manager_id, - ) - .annotate(total_cheese_amount=Sum("current_amount")) - .values("total_cheese_amount") - ) - if not result or result[0].get("total_cheese_amount") is None: - return 0 - - total_cheese_amount = result[0].get("total_cheese_amount") - return int(total_cheese_amount) if total_cheese_amount is not None else 0 - - @staticmethod - async def use_cheese(cheese_manager_id: int, amount: int) -> None: - using_cheese = await CheeseHistory.filter( - status=CheeseStatus.USING, cheese_manager_id=cheese_manager_id - ).order_by("cheese_history_id") - - remaining_amount = amount - - for cheese in using_cheese: - if cheese.current_amount >= remaining_amount: - cheese.current_amount -= remaining_amount - if cheese.current_amount == 0: - cheese.status = CheeseStatus.ALREADY_USED - await cheese.save() - return - - remaining_amount -= cheese.current_amount - cheese.current_amount = 0 - cheese.status = CheeseStatus.ALREADY_USED - await cheese.save() - - can_use_cheese = await CheeseHistory.filter( - status=CheeseStatus.CAN_USE, cheese_manager_id=cheese_manager_id - ).order_by("cheese_history_id") - - for cheese in can_use_cheese: - if cheese.current_amount >= remaining_amount: - cheese.current_amount -= remaining_amount - cheese.status = CheeseStatus.USING - await cheese.save() - return - - remaining_amount -= cheese.current_amount - cheese.current_amount = 0 - cheese.status = CheeseStatus.ALREADY_USED - await cheese.save() - - if remaining_amount > 0: - raise ValueError("Not enough cheese to complete the transaction") - - @staticmethod - async def add_cheese(cheese_manager_id: int, amount: int) -> None: - await CheeseHistory.create( - status=CheeseStatus.CAN_USE, - current_amount=amount, - starting_amount=amount, - cheese_manager_id=cheese_manager_id, - ) - - -class CheeseHistory(Model): - cheese_history_id = fields.BigIntField(pk=True) - status = fields.CharEnumField(CheeseStatus, max_length=50, null=True) # Enum Field - current_amount = fields.IntField() - starting_amount = fields.IntField() - cheese_manager: ForeignKeyRelation[CheeseManager] = fields.ForeignKeyField( - "models.CheeseManager", - related_name="histories", - on_delete=fields.CASCADE, - ) - - class Meta: - table = "cheese_history" diff --git a/src/app/v2/cheese_managers/router.py b/src/app/v2/cheese_managers/router.py deleted file mode 100644 index 2589c81..0000000 --- a/src/app/v2/cheese_managers/router.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi import APIRouter, status - -from app.v2.cheese_managers.dtos.cheese_dto import CheeseResponseDTO -from app.v2.cheese_managers.services.cheese_service import CheeseService -from app.v2.users.services.user_service import UserService - -router = APIRouter(prefix="/cheese", tags=["Cheese"]) - - -@router.get("", response_model=CheeseResponseDTO, status_code=status.HTTP_200_OK) -async def get_cheese_handler(user_id: str) -> CheeseResponseDTO: - - user = await UserService.get_user_info(user_id=user_id) - cheese_amount = await CheeseService.get_cheese_balance(user["cheese_manager_id"]) - print(cheese_amount) - - return CheeseResponseDTO.builder(cheese_balance=cheese_amount) diff --git a/src/app/v2/cheese_managers/services/cheese_service.py b/src/app/v2/cheese_managers/services/cheese_service.py deleted file mode 100644 index d846548..0000000 --- a/src/app/v2/cheese_managers/services/cheese_service.py +++ /dev/null @@ -1,12 +0,0 @@ -from app.v2.cheese_managers.models.cheese_manager import CheeseManager - - -class CheeseService: - - @classmethod - async def get_cheese_balance(cls, cheese_manager_id: int) -> int: - return await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=cheese_manager_id) or 0 - - @classmethod - async def add_cheese(cls, cheese_manager_id: int, amount: int) -> None: - await CheeseManager.add_cheese(cheese_manager_id=cheese_manager_id, amount=amount) diff --git a/src/app/v2/colors/dtos/color_dto.py b/src/app/v2/colors/dtos/color_dto.py deleted file mode 100644 index df1a390..0000000 --- a/src/app/v2/colors/dtos/color_dto.py +++ /dev/null @@ -1,23 +0,0 @@ -from pydantic import BaseModel - - -class ColorCodeDTO(BaseModel): - colorCode: str - - @classmethod - def builder(cls, color_raw: dict[str, str]) -> "ColorCodeDTO": - return cls(colorCode=color_raw.get("color_code", "")) - - -class ColorDTO(BaseModel): - colorCode: str - # colorName: str - # colorHexCode: str - - @classmethod - def builder(cls, color_raw: dict[str, str]) -> "ColorDTO": - return cls( - colorCode=color_raw.get("color_code", ""), - # colorName=color_raw.get("color_name", ""), - # colorHexCode=color_raw.get("color_hex_code", ""), - ) diff --git a/src/app/v2/colors/dtos/response.py b/src/app/v2/colors/dtos/response.py deleted file mode 100644 index 7a0459e..0000000 --- a/src/app/v2/colors/dtos/response.py +++ /dev/null @@ -1,6 +0,0 @@ -from app.v2.colors.dtos.color_dto import ColorDTO -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class ColorListResponseDTO(BaseResponseDTO): - data: list[ColorDTO] diff --git a/src/app/v2/colors/models/color.py b/src/app/v2/colors/models/color.py deleted file mode 100644 index acf8bc2..0000000 --- a/src/app/v2/colors/models/color.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Any - -from tortoise import fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.colors.querys.color_query import ( - INSERT_COLOR_CODE_FOR_USER_QUERY, - SELECT_COLOR_BY_USER_UUID_QUERY, - SELECT_COLOR_CODE_BY_USER_UUID_QUERY, -) -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class Color(Model): - color_id = fields.BigIntField(pk=True) - color_code = fields.CharField(max_length=255, null=True) - user: ForeignKeyRelation[User] = fields.ForeignKeyField( - "models.User", related_name="colors", on_delete=fields.CASCADE - ) - - class Meta: - table = "color" - - @classmethod - async def get_color_codes_by_user_id(cls, user_id: str) -> Any: - query = SELECT_COLOR_CODE_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="multiple") - - @classmethod - async def add_color_code_for_user(cls, user_id: str, color_code: str) -> Any: - query = INSERT_COLOR_CODE_FOR_USER_QUERY - values = (color_code, user_id) - return await QueryExecutor.execute_query(query, values=values, fetch_type="single") - - @classmethod - async def get_colors_with_details_by_user_id(cls, user_id: str) -> Any: - query = SELECT_COLOR_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="multiple") - - -class ColorInventory(Model): - color_code = fields.CharField(max_length=255, primary_key=True) - color_name = fields.CharField(max_length=255, null=True) - color_hex_code = fields.CharField(max_length=255, null=True) - - class Meta: - table = "color_inventory" # 테이블 이름을 명시 - - @classmethod - async def get_color_inventory(cls) -> list[dict[str, str]]: - return await cls.all().values("color_code", "color_name", "color_hex_code") diff --git a/src/app/v2/colors/router.py b/src/app/v2/colors/router.py deleted file mode 100644 index 0b11aa3..0000000 --- a/src/app/v2/colors/router.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import APIRouter, status - -from app.v2.colors.dtos.response import ColorListResponseDTO -from app.v2.colors.services.color_service import ColorService - -router = APIRouter(prefix="/user/color", tags=["Color"]) - - -@router.get( - "", - response_model=ColorListResponseDTO, - status_code=status.HTTP_200_OK, -) -async def get_user_color_handler(user_id: str) -> ColorListResponseDTO: - - colors = await ColorService.get_colors_with_details_by_user_id(user_id=user_id) - - return ColorListResponseDTO( - code=status.HTTP_200_OK, - message="보유 색상 정보 조회", - data=colors, - ) diff --git a/src/app/v2/colors/services/color_service.py b/src/app/v2/colors/services/color_service.py deleted file mode 100644 index 5b03137..0000000 --- a/src/app/v2/colors/services/color_service.py +++ /dev/null @@ -1,28 +0,0 @@ -from app.v2.colors.dtos.color_dto import ColorCodeDTO, ColorDTO -from app.v2.colors.models.color import Color, ColorInventory -from app.v2.users.services.user_service import UserService - - -class ColorService: - @classmethod - async def get_colors(cls, user_id: str) -> list[ColorCodeDTO]: - colors_raw = await Color.get_color_codes_by_user_id(user_id=user_id) - return [ColorCodeDTO.builder(color) for color in colors_raw] - - @classmethod - async def add_color(cls, user_id: str, color_code: str) -> None: - await Color.add_color_code_for_user(user_id=user_id, color_code=color_code) - - @classmethod - async def get_colors_with_details_by_user_id(cls, user_id: str) -> list[ColorDTO]: - user = await UserService.get_user_profile(user_id=user_id) - if user.is_premium: - colors_raw = await ColorInventory.get_color_inventory() - else: - colors_raw = await Color.get_colors_with_details_by_user_id(user_id=user_id) - - return [ColorDTO.builder(color) for color in colors_raw] - - @classmethod - async def get_color_inventory(cls) -> list[dict[str, str]]: - return await ColorInventory.get_color_inventory() diff --git a/src/app/v2/emotions/dtos/response.py b/src/app/v2/emotions/dtos/response.py deleted file mode 100644 index 3fcac1e..0000000 --- a/src/app/v2/emotions/dtos/response.py +++ /dev/null @@ -1,15 +0,0 @@ -from pydantic import BaseModel - -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class EmotionDTO(BaseModel): - emotionList: list[int] - - @classmethod - def build(cls, emotion_list: list[int]) -> "EmotionDTO": - return cls(emotionList=emotion_list) - - -class EmotionListResponseDTO(BaseResponseDTO): - data: EmotionDTO diff --git a/src/app/v2/emotions/models/emotion.py b/src/app/v2/emotions/models/emotion.py deleted file mode 100644 index 58732fd..0000000 --- a/src/app/v2/emotions/models/emotion.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any - -from tortoise import fields, models -from tortoise.fields import ForeignKeyRelation - -from app.v2.emotions.querys.emotion_query import ( - INSERT_EMOTION_CODE_FOR_USER_QUERY, - SELECT_EMOTION_CODE_BY_USER_UUID_QUERY, -) -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class Emotion(models.Model): - emotion_id = fields.BigIntField(pk=True) - emotion_code = fields.CharField(max_length=255, unique=True) - user: ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="emotions") - - class Meta: - table = "emotion" - - @classmethod - async def get_emotions_with_details_by_user_id(cls, user_id: str) -> Any: - query = SELECT_EMOTION_CODE_BY_USER_UUID_QUERY - values = user_id - return await QueryExecutor.execute_query(query, values=values, fetch_type="multiple") - - @classmethod - async def add_emotion(cls, user_id: str, emotion_code: str) -> None: - query = INSERT_EMOTION_CODE_FOR_USER_QUERY - values = (emotion_code, user_id) - await QueryExecutor.execute_query(query, values=values) - - -class EmotionInventory(models.Model): - emotion_inventory_id = fields.BigIntField(pk=True) - emotion_code = fields.CharField(max_length=255, unique=True) - emotion_name = fields.CharField(max_length=255) - - class Meta: - table = "emotion_inventory" - - @classmethod - async def get_emotion_inventory(cls) -> list[dict[str, str]]: - return await cls.all().values("emotion_code", "emotion_name") diff --git a/src/app/v2/emotions/router.py b/src/app/v2/emotions/router.py deleted file mode 100644 index 3fea988..0000000 --- a/src/app/v2/emotions/router.py +++ /dev/null @@ -1,19 +0,0 @@ -from fastapi import APIRouter, status - -from app.v2.emotions.dtos.response import EmotionListResponseDTO -from app.v2.emotions.services.emotion_service import EmotionService - -router = APIRouter(prefix="/user/emotion", tags=["Emotion"]) - - -@router.get( - "", - response_model=EmotionListResponseDTO, - status_code=status.HTTP_200_OK, -) -async def get_user_emotion_handler(user_id: str) -> EmotionListResponseDTO: - return EmotionListResponseDTO( - data=await EmotionService.mapping_emotion_list(user_id=user_id), - code=status.HTTP_200_OK, - message="보유 감정 정보 조회", - ) diff --git a/src/app/v2/emotions/services/emotion_service.py b/src/app/v2/emotions/services/emotion_service.py deleted file mode 100644 index bdcc9a6..0000000 --- a/src/app/v2/emotions/services/emotion_service.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any - -from app.v2.emotions.dtos.response import EmotionDTO, EmotionListResponseDTO -from app.v2.emotions.models.emotion import Emotion, EmotionInventory -from app.v2.users.services.user_service import UserService - -emotion_mapping = { - "EM_HAPPY": 1, - "EM_PROUD": 2, - "EM_OKAY": 3, - "EM_TIRED": 4, - "EM_SAD": 5, - "EM_ANGRY": 6, - "EM_EXCITED": 7, - "EM_FUN": 8, - "EM_RELAXED": 9, - "EM_APATHETIC": 10, - "EM_LONELY": 11, - "EM_COMPLEX": 12, -} - - -class EmotionService: - @classmethod - async def get_emotions(cls, user_id: str) -> Any: - return await Emotion.get_emotions_with_details_by_user_id(user_id=user_id) - - @classmethod - async def add_emotion(cls, user_id: str, emotion_code: str) -> None: - await Emotion.add_emotion(user_id=user_id, emotion_code=emotion_code) - - @classmethod - async def get_emotion_inventory(cls) -> list[dict[str, str]]: - return await EmotionInventory.get_emotion_inventory() - - @classmethod - async def mapping_emotion_list(cls, user_id: str) -> EmotionDTO: - user = await UserService.get_user_profile(user_id=user_id) - - if user.is_premium: - emotions = await cls.get_emotion_inventory() - else: - emotions = await cls.get_emotions(user_id=user_id) - - return EmotionDTO.build(emotion_list=await cls.get_mapped_emotions(emotions)) - - @classmethod - async def get_mapped_emotions(cls, emotions: list[dict[str, str]]) -> list[int]: - return [ - value - for value in (emotion_mapping.get(emotion["emotion_code"]) for emotion in emotions) - if value is not None - ] diff --git a/src/app/v2/items/dtos/item_dto.py b/src/app/v2/items/dtos/item_dto.py deleted file mode 100644 index 554b2be..0000000 --- a/src/app/v2/items/dtos/item_dto.py +++ /dev/null @@ -1,23 +0,0 @@ -# schemas.py -from typing import Optional - -from pydantic import BaseModel - - -class ItemInventorySchema(BaseModel): - item_category: Optional[str] - item_code: Optional[str] - - -class ProductInventorySchema(BaseModel): - price: Optional[float] - product_category: Optional[str] - product_code: Optional[str] - transaction_currency: Optional[str] - - -class ItemInventoryProductInventorySchema(BaseModel): - quantity: int - item_measurement: Optional[str] - item_inventory_id: int - product_inventory_id: int diff --git a/src/app/v2/items/models/item.py b/src/app/v2/items/models/item.py deleted file mode 100644 index 5717827..0000000 --- a/src/app/v2/items/models/item.py +++ /dev/null @@ -1,71 +0,0 @@ -from tortoise import fields, models -from tortoise.fields import ForeignKeyRelation - - -class ItemInventory(models.Model): - item_id = fields.BigIntField(pk=True) - item_category = fields.CharField(max_length=255, null=True) - item_code = fields.CharField(max_length=255, null=True) - - class Meta: - table = "item_inventory" - - -class ProductInventory(models.Model): - product_id = fields.BigIntField(pk=True) - price = fields.FloatField(null=True) - product_category = fields.CharField(max_length=255, null=True) - product_code = fields.CharField(max_length=255, null=True) - transaction_currency = fields.CharField(max_length=255, null=True) - - class Meta: - table = "product_inventory" - - -class ItemInventoryProductInventory(models.Model): - item_inventory_product_inventory_id = fields.BigIntField(pk=True) - quantity = fields.IntField() - item_inventory: ForeignKeyRelation[ItemInventory] = fields.ForeignKeyField( - "models.ItemInventory", related_name="product_inventories" - ) - product_inventory: ForeignKeyRelation[ProductInventory] = fields.ForeignKeyField( - "models.ProductInventory", related_name="item_inventories" - ) - item_measurement = fields.CharField(max_length=255, null=True) - - class Meta: - table = "item_inventory_product_inventory" - - -class RewardInventory(models.Model): - reward_inventory_id = fields.BigIntField(pk=True) - item_code = fields.CharField(max_length=255, null=True) - reward_code = fields.CharField(max_length=255, null=True) - reward_description = fields.CharField(max_length=255, null=True) - reward_name = fields.CharField(max_length=255, null=True) - - item_inventories = fields.ReverseRelation["ItemInventoryRewardInventory"] - - class Meta: - table = "reward_inventory" - - -class ItemInventoryRewardInventory(models.Model): - item_inventory_reward_invnetory_id = fields.BigIntField(pk=True) - quantity = fields.IntField() - item_inventory: ForeignKeyRelation[ItemInventory] = fields.ForeignKeyField( - "models.ItemInventory", - related_name="reward_inventories", - on_delete=fields.CASCADE, - db_column="item_inventory_id", - ) - reward_inventory: ForeignKeyRelation[RewardInventory] = fields.ForeignKeyField( - "models.RewardInventory", - related_name="item_inventories", - on_delete=fields.CASCADE, - db_column="reward_inventory_id", - ) - item_measurement = fields.CharField(max_length=255, null=True) - - class Meta: - table = "item_inventory_reward_inventory" diff --git a/src/app/v2/items/router.py b/src/app/v2/items/router.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/levels/__init__.py b/src/app/v2/levels/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/levels/dtos/__init__.py b/src/app/v2/levels/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/levels/dtos/level_dto.py b/src/app/v2/levels/dtos/level_dto.py deleted file mode 100644 index b9e8e25..0000000 --- a/src/app/v2/levels/dtos/level_dto.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - - -class LevelDTO(BaseModel): - level: int - currentExp: int - requiredExp: int | None = None - - @classmethod - def builder(cls, level: dict[str, Any]) -> "LevelDTO": - return cls( - level=level["level_level"], - currentExp=level["level_exp"], - requiredExp=level["required_exp"], - ) - - -class LevelInfoDTO(BaseModel): - levelDto: LevelDTO - daysToLevelUp: int - - @classmethod - def builder(cls, level_dto: LevelDTO, days_to_level_up: int) -> "LevelInfoDTO": - return cls( - levelDto=level_dto, - daysToLevelUp=days_to_level_up, - ) diff --git a/src/app/v2/levels/models/__init__.py b/src/app/v2/levels/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/levels/querys/__init__.py b/src/app/v2/levels/querys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/levels/router.py b/src/app/v2/levels/router.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/levels/services/__init__.py b/src/app/v2/levels/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/likes/__init__.py b/src/app/v2/likes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/likes/models/__init__.py b/src/app/v2/likes/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/likes/models/like.py b/src/app/v2/likes/models/like.py deleted file mode 100644 index 8369c8c..0000000 --- a/src/app/v2/likes/models/like.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Any - -from tortoise import fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.answers.models.answer import Answer -from app.v2.likes.querys.like_query import SELECT_UNIQUE_LIKES_COUNT_BY_USER_TODAY_QUERY -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class Like(Model): - likes_id = fields.BigIntField(pk=True) - answer: ForeignKeyRelation[Answer] = fields.ForeignKeyField( - "models.Answer", related_name="likes", on_delete=fields.CASCADE - ) - user: ForeignKeyRelation[User] = fields.ForeignKeyField( - "models.User", related_name="likes", on_delete=fields.CASCADE - ) - created_time = fields.DatetimeField(null=True) - modified_time = fields.DatetimeField(null=True) - created_at = fields.DatetimeField(auto_now_add=True) - updated_at = fields.DatetimeField(auto_now=True) - - class Meta: - table = "likes" - indexes = [ - ("answer_id",), - ("user_id",), - ] - - @staticmethod - async def get_unique_likes_today(user_id: str) -> Any: - query = SELECT_UNIQUE_LIKES_COUNT_BY_USER_TODAY_QUERY - values = (user_id,) - return await QueryExecutor.execute_query(query, values=values, fetch_type="single") diff --git a/src/app/v2/likes/querys/__init__.py b/src/app/v2/likes/querys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/missions/__init__.py b/src/app/v2/missions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/missions/dtos/__init__.py b/src/app/v2/missions/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/missions/dtos/mission_dto.py b/src/app/v2/missions/dtos/mission_dto.py deleted file mode 100644 index 4d73b7a..0000000 --- a/src/app/v2/missions/dtos/mission_dto.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - - -class UserMissionDTO(BaseModel): - user_mission_id: int - is_completed: bool - mission_code: str - progress_count: int - - @classmethod - def builder(cls, user_mission: dict[str, Any]) -> "UserMissionDTO": - is_completed_raw = user_mission.get("is_completed") - is_completed = ( - bool(int.from_bytes(is_completed_raw, byteorder="big")) - if isinstance(is_completed_raw, bytes) and is_completed_raw is not None - else bool(is_completed_raw) if is_completed_raw is not None else False - ) - return cls( - user_mission_id=user_mission.get("user_mission_id", 0), # 기본값 0 설정 - is_completed=is_completed, - mission_code=user_mission.get("mission_code", ""), # 기본값 빈 문자열 설정 - progress_count=user_mission.get("progress_count", 0), # 기본값 0 설정 - ) diff --git a/src/app/v2/missions/dtos/request.py b/src/app/v2/missions/dtos/request.py deleted file mode 100644 index 026bed1..0000000 --- a/src/app/v2/missions/dtos/request.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class MissionProgressRequest(BaseModel): - mission_code: str - progress_count: int diff --git a/src/app/v2/missions/dtos/response.py b/src/app/v2/missions/dtos/response.py deleted file mode 100644 index 46ed7df..0000000 --- a/src/app/v2/missions/dtos/response.py +++ /dev/null @@ -1,22 +0,0 @@ -# 응답 모델 정의 -from pydantic import BaseModel - - -class MissionProgressResponse(BaseModel): - mission_code: str - progress_count: int - is_completed: bool - mission_name: str - mission_description: str - target_count: int - - -class UserLevelResponse(BaseModel): - user_level: int - user_exp: int - level_up: bool - - -class ApiResponse(BaseModel): - mission_progress: MissionProgressResponse - user_level_info: UserLevelResponse diff --git a/src/app/v2/missions/dtos/reward_dto.py b/src/app/v2/missions/dtos/reward_dto.py deleted file mode 100644 index c0f46f9..0000000 --- a/src/app/v2/missions/dtos/reward_dto.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any, Optional - -from pydantic import BaseModel - - -class RewardDTO(BaseModel): - total_cheese: int - total_exp: int - badge_code: Optional[str] = None - badge_full_name: Optional[str] = None - - class META: - orm_mode = True - - @classmethod - async def build( - cls, - total_cheese: int, - total_exp: int, - badge_code: Optional[str] = None, - badge_full_name: Optional[str] = None, - ) -> "RewardDTO": - return cls( - total_cheese=total_cheese, - total_exp=total_exp, - badge_code=badge_code, - badge_full_name=badge_full_name, - ) diff --git a/src/app/v2/missions/models/__init__.py b/src/app/v2/missions/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/missions/models/mission.py b/src/app/v2/missions/models/mission.py deleted file mode 100644 index 94f281f..0000000 --- a/src/app/v2/missions/models/mission.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Any - -from tortoise import fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.missions.querys.mission_query import SELECT_USER_MISSIONS_QUERY, UPDATE_USER_MISSION_PROGRESS_QUERY -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class UserMission(Model): - user_mission_id = fields.BigIntField(pk=True) - is_completed = fields.BooleanField(default=False) - mission_code = fields.CharField(max_length=255) - progress_count = fields.IntField(default=0) - user: ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="missions") - - class Meta: - table = "user_mission" - - @classmethod - async def get_user_missions_by_condition_type(cls, user_id: str) -> Any: - query = SELECT_USER_MISSIONS_QUERY - values = (user_id,) - return await QueryExecutor.execute_query(query, values=values, fetch_type="multiple") - - @classmethod - async def update_user_mission_progress( - cls, - user_id: str, - mission_code: str, - new_progress_count: int, - is_completed: bool, - ) -> None: - query = UPDATE_USER_MISSION_PROGRESS_QUERY - values = (new_progress_count, int(is_completed), user_id, mission_code) - await QueryExecutor.execute_query(query, values=values, fetch_type="single") - - -class MissionInventory(Model): - mission_inventory_id = fields.BigIntField(pk=True) - condition_type = fields.CharField(max_length=255) - mission_code = fields.CharField(max_length=255) - mission_description = fields.CharField(max_length=255) - mission_name = fields.CharField(max_length=255) - reward_code = fields.CharField(max_length=255) - target_count = fields.IntField() - - class Meta: - table = "mission_inventory" diff --git a/src/app/v2/missions/querys/__init__.py b/src/app/v2/missions/querys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/missions/services/__init__.py b/src/app/v2/missions/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/mobiles/__init__.py b/src/app/v2/mobiles/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/mobiles/dtos/__init__.py b/src/app/v2/mobiles/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/mobiles/dtos/mypage_response.py b/src/app/v2/mobiles/dtos/mypage_response.py deleted file mode 100644 index 4d492aa..0000000 --- a/src/app/v2/mobiles/dtos/mypage_response.py +++ /dev/null @@ -1,25 +0,0 @@ -from pydantic import BaseModel - -from app.v2.levels.dtos.level_dto import LevelInfoDTO -from app.v2.users.dtos.user_profile_dto import UserProfileDTO -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class UserProfileWithLevel(BaseModel): - userProfile: UserProfileDTO - level: LevelInfoDTO - - @classmethod - def builder( - cls, - userProfile: UserProfileDTO, - level: LevelInfoDTO, - ) -> "UserProfileWithLevel": - return cls( - userProfile=userProfile, - level=level, - ) - - -class MyPageResponseDTO(BaseResponseDTO): - data: UserProfileWithLevel diff --git a/src/app/v2/mobiles/dtos/teller_card_response.py b/src/app/v2/mobiles/dtos/teller_card_response.py deleted file mode 100644 index 29f8349..0000000 --- a/src/app/v2/mobiles/dtos/teller_card_response.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import List, Optional - -from pydantic import BaseModel - -from app.v2.badges.dtos.badge_dto import BadgeDTO -from app.v2.colors.dtos.color_dto import ColorDTO -from app.v2.levels.dtos.level_dto import LevelDTO, LevelInfoDTO -from app.v2.users.dtos.user_info_dto import UserInfoDTO -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class DataDTO(BaseModel): - badges: list[BadgeDTO] - colors: list[ColorDTO] - userInfo: UserInfoDTO - levelInfo: LevelInfoDTO - recordCount: int = 0 - - @classmethod - def builder( - cls, - badges: list[BadgeDTO], - colors: list[ColorDTO], - userInfo: UserInfoDTO, - levelInfo: LevelInfoDTO, - recordCount: Optional[int] = None, - ) -> "DataDTO": - return cls( - badges=badges, - colors=colors, - userInfo=userInfo, - levelInfo=levelInfo, - recordCount=recordCount if recordCount is not None else 0, - ) - - -# 최종 응답 DTO -class TellerCardResponseDTO(BaseResponseDTO): - data: DataDTO diff --git a/src/app/v2/mobiles/router.py b/src/app/v2/mobiles/router.py deleted file mode 100644 index 1e2c9fa..0000000 --- a/src/app/v2/mobiles/router.py +++ /dev/null @@ -1,92 +0,0 @@ -import asyncio - -from fastapi import APIRouter, HTTPException, status - -from app.v2.answers.services.answer_service import AnswerService -from app.v2.badges.services.badge_service import BadgeService -from app.v2.cheese_managers.services.cheese_service import CheeseService -from app.v2.colors.services.color_service import ColorService -from app.v2.levels.services.level_service import LevelService -from app.v2.mobiles.dtos.mypage_response import MyPageResponseDTO, UserProfileWithLevel -from app.v2.mobiles.dtos.teller_card_response import DataDTO, TellerCardResponseDTO -from app.v2.teller_cards.services.teller_card_service import TellerCardService -from app.v2.users.dtos.user_info_dto import UserInfoDTO -from app.v2.users.dtos.user_profile_dto import UserProfileDTO -from app.v2.users.services.user_service import UserService - -router = APIRouter(prefix="/mobiles", tags=["모바일 화면용 컨트롤러"]) - - -@router.post("/main") -async def mobile_main_handler() -> None: - pass - - -@router.get( - "/tellercard", - response_model=TellerCardResponseDTO, - status_code=status.HTTP_200_OK, -) -async def mobile_teller_card_handler(user_id: str) -> TellerCardResponseDTO: - - badges_task = BadgeService.get_badges_with_details_by_user_id(user_id) - colors_task = ColorService.get_colors_with_details_by_user_id(user_id) - level_info_task = LevelService.get_level_info_add_answer_days(user_id) - teller_card_task = TellerCardService.get_teller_card(user_id) - user_info_task = UserService.get_user_info(user_id) - record_answer_task = AnswerService.get_answer_record(user_id=user_id) - - badges, colors, level_info, teller_card, user_raw, record_count = await asyncio.gather( - badges_task, colors_task, level_info_task, teller_card_task, user_info_task, record_answer_task - ) - - cheese_amount = await CheeseService.get_cheese_balance(user_raw["cheese_manager_id"]) - - user_info = UserInfoDTO.builder(user_raw, cheeseBalance=cheese_amount, tellerCard=teller_card) - - data = DataDTO.builder( - badges=badges, colors=colors, userInfo=user_info, levelInfo=level_info, recordCount=record_count - ) - - return TellerCardResponseDTO( - code=status.HTTP_200_OK, - data=data, - message="teller_card ui page", - ) - - -@router.get( - "/mypage", - response_model=MyPageResponseDTO, - status_code=status.HTTP_200_OK, -) -async def mobile_my_page_handler(user_id: str) -> MyPageResponseDTO: - - user, answer_count, badge_count, teller_card, level = await asyncio.gather( - UserService.get_user_profile(user_id=user_id), - AnswerService.get_answer_count(user_id=user_id), - BadgeService.get_badge_count(user_id=user_id), - TellerCardService.get_teller_card(user_id=user_id), - LevelService.get_level_info_add_answer_days(user_id), - ) - - cheese_amount = await CheeseService.get_cheese_balance(cheese_manager_id=user.cheese_manager_id) # type: ignore - - user_profile_data = UserProfileWithLevel.builder( - userProfile=UserProfileDTO.builder( - nickname=user.nickname, # type: ignore - cheeseBalance=cheese_amount, - badgeCode=teller_card.badgeCode, - badgeCount=badge_count, - answerCount=answer_count, - premium=user.is_premium, # type: ignore - allow_notification=user.allow_notification, # type: ignore - ), - level=level, - ) - - return MyPageResponseDTO( - code=status.HTTP_200_OK, - message="정상처리되었습니다", - data=user_profile_data, - ) diff --git a/src/app/v2/notices/__init__.py b/src/app/v2/notices/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/notices/dtos/__init__.py b/src/app/v2/notices/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/notices/models/__init__.py b/src/app/v2/notices/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/notices/services/__init__.py b/src/app/v2/notices/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/payments/__init__.py b/src/app/v2/payments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/payments/dtos/__init__.py b/src/app/v2/payments/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/payments/dtos/request.py b/src/app/v2/payments/dtos/request.py deleted file mode 100644 index fb55b02..0000000 --- a/src/app/v2/payments/dtos/request.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class PaymentRequestDTO(BaseModel): - user_id: str - productCode: str diff --git a/src/app/v2/payments/dtos/response.py b/src/app/v2/payments/dtos/response.py deleted file mode 100644 index 490deb4..0000000 --- a/src/app/v2/payments/dtos/response.py +++ /dev/null @@ -1,21 +0,0 @@ -from pydantic import BaseModel - -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class ProductDTO(BaseModel): - product_code: str - - -class PaymentResponseDTO(BaseResponseDTO): - data: ProductDTO - - @classmethod - def builder(cls, product_code: str) -> "PaymentResponseDTO": - return cls( - code=200, - message="Payment successful", - data=ProductDTO( - product_code=product_code, - ), - ) diff --git a/src/app/v2/payments/models/__init__.py b/src/app/v2/payments/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/payments/querys/__init__.py b/src/app/v2/payments/querys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/payments/router.py b/src/app/v2/payments/router.py deleted file mode 100644 index bb3d71c..0000000 --- a/src/app/v2/payments/router.py +++ /dev/null @@ -1,33 +0,0 @@ -from fastapi import APIRouter, HTTPException, status - -from app.v2.payments.dtos.request import PaymentRequestDTO -from app.v2.payments.dtos.response import PaymentResponseDTO -from app.v2.payments.services.payment_service import PaymentService -from app.v2.users.services.user_service import UserService - -router = APIRouter(prefix="/payment", tags=["Payment"]) - - -@router.post( - "", - response_model=PaymentResponseDTO, - status_code=status.HTTP_200_OK, -) -async def process_payment(request: PaymentRequestDTO) -> PaymentResponseDTO: - try: - user_id = request.user_id - product_code = request.productCode - - product, item_inventory_products = await PaymentService.validate_payment(product_code) - - user = await UserService.get_user_info(user_id=user_id) - - await PaymentService.process_cheese_payment( - product, item_inventory_products, user_id, user["cheese_manager_id"] - ) - return PaymentResponseDTO.builder(product_code=product.product_code) - - except HTTPException as e: - raise e - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) diff --git a/src/app/v2/payments/services/__init__.py b/src/app/v2/payments/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/purchases/__init__.py b/src/app/v2/purchases/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/purchases/dtos/__init__.py b/src/app/v2/purchases/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/purchases/dtos/purchase_dto.py b/src/app/v2/purchases/dtos/purchase_dto.py deleted file mode 100644 index a03b463..0000000 --- a/src/app/v2/purchases/dtos/purchase_dto.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Any, Optional - -from pydantic import BaseModel - -from app.v2.purchases.models.purchase_status import purchase_mapping -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class ReceiptInfoDTO(BaseModel): - transaction_id: str - original_transaction_id: str - expires_date_ms: int - purchase_date_ms: int - product_code: str - product_code_two: str - quantity: int - cancellation_date_ms: Optional[int] = None - - @classmethod - def build(cls, latest_receipt_info: dict[str, Any]) -> "ReceiptInfoDTO": - transaction_id = latest_receipt_info["transaction_id"] - original_transaction_id = latest_receipt_info["original_transaction_id"] - expires_date_ms = int(latest_receipt_info.get("expires_date_ms", 0)) - purchase_date_ms = int(latest_receipt_info.get("purchase_date_ms", 0)) - product_code = purchase_mapping.get(latest_receipt_info["product_id"], latest_receipt_info["product_id"]) - product_code_two = latest_receipt_info["product_id"] - quantity = int(latest_receipt_info.get("quantity", 1)) - cancellation_date_ms = latest_receipt_info.get("cancellation_date_ms") # 환불일 (밀리초) - - return cls( - transaction_id=transaction_id, - original_transaction_id=original_transaction_id, - expires_date_ms=expires_date_ms, - purchase_date_ms=purchase_date_ms, - product_code=product_code, - product_code_two=product_code_two, - quantity=quantity, - cancellation_date_ms=cancellation_date_ms, - ) - - -class PurchaseDTO(BaseModel): - productCode: str - isPremium: bool - - @classmethod - def build(cls, product_code: str, is_premium: bool) -> "PurchaseDTO": - return cls( - productCode=product_code, - isPremium=is_premium, - ) - - -class PurchaseResponseDTO(BaseModel): - message: str - data: PurchaseDTO - code: int - - @classmethod - def build(cls, is_premium: bool, product_code: str) -> "PurchaseResponseDTO": - return cls( - code=200, - message="Purchase successful.", - data=PurchaseDTO.build(product_code=product_code, is_premium=is_premium), - ) diff --git a/src/app/v2/purchases/dtos/requests.py b/src/app/v2/purchases/dtos/requests.py deleted file mode 100644 index a2f87fc..0000000 --- a/src/app/v2/purchases/dtos/requests.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic import BaseModel - - -class ReceiptRequestDTO(BaseModel): - receiptData: str - user_id: str - - -class PurchaseRequest(BaseModel): - user_id: str - product_code: str diff --git a/src/app/v2/purchases/models/__init__.py b/src/app/v2/purchases/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/purchases/models/purchase_history.py b/src/app/v2/purchases/models/purchase_history.py deleted file mode 100644 index c5206b9..0000000 --- a/src/app/v2/purchases/models/purchase_history.py +++ /dev/null @@ -1,166 +0,0 @@ -from datetime import datetime -from typing import Optional - -from tortoise import fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.users.models.user import User -from common.utils.query_executor import QueryExecutor - - -class Subscription(Model): - subscription_id = fields.BigIntField(pk=True, description="Primary key for the Subscription") - product_code = fields.CharField(max_length=255, null=False, description="Product code of the subscription") - status = fields.CharField(max_length=255, null=False, description="Status of the subscription") - current_transaction_id = fields.CharField(max_length=255, null=False, description="Current transaction ID") - expires_date = fields.DatetimeField(null=False, description="Expiration date of the subscription") - created_at = fields.DatetimeField(auto_now_add=True, description="When the subscription was created") - updated_at = fields.DatetimeField(auto_now=True, description="Last updated timestamp") - - user: ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", - related_name="subscriptions", - on_delete=fields.CASCADE, - description="User linked to the subscription", - ) - purchase_histories = fields.ReverseRelation["PurchaseHistory"] - - class Meta: - table = "subscription" - - @classmethod - async def get_subscription_by_user_id_and_product_code( - cls, user_id: str, product_code: str - ) -> Optional["Subscription"]: - query = """ - SELECT * FROM subscription - WHERE user_id = UNHEX(REPLACE(%s, '-', '')) AND product_code = %s - LIMIT 1; - """ - values = (user_id, product_code) - - result = await QueryExecutor.execute_query(query, values=values, fetch_type="single") - - if result: - return cls(**result) - return None - - @classmethod - async def create_or_update_subscription( - cls, - user_id: str, - product_code: str, - transaction_id: str, - expires_date_ms: int, - status: str, - ) -> "Subscription": - query = """ - INSERT INTO subscription (user_id, product_code, status, current_transaction_id, expires_date) - VALUES (UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, FROM_UNIXTIME(%s / 1000)) - ON DUPLICATE KEY UPDATE - current_transaction_id = VALUES(current_transaction_id), - expires_date = VALUES(expires_date), - status = VALUES(status); - """ - values = (user_id, product_code, status, transaction_id, expires_date_ms) - - await QueryExecutor.execute_query(query, values=values, fetch_type="none") - - return cls( - user_id=user_id, - product_code=product_code, - status=status, - current_transaction_id=transaction_id, - expires_date=datetime.fromtimestamp(expires_date_ms / 1000), - ) - - @classmethod - async def update_subscription( - cls, user_id: str, product_code: str, transaction_id: str, expires_date_ms: int - ) -> None: - query = """ - UPDATE subscription - SET current_transaction_id = %s, - expires_date = FROM_UNIXTIME(%s / 1000), - status = %s - WHERE user_id = UNHEX(REPLACE(%s, '-', '')) - AND product_code = %s; - """ - values = (transaction_id, expires_date_ms, "active", user_id, product_code) - - await QueryExecutor.execute_query(query, values=values, fetch_type="single") - - -class PurchaseHistory(Model): - purchase_history_id = fields.BigIntField(pk=True, description="Primary key for the Purchase History") - product_code = fields.CharField(max_length=255, null=False, description="Product code of the purchase") - transaction_id = fields.CharField(max_length=255, unique=True, null=False, description="Transaction ID") - original_transaction_id = fields.CharField(max_length=255, null=True, description="Original transaction ID") - status = fields.CharField(max_length=255, null=False, description="Purchase status") - expires_date = fields.DatetimeField(null=True, description="Expiration date of the purchase") - purchase_date = fields.DatetimeField(null=False, description="Date of the purchase") - quantity = fields.IntField(default=1, description="Quantity of items purchased") - receipt_data = fields.TextField(null=True, description="Raw receipt data from Apple") - created_at = fields.DatetimeField(auto_now_add=True, description="When the purchase was made") - updated_at = fields.DatetimeField(auto_now=True, description="Last updated timestamp") - - user: ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", - related_name="purchase_histories", - on_delete=fields.CASCADE, - description="User linked to the purchase", - ) - - subscription: Optional[ForeignKeyRelation["Subscription"]] = fields.ForeignKeyField( - "models.Subscription", - related_name="purchase_histories", - null=True, - on_delete=fields.SET_NULL, - description="Linked subscription", - ) - - class Meta: - table = "purchase_history" - - @classmethod - async def create_purchase_history( - cls, - user_id: str, - subscription_id: Optional[int], - product_code: str, - transaction_id: str, - original_transaction_id: str, - status: str, - expires_date_ms: Optional[int], - purchase_date_ms: int, - receipt_data: str, - quantity: int = 1, - ) -> None: - query = """ - INSERT INTO purchase_history ( - user_id, subscription_id, product_code, transaction_id, - original_transaction_id, status, expires_date, purchase_date, - quantity, receipt_data, created_at, updated_at - ) - VALUES ( - UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, - %s, %s, FROM_UNIXTIME(%s / 1000), FROM_UNIXTIME(%s / 1000), - %s, %s, NOW(), NOW() - ); - """ - - values = ( - user_id, - subscription_id, - product_code, - transaction_id, - original_transaction_id, - status, - expires_date_ms, - purchase_date_ms, - quantity, - receipt_data, - ) - - await QueryExecutor.execute_query(query, values=values, fetch_type="single") diff --git a/src/app/v2/purchases/models/purchase_status.py b/src/app/v2/purchases/models/purchase_status.py deleted file mode 100644 index f8789f7..0000000 --- a/src/app/v2/purchases/models/purchase_status.py +++ /dev/null @@ -1,21 +0,0 @@ -from enum import Enum - - -class PurchaseStatus(Enum): - AVAILABLE = "AVAILABLE" - CONSUMED = "CONSUMED" - EXPIRED = "EXPIRED" - REFUNDED = "REFUNDED" - CANCELED = "CANCELED" - - -class SubscriptionStatus(Enum): - ACTIVE = "ACTIVE" - EXPIRED = "EXPIRED" - CANCELED = "CANCELED" - - -purchase_mapping = { - "tellingme.plus.oneMonth": "PD_PLUS_MONTH_1_KR", - "tellingme.plus.oneYear": "PD_PLUS_YEAR_1_KR", -} diff --git a/src/app/v2/purchases/repositorys/__init__.py b/src/app/v2/purchases/repositorys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/purchases/router.py b/src/app/v2/purchases/router.py deleted file mode 100644 index 7cd7ba6..0000000 --- a/src/app/v2/purchases/router.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Any - -from fastapi import APIRouter, Depends, status - -from app.v2.purchases.dtos.purchase_dto import PurchaseResponseDTO -from app.v2.purchases.dtos.requests import ReceiptRequestDTO -from app.v2.purchases.services.purchase_service import PurchaseService - -router = APIRouter(prefix="/purchase", tags=["Purchase"]) - - -@router.post( - "/apple", - status_code=status.HTTP_200_OK, - response_model=PurchaseResponseDTO, - summary="apple 결제 api", - description="apple 결제 api", -) -async def process_receipt( - receipt: ReceiptRequestDTO, - purchase_service: PurchaseService = Depends(), -) -> PurchaseResponseDTO: - return await purchase_service.process_apple_purchase(receipt_data=receipt.receiptData, user_id=receipt.user_id) - - -@router.post("/receipt-test") -async def receipt_test( - receipt: ReceiptRequestDTO, - purchase_service: PurchaseService = Depends(), -) -> dict[str, Any]: - data = await purchase_service._validate_apple_receipt(receipt_data=receipt.receiptData) - return { - "code": 200, - "data": data, - "message": "정상처리되었습니다", - } - - -@router.get("/renew-test") -async def renew_test( - purchase_service: PurchaseService = Depends(), -) -> None: - return await purchase_service.process_subscriptions_renewal() - - -@router.get("/expired-test") -async def expired_test( - purchase_service: PurchaseService = Depends(), -) -> None: - await purchase_service.expire_subscriptions() diff --git a/src/app/v2/purchases/services/__init__.py b/src/app/v2/purchases/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/purchases/services/purchase_service.py b/src/app/v2/purchases/services/purchase_service.py deleted file mode 100644 index 6420521..0000000 --- a/src/app/v2/purchases/services/purchase_service.py +++ /dev/null @@ -1,309 +0,0 @@ -import time -import uuid -from datetime import date, datetime, timedelta, timezone -from typing import Any, Optional, cast - -import httpx -from fastapi import HTTPException -from tortoise.exceptions import DoesNotExist -from tortoise.transactions import atomic - -from app.v2.items.models.item import ItemInventory, ItemInventoryProductInventory, ProductInventory -from app.v2.purchases.dtos.purchase_dto import PurchaseResponseDTO, ReceiptInfoDTO -from app.v2.purchases.models.purchase_history import PurchaseHistory, Subscription -from app.v2.purchases.models.purchase_status import PurchaseStatus, SubscriptionStatus -from app.v2.users.models.user import User -from app.v2.users.services.user_service import UserService -from common.exceptions.custom_exception import CustomException -from common.exceptions.error_code import ErrorCode -from core.configs import settings - - -class PurchaseService: - @atomic() - async def process_apple_purchase(self, receipt_data: str, user_id: str) -> PurchaseResponseDTO: - response = await self._validate_apple_receipt(receipt_data=receipt_data) - - latest_receipt_info = self._extract_latest_receipt_info(response) - - if latest_receipt_info is None: - raise CustomException(ErrorCode.NO_VALID_RECEIPT) - - receipt_info = await self._parse_receipt_info(latest_receipt_info) - - subscription_status = self.get_subscription_status(receipt_info) - - await self._create_or_update_subscription( - user_id=user_id, - product_code=receipt_info.product_code, - transaction_id=receipt_info.transaction_id, - expires_date_ms=receipt_info.expires_date_ms, - status=subscription_status, - ) - - subscription = await self._get_subscription(user_id, receipt_info.product_code) - - if subscription is None: - raise DoesNotExist("Subscription not found") - - item_inventory_products = await self._validate_purchase(product_code=receipt_info.product_code) - - await self._process_purchase( - user_id=user_id, - item_inventory_products=item_inventory_products, - status=subscription_status, - ) - - user = await UserService.get_user_profile(user_id=user_id) - - return PurchaseResponseDTO.build(is_premium=user.is_premium, product_code=receipt_info.product_code_two) # type: ignore - - @staticmethod - def _extract_latest_receipt_info(response: dict[str, Any]) -> dict[str, Any] | None: - latest_receipt_info = response.get("latest_receipt_info") - - if isinstance(latest_receipt_info, list) and latest_receipt_info: - return latest_receipt_info[0] or {} - return None - - async def _validate_apple_receipt(self, receipt_data: str) -> dict[str, Any]: - url = settings.APPLE_URL - payload = self.create_receipt_validation_payload(receipt_data) - response = await self.send_receipt_validation_request(url, payload) - return await self.parse_apple_response(response) - - @staticmethod - def create_receipt_validation_payload(receipt_data: str) -> dict[str, Any]: - return { - "receipt-data": receipt_data, - "password": settings.APPLE_SHARED_SECRET, - } - - @staticmethod - async def send_receipt_validation_request(url: str, payload: dict[str, Any]) -> httpx.Response: - async with httpx.AsyncClient() as client: - response = await client.post(url, json=payload) - return response - - @staticmethod - async def parse_apple_response(response: httpx.Response) -> dict[str, Any]: - if response.status_code == 200: - return cast(dict[str, Any], response.json()) - else: - raise HTTPException(status_code=500, detail="Failed to connect to Apple server") - - @staticmethod - async def _create_or_update_subscription( - user_id: str, - product_code: str, - transaction_id: str, - expires_date_ms: int, - status: str, - ) -> None: - await Subscription.create_or_update_subscription( - user_id=user_id, - product_code=product_code, - transaction_id=transaction_id, - expires_date_ms=expires_date_ms, - status=status, - ) - - @staticmethod - def get_subscription_status(receipt: ReceiptInfoDTO) -> str: - current_time_ms = int(time.time() * 1000) - - if receipt.cancellation_date_ms: - return SubscriptionStatus.CANCELED.value - - if receipt.expires_date_ms < current_time_ms: - return SubscriptionStatus.EXPIRED.value - - return SubscriptionStatus.ACTIVE.value - - @staticmethod - def get_purchase_status(cancellation_date_ms: Optional[int]) -> str: - if cancellation_date_ms: - return PurchaseStatus.REFUNDED.value - return PurchaseStatus.AVAILABLE.value - - @staticmethod - async def _get_subscription(user_id: str, product_code: str) -> Subscription | None: - return await Subscription.get_subscription_by_user_id_and_product_code( - user_id=user_id, product_code=product_code - ) - - @staticmethod - async def _create_purchase_history( - user_id: str, - subscription: Subscription, - product_code: str, - transaction_id: str, - original_transaction_id: str, - status: str, - expires_date_ms: int, - purchase_date_ms: int, - quantity: int, - receipt_data: str, - ) -> None: - await PurchaseHistory.create_purchase_history( - user_id=user_id, - subscription_id=subscription.subscription_id if subscription else None, - product_code=product_code, - transaction_id=transaction_id, - original_transaction_id=original_transaction_id, - status=status, - expires_date_ms=expires_date_ms, - purchase_date_ms=purchase_date_ms, - quantity=quantity, - receipt_data=receipt_data, - ) - - @staticmethod - async def _validate_purchase( - product_code: str, - ) -> list[ItemInventoryProductInventory]: - try: - product = await ProductInventory.get(product_code=product_code) - - if product.transaction_currency not in ["KRW", "CHEESE"]: - raise HTTPException(status_code=400, detail="Invalid transaction currency for purchase.") - - item_inventory_products = await ItemInventoryProductInventory.filter( - product_inventory_id=product.product_id - ).all() - - if not item_inventory_products: - raise HTTPException(status_code=404, detail="No inventory found for this product.") - return item_inventory_products - except DoesNotExist: - raise HTTPException(status_code=404, detail="Product not found.") - - @classmethod - async def _process_purchase( - cls, - item_inventory_products: list[ItemInventoryProductInventory], - user_id: str, - status: str = "ACTIVE", - # cheese_manager_id: int, - ) -> None: - for item_inventory_product in item_inventory_products: - item: ItemInventory = await item_inventory_product.item_inventory - quantity = item_inventory_product.quantity - - if item.item_category == "SUBSCRIPTION": - if status == SubscriptionStatus.ACTIVE.value: - await UserService.set_is_premium(user_id=user_id, is_premium=True) - if status == SubscriptionStatus.CANCELED.value or status == SubscriptionStatus.EXPIRED.value: - await UserService.set_is_premium(user_id=user_id, is_premium=False) - # elif item.item_category == "CHEESE": - # await CheeseService.add_cheese(cheese_manager_id=cheese_manager_id, amount=quantity) - else: - raise ValueError(f"Invalid item category for purchase: {item.item_category}") - - async def renew_subscription(self, subscription: Subscription) -> None: - - purchase_history = await PurchaseHistory.filter(transaction_id=subscription.current_transaction_id).first() - - if not purchase_history: - return - - response = await self._validate_apple_receipt(receipt_data=purchase_history.receipt_data) - latest_receipt_info = self._extract_latest_receipt_info(response) - - if latest_receipt_info is None: - raise CustomException(ErrorCode.NO_VALID_RECEIPT) - - receipt_data = await self._parse_receipt_info(latest_receipt_info) - - purchase_status = self.get_purchase_status(receipt_data.cancellation_date_ms) - - if not await self._check_auto_renewal(response.get("pending_renewal_info", [])): - return - - await self._update_subscription_expiration( - subscription=subscription, - expires_date_ms=receipt_data.expires_date_ms, - transaction_id=receipt_data.transaction_id, - ) - - await self._create_purchase_history( - user_id=str(uuid.UUID(bytes=subscription.user.user_id)), # type: ignore - subscription=subscription, - product_code=receipt_data.product_code, - transaction_id=receipt_data.transaction_id, - original_transaction_id=receipt_data.original_transaction_id, - status=purchase_status, - expires_date_ms=receipt_data.expires_date_ms, - purchase_date_ms=receipt_data.purchase_date_ms, - quantity=receipt_data.quantity, - receipt_data=purchase_history.receipt_data, - ) - - @staticmethod - async def get_subscriptions_to_renew(today: datetime) -> list[Subscription]: - return ( - await Subscription.filter(expires_date__lte=today + timedelta(days=1), status="ACTIVE") - .select_related("user") - .all() - ) - - @atomic() - async def process_subscriptions_renewal(self) -> None: - today = datetime.now(timezone.utc) + timedelta(hours=9) - subscriptions_to_renew = await self.get_subscriptions_to_renew(today) - - for subscription in subscriptions_to_renew: - await self.renew_subscription(subscription) - - @staticmethod - async def _update_subscription_expiration( - subscription: Subscription, expires_date_ms: int, transaction_id: str - ) -> None: - new_expires_date = datetime.fromtimestamp(expires_date_ms / 1000) - subscription.expires_date = new_expires_date - subscription.current_transaction_id = transaction_id - await subscription.save() - - @staticmethod - async def _parse_receipt_info(latest_receipt_info: dict[str, Any]) -> ReceiptInfoDTO: - return ReceiptInfoDTO.build(latest_receipt_info) - - @staticmethod - async def _check_auto_renewal(pending_renewal_info: list[dict[str, Any]]) -> bool: - if pending_renewal_info: - auto_renew_status = pending_renewal_info[0].get("auto_renew_status") - expiration_intent = pending_renewal_info[0].get("expiration_intent") - - if auto_renew_status == "0" or expiration_intent == "1": - return False - return True - - @staticmethod - async def get_expired_subscriptions(today: date) -> list[Subscription]: - return ( - await Subscription.filter(status=SubscriptionStatus.ACTIVE.value, expires_date__lt=today) - .select_related("user") - .all() - ) - - @staticmethod - async def update_subscription_status(expired_subscriptions: list[Subscription]) -> None: - for subscription in expired_subscriptions: - subscription.status = SubscriptionStatus.EXPIRED.value - await Subscription.bulk_update(expired_subscriptions, fields=["status"]) - - @staticmethod - async def update_user_premium_status(expired_subscriptions: list[Subscription]) -> None: - user_ids = [subscription.user.user_id for subscription in expired_subscriptions] - if user_ids: - await User.bulk_update_is_premium(user_ids) # type: ignore - - @atomic() - async def expire_subscriptions(self) -> None: - today = date.today() - - expired_subscriptions = await self.get_expired_subscriptions(today) - - if expired_subscriptions: - await self.update_subscription_status(expired_subscriptions) - await self.update_user_premium_status(expired_subscriptions) diff --git a/src/app/v2/questions/__init__.py b/src/app/v2/questions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/questions/dtos/__init__.py b/src/app/v2/questions/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/questions/dtos/responses.py b/src/app/v2/questions/dtos/responses.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/questions/models/__init__.py b/src/app/v2/questions/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/questions/models/question.py b/src/app/v2/questions/models/question.py deleted file mode 100644 index f3571d5..0000000 --- a/src/app/v2/questions/models/question.py +++ /dev/null @@ -1,13 +0,0 @@ -from tortoise import fields -from tortoise.models import Model - - -class Question(Model): - date = fields.DateField(pk=True) # 기본 키로 설정된 날짜 필드 - phrase = fields.CharField(max_length=255) - title = fields.CharField(max_length=255) - spare_phrase = fields.CharField(max_length=255) - spare_title = fields.CharField(max_length=255) - - class Meta: - table = "question" # 데이터베이스에서 매핑할 테이블 이름 diff --git a/src/app/v2/questions/repositorys/__init__.py b/src/app/v2/questions/repositorys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/questions/router.py b/src/app/v2/questions/router.py deleted file mode 100644 index 0f789d9..0000000 --- a/src/app/v2/questions/router.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import APIRouter, HTTPException - -from app.v2.questions.models.question import Question - -router = APIRouter(prefix="/question", tags=["Question"]) - - -# @router.get("/questions/{date}") -# async def get_question_by_date(date: str): -# question = await Question.get_or_none(date=date) -# if question is None: -# raise HTTPException(status_code=404, detail="Question not found") -# return question diff --git a/src/app/v2/questions/services/__init__.py b/src/app/v2/questions/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/teller_cards/__init__.py b/src/app/v2/teller_cards/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/teller_cards/dtos/__init__.py b/src/app/v2/teller_cards/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/teller_cards/dtos/request.py b/src/app/v2/teller_cards/dtos/request.py deleted file mode 100644 index fecc72e..0000000 --- a/src/app/v2/teller_cards/dtos/request.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel - - -class TellerCardRequestDTO(BaseModel): - user_id: str - colorCode: Optional[str] = None - badgeCode: Optional[str] = None diff --git a/src/app/v2/teller_cards/dtos/response.py b/src/app/v2/teller_cards/dtos/response.py deleted file mode 100644 index 4df7b3e..0000000 --- a/src/app/v2/teller_cards/dtos/response.py +++ /dev/null @@ -1,21 +0,0 @@ -from pydantic import BaseModel - -from app.v2.teller_cards.dtos.teller_card_dto import TellerCardDTO as TellerCardLogicDTO -from common.base_models.base_dtos.base_response import BaseResponseDTO - - -class TellerCardDTO(BaseModel): - colorCode: str - badgeCode: str - - -class TellerCardResponseDTO(BaseResponseDTO): - data: TellerCardDTO - - @classmethod - def builder(cls, teller_card: TellerCardLogicDTO) -> "TellerCardResponseDTO": - return cls( - code=200, - message="success", - data=TellerCardDTO(colorCode=teller_card.colorCode, badgeCode=teller_card.badgeCode), - ) diff --git a/src/app/v2/teller_cards/dtos/teller_card_dto.py b/src/app/v2/teller_cards/dtos/teller_card_dto.py deleted file mode 100644 index a38e524..0000000 --- a/src/app/v2/teller_cards/dtos/teller_card_dto.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - - -class TellerCardDTO(BaseModel): - badgeCode: str - badgeName: str - badgeMiddleName: str - colorCode: str - - @classmethod - def builder(cls, teller_card_raw: dict[str, str]) -> "TellerCardDTO": - return cls( - badgeCode=teller_card_raw.get("activate_badge_code", ""), - badgeName=teller_card_raw.get("badge_name", ""), - badgeMiddleName=teller_card_raw.get("badge_middle_name", ""), - colorCode=teller_card_raw.get("activate_color_code", ""), - ) diff --git a/src/app/v2/teller_cards/models/__init__.py b/src/app/v2/teller_cards/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/teller_cards/querys/__init__.py b/src/app/v2/teller_cards/querys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/teller_cards/router.py b/src/app/v2/teller_cards/router.py deleted file mode 100644 index a66c981..0000000 --- a/src/app/v2/teller_cards/router.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastapi import APIRouter, status - -from app.v2.teller_cards.dtos.request import TellerCardRequestDTO -from app.v2.teller_cards.dtos.response import TellerCardResponseDTO -from app.v2.teller_cards.services.teller_card_service import TellerCardService - -router = APIRouter(prefix="/tellercard", tags=["TellerCard"]) - - -@router.post( - "", - response_model=TellerCardResponseDTO, - status_code=status.HTTP_200_OK, -) -async def patch_teller_card_handler( - body: TellerCardRequestDTO, -) -> TellerCardResponseDTO: - user_id = body.user_id - badge_code = body.badgeCode - color_code = body.colorCode - - await TellerCardService.validate_teller_card(badge_code=badge_code, color_code=color_code) - - await TellerCardService.patch_teller_card(user_id=user_id, badge_code=badge_code, color_code=color_code) - - teller_card = await TellerCardService.get_teller_card(user_id=user_id) - - return TellerCardResponseDTO.builder(teller_card=teller_card) diff --git a/src/app/v2/teller_cards/services/__init__.py b/src/app/v2/teller_cards/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/teller_cards/services/teller_card_service.py b/src/app/v2/teller_cards/services/teller_card_service.py deleted file mode 100644 index 7e34904..0000000 --- a/src/app/v2/teller_cards/services/teller_card_service.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Optional - -from app.v2.badges.models.badge import BadgeInventory -from app.v2.colors.models.color import ColorInventory -from app.v2.teller_cards.dtos.teller_card_dto import TellerCardDTO -from app.v2.teller_cards.models.teller_card import TellerCard - - -class TellerCardService: - @classmethod - async def get_teller_card(cls, user_id: str) -> TellerCardDTO: - teller_cards_raw: dict[str, str] = await TellerCard.get_teller_card_info_by_user_id(user_id=user_id) - return TellerCardDTO.builder(teller_cards_raw) - - @classmethod - async def patch_teller_card( - cls, user_id: str, badge_code: Optional[str] = None, color_code: Optional[str] = None - ) -> None: - await TellerCard.patch_teller_card_info_by_user_id( - user_id=user_id, badge_code=badge_code, color_code=color_code - ) - - @classmethod - async def validate_teller_card(cls, badge_code: Optional[str], color_code: Optional[str]) -> None: - badge_code_list = await BadgeInventory.all().values("badge_code") - color_code_list = await ColorInventory.all().values("color_code") - badge_codes = [badge["badge_code"] for badge in badge_code_list] - color_codes = [color["color_code"] for color in color_code_list] - - if badge_code and badge_code not in badge_codes: - raise ValueError("Invalid badge code") - - if color_code and color_code not in color_codes: - raise ValueError("Invalid color code") diff --git a/src/app/v2/users/__init__.py b/src/app/v2/users/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/users/dtos/__init__.py b/src/app/v2/users/dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/users/dtos/user_dto.py b/src/app/v2/users/dtos/user_dto.py deleted file mode 100644 index 4da00de..0000000 --- a/src/app/v2/users/dtos/user_dto.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Any, Optional - -from pydantic import BaseModel - - -class UserDTO(BaseModel): - user_id: Optional[str] = None - nickname: Optional[str] = None - profile_url: Optional[str] = None - is_premium: Optional[bool] = None - user_status: Optional[bool] = None - cheese_manager_id: Optional[int] = None - teller_card_id: Optional[int] = None - level_id: Optional[int] = None - allow_notification: Optional[bool] = None - - @classmethod - def build(cls, user: dict[str, Any]) -> "UserDTO": - is_premium = user.get("is_premium") != b"\x00" - allow_notification = user.get("allow_notification") != b"\x00" - return cls( - user_id=user.get("user_id", None), - nickname=user.get("nickname", None), - profile_url=user.get("profile_url", None), - is_premium=is_premium, - user_status=user.get("user_status", None), - cheese_manager_id=user.get("cheese_manager_id", None), - teller_card_id=user.get("teller_card_id", None), - level_id=user.get("level_id", None), - allow_notification=allow_notification, - ) diff --git a/src/app/v2/users/dtos/user_info_dto.py b/src/app/v2/users/dtos/user_info_dto.py deleted file mode 100644 index 4bc245d..0000000 --- a/src/app/v2/users/dtos/user_info_dto.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel - -from app.v2.teller_cards.dtos.teller_card_dto import TellerCardDTO - - -class UserInfoDTO(BaseModel): - nickname: str - cheeseBalance: int - tellerCard: TellerCardDTO - - @classmethod - def builder(cls, user_raw: dict[str, str], cheeseBalance: int, tellerCard: TellerCardDTO) -> "UserInfoDTO": - return cls( - nickname=user_raw.get("nickname", ""), - cheeseBalance=cheeseBalance, - tellerCard=tellerCard, - ) diff --git a/src/app/v2/users/dtos/user_profile_dto.py b/src/app/v2/users/dtos/user_profile_dto.py deleted file mode 100644 index 0aca6e3..0000000 --- a/src/app/v2/users/dtos/user_profile_dto.py +++ /dev/null @@ -1,32 +0,0 @@ -from pydantic import BaseModel - - -class UserProfileDTO(BaseModel): - nickname: str - badgeCode: str - cheeseBalance: int - badgeCount: int - answerCount: int - premium: bool - allowNotification: bool - - @classmethod - def builder( - cls, - nickname: str, - badgeCode: str, - cheeseBalance: int, - badgeCount: int, - answerCount: int, - premium: bool, - allow_notification: bool, - ) -> "UserProfileDTO": - return cls( - nickname=nickname, - badgeCode=badgeCode, - cheeseBalance=cheeseBalance, - badgeCount=badgeCount, - answerCount=answerCount, - premium=premium, - allowNotification=allow_notification, - ) diff --git a/src/app/v2/users/models/__init__.py b/src/app/v2/users/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/users/models/user.py b/src/app/v2/users/models/user.py deleted file mode 100644 index 72212a9..0000000 --- a/src/app/v2/users/models/user.py +++ /dev/null @@ -1,101 +0,0 @@ -import uuid -from datetime import datetime -from typing import Any, Optional - -from tortoise import Tortoise, fields -from tortoise.fields import ForeignKeyRelation -from tortoise.models import Model - -from app.v2.cheese_managers.models.cheese_manager import CheeseManager -from app.v2.levels.models.level import Level -from app.v2.teller_cards.models.teller_card import TellerCard -from app.v2.users.models.refresh_token import RefreshToken -from app.v2.users.querys.user_query import ( - SELECT_USER_INFO_BY_USER_UUID_QUERY, - SELECT_USER_PROFILE_BY_USER_ID_QUERY, - UPDATE_PREMIUM_STATUS_QUERY, -) -from common.utils.query_executor import QueryExecutor - - -class User(Model): - user_id = fields.CharField(max_length=255, pk=True, description="Primary key for the User") - allow_notification = fields.BooleanField(null=True) - birth_date = fields.CharField(max_length=8, null=True) - created_time = fields.DatetimeField(auto_now_add=True) - gender = fields.CharField(max_length=16, null=True) - job = fields.IntField() - mbti = fields.CharField(max_length=8, null=True) - nickname = fields.CharField(max_length=16) - purpose = fields.CharField(max_length=16) - push_token = fields.CharField(max_length=255, null=True) - social_id = fields.CharField(max_length=255) - social_login_type = fields.CharField(max_length=16) - user_status = fields.BooleanField() - withdraw_period = fields.DatetimeField(null=True) - refresh_token: Optional[ForeignKeyRelation[RefreshToken]] = fields.ForeignKeyField( - "models.RefreshToken", - related_name="users", - db_column="refresh_token_id", - null=True, - ) - is_premium = fields.BooleanField() - profile_url = fields.CharField( - max_length=255, - default="https://miro.medium.com/v2/resize:fit:1400/format:webp/1*dh7Xy5tFvRj7n2wf1UweAw.png", - ) - premium_started_at = fields.DatetimeField(null=True) - cheese_manager: ForeignKeyRelation[CheeseManager] = fields.ForeignKeyField( - "models.CheeseManager", - related_name="users", - db_column="cheese_manager_id", - ) - teller_card: ForeignKeyRelation[TellerCard] = fields.ForeignKeyField( - "models.TellerCard", - related_name="users", - db_column="teller_card_id", - ) - level: ForeignKeyRelation[Level] = fields.ForeignKeyField( - "models.Level", - related_name="users", - db_column="level_id", - ) - - class Meta: - table = "user" - - @classmethod - async def get_user_profile_by_user_id(cls, user_id: str) -> Any: - query = SELECT_USER_PROFILE_BY_USER_ID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") - - @classmethod - async def get_user_info_by_user_id(cls, user_id: str) -> Any: - query = SELECT_USER_INFO_BY_USER_UUID_QUERY - value = user_id - return await QueryExecutor.execute_query(query, values=value, fetch_type="single") - - @classmethod - async def set_is_premium(cls, user_id: str, is_premium: bool) -> Any: - query = UPDATE_PREMIUM_STATUS_QUERY - current_time = datetime.now() - values = (int(is_premium), current_time, user_id) - await QueryExecutor.execute_query(query, values=values, fetch_type="single") - - @classmethod - def format_user_id(cls, user_id_bytes: bytes) -> str: - return str(uuid.UUID(bytes=user_id_bytes)) - - @classmethod - def format_user_ids(cls, user_ids: list[bytes]) -> str: - return ", ".join([f"UNHEX(REPLACE('{str(uuid.UUID(bytes=user_id))}', '-', ''))" for user_id in user_ids]) - - @classmethod - async def bulk_update_is_premium(cls, user_ids: list[bytes]) -> None: - query = f""" - UPDATE user - SET is_premium = FALSE - WHERE user_id IN ({cls.format_user_ids(user_ids)}); - """ - await Tortoise.get_connection("default").execute_query(query) diff --git a/src/app/v2/users/querys/__init__.py b/src/app/v2/users/querys/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/users/services/__init__.py b/src/app/v2/users/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/v2/users/services/user_service.py b/src/app/v2/users/services/user_service.py deleted file mode 100644 index 95c3fb9..0000000 --- a/src/app/v2/users/services/user_service.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -from app.v2.cheese_managers.models.cheese_manager import CheeseManager -from app.v2.users.dtos.user_dto import UserDTO -from app.v2.users.models.user import User - - -class UserService: - @staticmethod - async def get_user_info(user_id: str) -> Any: - return await User.get_user_info_by_user_id(user_id=user_id) - - @classmethod - async def get_user_profile(cls, user_id: str) -> UserDTO: - return UserDTO.build(await User.get_user_profile_by_user_id(user_id=user_id)) - - @staticmethod - async def set_is_premium(user_id: str, is_premium: bool) -> None: - await User.set_is_premium(user_id=user_id, is_premium=is_premium) diff --git a/src/celery_worker.py b/src/celery_worker.py deleted file mode 100644 index 7eeeeb6..0000000 --- a/src/celery_worker.py +++ /dev/null @@ -1,3 +0,0 @@ -from core.configs.celery_settings import celery_app - -__all__ = ("celery_app",) diff --git a/src/common/__init__.py b/src/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/base_models/__init__.py b/src/common/base_models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/base_models/base_dtos/__init__.py b/src/common/base_models/base_dtos/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/base_models/base_dtos/base_response.py b/src/common/base_models/base_dtos/base_response.py deleted file mode 100644 index 13d2463..0000000 --- a/src/common/base_models/base_dtos/base_response.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any, Optional - -from pydantic import BaseModel - - -# 공통 응답 모델 정의 -class BaseResponseDTO(BaseModel): - code: int - message: str - data: Optional[Any] = None diff --git a/src/common/base_models/custom_fields/__init__.py b/src/common/base_models/custom_fields/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/exceptions/__init__.py b/src/common/exceptions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/handlers/__init__.py b/src/common/handlers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/handlers/router_handler.py b/src/common/handlers/router_handler.py deleted file mode 100644 index ff6b67e..0000000 --- a/src/common/handlers/router_handler.py +++ /dev/null @@ -1,27 +0,0 @@ -from fastapi import FastAPI - -from app.v2.answers.router import router as answer_router -from app.v2.badges.router import router as badge_router -from app.v2.cheese_managers.router import router as cheese_router -from app.v2.colors.router import router as color_router -from app.v2.emotions.router import router as emotion_router -from app.v2.missions.router import router as mission_router -from app.v2.mobiles.router import router as mobile_router -from app.v2.payments.router import router as payment_router -from app.v2.purchases.router import router as purchase_router -from app.v2.questions.router import router as question_router -from app.v2.teller_cards.router import router as teller_card_router - - -def attach_router_handlers(app: FastAPI) -> None: - app.include_router(router=mobile_router, prefix="/api/v2") - app.include_router(router=badge_router, prefix="/api/v2") - app.include_router(router=color_router, prefix="/api/v2") - app.include_router(router=question_router, prefix="/api/v2") - app.include_router(router=teller_card_router, prefix="/api/v2") - app.include_router(router=payment_router, prefix="/api/v2") - app.include_router(router=purchase_router, prefix="/api/v2") - app.include_router(router=mission_router, prefix="/api/v2") - app.include_router(router=cheese_router, prefix="/api/v2") - app.include_router(router=answer_router, prefix="/test") - app.include_router(router=emotion_router, prefix="/api/v2") diff --git a/src/common/post_construct.py b/src/common/post_construct.py deleted file mode 100644 index f72dfb4..0000000 --- a/src/common/post_construct.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import FastAPI - -from common.handlers.exception_handler import attach_exception_handlers -from common.handlers.router_handler import attach_router_handlers -from common.utils.scheduler import start_scheduler -from core.database.database_settings import database_initialize - - -def post_construct(app: FastAPI) -> None: - attach_router_handlers(app) - attach_exception_handlers(app) - database_initialize(app) - start_scheduler() diff --git a/src/common/tasks/__init__.py b/src/common/tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/common/tasks/mission_task.py b/src/common/tasks/mission_task.py deleted file mode 100644 index 8e3ed33..0000000 --- a/src/common/tasks/mission_task.py +++ /dev/null @@ -1,11 +0,0 @@ -import asyncio - -from app.v2.missions.models.mission import UserMission - - -async def mission_reset_task() -> None: - await asyncio.gather( - UserMission.filter(mission_code__in=["MS_LV_UP", "MS_DAILY_LIKE_3_PER_DAY", "MS_DAILY_POST_GENERAL"]).update( - is_completed=False, progress_count=0 - ), - ) diff --git a/src/common/tasks/renew_subscription_task.py b/src/common/tasks/renew_subscription_task.py deleted file mode 100644 index eb71fb9..0000000 --- a/src/common/tasks/renew_subscription_task.py +++ /dev/null @@ -1,11 +0,0 @@ -from app.v2.purchases.services.purchase_service import PurchaseService - - -async def renew_subscription_task() -> None: - purchase_service = PurchaseService() - await purchase_service.process_subscriptions_renewal() - - -async def expire_subscription_task() -> None: - purchase_service = PurchaseService() - await purchase_service.expire_subscriptions() diff --git a/src/common/utils/__init__.py b/src/common/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/__init__.py b/src/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/database/__init__.py b/src/core/database/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test.sh b/test.sh index 780ed8a..3c24a41 100755 --- a/test.sh +++ b/test.sh @@ -7,12 +7,12 @@ echo "Starting black" poetry run black . echo "OK" -echo "Starting isort" -poetry run isort . +echo "Starting ruff" +poetry run ruff check . --fix echo "OK" echo "Starting mypy" -poetry run mypy . +poetry run dmypy run -- . echo "OK" echo "Starting pytest with coverage" @@ -20,4 +20,4 @@ poetry run coverage run -m pytest poetry run coverage report -m poetry run coverage html -echo "${COLOR_GREEN}All tests passed successfully!${COLOR_NC}" \ No newline at end of file +echo "${COLOR_GREEN}All tests passed successfully!${COLOR_NC}"