diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d756559..09d40a6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,6 +1,5 @@ name: Code Quality Checks -# 트리거 이벤트: push 또는 pull_request 시 CI가 실행됩니다. on: push: branches: @@ -9,77 +8,34 @@ on: jobs: ci: - # 가장 최신버전의 ubuntu를 OS 환경으로 설정합니다. runs-on: ubuntu-latest - # services 키워드를 사용하여 MySQL 서비스를 설정 - services: - db: - image: mysql:8.0 - ports: - - 3306:3306 - # Github Secrets에서 가져와서 env로 등록, MySQL 데이터베이스 연결 설정 - env: - MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} - MYSQL_DATABASE: ${{ secrets.MYSQL_DB }} - MYSQL_USER: ${{ secrets.MYSQL_USER }} - MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} - # MySQL 연결 상태를 확인. 10초 단위로 5번 재시도. 5초간 기다림. - options: >- - --health-cmd "mysqladmin ping -h localhost" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - # CI 환경에서 코드를 체크아웃합니다. - name: Checkout code uses: actions/checkout@v3 - # CI 환경에서 사용할 파이썬 버전을 지정합니다. - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - # 환경 변수를 추가 -# env: - - - # Poetry를 설치합니다. - name: Install Poetry run: | curl -sSL https://install.python-poetry.org | python3 - echo "${HOME}/.local/bin" >> $GITHUB_PATH - # Poetry를 사용하여 의존성 패키지들을 설치합니다. - name: Install Packages & Libraries run: | poetry install - # isort를 사용하여 import 구문의 정렬 순서를 체크합니다. - name: Run isort (Import sorting) run: | poetry run isort . --check --diff - # black을 사용하여 PEP8 코드스타일을 준수했는지 체크합니다. - name: Run black (Code formatting) run: | poetry run black . --check - # mypy를 사용해서 타입힌팅 체크 - name: Run Mypy run: | poetry run mypy . - - # MySQL 연결을 테스트 - - name: Wait for MySQL - run: | - until mysqladmin ping -h localhost -u ${{ secrets.MYSQL_USER }} -p${{ secrets.MYSQL_PASSWORD }}; do - sleep 1 - done - - # FastAPI 테스트 실행 - - name: Run FastAPI tests with pytest - run: | - poetry run pytest diff --git a/.gitignore b/.gitignore index 09a9681..2733f73 100644 --- a/.gitignore +++ b/.gitignore @@ -122,7 +122,7 @@ celerybeat.pid *.sage.py # Environments -.env +src/.env.local .venv env/ venv/ diff --git a/poetry.lock b/poetry.lock index d9c6e44..f522317 100644 --- a/poetry.lock +++ b/poetry.lock @@ -18,6 +18,38 @@ PyMySQL = ">=1.0" rsa = ["PyMySQL[rsa] (>=1.0)"] sa = ["sqlalchemy (>=1.3,<1.4)"] +[[package]] +name = "aiosqlite" +version = "0.20.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"}, + {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] + +[[package]] +name = "amqp" +version = "5.3.1" +description = "Low-level AMQP client for Python (fork of amqplib)." +optional = false +python-versions = ">=3.6" +files = [ + {file = "amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2"}, + {file = "amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432"}, +] + +[package.dependencies] +vine = ">=5.0.0,<6.0.0" + [[package]] name = "annotated-types" version = "0.7.0" @@ -49,6 +81,44 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] trio = ["trio (>=0.26.1)"] +[[package]] +name = "apscheduler" +version = "3.11.0" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, + {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytz", "twisted"] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "billiard" +version = "4.2.1" +description = "Python multiprocessing fork with improvements and bugfixes" +optional = false +python-versions = ">=3.7" +files = [ + {file = "billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb"}, + {file = "billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f"}, +] + [[package]] name = "black" version = "24.8.0" @@ -93,6 +163,78 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "celery" +version = "5.4.0" +description = "Distributed Task Queue." +optional = false +python-versions = ">=3.8" +files = [ + {file = "celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64"}, + {file = "celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706"}, +] + +[package.dependencies] +billiard = ">=4.2.0,<5.0" +click = ">=8.1.2,<9.0" +click-didyoumean = ">=0.3.0" +click-plugins = ">=1.1.1" +click-repl = ">=0.2.0" +kombu = ">=5.3.4,<6.0" +python-dateutil = ">=2.8.2" +sqlalchemy = {version = ">=1.4.48,<2.1", optional = true, markers = "extra == \"sqlalchemy\""} +tzdata = ">=2022.7" +vine = ">=5.1.0,<6.0" + +[package.extras] +arangodb = ["pyArango (>=2.0.2)"] +auth = ["cryptography (==42.0.5)"] +azureblockblob = ["azure-storage-blob (>=12.15.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver (>=3.25.0,<4)"] +consul = ["python-consul2 (==0.1.5)"] +cosmosdbsql = ["pydocumentdb (==2.3.5)"] +couchbase = ["couchbase (>=3.0.0)"] +couchdb = ["pycouchdb (==1.14.2)"] +django = ["Django (>=2.2.28)"] +dynamodb = ["boto3 (>=1.26.143)"] +elasticsearch = ["elastic-transport (<=8.13.0)", "elasticsearch (<=8.13.0)"] +eventlet = ["eventlet (>=0.32.0)"] +gcs = ["google-cloud-storage (>=2.10.0)"] +gevent = ["gevent (>=1.5.0)"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +memcache = ["pylibmc (==1.6.3)"] +mongodb = ["pymongo[srv] (>=4.0.2)"] +msgpack = ["msgpack (==1.0.8)"] +pymemcache = ["python-memcached (>=1.61)"] +pyro = ["pyro4 (==4.82)"] +pytest = ["pytest-celery[all] (>=1.0.0)"] +redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +s3 = ["boto3 (>=1.26.143)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem (==4.1.5)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.3.4)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard (==0.22.0)"] + +[[package]] +name = "celery-stubs" +version = "0.1.3" +description = "celery stubs" +optional = false +python-versions = "*" +files = [ + {file = "celery-stubs-0.1.3.tar.gz", hash = "sha256:0fb5345820f8a2bd14e6ffcbef2d10181e12e40f8369f551d7acc99d8d514919"}, + {file = "celery_stubs-0.1.3-py3-none-any.whl", hash = "sha256:dfb9ad27614a8af028b2055bb4a4ae99ca5e9a8d871428a506646d62153218d7"}, +] + +[package.dependencies] +mypy = ">=0.950" +typing-extensions = ">=4.2.0" + [[package]] name = "certifi" version = "2024.8.30" @@ -104,6 +246,85 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.7" @@ -118,6 +339,55 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-didyoumean" +version = "0.3.1" +description = "Enables git-like *did-you-mean* feature in click" +optional = false +python-versions = ">=3.6.2" +files = [ + {file = "click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c"}, + {file = "click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463"}, +] + +[package.dependencies] +click = ">=7" + +[[package]] +name = "click-plugins" +version = "1.1.1" +description = "An extension module for click to enable registering CLI commands via setuptools entry-points." +optional = false +python-versions = "*" +files = [ + {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"}, + {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"}, +] + +[package.dependencies] +click = ">=4.0" + +[package.extras] +dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] + +[[package]] +name = "click-repl" +version = "0.3.0" +description = "REPL plugin for Click" +optional = false +python-versions = ">=3.6" +files = [ + {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, + {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, +] + +[package.dependencies] +click = ">=7.0" +prompt-toolkit = ">=3.0.36" + +[package.extras] +testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] + [[package]] name = "colorama" version = "0.4.6" @@ -129,6 +399,129 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.6.8" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"}, + {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"}, + {file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"}, + {file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"}, + {file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"}, + {file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"}, + {file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"}, + {file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"}, + {file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"}, + {file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"}, + {file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"}, + {file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"}, + {file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"}, + {file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"}, + {file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"}, + {file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"}, +] + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cryptography" +version = "43.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "dnspython" version = "2.6.1" @@ -445,6 +838,17 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "iso8601" +version = "2.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, + {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, +] + [[package]] name = "isort" version = "5.13.2" @@ -476,6 +880,39 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "kombu" +version = "5.4.2" +description = "Messaging library for Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "kombu-5.4.2-py3-none-any.whl", hash = "sha256:14212f5ccf022fc0a70453bb025a1dcc32782a588c49ea866884047d66e14763"}, + {file = "kombu-5.4.2.tar.gz", hash = "sha256:eef572dd2fd9fc614b37580e3caeafdd5af46c1eff31e7fba89138cdb406f2cf"}, +] + +[package.dependencies] +amqp = ">=5.1.1,<6.0.0" +tzdata = {version = "*", markers = "python_version >= \"3.9\""} +vine = "5.1.0" + +[package.extras] +azureservicebus = ["azure-servicebus (>=7.10.0)"] +azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] +confluentkafka = ["confluent-kafka (>=2.2.0)"] +consul = ["python-consul2 (==0.1.5)"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +mongodb = ["pymongo (>=4.1.1)"] +msgpack = ["msgpack (==1.1.0)"] +pyro = ["pyro4 (==4.82)"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=2.8.0)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -690,6 +1127,31 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.9.2" @@ -863,6 +1325,17 @@ files = [ ed25519 = ["PyNaCl (>=1.4.0)"] rsa = ["cryptography"] +[[package]] +name = "pypika-tortoise" +version = "0.2.1" +description = "Forked from pypika and streamline just for tortoise-orm" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "pypika_tortoise-0.2.1-py3-none-any.whl", hash = "sha256:e91a1c5a78c6753ead1a9ba1aa169a1f1282c5035170e5462f0073564bc18886"}, + {file = "pypika_tortoise-0.2.1.tar.gz", hash = "sha256:979bbb9d60fe9f6e4129a25c44ee008aab4a4e97b296350be9983dcaa2766354"}, +] + [[package]] name = "pytest" version = "8.3.3" @@ -883,6 +1356,38 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.0.1" @@ -908,6 +1413,17 @@ files = [ {file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"}, ] +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -970,6 +1486,21 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "redis" +version = "5.2.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.8" +files = [ + {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, + {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, +] + +[package.extras] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] + [[package]] name = "rich" version = "13.8.1" @@ -999,6 +1530,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1012,60 +1554,68 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.35" +version = "2.0.36" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" files = [ - {file = "SQLAlchemy-2.0.35-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67219632be22f14750f0d1c70e62f204ba69d28f62fd6432ba05ab295853de9b"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4668bd8faf7e5b71c0319407b608f278f279668f358857dbfd10ef1954ac9f90"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb8bea573863762bbf45d1e13f87c2d2fd32cee2dbd50d050f83f87429c9e1ea"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f552023710d4b93d8fb29a91fadf97de89c5926c6bd758897875435f2a939f33"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:016b2e665f778f13d3c438651dd4de244214b527a275e0acf1d44c05bc6026a9"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7befc148de64b6060937231cbff8d01ccf0bfd75aa26383ffdf8d82b12ec04ff"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-win32.whl", hash = "sha256:22b83aed390e3099584b839b93f80a0f4a95ee7f48270c97c90acd40ee646f0b"}, - {file = "SQLAlchemy-2.0.35-cp310-cp310-win_amd64.whl", hash = "sha256:a29762cd3d116585278ffb2e5b8cc311fb095ea278b96feef28d0b423154858e"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e21f66748ab725ade40fa7af8ec8b5019c68ab00b929f6643e1b1af461eddb60"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8a6219108a15fc6d24de499d0d515c7235c617b2540d97116b663dade1a54d62"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042622a5306c23b972192283f4e22372da3b8ddf5f7aac1cc5d9c9b222ab3ff6"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:627dee0c280eea91aed87b20a1f849e9ae2fe719d52cbf847c0e0ea34464b3f7"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4fdcd72a789c1c31ed242fd8c1bcd9ea186a98ee8e5408a50e610edfef980d71"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89b64cd8898a3a6f642db4eb7b26d1b28a497d4022eccd7717ca066823e9fb01"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-win32.whl", hash = "sha256:6a93c5a0dfe8d34951e8a6f499a9479ffb9258123551fa007fc708ae2ac2bc5e"}, - {file = "SQLAlchemy-2.0.35-cp311-cp311-win_amd64.whl", hash = "sha256:c68fe3fcde03920c46697585620135b4ecfdfc1ed23e75cc2c2ae9f8502c10b8"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:eb60b026d8ad0c97917cb81d3662d0b39b8ff1335e3fabb24984c6acd0c900a2"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6921ee01caf375363be5e9ae70d08ce7ca9d7e0e8983183080211a062d299468"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdf1a0dbe5ced887a9b127da4ffd7354e9c1a3b9bb330dce84df6b70ccb3a8d"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a71c8601e823236ac0e5d087e4f397874a421017b3318fd92c0b14acf2b6db"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e04b622bb8a88f10e439084486f2f6349bf4d50605ac3e445869c7ea5cf0fa8c"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b56961e2d31389aaadf4906d453859f35302b4eb818d34a26fab72596076bb8"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-win32.whl", hash = "sha256:0f9f3f9a3763b9c4deb8c5d09c4cc52ffe49f9876af41cc1b2ad0138878453cf"}, - {file = "SQLAlchemy-2.0.35-cp312-cp312-win_amd64.whl", hash = "sha256:25b0f63e7fcc2a6290cb5f7f5b4fc4047843504983a28856ce9b35d8f7de03cc"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f021d334f2ca692523aaf7bbf7592ceff70c8594fad853416a81d66b35e3abf9"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05c3f58cf91683102f2f0265c0db3bd3892e9eedabe059720492dbaa4f922da1"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:032d979ce77a6c2432653322ba4cbeabf5a6837f704d16fa38b5a05d8e21fa00"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2e795c2f7d7249b75bb5f479b432a51b59041580d20599d4e112b5f2046437a3"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:cc32b2990fc34380ec2f6195f33a76b6cdaa9eecf09f0c9404b74fc120aef36f"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-win32.whl", hash = "sha256:9509c4123491d0e63fb5e16199e09f8e262066e58903e84615c301dde8fa2e87"}, - {file = "SQLAlchemy-2.0.35-cp37-cp37m-win_amd64.whl", hash = "sha256:3655af10ebcc0f1e4e06c5900bb33e080d6a1fa4228f502121f28a3b1753cde5"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4c31943b61ed8fdd63dfd12ccc919f2bf95eefca133767db6fbbd15da62078ec"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a62dd5d7cc8626a3634208df458c5fe4f21200d96a74d122c83bc2015b333bc1"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0630774b0977804fba4b6bbea6852ab56c14965a2b0c7fc7282c5f7d90a1ae72"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d625eddf7efeba2abfd9c014a22c0f6b3796e0ffb48f5d5ab106568ef01ff5a"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ada603db10bb865bbe591939de854faf2c60f43c9b763e90f653224138f910d9"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c41411e192f8d3ea39ea70e0fae48762cd11a2244e03751a98bd3c0ca9a4e936"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-win32.whl", hash = "sha256:d299797d75cd747e7797b1b41817111406b8b10a4f88b6e8fe5b5e59598b43b0"}, - {file = "SQLAlchemy-2.0.35-cp38-cp38-win_amd64.whl", hash = "sha256:0375a141e1c0878103eb3d719eb6d5aa444b490c96f3fedab8471c7f6ffe70ee"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ccae5de2a0140d8be6838c331604f91d6fafd0735dbdcee1ac78fc8fbaba76b4"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2a275a806f73e849e1c309ac11108ea1a14cd7058577aba962cd7190e27c9e3c"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732e026240cdd1c1b2e3ac515c7a23820430ed94292ce33806a95869c46bd139"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890da8cd1941fa3dab28c5bac3b9da8502e7e366f895b3b8e500896f12f94d11"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0d8326269dbf944b9201911b0d9f3dc524d64779a07518199a58384c3d37a44"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b76d63495b0508ab9fc23f8152bac63205d2a704cd009a2b0722f4c8e0cba8e0"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-win32.whl", hash = "sha256:69683e02e8a9de37f17985905a5eca18ad651bf592314b4d3d799029797d0eb3"}, - {file = "SQLAlchemy-2.0.35-cp39-cp39-win_amd64.whl", hash = "sha256:aee110e4ef3c528f3abbc3c2018c121e708938adeeff9006428dd7c8555e9b3f"}, - {file = "SQLAlchemy-2.0.35-py3-none-any.whl", hash = "sha256:2ab3f0336c0387662ce6221ad30ab3a5e6499aab01b9790879b6578fd9b8faa1"}, - {file = "sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b8f3adb3971929a3e660337f5dacc5942c2cdb760afcabb2614ffbda9f9f72"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37350015056a553e442ff672c2d20e6f4b6d0b2495691fa239d8aa18bb3bc908"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8318f4776c85abc3f40ab185e388bee7a6ea99e7fa3a30686580b209eaa35c08"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c245b1fbade9c35e5bd3b64270ab49ce990369018289ecfde3f9c318411aaa07"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:69f93723edbca7342624d09f6704e7126b152eaed3cdbb634cb657a54332a3c5"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f9511d8dd4a6e9271d07d150fb2f81874a3c8c95e11ff9af3a2dfc35fe42ee44"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-win32.whl", hash = "sha256:c3f3631693003d8e585d4200730616b78fafd5a01ef8b698f6967da5c605b3fa"}, + {file = "SQLAlchemy-2.0.36-cp310-cp310-win_amd64.whl", hash = "sha256:a86bfab2ef46d63300c0f06936bd6e6c0105faa11d509083ba8f2f9d237fb5b5"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd3a55deef00f689ce931d4d1b23fa9f04c880a48ee97af488fd215cf24e2a6c"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f5e9cd989b45b73bd359f693b935364f7e1f79486e29015813c338450aa5a71"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ddd9db6e59c44875211bc4c7953a9f6638b937b0a88ae6d09eb46cced54eff"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2519f3a5d0517fc159afab1015e54bb81b4406c278749779be57a569d8d1bb0d"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59b1ee96617135f6e1d6f275bbe988f419c5178016f3d41d3c0abb0c819f75bb"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39769a115f730d683b0eb7b694db9789267bcd027326cccc3125e862eb03bfd8"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-win32.whl", hash = "sha256:66bffbad8d6271bb1cc2f9a4ea4f86f80fe5e2e3e501a5ae2a3dc6a76e604e6f"}, + {file = "SQLAlchemy-2.0.36-cp311-cp311-win_amd64.whl", hash = "sha256:23623166bfefe1487d81b698c423f8678e80df8b54614c2bf4b4cfcd7c711959"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e"}, + {file = "SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436"}, + {file = "SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:be9812b766cad94a25bc63bec11f88c4ad3629a0cec1cd5d4ba48dc23860486b"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aae840ebbd6cdd41af1c14590e5741665e5272d2fee999306673a1bb1fdb4d"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4557e1f11c5f653ebfdd924f3f9d5ebfc718283b0b9beebaa5dd6b77ec290971"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07b441f7d03b9a66299ce7ccf3ef2900abc81c0db434f42a5694a37bd73870f2"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:28120ef39c92c2dd60f2721af9328479516844c6b550b077ca450c7d7dc68575"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-win32.whl", hash = "sha256:b81ee3d84803fd42d0b154cb6892ae57ea6b7c55d8359a02379965706c7efe6c"}, + {file = "SQLAlchemy-2.0.36-cp37-cp37m-win_amd64.whl", hash = "sha256:f942a799516184c855e1a32fbc7b29d7e571b52612647866d4ec1c3242578fcb"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3d6718667da04294d7df1670d70eeddd414f313738d20a6f1d1f379e3139a545"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:72c28b84b174ce8af8504ca28ae9347d317f9dba3999e5981a3cd441f3712e24"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b11d0cfdd2b095e7b0686cf5fabeb9c67fae5b06d265d8180715b8cfa86522e3"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e32092c47011d113dc01ab3e1d3ce9f006a47223b18422c5c0d150af13a00687"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6a440293d802d3011028e14e4226da1434b373cbaf4a4bbb63f845761a708346"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c54a1e53a0c308a8e8a7dffb59097bff7facda27c70c286f005327f21b2bd6b1"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-win32.whl", hash = "sha256:1e0d612a17581b6616ff03c8e3d5eff7452f34655c901f75d62bd86449d9750e"}, + {file = "SQLAlchemy-2.0.36-cp38-cp38-win_amd64.whl", hash = "sha256:8958b10490125124463095bbdadda5aa22ec799f91958e410438ad6c97a7b793"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc022184d3e5cacc9579e41805a681187650e170eb2fd70e28b86192a479dcaa"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b817d41d692bf286abc181f8af476c4fbef3fd05e798777492618378448ee689"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e46a888b54be23d03a89be510f24a7652fe6ff660787b96cd0e57a4ebcb46d"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4ae3005ed83f5967f961fd091f2f8c5329161f69ce8480aa8168b2d7fe37f06"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03e08af7a5f9386a43919eda9de33ffda16b44eb11f3b313e6822243770e9763"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb986bad3ed5ceaf090200eba750b5245150bd97d3e67343a3cfed06feecf7"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-win32.whl", hash = "sha256:9fe53b404f24789b5ea9003fc25b9a3988feddebd7e7b369c8fac27ad6f52f28"}, + {file = "SQLAlchemy-2.0.36-cp39-cp39-win_amd64.whl", hash = "sha256:af148a33ff0349f53512a049c6406923e4e02bf2f26c5fb285f143faf4f0e46a"}, + {file = "SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e"}, + {file = "sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5"}, ] [package.dependencies] @@ -1078,7 +1628,7 @@ aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] asyncio = ["greenlet (!=0.4.17)"] asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] mssql = ["pyodbc"] mssql-pymssql = ["pymssql"] mssql-pyodbc = ["pyodbc"] @@ -1098,49 +1648,48 @@ pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] [[package]] -name = "sqlalchemy-utils" -version = "0.41.2" -description = "Various utility functions for SQLAlchemy." +name = "starlette" +version = "0.38.6" +description = "The little ASGI library that shines." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990"}, - {file = "SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e"}, + {file = "starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05"}, + {file = "starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead"}, ] [package.dependencies] -SQLAlchemy = ">=1.3" +anyio = ">=3.4.0,<5" [package.extras] -arrow = ["arrow (>=0.3.4)"] -babel = ["Babel (>=1.3)"] -color = ["colour (>=0.0.4)"] -encrypted = ["cryptography (>=0.6)"] -intervals = ["intervals (>=0.7.1)"] -password = ["passlib (>=1.6,<2.0)"] -pendulum = ["pendulum (>=2.0.5)"] -phone = ["phonenumbers (>=5.9.2)"] -test = ["Jinja2 (>=2.3)", "Pygments (>=1.2)", "backports.zoneinfo", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "isort (>=4.2.2)", "pg8000 (>=1.12.4)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] -test-all = ["Babel (>=1.3)", "Jinja2 (>=2.3)", "Pygments (>=1.2)", "arrow (>=0.3.4)", "backports.zoneinfo", "colour (>=0.0.4)", "cryptography (>=0.6)", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "furl (>=0.4.1)", "intervals (>=0.7.1)", "isort (>=4.2.2)", "passlib (>=1.6,<2.0)", "pendulum (>=2.0.5)", "pg8000 (>=1.12.4)", "phonenumbers (>=5.9.2)", "psycopg (>=3.1.8)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (==7.4.4)", "python-dateutil", "python-dateutil (>=2.6)", "pytz (>=2014.2)"] -timezone = ["python-dateutil"] -url = ["furl (>=0.4.1)"] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] [[package]] -name = "starlette" -version = "0.38.6" -description = "The little ASGI library that shines." +name = "tortoise-orm" +version = "0.21.7" +description = "Easy async ORM for python, built with relations in mind" optional = false -python-versions = ">=3.8" +python-versions = "<4.0,>=3.8" files = [ - {file = "starlette-0.38.6-py3-none-any.whl", hash = "sha256:4517a1409e2e73ee4951214ba012052b9e16f60e90d73cfb06192c19203bbb05"}, - {file = "starlette-0.38.6.tar.gz", hash = "sha256:863a1588f5574e70a821dadefb41e4881ea451a47a3cd1b4df359d4ffefe5ead"}, + {file = "tortoise_orm-0.21.7-py3-none-any.whl", hash = "sha256:2229925885461f424673223ea1875bd5e6961384c766833af55a1ea11a9b25eb"}, + {file = "tortoise_orm-0.21.7.tar.gz", hash = "sha256:8a790a931828aa37ac364b344c561e603422aced2af5e403f6790575da0f19c5"}, ] [package.dependencies] -anyio = ">=3.4.0,<5" +aiomysql = {version = "*", optional = true, markers = "extra == \"aiomysql\""} +aiosqlite = ">=0.16.0,<0.21.0" +iso8601 = ">=2.1.0,<3.0.0" +pydantic = ">=2.0,<2.7.0 || >2.7.0,<3.0" +pypika-tortoise = ">=0.2.1,<0.3.0" +pytz = "*" [package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +accel = ["ciso8601", "orjson", "uvloop"] +aiomysql = ["aiomysql"] +asyncmy = ["asyncmy (>=0.2.8,<0.3.0)"] +asyncodbc = ["asyncodbc (>=0.1.1,<0.2.0)"] +asyncpg = ["asyncpg"] +psycopg = ["psycopg[binary,pool] (>=3.0.12,<4.0.0)"] [[package]] name = "typer" @@ -1170,6 +1719,34 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzdata" +version = "2024.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + +[[package]] +name = "tzlocal" +version = "5.2" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, + {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + [[package]] name = "uvicorn" version = "0.30.6" @@ -1239,6 +1816,17 @@ files = [ docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] +[[package]] +name = "vine" +version = "5.1.0" +description = "Python promises." +optional = false +python-versions = ">=3.6" +files = [ + {file = "vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc"}, + {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, +] + [[package]] name = "watchfiles" version = "0.24.0" @@ -1334,6 +1922,17 @@ files = [ [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "websockets" version = "13.1" @@ -1432,4 +2031,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "f8e5b5d5c5cb7aba3c1540a9af858829fc894bcfd66c2aba5f75d3cb9abd6ddb" +content-hash = "42b1f2c02091e30e76d806b1415db897e5feb02b3daf41250bbc809f76e05ea2" diff --git a/pyproject.toml b/pyproject.toml index aa76bb9..02211f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,14 +9,16 @@ readme = "README.md" python = "^3.12" fastapi = {extras = ["standard"], version = "^0.115.0"} gunicorn = "^23.0.0" -sqlalchemy = "^2.0.35" -pymysql = "^1.1.1" -aiomysql = "^0.2.0" greenlet = "^3.1.1" -sqlalchemy-utils = "^0.41.2" python-multipart = "^0.0.12" pydantic-settings = "^2.5.2" python-dotenv = "^1.0.1" +cryptography = "^43.0.1" +httpx = "^0.27.2" +tortoise-orm = {version = "^0.21.6", extras = ["aiomysql"]} +celery = {extras = ["sqlalchemy"], version = "^5.4.0"} +apscheduler = "^3.11.0" +redis = "^5.2.0" [tool.poetry.group.dev.dependencies] @@ -24,7 +26,29 @@ isort = "^5.13.2" mypy = "^1.11.2" black = "^24.8.0" pytest = "^8.3.3" +pytest-asyncio = "^0.24.0" +mypy-extensions = "^1.0.0" +coverage = "^7.6.8" +celery-stubs = "^0.1.3" + +[tool.mypy] +files = "src" +strict = true + + +#disallow_untyped_calls = true # 타입이 없는 함수 호출 금지 +#disallow_untyped_defs = true # 타입이 없는 함수 정의 금지 +#ignore_missing_imports = true # 누락된 import 무시 [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 120 + + +[tool.isort] +profile = "black" +line_length = 120 + diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/__init__.py b/src/app/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/__init__.py b/src/app/v2/answers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/dtos/__init__.py b/src/app/v2/answers/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/dtos/answer_dto.py b/src/app/v2/answers/dtos/answer_dto.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/models/__init__.py b/src/app/v2/answers/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/models/answer.py b/src/app/v2/answers/models/answer.py new file mode 100644 index 0000000..2437b90 --- /dev/null +++ b/src/app/v2/answers/models/answer.py @@ -0,0 +1,58 @@ +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_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 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/querys/__init__.py b/src/app/v2/answers/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/querys/answer_query.py b/src/app/v2/answers/querys/answer_query.py new file mode 100644 index 0000000..6c61924 --- /dev/null +++ b/src/app/v2/answers/querys/answer_query.py @@ -0,0 +1,18 @@ +from app.v2.users.querys.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_BY_USER_UUID_QUERY = f""" + SELECT * FROM answer + WHERE {USER_ID_QUERY} + AND date BETWEEN %s AND %s + ORDER BY date DESC + """ + +SELECT_MOST_RECENT_ANSWER_BY_USER_UUID_QUERY = f""" + SELECT * + FROM answer + WHERE {USER_ID_QUERY} + ORDER BY created_at DESC + LIMIT 1 +""" diff --git a/src/app/v2/answers/router.py b/src/app/v2/answers/router.py new file mode 100644 index 0000000..821fb95 --- /dev/null +++ b/src/app/v2/answers/router.py @@ -0,0 +1,21 @@ +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/__init__.py b/src/app/v2/answers/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/answers/services/answer_service.py b/src/app/v2/answers/services/answer_service.py new file mode 100644 index 0000000..9b7f9c8 --- /dev/null +++ b/src/app/v2/answers/services/answer_service.py @@ -0,0 +1,46 @@ +from datetime import datetime, timedelta +from typing import Any + +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_record(cls, user_id: str) -> int: + end_date = datetime.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/__init__.py b/src/app/v2/badges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/badges/dtos/__init__.py b/src/app/v2/badges/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/badges/dtos/badge_dto.py b/src/app/v2/badges/dtos/badge_dto.py new file mode 100644 index 0000000..30772de --- /dev/null +++ b/src/app/v2/badges/dtos/badge_dto.py @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..efba691 --- /dev/null +++ b/src/app/v2/badges/dtos/response.py @@ -0,0 +1,6 @@ +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/__init__.py b/src/app/v2/badges/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/badges/models/badge.py b/src/app/v2/badges/models/badge.py new file mode 100644 index 0000000..3de92a8 --- /dev/null +++ b/src/app/v2/badges/models/badge.py @@ -0,0 +1,61 @@ +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/querys/__init__.py b/src/app/v2/badges/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/badges/querys/badge_query.py b/src/app/v2/badges/querys/badge_query.py new file mode 100644 index 0000000..2dae63e --- /dev/null +++ b/src/app/v2/badges/querys/badge_query.py @@ -0,0 +1,30 @@ +from app.v2.users.querys.user_query import USER_ID_QUERY + +SELECT_BADGE_COUNT_BY_USER_UUID_QUERY = f""" + SELECT COUNT(*) as badge_count + FROM badge + WHERE {USER_ID_QUERY} +""" + +SELECT_BADGE_BY_USER_UUID_QUERY = f""" + SELECT + b.badge_code, + bi.badge_name, + bi.badge_condition, + bi.badge_middle_name + FROM badge b + JOIN badge_inventory bi ON b.badge_code = bi.badge_code + 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 + FROM user + WHERE {USER_ID_QUERY} + """ diff --git a/src/app/v2/badges/router.py b/src/app/v2/badges/router.py new file mode 100644 index 0000000..a3ec584 --- /dev/null +++ b/src/app/v2/badges/router.py @@ -0,0 +1,22 @@ +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/__init__.py b/src/app/v2/badges/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/badges/services/badge_service.py b/src/app/v2/badges/services/badge_service.py new file mode 100644 index 0000000..e611757 --- /dev/null +++ b/src/app/v2/badges/services/badge_service.py @@ -0,0 +1,29 @@ +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/__init__.py b/src/app/v2/cheese_managers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/cheese_managers/dtos/__init__.py b/src/app/v2/cheese_managers/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/cheese_managers/dtos/cheese_dto.py b/src/app/v2/cheese_managers/dtos/cheese_dto.py new file mode 100644 index 0000000..83dc866 --- /dev/null +++ b/src/app/v2/cheese_managers/dtos/cheese_dto.py @@ -0,0 +1,29 @@ +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/__init__.py b/src/app/v2/cheese_managers/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/cheese_managers/models/cheese_manager.py b/src/app/v2/cheese_managers/models/cheese_manager.py new file mode 100644 index 0000000..ea7dbfd --- /dev/null +++ b/src/app/v2/cheese_managers/models/cheese_manager.py @@ -0,0 +1,96 @@ +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/models/cheese_status.py b/src/app/v2/cheese_managers/models/cheese_status.py new file mode 100644 index 0000000..653694e --- /dev/null +++ b/src/app/v2/cheese_managers/models/cheese_status.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class CheeseStatus(Enum): + CAN_USE = "CAN_USE" # 치즈 사용 가능 + USING = "USING" # 치즈 사용 중 + ALREADY_USED = "ALREADY_USED" # 치즈 사용 완료 diff --git a/src/app/v2/cheese_managers/querys/__init__.py b/src/app/v2/cheese_managers/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/cheese_managers/router.py b/src/app/v2/cheese_managers/router.py new file mode 100644 index 0000000..2589c81 --- /dev/null +++ b/src/app/v2/cheese_managers/router.py @@ -0,0 +1,17 @@ +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/__init__.py b/src/app/v2/cheese_managers/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/cheese_managers/services/cheese_service.py b/src/app/v2/cheese_managers/services/cheese_service.py new file mode 100644 index 0000000..d846548 --- /dev/null +++ b/src/app/v2/cheese_managers/services/cheese_service.py @@ -0,0 +1,12 @@ +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/__init__.py b/src/app/v2/colors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/colors/dtos/__init__.py b/src/app/v2/colors/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/colors/dtos/color_dto.py b/src/app/v2/colors/dtos/color_dto.py new file mode 100644 index 0000000..df1a390 --- /dev/null +++ b/src/app/v2/colors/dtos/color_dto.py @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..7a0459e --- /dev/null +++ b/src/app/v2/colors/dtos/response.py @@ -0,0 +1,6 @@ +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/__init__.py b/src/app/v2/colors/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/colors/models/color.py b/src/app/v2/colors/models/color.py new file mode 100644 index 0000000..acf8bc2 --- /dev/null +++ b/src/app/v2/colors/models/color.py @@ -0,0 +1,55 @@ +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/querys/__init__.py b/src/app/v2/colors/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/colors/querys/color_query.py b/src/app/v2/colors/querys/color_query.py new file mode 100644 index 0000000..0121d8d --- /dev/null +++ b/src/app/v2/colors/querys/color_query.py @@ -0,0 +1,24 @@ +from app.v2.users.querys.user_query import USER_ID_QUERY + +SELECT_COLOR_CODE_BY_USER_UUID_QUERY = f""" + SELECT color_code + FROM color + WHERE {USER_ID_QUERY} +""" + +INSERT_COLOR_CODE_FOR_USER_QUERY = f""" + INSERT INTO color (color_code, user_id) + SELECT %s, user_id + FROM user + WHERE {USER_ID_QUERY} +""" + +SELECT_COLOR_BY_USER_UUID_QUERY = f""" + SELECT + c.color_code, + ci.color_name, + ci.color_hex_code + FROM color c + JOIN color_inventory ci on c.color_code = ci.color_code + WHERE {USER_ID_QUERY} +""" diff --git a/src/app/v2/colors/router.py b/src/app/v2/colors/router.py new file mode 100644 index 0000000..0b11aa3 --- /dev/null +++ b/src/app/v2/colors/router.py @@ -0,0 +1,22 @@ +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/__init__.py b/src/app/v2/colors/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/colors/services/color_service.py b/src/app/v2/colors/services/color_service.py new file mode 100644 index 0000000..5b03137 --- /dev/null +++ b/src/app/v2/colors/services/color_service.py @@ -0,0 +1,28 @@ +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/__init__.py b/src/app/v2/emotions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/emotions/dtos/__init__.py b/src/app/v2/emotions/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/emotions/dtos/response.py b/src/app/v2/emotions/dtos/response.py new file mode 100644 index 0000000..3fcac1e --- /dev/null +++ b/src/app/v2/emotions/dtos/response.py @@ -0,0 +1,15 @@ +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/__init__.py b/src/app/v2/emotions/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/emotions/models/emotion.py b/src/app/v2/emotions/models/emotion.py new file mode 100644 index 0000000..58732fd --- /dev/null +++ b/src/app/v2/emotions/models/emotion.py @@ -0,0 +1,45 @@ +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/querys/__init__.py b/src/app/v2/emotions/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/emotions/querys/emotion_query.py b/src/app/v2/emotions/querys/emotion_query.py new file mode 100644 index 0000000..813d727 --- /dev/null +++ b/src/app/v2/emotions/querys/emotion_query.py @@ -0,0 +1,14 @@ +from app.v2.users.querys.user_query import USER_ID_QUERY + +SELECT_EMOTION_CODE_BY_USER_UUID_QUERY = f""" + SELECT emotion_code + FROM emotion + WHERE {USER_ID_QUERY} + """ + +INSERT_EMOTION_CODE_FOR_USER_QUERY = f""" + INSERT INTO emotion (emotion_code, user_id) + SELECT %s, user_id + FROM user + WHERE {USER_ID_QUERY} + """ diff --git a/src/app/v2/emotions/router.py b/src/app/v2/emotions/router.py new file mode 100644 index 0000000..3fea988 --- /dev/null +++ b/src/app/v2/emotions/router.py @@ -0,0 +1,19 @@ +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/__init__.py b/src/app/v2/emotions/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/emotions/services/emotion_service.py b/src/app/v2/emotions/services/emotion_service.py new file mode 100644 index 0000000..bdcc9a6 --- /dev/null +++ b/src/app/v2/emotions/services/emotion_service.py @@ -0,0 +1,53 @@ +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/__init__.py b/src/app/v2/items/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/items/dtos/__init__.py b/src/app/v2/items/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/items/dtos/item_dto.py b/src/app/v2/items/dtos/item_dto.py new file mode 100644 index 0000000..554b2be --- /dev/null +++ b/src/app/v2/items/dtos/item_dto.py @@ -0,0 +1,23 @@ +# 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/__init__.py b/src/app/v2/items/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/items/models/item.py b/src/app/v2/items/models/item.py new file mode 100644 index 0000000..5717827 --- /dev/null +++ b/src/app/v2/items/models/item.py @@ -0,0 +1,71 @@ +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/repositorys/__init__.py b/src/app/v2/items/repositorys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/items/router.py b/src/app/v2/items/router.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/items/services/__init__.py b/src/app/v2/items/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/__init__.py b/src/app/v2/levels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/dtos/__init__.py b/src/app/v2/levels/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/dtos/level_dto.py b/src/app/v2/levels/dtos/level_dto.py new file mode 100644 index 0000000..b9e8e25 --- /dev/null +++ b/src/app/v2/levels/dtos/level_dto.py @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/models/level.py b/src/app/v2/levels/models/level.py new file mode 100644 index 0000000..8081d0f --- /dev/null +++ b/src/app/v2/levels/models/level.py @@ -0,0 +1,34 @@ +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 + + +class Level(Model): + level_id = fields.BigIntField(pk=True) + user_exp = fields.IntField() + user_level = fields.IntField() + + class Meta: + table = "level" + + @classmethod + async def get_level_info(cls, user_id: str) -> Any: + query = SELECT_USER_LEVEL_AND_REQUIRED_EXP_QUERY + value = user_id + return await QueryExecutor.execute_query(query, values=(value,), fetch_type="single") + + @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/src/app/v2/levels/querys/__init__.py b/src/app/v2/levels/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/querys/level_query.py b/src/app/v2/levels/querys/level_query.py new file mode 100644 index 0000000..e92b0fe --- /dev/null +++ b/src/app/v2/levels/querys/level_query.py @@ -0,0 +1,44 @@ +from app.v2.users.querys.user_query import USER_ID_QUERY + +SELECT_USER_LEVEL_AND_EXP_BY_USER_UUID_QUERY = f""" + SELECT + l.user_exp AS level_exp, + l.user_level AS level_level + FROM + user u + JOIN + level l ON u.level_id = l.level_id + WHERE {USER_ID_QUERY} +""" + +SELECT_USER_EXP_QUERY = f""" +SELECT li.required_exp +FROM user u +JOIN level l ON u.level_id = l.level_id +JOIN level_inventory li ON l.user_level = li.level +WHERE {USER_ID_QUERY} +LIMIT 1; +""" + +UPDATE_USER_LEVEL_AND_EXP_QUERY = f""" + UPDATE level l + JOIN user u ON u.level_id = l.level_id + SET l.user_level = %s, l.user_exp = %s + WHERE {USER_ID_QUERY}; +""" + +SELECT_USER_LEVEL_AND_REQUIRED_EXP_QUERY = f""" + SELECT + l.user_exp AS level_exp, + l.user_level AS level_level, + li.required_exp AS required_exp + FROM + user u + JOIN + level l ON u.level_id = l.level_id + JOIN + level_inventory li ON l.user_level = li.level + WHERE + {USER_ID_QUERY} + LIMIT 1; +""" diff --git a/src/app/v2/levels/router.py b/src/app/v2/levels/router.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/services/__init__.py b/src/app/v2/levels/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/levels/services/level_service.py b/src/app/v2/levels/services/level_service.py new file mode 100644 index 0000000..1321bf6 --- /dev/null +++ b/src/app/v2/levels/services/level_service.py @@ -0,0 +1,84 @@ +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 + + +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) + + @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) + + if level_dto.requiredExp is None: + raise ValueError("Required experience cannot be None") + + 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, + ) + + @classmethod + async def level_up(cls, user_id: str) -> int: + level_dto = await cls.get_level_info(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 + + await Level.update_level_and_exp(user_id=user_id, new_level=new_level, new_exp=new_exp) + return 1 + return 0 + + @classmethod + async def add_exp(cls, user_id: str, exp: int) -> None: + level_dto = await cls.get_level_info(user_id=user_id) + + current_exp = level_dto.currentExp + new_exp = current_exp + exp + + await Level.update_level_and_exp(user_id=user_id, new_level=level_dto.level, new_exp=new_exp) + + @classmethod + async def calculate_days_to_level_up(cls, user_id: str, current_exp: int, required_exp: int) -> int: + remaining_exp = required_exp - current_exp + days_needed = 0 + + answer_count = await AnswerService.get_answer_count(user_id=user_id) + bonus_points = await AnswerService.calculate_consecutive_answer_points(user_id=user_id) + + while remaining_exp > 0: + if answer_count == 1: + calculated_points = 10 + bonus_points + elif 2 <= answer_count <= 5: + calculated_points = 5 + bonus_points + else: + calculated_points = 1 + bonus_points + + remaining_exp -= calculated_points + + days_needed += 1 + + answer_count += 1 + bonus_points = min(bonus_points + 1, 10) + + return days_needed diff --git a/src/app/v2/likes/__init__.py b/src/app/v2/likes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/likes/models/__init__.py b/src/app/v2/likes/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/likes/models/like.py b/src/app/v2/likes/models/like.py new file mode 100644 index 0000000..8369c8c --- /dev/null +++ b/src/app/v2/likes/models/like.py @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/likes/querys/like_query.py b/src/app/v2/likes/querys/like_query.py new file mode 100644 index 0000000..a8780dd --- /dev/null +++ b/src/app/v2/likes/querys/like_query.py @@ -0,0 +1,6 @@ +SELECT_UNIQUE_LIKES_COUNT_BY_USER_TODAY_QUERY = """ + SELECT COUNT(DISTINCT answer_id) AS unique_likes + FROM likes + WHERE user_id = UNHEX(REPLACE(%s, '-', '')) + AND DATE(created_time) = CURDATE(); +""" diff --git a/src/app/v2/missions/__init__.py b/src/app/v2/missions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/missions/dtos/__init__.py b/src/app/v2/missions/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/missions/dtos/mission_dto.py b/src/app/v2/missions/dtos/mission_dto.py new file mode 100644 index 0000000..4d73b7a --- /dev/null +++ b/src/app/v2/missions/dtos/mission_dto.py @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..026bed1 --- /dev/null +++ b/src/app/v2/missions/dtos/request.py @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..46ed7df --- /dev/null +++ b/src/app/v2/missions/dtos/response.py @@ -0,0 +1,22 @@ +# 응답 모델 정의 +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 new file mode 100644 index 0000000..c0f46f9 --- /dev/null +++ b/src/app/v2/missions/dtos/reward_dto.py @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/missions/models/mission.py b/src/app/v2/missions/models/mission.py new file mode 100644 index 0000000..94f281f --- /dev/null +++ b/src/app/v2/missions/models/mission.py @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/missions/querys/mission_query.py b/src/app/v2/missions/querys/mission_query.py new file mode 100644 index 0000000..40f5671 --- /dev/null +++ b/src/app/v2/missions/querys/mission_query.py @@ -0,0 +1,16 @@ +from app.v2.users.querys.user_query import USER_ID_QUERY + +SELECT_USER_MISSIONS_QUERY = """ + SELECT um.* + FROM user_mission um + JOIN mission_inventory mi ON um.mission_code = mi.mission_code + WHERE um.user_id = UNHEX(REPLACE(%s, '-', '')) +""" + + +UPDATE_USER_MISSION_PROGRESS_QUERY = """ + UPDATE user_mission + SET progress_count = %s, is_completed = %s + WHERE user_id = UNHEX(REPLACE(%s, '-', '')) + AND mission_code = %s +""" diff --git a/src/app/v2/missions/router.py b/src/app/v2/missions/router.py new file mode 100644 index 0000000..37564a8 --- /dev/null +++ b/src/app/v2/missions/router.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from core.configs.celery_settings import process_mission_in_background + +router = APIRouter(prefix="/mission", tags=["Mission"]) + + +@router.get("") +async def mission_handler(user_id: str) -> None: + process_mission_in_background.delay(user_id) diff --git a/src/app/v2/missions/services/__init__.py b/src/app/v2/missions/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/missions/services/mission_service.py b/src/app/v2/missions/services/mission_service.py new file mode 100644 index 0000000..ff23e19 --- /dev/null +++ b/src/app/v2/missions/services/mission_service.py @@ -0,0 +1,403 @@ +import asyncio +from datetime import date, datetime, timedelta, timezone +from typing import Any, Optional + +from fastapi import HTTPException +from tortoise.exceptions import DoesNotExist +from tortoise.transactions import atomic + +from app.v2.answers.models.answer import Answer +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 + + +class MissionService: + @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] + + @staticmethod + async def _update_user_mission_progress( + user_id: str, + mission_code: str, + new_progress_count: int, + is_completed: bool, + ) -> None: + await UserMission.update_user_mission_progress( + user_id=user_id, + mission_code=mission_code, + new_progress_count=new_progress_count, + is_completed=is_completed, + ) + + @atomic() + 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), + self.get_user_missions(user_id=user_id), + MissionInventory.all(), + ) + + cheese_manager_id: int = 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) + + await asyncio.gather( + *[self._process_mission(mission, mission_dict, cheese_manager_id, user_id) for mission in badge_missions], + *[self._process_mission(mission, mission_dict, cheese_manager_id, user_id) for mission in daily_missions], + ) + + 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, + mission_dict: dict[str, MissionInventory], + cheese_manager_id: int, + user_id: str, + ) -> None: + mission = mission_dict.get(user_mission.mission_code) + + if user_mission.is_completed or not mission: + return + + increment = await self.evaluate_mission_condition(user_id, user_mission.mission_code) + user_mission.progress_count += increment + + if user_mission.progress_count >= mission.target_count and not user_mission.is_completed: + user_mission.is_completed = True + await self._update_user_mission_progress( + user_id=user_id, + mission_code=user_mission.mission_code, + new_progress_count=user_mission.progress_count, + is_completed=user_mission.is_completed, + ) + await self._handle_mission_reward( + user_id=user_id, + mission_code=user_mission.mission_code, + reward_code=mission.reward_code, + cheese_manager_id=cheese_manager_id, + ) + + async def _handle_mission_reward( + self, + user_id: str, + mission_code: str, + reward_code: str, + cheese_manager_id: int, + ) -> None: + 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": + 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"): + 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): + return 1 + 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): + return 1 + 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): + return 1 + 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): + return 1 + elif mission_code == f"MS_LV_UP" and await LevelService.level_up(user_id=user_id): + return 1 + elif mission_code == "MS_DAILY_POST_GENERAL" and await self.check_daily_post(user_id): + return 1 + return 0 + + @staticmethod + async def check_first_post(user_id: str) -> bool: + return await AnswerService.get_answer_count(user_id=user_id) > 0 + + @staticmethod + async def get_answer_count(user_id: str) -> int: + return await AnswerService.get_answer_count(user_id=user_id) + + @staticmethod + async def check_post_count_range(answer_count: int, min_count: int, max_count: int) -> bool: + return min_count <= answer_count <= max_count + + @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 + + @staticmethod + async def check_consecutive_days(user_id: str) -> bool: + consecutive_days = await AnswerService.get_answer_record(user_id) + return consecutive_days >= 7 + + @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 + + @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"]) + + return cheese_amount >= 50 + + @staticmethod + async def check_christmas_period() -> bool: + # 현재 시간 한국 시간(KST) 기준 + now = datetime.now(timezone(timedelta(hours=9))) + + start_date = datetime(2024, 12, 23, 6, 0, tzinfo=timezone(timedelta(hours=9))) + end_date = datetime(2024, 12, 28, 5, 59, tzinfo=timezone(timedelta(hours=9))) + + return start_date <= now <= end_date + + @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) + return like_count >= 3 + + @staticmethod + async def check_daily_post(user_id: str) -> bool: + answer = await AnswerService.get_most_recent_answer(user_id=user_id) + + answer_date = answer.get("date") + + return answer_date == date.today() # type: ignore + + @staticmethod + async def validate_reward(reward_code: str): # type: ignore + try: + 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.") + + item_inventory_rewards = reward.item_inventories + + return item_inventory_rewards + + except DoesNotExist: + raise HTTPException(status_code=404, detail="Reward not found.") + + async def process_reward( + self, + item_inventory_rewards: list[ItemInventoryRewardInventory], + user_id: str, + cheese_manager_id: int, + ) -> RewardDTO: + total_cheese = 0 + total_exp = 0 + badge_info = [] + + for item_inventory_reward in item_inventory_rewards: + item: ItemInventory = await item_inventory_reward.item_inventory + quantity = item_inventory_reward.quantity + + if item.item_category == "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) + badge_info.append(badge) + + elif item.item_category == "COLOR": + for _ in range(quantity): + await ColorService.add_color(user_id=user_id, color_code=item.item_code) + elif item.item_category == "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}") + + 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 + ) + + async def reward_daily_post(self, user_id: str, cheese_manager_id: int) -> None: + # 1. 경험치 및 치즈 계산 + exp, cheese, consecutive_date = await self._calculate_exp_and_cheese(user_id) + + # 2. 경험치와 치즈 추가 + await self._add_exp_and_cheese(user_id, cheese_manager_id, exp, cheese) + + # 3. 보상 알림 생성 + await self._create_reward_notice( + user_id=user_id, + reward_type="DAILY_MISSION", + total_exp=exp, + total_cheese=cheese, + ) + + async def _calculate_exp_and_cheese(self, user_id: str) -> tuple[int, int, int]: + # 1. 연속 답변 포인트 계산 + consecutive_date = await AnswerService.calculate_consecutive_answer_points(user_id=user_id) + + # 2. 경험치 계산 + exp = await self._calculate_exp(user_id, consecutive_date) + + # 3. 치즈 계산 + cheese = await self._calculate_cheese(consecutive_date) + + return exp, cheese, consecutive_date + + async def _calculate_exp(self, user_id: str, consecutive_date: int) -> int: + exp = 0 + + answer_count = await self.get_answer_count(user_id=user_id) + + if answer_count == 1: + exp += 10 + elif await self.check_post_count_range(answer_count, 2, 5): + exp += 5 + + exp += consecutive_date + + return exp + + @staticmethod + async def _calculate_cheese(consecutive_date: int) -> int: + cheese = 0 + + if consecutive_date == 0: + cheese = 0 + elif 2 <= consecutive_date <= 5: + cheese = 1 + elif 5 <= consecutive_date <= 8: + cheese = 2 + elif consecutive_date >= 9: + cheese = 3 + + return cheese + + @staticmethod + async def _add_exp_and_cheese(user_id: str, cheese_manager_id: int, exp: int, cheese: int) -> None: + # 경험치 추가 + await LevelService.add_exp(user_id=user_id, exp=exp) + + # 치즈 추가 + await CheeseService.add_cheese(cheese_manager_id=cheese_manager_id, amount=cheese) + + @staticmethod + async def _create_reward_notice( + user_id: str, + 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, + ) -> None: + await NoticeService.create_reward_notice( + user_id=user_id, + reward_type=reward_type, + total_cheese=total_cheese, + total_exp=total_exp, + badge_full_name=badge_full_name, + badge_code=badge_code, + level_up=level_up, + nickname=nickname, + new_level=new_level, + ) + + async def reward_level_up_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, + ) + level_info = await LevelService.get_level_info_add_answer_days(user_id) + user_info = await UserService.get_user_profile(user_id=user_id) + + nickname = user_info.nickname + level = level_info.levelDto.level + + await self._create_reward_notice( + user_id=user_id, + reward_type="LEVEL_UP", + total_exp=reward_dto.total_exp, + total_cheese=reward_dto.total_cheese, + level_up=True, + nickname=nickname, + new_level=level, + ) + + async def reward_badge_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="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/mobiles/__init__.py b/src/app/v2/mobiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/mobiles/dtos/__init__.py b/src/app/v2/mobiles/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/mobiles/dtos/mypage_response.py b/src/app/v2/mobiles/dtos/mypage_response.py new file mode 100644 index 0000000..4d492aa --- /dev/null +++ b/src/app/v2/mobiles/dtos/mypage_response.py @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..29f8349 --- /dev/null +++ b/src/app/v2/mobiles/dtos/teller_card_response.py @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..1e2c9fa --- /dev/null +++ b/src/app/v2/mobiles/router.py @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/notices/dtos/__init__.py b/src/app/v2/notices/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/notices/models/__init__.py b/src/app/v2/notices/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/notices/models/notice.py b/src/app/v2/notices/models/notice.py new file mode 100644 index 0000000..f189219 --- /dev/null +++ b/src/app/v2/notices/models/notice.py @@ -0,0 +1,55 @@ +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 Notice(Model): + notice_id = fields.BigIntField(pk=True) + title = fields.CharField(max_length=255, null=False) + 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) + date = fields.DateField(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" + + @classmethod + async def create_notice( + cls, + title: str, + content: str, + user_id: str, + link: Optional[str] = None, + is_read: Optional[bool] = False, + is_internal: Optional[bool] = False, + answer_id: Optional[int] = None, + date: Optional[str] = None, + reward_type: Optional[str] = None, + badge_code: Optional[str] = None, + ) -> None: + query = """ + INSERT INTO notice ( + title, content, user_id, link, is_internal, is_read, answer_id, date, reward_type, + badge_code, created_at + ) + VALUES ( + %s, %s, UNHEX(REPLACE(%s, '-', '')), %s, %s, %s, %s, %s, %s, %s, NOW() + ); + """ + values = (title, content, user_id, link, is_internal, is_read, answer_id, date, reward_type, badge_code) + + await QueryExecutor.execute_query(query, values=values, fetch_type="single") diff --git a/src/app/v2/notices/services/__init__.py b/src/app/v2/notices/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/notices/services/notice_service.py b/src/app/v2/notices/services/notice_service.py new file mode 100644 index 0000000..5c94902 --- /dev/null +++ b/src/app/v2/notices/services/notice_service.py @@ -0,0 +1,97 @@ +from typing import Optional + +from app.v2.badges.models.badge import BadgeInventory +from app.v2.notices.models.notice import Notice + + +class NoticeService: + + @classmethod + async def create_notice( + cls, + user_id: str, + title: str, + reward_type: str, + content: str, + badge_code: Optional[str] = None, + ) -> None: + return await Notice.create_notice( + title=title, + content=content, + user_id=user_id, + reward_type=reward_type, + is_internal=True, + is_read=False, + badge_code=badge_code, + ) + + @classmethod + async def create_reward_notice( + cls, + user_id: str, + 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, + ) -> 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, + level_up=level_up, + nickname=nickname, + new_level=new_level, + ) + + # 2. 메시지 생성 + content = cls.create_reward_message( + total_cheese=total_cheese, + total_exp=total_exp, + badge_full_name=badge_full_name, + level_up=level_up, + ) + + badge_code = badge_code if badge_code else None + + # 3. 알림 생성 + await cls.create_notice( + user_id=user_id, title=title, content=content, reward_type=reward_type, badge_code=badge_code + ) + + @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, + ) -> str: + if level_up and nickname and new_level is not None: + return f"{nickname} LV{new_level}로 레벨업!" + if badge_full_name: + return badge_full_name + return "보상을 받았어요!" + + @classmethod + def create_reward_message( + cls, total_cheese: int, total_exp: int, badge_full_name: Optional[str] = None, level_up: Optional[bool] = False + ) -> str: + if level_up: + return f"선물로 치즈 {total_cheese}개를 드릴게요!" if total_cheese > 0 else "레벨업을 축하드립니다!" + + if badge_full_name: + return f"선물로 치즈 {total_cheese}개를 드릴게요!" if total_cheese > 0 else "뱃지 획득을 축하드립니다!" + + if total_cheese > 0 and total_exp > 0: + return f"치즈 {total_cheese}개와 경험치 {total_exp}P를 받았어요!" + elif total_cheese > 0: + return f"치즈 {total_cheese}개를 받았어요!" + elif total_exp > 0: + return f"경험치 {total_exp}P를 받았어요!" + return "보상을 받지 못했어요!" diff --git a/src/app/v2/payments/__init__.py b/src/app/v2/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/payments/dtos/__init__.py b/src/app/v2/payments/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/payments/dtos/request.py b/src/app/v2/payments/dtos/request.py new file mode 100644 index 0000000..fb55b02 --- /dev/null +++ b/src/app/v2/payments/dtos/request.py @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..490deb4 --- /dev/null +++ b/src/app/v2/payments/dtos/response.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/payments/querys/__init__.py b/src/app/v2/payments/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/payments/router.py b/src/app/v2/payments/router.py new file mode 100644 index 0000000..bb3d71c --- /dev/null +++ b/src/app/v2/payments/router.py @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/payments/services/payment_service.py b/src/app/v2/payments/services/payment_service.py new file mode 100644 index 0000000..b03d68c --- /dev/null +++ b/src/app/v2/payments/services/payment_service.py @@ -0,0 +1,75 @@ +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 + + +class PaymentService: + @staticmethod + async def validate_payment( + product_code: str, + ) -> tuple[ProductInventory, list[ItemInventoryProductInventory]]: + try: + product = await ProductInventory.get(product_code=product_code) + + if product.transaction_currency != "CHEESE": + raise CustomException(ErrorCode.INVALID_TRANSACTION_CURRENCY) + + item_inventory_products = await ItemInventoryProductInventory.filter( + product_inventory_id=product.product_id + ).all() + + if not item_inventory_products: + raise CustomException(ErrorCode.NO_INVENTORY_FOR_PRODUCT) + + return product, item_inventory_products + + except DoesNotExist: + raise CustomException(ErrorCode.PRODUCT_NOT_FOUND) + + @classmethod + @atomic() + async def process_cheese_payment( + cls, + product: ProductInventory, + item_inventory_products: list[ItemInventoryProductInventory], + user_id: str, + cheese_manager_id: int, + ) -> None: + total_cheese = await CheeseManager.get_total_cheese_amount_by_manager(cheese_manager_id=cheese_manager_id) + + 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) + + 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": + for _ in range(quantity): + await BadgeService.add_badge(user_id=user_id, badge_code=item.item_code) + elif item.item_category == "COLOR": + for _ in range(quantity): + await ColorService.add_color(user_id=user_id, color_code=item.item_code) + elif item.item_category == "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) + except IntegrityError: + raise CustomException(ErrorCode.DUPLICATE_PURCHASE) diff --git a/src/app/v2/purchases/__init__.py b/src/app/v2/purchases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/purchases/dtos/__init__.py b/src/app/v2/purchases/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/purchases/dtos/purchase_dto.py b/src/app/v2/purchases/dtos/purchase_dto.py new file mode 100644 index 0000000..a03b463 --- /dev/null +++ b/src/app/v2/purchases/dtos/purchase_dto.py @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..a2f87fc --- /dev/null +++ b/src/app/v2/purchases/dtos/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/purchases/models/__init__.py b/src/app/v2/purchases/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/purchases/models/purchase_history.py b/src/app/v2/purchases/models/purchase_history.py new file mode 100644 index 0000000..c5206b9 --- /dev/null +++ b/src/app/v2/purchases/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.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 new file mode 100644 index 0000000..f8789f7 --- /dev/null +++ b/src/app/v2/purchases/models/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/src/app/v2/purchases/repositorys/__init__.py b/src/app/v2/purchases/repositorys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/purchases/router.py b/src/app/v2/purchases/router.py new file mode 100644 index 0000000..7cd7ba6 --- /dev/null +++ b/src/app/v2/purchases/router.py @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/purchases/services/purchase_service.py b/src/app/v2/purchases/services/purchase_service.py new file mode 100644 index 0000000..6420521 --- /dev/null +++ b/src/app/v2/purchases/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.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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/questions/dtos/__init__.py b/src/app/v2/questions/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/questions/dtos/responses.py b/src/app/v2/questions/dtos/responses.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/questions/models/__init__.py b/src/app/v2/questions/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/questions/models/question.py b/src/app/v2/questions/models/question.py new file mode 100644 index 0000000..f3571d5 --- /dev/null +++ b/src/app/v2/questions/models/question.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/questions/router.py b/src/app/v2/questions/router.py new file mode 100644 index 0000000..0f789d9 --- /dev/null +++ b/src/app/v2/questions/router.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/teller_cards/__init__.py b/src/app/v2/teller_cards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/teller_cards/dtos/__init__.py b/src/app/v2/teller_cards/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/teller_cards/dtos/request.py b/src/app/v2/teller_cards/dtos/request.py new file mode 100644 index 0000000..fecc72e --- /dev/null +++ b/src/app/v2/teller_cards/dtos/request.py @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..4df7b3e --- /dev/null +++ b/src/app/v2/teller_cards/dtos/response.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..a38e524 --- /dev/null +++ b/src/app/v2/teller_cards/dtos/teller_card_dto.py @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/teller_cards/models/teller_card.py b/src/app/v2/teller_cards/models/teller_card.py new file mode 100644 index 0000000..7314021 --- /dev/null +++ b/src/app/v2/teller_cards/models/teller_card.py @@ -0,0 +1,33 @@ +from typing import Any, Optional + +from tortoise import fields +from tortoise.models import Model + +from app.v2.teller_cards.querys.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) + activate_badge_code = fields.CharField(max_length=255, null=True) + activate_color_code = fields.CharField(max_length=255, null=True) + + class Meta: + table = "teller_card" + + @classmethod + async def get_teller_card_info_by_user_id(cls, user_id: str) -> Any: # type ignore + 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 + + @classmethod + async def patch_teller_card_info_by_user_id( + cls, user_id: str, badge_code: Optional[str] = None, color_code: Optional[str] = None + ) -> None: + query = PATCH_TELLER_CARD_QUERY + values = (badge_code, color_code, user_id) + await QueryExecutor.execute_query(query, values=values, fetch_type="single") diff --git a/src/app/v2/teller_cards/querys/__init__.py b/src/app/v2/teller_cards/querys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/teller_cards/querys/teller_card_query.py b/src/app/v2/teller_cards/querys/teller_card_query.py new file mode 100644 index 0000000..7e64673 --- /dev/null +++ b/src/app/v2/teller_cards/querys/teller_card_query.py @@ -0,0 +1,39 @@ +from app.v2.users.querys.user_query import USER_ID_QUERY + +SELECT_TELLER_CARD_INFO_BY_USER_UUID_QUERY = f""" + SELECT + tc.activate_badge_code, + bi.badge_name, + bi.badge_middle_name, + tc.activate_color_code + 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 + WHERE {USER_ID_QUERY} + ) +""" + +PATCH_TELLER_CARD_QUERY = """ + UPDATE teller_card + 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 + FROM user u + WHERE u.user_id = UNHEX(REPLACE(%s, '-', '')) +) +""" + + +GET_UPDATED_TELLER_CARD_QUERY = """ + SELECT activate_badge_code AS badgeCode, activate_color_code AS colorCode + FROM teller_card + WHERE teller_card_id = ( + SELECT u.teller_card_id + FROM user u + WHERE u.user_id = UNHEX(REPLACE(%s, '-', '')) + ) +""" diff --git a/src/app/v2/teller_cards/router.py b/src/app/v2/teller_cards/router.py new file mode 100644 index 0000000..a66c981 --- /dev/null +++ b/src/app/v2/teller_cards/router.py @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/teller_cards/services/teller_card_service.py b/src/app/v2/teller_cards/services/teller_card_service.py new file mode 100644 index 0000000..7e34904 --- /dev/null +++ b/src/app/v2/teller_cards/services/teller_card_service.py @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/users/dtos/__init__.py b/src/app/v2/users/dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/users/dtos/user_dto.py b/src/app/v2/users/dtos/user_dto.py new file mode 100644 index 0000000..4da00de --- /dev/null +++ b/src/app/v2/users/dtos/user_dto.py @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..4bc245d --- /dev/null +++ b/src/app/v2/users/dtos/user_info_dto.py @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..0aca6e3 --- /dev/null +++ b/src/app/v2/users/dtos/user_profile_dto.py @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/users/models/refresh_token.py b/src/app/v2/users/models/refresh_token.py new file mode 100644 index 0000000..15d5e03 --- /dev/null +++ b/src/app/v2/users/models/refresh_token.py @@ -0,0 +1,12 @@ +from tortoise import fields +from tortoise.models import Model + + +class RefreshToken(Model): + refresh_token_id = fields.BigIntField(pk=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로 선언 + + class Meta: + table = "refresh_token" diff --git a/src/app/v2/users/models/user.py b/src/app/v2/users/models/user.py new file mode 100644 index 0000000..72212a9 --- /dev/null +++ b/src/app/v2/users/models/user.py @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/users/querys/user_query.py b/src/app/v2/users/querys/user_query.py new file mode 100644 index 0000000..9966e95 --- /dev/null +++ b/src/app/v2/users/querys/user_query.py @@ -0,0 +1,36 @@ +USER_ID_QUERY = "user_id = UNHEX(REPLACE(%s, '-', ''))" + +SELECT_USER_BY_UUID_QUERY = f"SELECT * FROM user WHERE {USER_ID_QUERY} LIMIT 1" + + +SELECT_USER_PROFILE_BY_USER_ID_QUERY = f""" + SELECT + u.nickname, + u.is_premium, + u.cheese_manager_id, + u.allow_notification + FROM user u + WHERE {USER_ID_QUERY} +""" + +SELECT_USER_INFO_BY_USER_UUID_QUERY = f""" + SELECT + u.nickname, + u.cheese_manager_id + FROM user u + WHERE {USER_ID_QUERY} +""" + +SELECT_USER_TELLER_CARD_ID_BY_USER_UUID_QUERY = f""" + SELECT + u.teller_card_id + 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/users/services/__init__.py b/src/app/v2/users/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/v2/users/services/user_service.py b/src/app/v2/users/services/user_service.py new file mode 100644 index 0000000..95c3fb9 --- /dev/null +++ b/src/app/v2/users/services/user_service.py @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..7eeeeb6 --- /dev/null +++ b/src/celery_worker.py @@ -0,0 +1,3 @@ +from core.configs.celery_settings import celery_app + +__all__ = ("celery_app",) diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common/base_models/__init__.py b/src/common/base_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common/base_models/base_dtos/__init__.py b/src/common/base_models/base_dtos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common/base_models/base_dtos/base_response.py b/src/common/base_models/base_dtos/base_response.py new file mode 100644 index 0000000..13d2463 --- /dev/null +++ b/src/common/base_models/base_dtos/base_response.py @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/common/exceptions/__init__.py b/src/common/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common/exceptions/custom_exception.py b/src/common/exceptions/custom_exception.py new file mode 100644 index 0000000..48e6fb3 --- /dev/null +++ b/src/common/exceptions/custom_exception.py @@ -0,0 +1,16 @@ +from typing import Any + +from common.exceptions.error_code import ErrorCode + + +class CustomException(Exception): + def __init__(self, error_code: ErrorCode) -> None: + self.error_code = error_code + super().__init__(error_code.message) + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.error_code.code, + "message": self.error_code.message, + "data": None, + } diff --git a/src/common/exceptions/error_code.py b/src/common/exceptions/error_code.py new file mode 100644 index 0000000..22afa5a --- /dev/null +++ b/src/common/exceptions/error_code.py @@ -0,0 +1,26 @@ +from enum import Enum + + +class ErrorCode(Enum): + NOT_ENOUGH_CHEESE = (4003, "치즈가 부족하여 구매를 진행할 수 없습니다.") + INVALID_ITEM_CATEGORY = (4004, "치즈 결제에 유효하지 않은 아이템 카테고리입니다.") + INVALID_TRANSACTION_CURRENCY = (4001, "결제에 유효하지 않은 거래 통화입니다.") + DUPLICATE_PURCHASE = (4005, "이미 소유한 제품입니다.") + + # 404 Not Found + NO_INVENTORY_FOR_PRODUCT = (4041, "이 상품에 대한 재고가 없습니다.") + PRODUCT_NOT_FOUND = (4042, "해당 상품을 찾을 수 없습니다.") + + NO_VALID_RECEIPT = (4006, "유효한 영수증이 없습니다.") + + def __init__(self, code: int, message: str) -> None: + self._code = code + self._message = message + + @property + def code(self) -> int: + return self._code + + @property + def message(self) -> str: + return self._message diff --git a/src/common/handlers/__init__.py b/src/common/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common/handlers/exception_handler.py b/src/common/handlers/exception_handler.py new file mode 100644 index 0000000..84313d0 --- /dev/null +++ b/src/common/handlers/exception_handler.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI, Request +from starlette.responses import JSONResponse + +from common.exceptions.custom_exception import CustomException + + +def attach_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(Exception) + async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=500, + content={ + "code": 500, + "data": str(exc), + "message": "An unexpected error occurred. Please try again later.", + }, + ) + + @app.exception_handler(CustomException) + async def custom_exception_handler(request: Request, exc: CustomException) -> JSONResponse: + status_code = exc.error_code.code // 10 + return JSONResponse( + status_code=status_code, + content=exc.to_dict(), + ) diff --git a/src/common/handlers/router_handler.py b/src/common/handlers/router_handler.py new file mode 100644 index 0000000..ff6b67e --- /dev/null +++ b/src/common/handlers/router_handler.py @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..f72dfb4 --- /dev/null +++ b/src/common/post_construct.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/common/tasks/mission_task.py b/src/common/tasks/mission_task.py new file mode 100644 index 0000000..8e3ed33 --- /dev/null +++ b/src/common/tasks/mission_task.py @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..eb71fb9 --- /dev/null +++ b/src/common/tasks/renew_subscription_task.py @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..e69de29 diff --git a/src/common/utils/get_user_id.py b/src/common/utils/get_user_id.py new file mode 100644 index 0000000..612b804 --- /dev/null +++ b/src/common/utils/get_user_id.py @@ -0,0 +1,2 @@ +async def get_user_id() -> str: + return "180a4e40-62f8-46be-b1eb-e7e3dd91cddf" diff --git a/src/common/utils/query_executor.py b/src/common/utils/query_executor.py new file mode 100644 index 0000000..cb10261 --- /dev/null +++ b/src/common/utils/query_executor.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Any, Union + +from tortoise import Tortoise + + +class QueryExecutor: + + @staticmethod + async def execute_query( + query: str, + values: Any = (), + fetch_type: str = "multiple", + ) -> Any: # type ignore + """ + SQL 쿼리를 실행하고 결과를 반환합니다. + + :param query: 실행할 SQL 쿼리 문자열 + :param values: 쿼리에 바인딩할 값들 (tuple 또는 단일 값) + :param fetch_type: "single"일 경우 단일 값을 반환하고, "multiple"일 경우 여러 값을 반환 + :return: 단일 값 또는 여러 값(딕셔너리 리스트) + """ + 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,) + + result = await connection.execute_query_dict(query, processed_values) # type: ignore + + if result and len(result) > 0: + if fetch_type == "single": + return result[0] + elif fetch_type == "multiple": + return result + return 0 if fetch_type == "single" else [] diff --git a/src/common/utils/query_formatter.py b/src/common/utils/query_formatter.py new file mode 100644 index 0000000..5ddf3a1 --- /dev/null +++ b/src/common/utils/query_formatter.py @@ -0,0 +1,21 @@ +from typing import Union + + +class QueryFormatter: + @staticmethod + def format(query_template: str, values: Union[str, list[Union[str, int]], tuple[Union[str, int], ...]]) -> str: + """ + 쿼리 템플릿과 단일 값 또는 리스트/튜플 형태의 값을 받아 SQL 쿼리를 포맷팅하는 메서드 (%s 사용) + + :param query_template: SQL 쿼리 템플릿 + :param values: 단일 값 또는 리스트/튜플 + :return: 포맷팅된 SQL 쿼리 + """ + # 단일 값일 경우 문자열 포맷팅 처리 + if isinstance(values, (list, tuple)): + formatted_values = tuple(f"'{value}'" if isinstance(value, str) else value for value in values) + return query_template % formatted_values + else: + # 단일 문자열일 경우 따옴표 추가 + formatted_value = f"'{values}'" if isinstance(values, str) else values + return query_template % formatted_value diff --git a/src/common/utils/scheduler.py b/src/common/utils/scheduler.py new file mode 100644 index 0000000..30c1f07 --- /dev/null +++ b/src/common/utils/scheduler.py @@ -0,0 +1,30 @@ +import logging + +from apscheduler.jobstores.redis import RedisJobStore # type: ignore +from apscheduler.schedulers.background import BackgroundScheduler # type: ignore + +from core.configs.celery_settings import celery_app + +logger = logging.getLogger(__name__) + + +def execute_daily_task() -> None: + celery_app.send_task("daily_task") + + +def start_scheduler() -> None: + scheduler = BackgroundScheduler( + jobstores={"default": RedisJobStore(host="localhost", port=6379, db=0)}, + timezone="Asia/Seoul", + ) + scheduler.start() + logger.info("Scheduler started") + + scheduler.add_job( + func=execute_daily_task, + trigger="cron", + hour=00, + minute=00, + id="daily_task", + replace_existing=True, + ) diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/configs/__init__.py b/src/core/configs/__init__.py new file mode 100644 index 0000000..8110387 --- /dev/null +++ b/src/core/configs/__init__.py @@ -0,0 +1,8 @@ +from core.configs.base_settings import Settings + + +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/src/core/configs/base_settings.py b/src/core/configs/base_settings.py new file mode 100644 index 0000000..247b705 --- /dev/null +++ b/src/core/configs/base_settings.py @@ -0,0 +1,27 @@ +import os +from enum import StrEnum + +from pydantic_settings import BaseSettings + + +class Env(StrEnum): + LOCAL = "local" + STAGE = "stage" + PROD = "prod" + + +class Settings(BaseSettings): + ENV: Env = Env.LOCAL + DB_HOST: str = "localhost" + DB_PORT: int = 3306 + DB_USER: str = "root" + DB_PASSWORD: str = "password" + DB_NAME: str = "database_name" + DB_TIMEZONE: str = "Asia/Seoul" + DB_CHARSET: str = "utf8mb4" + APPLE_URL: str = "https://sandbox.itunes.apple.com/verifyReceipt" + APPLE_SHARED_SECRET: str = "YOUR_SHARED_SECRET" + + class Config: + env_file = f".env.{os.getenv('ENV', 'local')}" + env_file_encoding = "utf-8" diff --git a/src/core/configs/celery_settings.py b/src/core/configs/celery_settings.py new file mode 100644 index 0000000..cf66aaa --- /dev/null +++ b/src/core/configs/celery_settings.py @@ -0,0 +1,69 @@ +import asyncio +import logging + +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 + +celery_app = Celery( + "telling-me-celery", + broker="redis://localhost:6379/0", # Redis를 브로커로 사용 + backend="redis://localhost:6379/0", # Redis를 결과 백엔드로 사용 +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Asia/Seoul", + enable_utc=True, + result_expires=3600, # 작업 결과 만료 시간 (초 단위) +) + + +@celery_app.task +def process_mission_in_background(user_id: str) -> None: + asyncio.run(execute_async_mission_task(user_id=user_id)) + + +@celery_app.task(name="daily_task") +def daily_task() -> None: + asyncio.run(execute_async_daily_task()) + + +async def execute_async_daily_task() -> None: + await initialize_celery() + try: + await mission_reset_task() + finally: + await close_celery_connections() + + +async def execute_async_mission_task(user_id: str) -> None: + await initialize_celery() + try: + mission_service = MissionService() + await mission_service.update_mission_progress(user_id=user_id) + finally: + await close_celery_connections() + + +async def initialize_celery() -> None: + logger = logging.getLogger(__name__) + logger.info(f"Current path: 여기") + logging.basicConfig(level=logging.DEBUG) + db_client_logger = logging.getLogger("tortoise.db_client") + db_client_logger.setLevel(logging.DEBUG) + + await Tortoise.init(config=TORTOISE_ORM) + + +async def close_celery_connections() -> None: + await Tortoise.close_connections() + + +if __name__ == "__main__": + celery_app.start() diff --git a/src/core/database/__init__.py b/src/core/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/database/database_settings.py b/src/core/database/database_settings.py new file mode 100644 index 0000000..98477c4 --- /dev/null +++ b/src/core/database/database_settings.py @@ -0,0 +1,59 @@ +from fastapi import FastAPI +from tortoise import Tortoise +from tortoise.contrib.fastapi import register_tortoise + +from 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", +] + +TORTOISE_ORM = { + "connections": { + "default": { + "engine": "tortoise.backends.mysql", + "credentials": { + "host": settings.DB_HOST, + "port": settings.DB_PORT, + "user": settings.DB_USER, + "password": settings.DB_PASSWORD, + "database": settings.DB_NAME, + "connect_timeout": 5, + "charset": settings.DB_CHARSET, + "minsize": 5, # 커넥션 풀 최소 크기 + "maxsize": 10, # 커넥션 풀 최대 크기 + }, + }, + }, + "apps": { + "models": { + "models": TORTOISE_APP_MODELS, + "default_connection": "default", + }, + }, + "use_tz": False, # 비동기 ORM에서 타임존 설정을 고려하지 않는다면 False + "timezone": settings.DB_TIMEZONE, # 타임존 설정 +} + + +def database_initialize(app: FastAPI) -> None: + Tortoise.init_models(TORTOISE_APP_MODELS, "models") + register_tortoise( + app, + config=TORTOISE_ORM, + generate_schemas=False, + add_exception_handlers=True, + ) diff --git a/src/main.py b/src/main.py index 6620dee..7c23be5 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,20 @@ +import logging + from fastapi import FastAPI +from common.post_construct import post_construct + +logging.basicConfig(level=logging.DEBUG) + +db_client_logger = logging.getLogger("tortoise.db_client") +db_client_logger.setLevel(logging.DEBUG) + app = FastAPI() +post_construct(app) @app.get("/health_check") -def health_check(): +def health_check() -> dict[str, str]: return {"message": "Hello World"} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..780ed8a --- /dev/null +++ b/test.sh @@ -0,0 +1,23 @@ +set -eo pipefail + +COLOR_GREEN=`tput setaf 2;` +COLOR_NC=`tput sgr0;` # No Color + +echo "Starting black" +poetry run black . +echo "OK" + +echo "Starting isort" +poetry run isort . +echo "OK" + +echo "Starting mypy" +poetry run mypy . +echo "OK" + +echo "Starting pytest with coverage" +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