Compare commits

..

102 Commits

Author SHA1 Message Date
IluaAir
33bd88629f add login card and shadcn components 2025-10-04 23:54:19 +03:00
IluaAir
e41b8922e5 add neon and font style 2025-10-04 02:04:51 +03:00
IluaAir
4211be86ff init frontend 2025-10-04 00:03:42 +03:00
IluaAir
88ace64d13 add priority filter 2025-09-30 22:19:43 +03:00
IluaAir
9decb7591e change admin dep 2025-09-30 22:09:26 +03:00
IluaAir
41c64127a5 del old task dep, page start from 1 2025-09-30 00:23:16 +03:00
IluaAir
ac28f9b420 add limit page date to date from 2025-09-30 00:01:13 +03:00
IluaAir
91daaf9275 delete protocol 2025-09-28 22:34:14 +03:00
IluaAir
64dcc77518 delete old dep 2025-09-28 22:20:34 +03:00
IluaAir
ddaa5b0c74 fix dep user for endpoints 2025-09-28 22:18:23 +03:00
IluaAir
23927e5347 add id tasks endpoint 2025-09-27 23:58:39 +03:00
IluaAir
67a570ceac format and login exception 2025-09-27 12:07:31 +03:00
IluaAir
966b59ff20 fix max age 2025-09-27 12:00:17 +03:00
IluaAir
b87c37b149 add test fingerprint 2025-09-27 11:52:55 +03:00
IluaAir
4e47ce80fd fix fingerprint body 2025-09-27 11:26:10 +03:00
IluaAir
f4e46bef22 full refresh token 2025-09-27 11:24:57 +03:00
IluaAir
3dc36a2f25 add fingerprint httpbearer schema 2025-09-21 21:35:46 +03:00
IluaAir
408e32d05f add expire and fingerprint for refreshed 2025-09-21 19:54:17 +03:00
IluaAir
42b8c3a2c9 new refresh dep add refresh endpoint 2025-09-21 15:17:27 +03:00
IluaAir
c3bfb9cb6a add logout 2025-09-21 12:10:50 +03:00
IluaAir
cd51902313 add login endpoint 2025-09-21 12:08:04 +03:00
IluaAir
8620e9b5a1 add creation refresh token 2025-09-20 21:54:56 +03:00
IluaAir
9099120ee2 username validation 2025-09-20 14:11:14 +03:00
IluaAir
c642b89581 full view User profile 2025-09-20 14:04:07 +03:00
IluaAir
e0cddbdd34 add is_superuser for jwt token 2025-09-20 13:55:36 +03:00
IluaAir
0de7d63817 add migration 2025-09-08 15:10:07 +03:00
IluaAir
c18487e22a refresh token model 2025-09-08 14:53:00 +03:00
IluaAir
c35416bece add test_task_crud 2025-09-06 22:16:42 +03:00
IluaAir
6fa74bffe8 del user update repo 2025-09-06 22:07:00 +03:00
IluaAir
7fe13be684 add base update one 2025-09-06 13:54:52 +03:00
IluaAir
d7e522d362 add patch endpoint and service update_task 2025-09-03 23:55:26 +03:00
IluaAir
6e6613662a patch schema 2025-09-03 23:32:31 +03:00
IluaAir
e1c554e4f0 add statusenum 2025-09-03 23:12:27 +03:00
IluaAir
b1ae775706 fix status for tasks 2025-08-31 23:36:58 +03:00
IluaAir
a9fc764c38 add status to user with loads 2025-08-31 23:27:02 +03:00
IluaAir
bd9786c14c add to tasks service 2025-08-29 22:10:15 +03:00
IluaAir
07f14b0564 add date_to date_from endpoints 2025-08-29 21:48:38 +03:00
IluaAir
3e6468fa38 add quary status 2025-08-29 21:37:42 +03:00
IluaAir
c941b25a90 repo tasks count 2025-08-28 00:36:59 +03:00
IluaAir
45c5492ff8 api pagin, db _task_subquary, tasks depends 2025-08-28 00:27:27 +03:00
IluaAir
cd4eb11604 tests user_with_load 2025-08-27 01:17:24 +03:00
IluaAir
2d54f595db add date from and date to 2025-08-24 21:55:42 +03:00
IluaAir
186b497130 repo user with loads filters 2025-08-24 13:43:07 +03:00
2dde22eb19 Merge pull request 'add tests for project' (#1) from test into dev1
Reviewed-on: #1
2025-08-24 09:52:07 +03:00
IluaAir
543920f476 test crud 2025-08-20 00:15:15 +03:00
IluaAir
a8c4b622a4 test auth api, add auth fixture 2025-08-19 17:06:53 +03:00
IluaAir
cc3aad5c20 test auth api 2025-08-18 18:26:45 +03:00
IluaAir
eb5a1b9d85 add test_db dep 2025-08-17 14:10:32 +03:00
IluaAir
340c3e1077 add jwt check 2025-08-17 12:55:41 +03:00
IluaAir
4104dd7e15 add conftest 2025-08-16 13:16:46 +03:00
IluaAir
053f97daf0 add pytest 2025-08-16 12:40:25 +03:00
IluaAir
9a1b2b4f93 add get-task 2025-08-16 12:37:39 +03:00
IluaAir
9346aae5ef add delete task, update dep 2025-08-16 11:52:53 +03:00
IluaAir
464689bd29 add user with tasks and endpoint 2025-08-15 16:20:56 +03:00
IluaAir
a9997b1207 get one with load 2025-08-15 15:47:58 +03:00
IluaAir
9e5cde8e28 fix token data 2025-08-15 01:06:25 +03:00
IluaAir
723f59d35e add task post 2025-08-15 01:03:48 +03:00
IluaAir
fdc688cf5e repo typing fix 2025-08-11 10:58:41 +03:00
IluaAir
860962bcc3 pydantic validation in service 2025-08-11 10:15:27 +03:00
IluaAir
ddc38dbd07 type cheking 2025-08-11 10:13:08 +03:00
IluaAir
644d5614b9 add get filtered 2025-08-10 20:51:25 +03:00
IluaAir
d6646716b8 move get_all_users to get_all 2025-08-09 01:00:39 +03:00
IluaAir
b2824c2bb2 move delete_one 2025-08-09 00:56:13 +03:00
IluaAir
2471c2981f big fix imports 2025-08-08 15:30:17 +03:00
IluaAir
9c20b7690c add tasks repo update uow and manager 2025-08-08 14:04:33 +03:00
IluaAir
6657f55545 ruff check 2025-08-07 00:22:32 +03:00
IluaAir
45ebed40bc simple get all tasks 2025-08-07 00:20:14 +03:00
IluaAir
c8e1863ee6 ruff format 2025-08-07 00:20:05 +03:00
IluaAir
c61db4bc9d casacade delete tasks 2025-08-06 23:58:29 +03:00
IluaAir
93cf7b2d24 event listens for sqlalchemy 2025-08-06 23:38:17 +03:00
IluaAir
acb3eefcbe fix typing 2025-08-04 10:59:23 +03:00
IluaAir
a6c00e20c3 create patch user 2025-08-02 18:59:43 +03:00
IluaAir
0ab17f3a99 add delete user endpoint 2025-08-02 18:22:01 +03:00
IluaAir
22ee887238 del old user or admin 2025-08-02 15:55:16 +03:00
IluaAir
cdee74210e fix user or admin dep 2025-07-29 00:25:34 +03:00
IluaAir
b9a9e4e094 current or admin dep created 2025-07-27 13:54:39 +03:00
IluaAir
9f105c32c6 fix router request user data 2025-07-27 13:06:00 +03:00
IluaAir
7c334491c3 add user get, access token add id 2025-07-27 12:55:58 +03:00
IluaAir
d3eba77444 add get users 2025-07-27 12:41:35 +03:00
IluaAir
36dad7b441 add admin check 2025-07-27 12:30:06 +03:00
IluaAir
1a495e28c5 update .gitignore 2025-07-21 12:02:41 +03:00
IluaAir
4789da1b70 create active user 2025-07-20 12:37:57 +03:00
IluaAir
8ec8639848 create cur user 2025-07-20 12:34:18 +03:00
IluaAir
d639abfbc5 service auth fix 2025-07-19 12:10:00 +03:00
IluaAir
d530412805 ready login endpoint 2025-07-14 12:41:03 +03:00
IluaAir
19a9b36173 add OAuth2PasswordRequestForm 2025-07-14 12:08:50 +03:00
IluaAir
dd6f06cd12 fix auth manager 2025-07-14 11:58:12 +03:00
IluaAir
a0f9aaeaea add endpoint /login 2025-07-13 11:59:03 +03:00
IluaAir
e5dec473cb fix alembic env path 2025-07-13 11:48:25 +03:00
IluaAir
f3030593da add User return schema 2025-07-13 11:41:58 +03:00
IluaAir
d27aafb4f6 ensure password 2025-07-12 13:10:22 +03:00
IluaAir
18b5a573c1 add password validation example 2025-07-11 21:54:01 +03:00
IluaAir
b12bb65268 create interface uow update services to uow 2025-07-11 21:48:48 +03:00
IluaAir
61512f70d4 add singup endpoint 2025-07-07 12:01:02 +03:00
IluaAir
31879ddf30 fix auth 2025-07-06 12:20:41 +03:00
IluaAir
b6ec15af29 fix pydantic schema 2025-07-06 12:15:01 +03:00
IluaAir
459969f2be create AuthManager 2025-07-06 12:10:58 +03:00
IluaAir
4de11f4149 base logic 2025-07-06 11:58:45 +03:00
IluaAir
817a799ef5 update database settings 2025-07-06 01:09:18 +03:00
IluaAir
ef38c45f14 update settings 2025-07-06 01:07:25 +03:00
IluaAir
1a45e2c284 fix alembic init 2025-07-06 00:07:35 +03:00
IluaAir
231caf7c6e update alembic 2025-07-06 00:03:55 +03:00
90 changed files with 8018 additions and 596 deletions

7
.gitignore vendored
View File

@@ -1,4 +1,7 @@
/.venv/
/.idea
/src/db/*.db
.env
__pycache__/
*.db
.DS_Store
.env
.vscode/

View File

@@ -23,6 +23,7 @@ ___
- 🔥 Установка приоритетов и дедлайнов
- 🔔 Напоминания и уведомления
- ⚙️ Асинхронная обработка задач
- 📄 Пагинация и фильтрация задач с поддержкой limit/offset
- 💡 Современный и интуитивно понятный интерфейс
---

View File

@@ -9,6 +9,10 @@ ___
- `GET /users` — Получить список всех пользователей
- `GET /users/{user_id}` — Получить конкретного пользователя
- `GET /users/{user_id}/tasks` — Получить задачи пользователя
- **Query параметры:**
- `limit` (int, опционально) — Максимальное количество задач для возврата
- `offset` (int, опционально, по умолчанию 0) — Количество задач для пропуска
- **Пример:** `GET /users/1/tasks?limit=10&offset=20`
- `POST /users` — Создать нового пользователя
- `PUT /users/{user_id}` — Обновить данные пользователя
- `PATCH /users/{user_id}` — Частично обновить данные пользователя

467
poetry.lock generated
View File

@@ -74,209 +74,40 @@ test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "except
trio = ["trio (>=0.26.1)"]
[[package]]
name = "argon2-cffi"
version = "23.1.0"
description = "Argon2 for Python"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"},
{file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"},
]
[package.dependencies]
argon2-cffi-bindings = "*"
[package.extras]
dev = ["argon2-cffi[tests,typing]", "tox (>4)"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"]
tests = ["hypothesis", "pytest"]
typing = ["mypy"]
[[package]]
name = "argon2-cffi-bindings"
version = "21.2.0"
description = "Low-level CFFI bindings for Argon2"
name = "bcrypt"
version = "4.0.1"
description = "Modern password hashing for your software and your servers"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"},
{file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"},
{file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"},
{file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"},
{file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"},
]
[package.dependencies]
cffi = ">=1.0.1"
[package.extras]
dev = ["cogapp", "pre-commit", "pytest", "wheel"]
tests = ["pytest"]
[[package]]
name = "bcrypt"
version = "4.3.0"
description = "Modern password hashing for your software and your servers"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"},
{file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"},
{file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"},
{file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"},
{file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"},
{file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"},
{file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"},
{file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"},
{file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"},
{file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"},
{file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"},
{file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"},
{file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"},
{file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"},
{file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"},
{file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"},
{file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"},
{file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"},
{file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"},
{file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"},
{file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"},
{file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"},
{file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"},
{file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"},
{file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"},
{file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"},
{file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"},
{file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"},
{file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"},
{file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"},
{file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"},
{file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"},
{file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"},
{file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"},
{file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"},
{file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"},
{file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"},
{file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"},
{file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"},
{file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"},
{file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"},
{file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"},
{file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"},
{file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"},
{file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"},
{file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"},
{file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"},
{file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"},
{file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"},
{file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"},
{file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"},
{file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"},
{file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"},
{file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"},
]
[package.extras]
tests = ["pytest (>=3.2.1,!=3.3.0)"]
typecheck = ["mypy"]
[[package]]
name = "cffi"
version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main"]
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.2.1"
@@ -298,72 +129,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\""
groups = ["main", "test"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "cryptography"
version = "45.0.3"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main"]
files = [
{file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"},
{file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"},
{file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"},
{file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"},
{file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"},
{file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"},
{file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"},
{file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"},
{file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"},
{file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"},
{file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"},
]
[package.dependencies]
cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""]
pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==45.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
markers = {main = "platform_system == \"Windows\"", test = "sys_platform == \"win32\""}
[[package]]
name = "dnspython"
@@ -404,14 +175,14 @@ idna = ">=2.0.0"
[[package]]
name = "fastapi"
version = "0.115.12"
version = "0.115.14"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"},
{file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"},
{file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"},
{file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"},
]
[package.dependencies]
@@ -423,49 +194,6 @@ typing-extensions = ">=4.8.0"
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "fastapi-users"
version = "14.0.1"
description = "Ready-to-use and customizable users management for FastAPI"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "fastapi_users-14.0.1-py3-none-any.whl", hash = "sha256:074df59676dccf79412d2880bdcb661ab1fabc2ecec1f043b4e6a23be97ed9e1"},
{file = "fastapi_users-14.0.1.tar.gz", hash = "sha256:8c032b3a75c6fb2b1f5eab8ffce5321176e9916efe1fe93e7c15ee55f0b02236"},
]
[package.dependencies]
email-validator = ">=1.1.0,<2.3"
fastapi = ">=0.65.2"
fastapi-users-db-sqlalchemy = {version = ">=7.0.0", optional = true, markers = "extra == \"sqlalchemy\""}
makefun = ">=1.11.2,<2.0.0"
pwdlib = {version = "0.2.1", extras = ["argon2", "bcrypt"]}
pyjwt = {version = "2.10.1", extras = ["crypto"]}
python-multipart = "0.0.20"
[package.extras]
beanie = ["fastapi-users-db-beanie (>=4.0.0)"]
oauth = ["httpx-oauth (>=0.13)"]
redis = ["redis (>=4.3.3,<6.0.0)"]
sqlalchemy = ["fastapi-users-db-sqlalchemy (>=7.0.0)"]
[[package]]
name = "fastapi-users-db-sqlalchemy"
version = "7.0.0"
description = "FastAPI Users database adapter for SQLAlchemy"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "fastapi_users_db_sqlalchemy-7.0.0-py3-none-any.whl", hash = "sha256:5fceac018e7cfa69efc70834dd3035b3de7988eb4274154a0dbe8b14f5aa001e"},
{file = "fastapi_users_db_sqlalchemy-7.0.0.tar.gz", hash = "sha256:6823eeedf8a92f819276a2b2210ef1dcfd71fe8b6e37f7b4da8d1c60e3dfd595"},
]
[package.dependencies]
fastapi-users = ">=10.0.0"
sqlalchemy = {version = ">=2.0.0,<2.1.0", extras = ["asyncio"]}
[[package]]
name = "greenlet"
version = "3.2.2"
@@ -563,15 +291,15 @@ files = [
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "makefun"
version = "1.16.0"
description = "Small library to dynamically create python functions."
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = "*"
groups = ["main"]
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "makefun-1.16.0-py2.py3-none-any.whl", hash = "sha256:43baa4c3e7ae2b17de9ceac20b669e9a67ceeadff31581007cca20a07bbe42c4"},
{file = "makefun-1.16.0.tar.gz", hash = "sha256:e14601831570bff1f6d7e68828bcd30d2f5856f24bad5de0ccb22921ceebc947"},
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
@@ -666,47 +394,61 @@ files = [
]
[[package]]
name = "pwdlib"
version = "0.2.1"
description = "Modern password hashing for Python"
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["test"]
files = [
{file = "pwdlib-0.2.1-py3-none-any.whl", hash = "sha256:1823dc6f22eae472b540e889ecf57fd424051d6a4023ec0bcf7f0de2d9d7ef8c"},
{file = "pwdlib-0.2.1.tar.gz", hash = "sha256:9a1d8a8fa09a2f7ebf208265e55d7d008103cbdc82b9e4902ffdd1ade91add5e"},
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[package.dependencies]
argon2-cffi = {version = ">=23.1.0,<24", optional = true, markers = "extra == \"argon2\""}
bcrypt = {version = ">=4.1.2,<5", optional = true, markers = "extra == \"bcrypt\""}
[package.extras]
argon2 = ["argon2-cffi (>=23.1.0,<24)"]
bcrypt = ["bcrypt (>=4.1.2,<5)"]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
name = "passlib"
version = "1.7.4"
description = "comprehensive password hashing framework supporting over 30 schemes"
optional = false
python-versions = ">=3.8"
python-versions = "*"
groups = ["main"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
]
[package.extras]
argon2 = ["argon2-cffi (>=18.2.0)"]
bcrypt = ["bcrypt (>=3.1.0)"]
build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"]
totp = ["cryptography"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pydantic"
version = "2.11.5"
version = "2.11.7"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7"},
{file = "pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a"},
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
]
[package.dependencies]
@@ -855,6 +597,21 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyjwt"
version = "2.10.1"
@@ -867,15 +624,53 @@ files = [
{file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
]
[package.dependencies]
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pytest"
version = "8.4.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"},
{file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"},
{file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"},
]
[package.dependencies]
pytest = ">=8.2,<9"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "python-dotenv"
version = "1.1.0"
@@ -1011,7 +806,7 @@ files = [
]
[package.dependencies]
greenlet = {version = ">=1", optional = true, markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""}
greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
typing-extensions = ">=4.6.0"
[package.extras]
@@ -1106,4 +901,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)
[metadata]
lock-version = "2.1"
python-versions = ">=3.12"
content-hash = "40669246978dc70b09cd8fef22d81e8140d3474feeba1023d8178124914ebcf8"
content-hash = "cc2947613c2711ad32ccfa1b8f04a0d8bad6043c6e52bffaff12761ec76cc805"

View File

@@ -9,18 +9,89 @@ license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi (>=0.115.12,<0.116.0)",
"sqlalchemy (>=2.0.41,<3.0.0)",
"fastapi-users[sqlalchemy] (>=14.0.1,<15.0.0)",
"uvicorn (>=0.34.2,<0.35.0)",
"aiosqlite (>=0.21.0,<0.22.0)",
"pydantic-settings (>=2.9.1,<3.0.0)",
"alembic (>=1.16.1,<2.0.0)",
"ruff (>=0.11.12,<0.12.0)",
"greenlet (>=3.2.2,<4.0.0)"
"greenlet (>=3.2.2,<4.0.0)",
"pydantic (>=2.11.7,<3.0.0)",
"idna (>=3.10,<4.0)",
"fastapi (>=0.115.14,<0.116.0)",
"pyjwt (>=2.10.1,<3.0.0)",
"passlib (>=1.7.4,<2.0.0)",
"email-validator (>=2.2.0,<3.0.0)",
"bcrypt (==4.0.1)",
"python-multipart (>=0.0.20,<0.0.21)",
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
# Exclude commonly ignored directories and files.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
# Set the maximum line length for both linting and formatting.
line-length = 88
# Assume Python 3.9 for compatibility checks.
target-version = "py312"
# Enable preview features for early access to new rules and formatting changes.
preview = true
[tool.ruff.lint]
# Select specific rule groups to enable.
# 'E' for pycodestyle, 'F' for Pyflakes, 'I' for isort, 'B' for flake8-bugbear.
select = [
"E",
"F",
"I",
"B",
"UP", # pyupgrade
"SIM", # flake8-simplify
]
# Ignore specific rules within the selected groups.
ignore = [
"UP",
"B903",
"B904",
"E501",
"B008",
]
[tool.ruff.format]
# Enable Ruff's formatter.
docstring-code-format = true
[tool.poetry.group.test.dependencies]
pytest = "^8.4.1"
pytest-asyncio = "^1.1.0"

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = . src
asyncio_mode = auto

View File

@@ -1,8 +1,8 @@
from fastapi import APIRouter
from src.api.users import router as users_router
from src.api.tasks import router as tasks_router
router = APIRouter()
from src.api.v1 import router as v1_router
from src.core.settings import settings
router.include_router(router=users_router)
router.include_router(router=tasks_router)
router = APIRouter(prefix=settings.api.prefix)
router.include_router(router=v1_router)

View File

@@ -0,0 +1,15 @@
from typing import Annotated, AsyncGenerator
from fastapi import Depends
from src.core.database import async_session_maker
from src.core.db_manager import DBManager
from src.core.interfaces import IUOWDB
async def get_db() -> AsyncGenerator[IUOWDB, None]:
async with DBManager(async_session_maker) as db:
yield db
sessionDep = Annotated[IUOWDB, Depends(get_db)]

View File

@@ -1,38 +0,0 @@
from typing import Annotated, AsyncGenerator
from fastapi import Depends
from fastapi_users.authentication.strategy import AccessTokenDatabase
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.database import async_session_maker
from src.models import UsersORM, AccessToken
from src.utils.user_manager import UserManager
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker as db:
yield db
DBDep = Annotated[AsyncSession, Depends(get_db)]
async def get_users_db(session: DBDep):
yield SQLAlchemyUserDatabase(session, UsersORM)
async def get_access_token_db(
session: DBDep,
):
yield SQLAlchemyAccessTokenDatabase(session, AccessToken)
ATDep = Annotated[AccessTokenDatabase[AccessToken], Depends(get_access_token_db)]
async def get_user_manager(
users_db: Annotated[SQLAlchemyUserDatabase, Depends(get_users_db)],
):
yield UserManager(users_db)

View File

@@ -1,21 +0,0 @@
from fastapi import Depends
from fastapi_users.authentication import AuthenticationBackend
from fastapi_users.authentication.strategy import DatabaseStrategy, AccessTokenDatabase
from src.api.dependacies.dependancies import get_access_token_db
from src.api.dependacies.transport import bearer_transport
from src.models import AccessToken
from src.settings import settings
def get_database_strategy(
access_token_db: AccessTokenDatabase[AccessToken] = Depends(get_access_token_db),
) -> DatabaseStrategy:
return DatabaseStrategy(access_token_db, lifetime_seconds=settings.lifetime)
auth_backend = AuthenticationBackend(
name="database",
transport=bearer_transport,
get_strategy=get_database_strategy,
)

View File

@@ -0,0 +1,41 @@
from datetime import date
from typing import Annotated
from fastapi import Depends, Query
from fastapi.exceptions import HTTPException
from pydantic import BaseModel, model_validator
from src.schemas.tasks import PriorityEnum, StatusEnum
class Date(BaseModel):
date_from: date | None = Query(default=None)
date_to: date | None = Query(default=None)
@model_validator(mode="after")
def check_dates(self):
if self.date_from and self.date_to and self.date_to < self.date_from:
raise HTTPException(
status_code=422, detail="date_to cannot be less than date_from"
)
return self
class Page(BaseModel):
limit: int = Query(default=30, ge=1, le=100)
page: int | None = Query(default=1, ge=1)
class Status(BaseModel):
status: StatusEnum | None = Query(default=None)
class Priority(BaseModel):
priority: PriorityEnum | None = Query(default=None)
class Filters(Date, Status, Priority, Page):
pass
FilterDep = Annotated[Filters, Depends()]

View File

@@ -1,4 +0,0 @@
from fastapi_users.authentication import BearerTransport
bearer_transport = BearerTransport(tokenUrl="auth/login")

View File

@@ -0,0 +1,63 @@
from typing import Annotated
from fastapi import Depends, HTTPException
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
OAuth2PasswordBearer,
)
from jwt import InvalidTokenError
from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import TokenData
http_bearer = HTTPBearer(auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login")
AccessTokenDep = Annotated[HTTPAuthorizationCredentials, Depends(http_bearer)]
async def get_current_user(
token: AccessTokenDep, verify_exp: bool = True, check_active: bool = False
):
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = AuthManager.decode_access_token(token.credentials, verify_exp)
if payload is None:
raise credentials_exception
user = TokenData(**payload)
if check_active and not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
except (InvalidTokenError, AttributeError):
raise credentials_exception
return user
async def get_current_user_basic(token: AccessTokenDep):
return await get_current_user(token, verify_exp=True, check_active=False)
async def get_current_active_user(token: AccessTokenDep):
return await get_current_user(token, verify_exp=True, check_active=True)
async def get_current_user_for_refresh(token: AccessTokenDep):
return await get_current_user(token, verify_exp=False, check_active=True)
async def get_current_user_for_admin(token: AccessTokenDep):
admin = await get_current_user(token, verify_exp=True, check_active=True)
if not admin.is_superuser:
raise HTTPException(status_code=403, detail="Admin access required")
return admin
CurrentUser = Annotated[TokenData, Depends(get_current_user_basic)]
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)]
AdminUser = Annotated[TokenData, Depends(get_current_user_for_admin)]

View File

@@ -1,27 +0,0 @@
from fastapi import APIRouter
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
async def get_tasks(): ...
@router.get("/{id}")
async def get_task_id(id: int): ...
@router.post("/")
async def post_task(): ...
@router.put("/{id}")
async def put_task(id: int): ...
@router.patch("/{id}")
async def patch_task(id: int): ...
@router.delete("/{id}")
async def delete_task(id: int): ...

View File

@@ -1,3 +0,0 @@
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["Users"])

12
src/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from src.api.v1.auth import router as auth_router
from src.api.v1.tasks import router as tasks_router
from src.api.v1.users import router as users_router
from src.core.settings import settings
router = APIRouter(prefix=settings.api.v1.prefix)
router.include_router(router=auth_router)
router.include_router(router=users_router)
router.include_router(router=tasks_router)

86
src/api/v1/auth.py Normal file
View File

@@ -0,0 +1,86 @@
from typing import Annotated
from fastapi import APIRouter, Body, Cookie, Depends, Form, HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser, RefreshUser, http_bearer
from src.core.settings import settings
from src.schemas.auth import Token
from src.schemas.users import UserRequestADD
from src.services.auth import AuthService
from src.services.users import UserService
router = APIRouter(
prefix=settings.api.v1.auth, tags=["Auth"], dependencies=[Depends(http_bearer)]
)
@router.post(path="/signup")
async def registration(session: sessionDep, credential: UserRequestADD):
auth = await AuthService(session).registration(credential)
return auth
@router.post(path="/login", response_model=Token)
async def login(
session: sessionDep,
credential: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response,
fingerprint: str = Form(min_length=5),
):
result = await AuthService(session).login(
credential.username, credential.password, fingerprint=fingerprint
)
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.post(path="/refresh", response_model=Token)
async def refresh(
session: sessionDep,
current_user: RefreshUser,
response: Response,
fingerprint: Annotated[str, Body(embed=True)],
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
result = await AuthService(session).refresh_tokens(
refresh_token, current_user, fingerprint
)
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.get("/me")
async def get_me(session: sessionDep, user: ActiveUser):
cur_user = await UserService(session).get_user_by_filter_or_raise(id=user.id)
return cur_user
@router.post(path="/logout")
async def logout(
session: sessionDep,
response: Response,
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
await AuthService(session).delete_token(token=refresh_token)
response.delete_cookie(key="refresh_token")
return {"status": "ok"}

59
src/api/v1/tasks.py Normal file
View File

@@ -0,0 +1,59 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser
from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest
from src.services.tasks import TaskService
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/{id}")
async def get_task_id(session: sessionDep, id: int, user: ActiveUser):
task = await TaskService(session).get_task(id)
if task.user_id != user.id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
return task
@router.post("/")
async def post_task(
task_data: Annotated[TaskADDRequest, Depends()],
session: sessionDep,
user: ActiveUser,
):
result = await TaskService(session).create_task(
user_id=user.id, task_data=task_data
)
return result
@router.patch("/{id}")
async def patch_task(
session: sessionDep,
id: int,
user: ActiveUser,
task_data: TaskPATCHRequest = Body(),
):
if user.is_superuser is False:
task = await TaskService(session).get_task(id)
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
updated_task = await TaskService(session).update_task(id, task_data)
return updated_task
@router.delete("/{id}")
async def delete_task(
session: sessionDep,
id: int,
user: ActiveUser,
):
if user.is_superuser is False:
task = await TaskService(session).get_task(id)
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await TaskService(session).delete_task(id)
return {"message": "Task deleted successfully"}

56
src/api/v1/users.py Normal file
View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, Body, HTTPException
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.task_dep import FilterDep
from src.api.dependacies.user_dep import (
ActiveUser,
AdminUser,
)
from src.core.settings import settings
from src.schemas.users import UserUpdate
from src.services.users import UserService
router = APIRouter(prefix=settings.api.v1.users, tags=["Users"])
@router.get("/")
async def get_all_users(session: sessionDep, _: AdminUser):
users = await UserService(session).get_all_users()
return users
@router.get("/{id}")
async def get_user_by_id(session: sessionDep, id: int, _: AdminUser):
user = await UserService(session).get_user_by_filter_or_raise(id=id)
return user
@router.get("/{id}/tasks")
async def get_user_tasks(
session: sessionDep, id: int, user: ActiveUser, filters: FilterDep
):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
tasks = await UserService(session).get_user_with_tasks(id, **filters.model_dump())
return tasks.tasks
@router.patch("/{id}")
async def patch_user(
session: sessionDep,
id: int,
user: ActiveUser,
user_update: UserUpdate = Body(),
):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
updated_user = await UserService(session).update_user(
id=id, update_data=user_update
)
return updated_user
@router.delete("/{id}")
async def delete_user(session: sessionDep, id: int, user: AdminUser):
await UserService(session).delete_user(id)
return {"message": "User deleted successfully"}

0
src/core/__init__.py Normal file
View File

50
src/core/auth_manager.py Normal file
View File

@@ -0,0 +1,50 @@
import secrets
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
from src.core.settings import settings
class AuthManager:
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@classmethod
def verify_password(cls, plain_password, hashed_password):
return cls.pwd_context.verify(plain_password, hashed_password)
@classmethod
def get_password_hash(cls, password):
return cls.pwd_context.hash(password)
@classmethod
def create_access_token(cls, data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token.expire_minutes
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode,
settings.access_token.secret_key,
algorithm=settings.access_token.algorithm,
)
return encoded_jwt
@classmethod
def create_refresh_token(cls) -> str:
token_hash = secrets.token_urlsafe(32)
return token_hash
@classmethod
def decode_access_token(cls, token: str, verify_exp: bool = True) -> dict:
return jwt.decode(
token,
settings.access_token.secret_key,
algorithms=[settings.access_token.algorithm],
options={"verify_exp": verify_exp},
)

26
src/core/database.py Normal file
View File

@@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, event, func
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from src.core.settings import settings
engine = create_async_engine(settings.db.url, echo=settings.db.echo)
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
if "sqlite" in settings.db.url:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
class Base(DeclarativeBase):
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)

26
src/core/db_manager.py Normal file
View File

@@ -0,0 +1,26 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo
class DBManager:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
self.session_factory = session_factory
async def __aenter__(self) -> "DBManager":
self.session: AsyncSession = self.session_factory()
self.user = UsersRepo(self.session)
self.task = TasksRepo(self.session)
self.auth = AuthRepo(self.session)
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
await self.session.rollback()
await self.session.close()
async def commit(self) -> None:
await self.session.commit()

21
src/core/interfaces.py Normal file
View File

@@ -0,0 +1,21 @@
from typing import TYPE_CHECKING, Any, Protocol
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo
class IUOWDB(Protocol):
session: AsyncSession
user: "UsersRepo"
task: "TasksRepo"
auth: "AuthRepo"
async def __aenter__(self) -> "IUOWDB": ...
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ...
async def commit(self) -> None: ...

55
src/core/settings.py Normal file
View File

@@ -0,0 +1,55 @@
from pathlib import Path
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).parent.parent
DB_PATH = BASE_DIR / "db/taskncoffee.db"
class ApiV1Prefix(BaseModel):
prefix: str = "/v1"
auth: str = "/auth"
users: str = "/users"
@property
def login_url(self) -> str:
return f"{self.prefix}{self.auth}"
class ApiPrefix(BaseModel):
prefix: str = "/api"
v1: ApiV1Prefix = ApiV1Prefix()
@property
def v1_login_url(self) -> str:
return f"{self.prefix}{self.v1.login_url}"
class DbSettings(BaseModel):
url: str = f"sqlite+aiosqlite:///{DB_PATH}"
echo: bool = False
class AccessToken(BaseModel):
expire_minutes: int
secret_key: str
algorithm: str = "HS256"
token_type: str = "bearer" # noqa: S105
class RefreshToken(BaseModel):
expire_days: int
class Settings(BaseSettings):
api: ApiPrefix = ApiPrefix()
db: DbSettings = DbSettings()
access_token: AccessToken
refresh_token: RefreshToken
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="__"
)
settings = Settings() # type: ignore

View File

@@ -1,15 +0,0 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, func
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
engine = create_async_engine("sqlite+aiosqlite:///src/db/taskncoffee.db", echo=True)
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
class Base(DeclarativeBase):
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)

View File

@@ -1,10 +1,14 @@
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI
sys.path.append(str(Path(__file__).parent.parent))
from src.api import router
app = FastAPI()
app = FastAPI(title="Task&Coffee")
app.include_router(router=router)
if __name__ == "__main__":
uvicorn.run("src.main:app", port=5000, log_level="info", reload=True)
uvicorn.run("src.main:app", port=8000, log_level="info", reload=True)

View File

@@ -1,11 +1,9 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import engine_from_config, event, pool
from src.db.database import Base
from src.core.database import Base
from src.models import * # noqa
# this is the Alembic Config object, which provides
@@ -67,6 +65,14 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
# Enable foreign keys for SQLite in migrations
@event.listens_for(connectable, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
print("⚙️ Enabling PRAGMA foreign_keys=ON for Alembic")
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)

View File

@@ -1,44 +0,0 @@
"""access token
Revision ID: bc0bdd74718c
Revises: 932121e6b220
Create Date: 2025-06-22 12:11:19.223212
"""
from typing import Sequence, Union
import fastapi_users_db_sqlalchemy
from alembic import op
import sqlalchemy as sa
revision: str = "bc0bdd74718c"
down_revision: Union[str, None] = "932121e6b220"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"accesstoken",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=43), nullable=False),
sa.Column(
"created_at",
fastapi_users_db_sqlalchemy.generics.TIMESTAMPAware(timezone=True),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="cascade"),
sa.PrimaryKeyConstraint("token"),
)
op.create_index(
op.f("ix_accesstoken_created_at"), "accesstoken", ["created_at"], unique=False
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_accesstoken_created_at"), table_name="accesstoken")
op.drop_table("accesstoken")

View File

@@ -1,18 +1,18 @@
"""init
Revision ID: 932121e6b220
Revision ID: a2fdd0ec4a96
Revises:
Create Date: 2025-06-22 11:52:49.691545
Create Date: 2025-07-06 00:02:09.254907
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from alembic import op
revision: str = "932121e6b220"
# revision identifiers, used by Alembic.
revision: str = "a2fdd0ec4a96"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -20,17 +20,17 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=30), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("telegram_id", sa.BigInteger(), nullable=True),
sa.Column("avatar_path", sa.String(length=255), nullable=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("hashed_password", sa.String(length=1024), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column("is_verified", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
@@ -38,8 +38,8 @@ def upgrade() -> None:
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
op.create_table(
"tasks",
@@ -77,11 +77,13 @@ def upgrade() -> None:
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("tasks")
op.drop_index(op.f("ix_users_username"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
# ### end Alembic commands ###

View File

@@ -0,0 +1,58 @@
"""add_cascade_delete_to_tasks
Revision ID: 197b195208e8
Revises: a2fdd0ec4a96
Create Date: 2025-08-06 23:41:56.778423
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "197b195208e8"
down_revision: Union[str, None] = "a2fdd0ec4a96"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
"""Upgrade schema."""
op.execute("PRAGMA foreign_keys=ON")
with op.batch_alter_table("tasks", schema=None) as batch_op:
connection = op.get_bind()
inspector = sa.inspect(connection)
foreign_keys = inspector.get_foreign_keys("tasks")
constraint_name = None
for fk in foreign_keys:
if "user_id" in fk["constrained_columns"]:
constraint_name = fk["name"]
break
if constraint_name:
try: # noqa: SIM105
batch_op.drop_constraint(constraint_name, type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"], ondelete="CASCADE"
)
def downgrade():
"""Downgrade schema."""
with op.batch_alter_table("tasks", schema=None) as batch_op:
try: # noqa: SIM105
batch_op.drop_constraint("fk_tasks_user_id_users", type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"]
)

View File

@@ -0,0 +1,52 @@
"""fix_duplicate_foreign_keys
Revision ID: 4b0f3ea2fd26
Revises: 197b195208e8
Create Date: 2025-08-06 23:54:24.308488
"""
from typing import Sequence, Union # noqa: UP035
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4b0f3ea2fd26"
down_revision: Union[str, None] = "197b195208e8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
"""Upgrade schema."""
op.execute("PRAGMA foreign_keys=ON")
with op.batch_alter_table("tasks", schema=None) as batch_op:
connection = op.get_bind()
inspector = sa.inspect(connection)
foreign_keys = inspector.get_foreign_keys("tasks")
for fk in foreign_keys:
if "user_id" in fk["constrained_columns"]:
try: # noqa: SIM105
batch_op.drop_constraint(fk["name"], type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"], ondelete="CASCADE"
)
def downgrade():
"""Downgrade schema."""
with op.batch_alter_table("tasks", schema=None) as batch_op:
try: # noqa: SIM105
batch_op.drop_constraint("fk_tasks_user_id_users", type_="foreignkey")
except: # noqa E722
pass
batch_op.create_foreign_key(
"fk_tasks_user_id_users", "users", ["user_id"], ["id"]
)

View File

@@ -0,0 +1,48 @@
"""add refresh token
Revision ID: b879d3502c37
Revises: 4b0f3ea2fd26
Create Date: 2025-09-08 14:56:01.439089
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b879d3502c37"
down_revision: Union[str, None] = "4b0f3ea2fd26"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("refresh_tokens")
# ### end Alembic commands ###

View File

@@ -0,0 +1,45 @@
"""expire and fingerprintjs token
Revision ID: 5821f37941a8
Revises: b879d3502c37
Create Date: 2025-09-21 20:16:48.289050
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5821f37941a8"
down_revision: Union[str, None] = "b879d3502c37"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"refresh_tokens",
sa.Column("fingerprint", sa.String(length=255), nullable=False),
)
op.add_column(
"refresh_tokens",
sa.Column(
"expired_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(datetime('now', '+7 days'))"),
nullable=False,
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("refresh_tokens", "expired_at")
op.drop_column("refresh_tokens", "fingerprint")
# ### end Alembic commands ###

View File

@@ -1,9 +1,5 @@
from src.models.token import AccessToken
from src.models.users import UsersORM
from src.models.tasks import TasksORM
from src.models.tokens import RefreshTokensORM
from src.models.users import UsersORM
__all__ = [
"UsersORM",
"TasksORM",
"AccessToken",
]
__all__ = ["UsersORM", "TasksORM", "RefreshTokensORM"]

View File

@@ -1,10 +1,10 @@
from datetime import date
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, Text, Date, Enum, String
from sqlalchemy import Date, Enum, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
from src.core.database import Base
if TYPE_CHECKING:
from src.models.users import UsersORM
@@ -16,7 +16,7 @@ priority_enum = Enum("low", "medium", "high", "critical", name="priority_enum")
class TasksORM(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(String(100))
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)

View File

@@ -1,13 +0,0 @@
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyBaseAccessTokenTable
from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, declared_attr
from src.db.database import Base
class AccessToken(SQLAlchemyBaseAccessTokenTable[int], Base):
@declared_attr
def user_id(cls) -> Mapped[int]:
return mapped_column(
Integer, ForeignKey("users.id", ondelete="cascade"), nullable=False
)

21
src/models/tokens.py Normal file
View File

@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from src.core.database import Base
from src.core.settings import settings
class RefreshTokensORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
token: Mapped[str] = mapped_column(String(255), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
expired_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=text(
f"datetime('now', '+{settings.refresh_token.expire_days} days')"
),
)

View File

@@ -1,22 +1,28 @@
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable
from sqlalchemy import String, BigInteger, Integer
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
from src.core.database import Base
if TYPE_CHECKING:
from src.models.tasks import TasksORM
class UsersORM(SQLAlchemyBaseUserTable[int], Base):
class UsersORM(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[Optional[str]] = mapped_column(
username: Mapped[str] = mapped_column(
String(30), nullable=False, unique=True, index=True
)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(
String(255), unique=True, nullable=True
)
telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
avatar_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
tasks: Mapped[list["TasksORM"]] = relationship(back_populates="user")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tasks: Mapped[list["TasksORM"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)

View File

6
src/repository/auth.py Normal file
View File

@@ -0,0 +1,6 @@
from src.models.tokens import RefreshTokensORM
from src.repository.base import BaseRepo
class AuthRepo(BaseRepo[RefreshTokensORM]):
model: type[RefreshTokensORM] = RefreshTokensORM

52
src/repository/base.py Normal file
View File

@@ -0,0 +1,52 @@
from typing import Any, Generic, Mapping, Sequence, Type, TypeVar
from sqlalchemy import delete, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
ModelType = TypeVar("ModelType")
class BaseRepo(Generic[ModelType]):
model: Type[ModelType]
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def get_filtered(
self, *filters: Any, **filter_by: Any
) -> Sequence[ModelType]:
query = select(self.model).filter(*filters).filter_by(**filter_by)
result = await self.session.execute(query)
models = result.scalars().all()
return models
async def create_one(self, data: Mapping[str, Any]) -> ModelType:
statement = insert(self.model).values(data).returning(self.model)
result = await self.session.execute(statement)
obj: ModelType = result.scalar_one()
return obj
async def create_bulk(self, data: Sequence[Mapping[str, Any]]) -> list[ModelType]:
result = [await self.create_one(item) for item in data]
return result
async def get_one_or_none(self, **filter_by: Any) -> ModelType | None:
query = select(self.model).filter_by(**filter_by)
result = await self.session.execute(query)
model_obj: ModelType | None = result.scalars().one_or_none()
return model_obj
async def get_all(self, *args: Any, **kwargs: Any) -> Sequence[ModelType]:
result: Sequence[ModelType] = await self.get_filtered(*args, **kwargs)
return result
async def delete_one(self, **filter_by) -> None:
await self.session.execute(delete(self.model).filter_by(**filter_by))
async def update_one(self, data: dict[str, Any], **filter_by: Any) -> ModelType:
stmt = (
update(self.model).filter_by(**filter_by).values(data).returning(self.model)
)
result = await self.session.execute(stmt)
model = result.scalar_one()
return model

6
src/repository/tasks.py Normal file
View File

@@ -0,0 +1,6 @@
from src.models.tasks import TasksORM
from src.repository.base import BaseRepo
class TasksRepo(BaseRepo[TasksORM]):
model: type[TasksORM] = TasksORM

83
src/repository/users.py Normal file
View File

@@ -0,0 +1,83 @@
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from src.models import UsersORM
from src.models.tasks import TasksORM
from src.repository.base import BaseRepo
class UsersRepo(BaseRepo[UsersORM]):
model: type[UsersORM] = UsersORM
async def get_one_with_load(
self,
user_id: int,
status: str | None = None,
priority: str | None = None,
tasks_limit: int | None = None,
tasks_offset: int | None = 0,
date_to: date | None = None,
date_from: date | None = None,
) -> UsersORM | None:
filters_sq: dict = {"user_id": user_id}
if status:
filters_sq["status"] = status
if priority:
filters_sq["priority"] = priority
tasks_subquery = self._tasks_subquary(
date_from=date_from, date_to=date_to, **filters_sq
)
if tasks_limit is not None:
tasks_subquery = tasks_subquery.limit(tasks_limit)
if tasks_offset is not None and tasks_offset > 0:
tasks_subquery = tasks_subquery.offset(tasks_offset)
query = (
select(self.model)
.where(self.model.id == user_id)
.options(
selectinload(
self.model.tasks.and_(TasksORM.id.in_(tasks_subquery))
).load_only(
TasksORM.id,
TasksORM.title,
TasksORM.due_date,
TasksORM.priority,
TasksORM.status,
)
)
)
result = await self.session.execute(query)
obj = result.scalar_one_or_none()
if obj and obj.tasks:
obj.tasks.sort(
key=lambda task: (
task.due_date or date.max,
-self._priority(task.priority),
)
)
return obj
async def get_tasks_count(
self, date_from: date | None, date_to: date | None, **filter_by
) -> int:
subq = self._tasks_subquary(date_from, date_to, **filter_by).subquery()
stmt = select(func.count()).select_from(subq)
result = await self.session.execute(stmt)
return result.scalar_one()
def _priority(self, priority: str):
priority_map = {"low": 1, "medium": 2, "high": 3, "critical": 4}
return priority_map.get(priority, 0)
def _tasks_subquary(
self, date_from: date | None, date_to: date | None, **filter_by
):
subq = select(TasksORM.id).filter_by(**filter_by)
if date_from is not None:
subq = subq.filter(TasksORM.due_date >= date_from)
if date_to is not None:
subq = subq.filter(TasksORM.due_date <= date_to)
return subq

16
src/schemas/auth.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel, ConfigDict, Field
class Token(BaseModel):
access_token: str
token_type: str
model_config = ConfigDict(extra="ignore")
class TokenData(BaseModel):
id: int
sub: str = Field(alias="username")
is_superuser: bool
is_active: bool
model_config = ConfigDict(populate_by_name=True)

View File

@@ -0,0 +1,52 @@
from datetime import date
from enum import Enum
from pydantic import BaseModel, ConfigDict
class PriorityEnum(str, Enum):
low = "low"
medium = "medium"
high = "high"
critical = "critical"
class StatusEnum(str, Enum):
open = "open"
closed = "closed"
in_progress = "in_progress"
todo = "todo"
class TaskShort(BaseModel):
title: str
due_date: date | None = None
priority: PriorityEnum = PriorityEnum.medium
model_config = ConfigDict(from_attributes=True)
class TaskWithId(TaskShort):
id: int
status: str
class TaskADDRequest(TaskShort):
description: str | None = None
class TaskPATCHRequest(BaseModel):
title: str | None = None
description: str | None = None
due_date: date | None = None
status: StatusEnum | None = None
priority: PriorityEnum | None = None
time_spent: int | None = None
class Task(TaskADDRequest):
id: int
user_id: int
status: StatusEnum
time_spent: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,15 +1,49 @@
from typing import Optional
from typing import Annotated
from fastapi_users import schemas
from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr
from src.schemas.tasks import TaskWithId
from src.schemas.validators import ensure_password, ensure_username
class UserRead(schemas.BaseUser[int]):
class UserUpdate(BaseModel):
email: EmailStr | None = None
username: str | None = None
is_active: bool | None = None
model_config = ConfigDict(from_attributes=True, extra="ignore")
class User(BaseModel):
id: int
email: EmailStr | None
username: str
is_active: bool
is_superuser: bool
telegram_id: int | None
avatar_path: str | None
model_config = ConfigDict(from_attributes=True, extra="ignore")
class UserCreate(schemas.BaseUserCreate):
class UserWithHashedPass(User):
hashed_password: str
class UserWithTasks(User):
tasks: list[TaskWithId]
class UserRequest(BaseModel):
username: str
password: str
class UserUpdate(schemas.BaseUserUpdate):
username: Optional[str] = None
class UserRequestADD(BaseModel):
username: Annotated[str, BeforeValidator(ensure_username)]
email: EmailStr | None = None
password: Annotated[str, BeforeValidator(ensure_password)]
class UserAdd(BaseModel):
email: EmailStr | None
username: str
hashed_password: str

21
src/schemas/validators.py Normal file
View File

@@ -0,0 +1,21 @@
from typing import Any
def ensure_password(value: Any) -> str:
if not isinstance(value, str):
raise TypeError("Password must be a string")
value = value.strip()
if len(value) < 8:
raise ValueError("Password must be at least 8 characters")
elif value.strip() == "":
raise ValueError("Password cannot be empty")
return value
def ensure_username(value: str) -> str:
value = value.strip()
if len(value) < 3:
raise ValueError("Username must be at least 3 characters")
elif value.lower() in ["admin", "moderator", "админ", "модератор"]:
raise ValueError("Login is already taken")
return value

0
src/services/__init__.py Normal file
View File

68
src/services/auth.py Normal file
View File

@@ -0,0 +1,68 @@
from fastapi import HTTPException
from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import TokenData
from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass
from src.services.base import BaseService
class AuthService(BaseService):
async def registration(self, cred: UserRequestADD) -> User:
hashed_pass = AuthManager.get_password_hash(cred.password)
user_to_insert = UserAdd(
username=cred.username,
email=cred.email,
hashed_password=hashed_pass,
)
result = await self.session.user.create_one(user_to_insert.model_dump())
await self.session.commit()
return User.model_validate(result)
async def _tokens_create(self, user_data: TokenData, fingerprint: str):
access_token = AuthManager.create_access_token(user_data.model_dump())
refresh_token = AuthManager.create_refresh_token()
await self.session.auth.create_one({
"token": refresh_token,
"user_id": user_data.id,
"fingerprint": fingerprint,
})
return {
"access_token": access_token,
"token_type": settings.access_token.token_type,
"refresh_token": refresh_token,
}
async def login(self, username: str, password: str, fingerprint: str) -> dict:
login_exception = HTTPException(
status_code=401,
detail="Incorrect username or password",
)
result = await self.session.user.get_one_or_none(username=username)
if result is None:
raise login_exception
user = UserWithHashedPass.model_validate(result)
token_data = TokenData.model_validate(user.model_dump())
verify = AuthManager.verify_password(
plain_password=password, hashed_password=user.hashed_password
)
if not verify or user.is_active is False:
raise login_exception
tokens = await self._tokens_create(token_data, fingerprint)
await self.session.commit()
return tokens
async def refresh_tokens(
self, refresh_token: str, user_data: TokenData, fingerprint: str
) -> dict:
token_record = await self.session.auth.get_one_or_none(token=refresh_token)
if not token_record or token_record.user_id != user_data.id:
raise HTTPException(status_code=401, detail="Invalid refresh token")
token = await self._tokens_create(user_data, fingerprint)
await self.session.auth.delete_one(token=refresh_token)
await self.session.commit()
return token
async def delete_token(self, token: str) -> None:
await self.session.auth.delete_one(token=token)
await self.session.commit()

8
src/services/base.py Normal file
View File

@@ -0,0 +1,8 @@
from src.core.interfaces import IUOWDB
class BaseService:
session: IUOWDB
def __init__(self, session: "IUOWDB"):
self.session = session

38
src/services/tasks.py Normal file
View File

@@ -0,0 +1,38 @@
from fastapi import HTTPException
from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest
from src.services.base import BaseService
class TaskService(BaseService):
async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task:
user = await self.session.user.get_one_or_none(id=user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
data_to_insert = task_data.model_dump(exclude_none=True)
data_to_insert["user_id"] = user.id
created_task_orm = await self.session.task.create_one(data_to_insert)
await self.session.commit()
return Task.model_validate(created_task_orm)
async def get_task(self, task_id: int):
task = await self.session.task.get_one_or_none(id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found.")
return Task.model_validate(task)
async def delete_task(self, task_id: int):
await self.session.task.delete_one(id=task_id)
await self.session.commit()
async def update_task(
self,
task_id: int,
task_data: TaskPATCHRequest,
exclude_unset: bool = True,
):
task = await self.session.task.update_one(
data=task_data.model_dump(exclude_unset=exclude_unset), id=task_id
)
await self.session.commit()
return Task.model_validate(task)

58
src/services/users.py Normal file
View File

@@ -0,0 +1,58 @@
from fastapi import HTTPException
from src.schemas.users import User, UserUpdate, UserWithTasks
from src.services.base import BaseService
class UserService(BaseService):
async def get_user_by_filter(self, **filter_by) -> User | None:
result = await self.session.user.get_one_or_none(**filter_by)
if result is None:
return None
return User.model_validate(result)
async def get_user_by_filter_or_raise(self, **filter_by) -> User:
user = await self.get_user_by_filter(**filter_by)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
async def validate_admin_user(self, username: str) -> User:
user = await self.get_user_by_filter_or_raise(username=username)
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Admin access required")
return user
async def get_all_users(self) -> list[User]:
users = await self.session.user.get_all()
return [User.model_validate(user) for user in users]
async def delete_user(self, id: int) -> None:
await self.session.user.delete_one(id=id)
await self.session.commit()
async def update_user(self, id: int, update_data: UserUpdate) -> User:
await self.get_user_by_filter_or_raise(id=id)
user = await self.session.user.update_one(
data=update_data.model_dump(exclude_unset=True), id=id
)
await self.session.commit()
return User.model_validate(user)
async def get_user_with_tasks(self, user_id: int, **attrs):
if attrs.get("page") and attrs.get("limit"):
tasks_offset = (attrs.get("page", 0) - 1) * attrs.get("limit")
else:
tasks_offset = None
user = await self.session.user.get_one_with_load(
user_id=user_id,
status=attrs.get("status"),
priority=attrs.get("priority"),
tasks_limit=attrs.get("limit"),
tasks_offset=tasks_offset,
date_from=attrs.get("date_from"),
date_to=attrs.get("date_to"),
)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
return UserWithTasks.model_validate(user)

View File

@@ -1,10 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
LIFETIME: int
SECRET: str
settings = Settings()

View File

@@ -1,34 +0,0 @@
import logging
from typing import Optional
from fastapi import Request
from fastapi_users import BaseUserManager, IntegerIDMixin
from src.models import UsersORM
from src.settings import settings
logger = logging.getLogger()
class UserManager(IntegerIDMixin, BaseUserManager[UsersORM, int]):
reset_password_token_secret = settings.SECRET
verification_token_secret = settings.SECRET
async def on_after_register(
self, user: UsersORM, request: Optional[Request] = None
):
logger.warning("User %r has registered.", user.id)
async def on_after_forgot_password(
self, user: UsersORM, token: str, request: Optional[Request] = None
):
logger.warning(
"User %r has forgot their password. Reset token: %r", user.id, token
)
async def on_after_request_verify(
self, user: UsersORM, token: str, request: Optional[Request] = None
):
logger.warning(
"Verification requested for user %r. Verification token: %r", user.id, token
)

24
taskncoffee-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
taskncoffee-app/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,39 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
"react/jsx-uses-react": "error",
'no-unused-vars': ['error', {
varsIgnorePattern: '^[A-Z_]',
argsIgnorePattern: '^_',
ignoreRestSiblings: true
}],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>taskncoffee-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

5530
taskncoffee-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "taskncoffee-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"motion": "^12.23.22",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tw-animate-css": "^1.4.0",
"vite": "^7.1.7"
}
}

View File

View File

@@ -0,0 +1,13 @@
import './App.css'
import { LoginPage } from './pages/Login'
function App() {
return (
<>
<LoginPage className="flex min-h-svh flex-col items-center justify-center bg-background text-foreground" />
</>
)
}
export default App

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,58 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
export default function ExitAnimation() {
const [isVisible, setIsVisible] = useState(true)
const container = {
display: "flex",
flexDirection: "column",
width: 100,
height: 160,
position: "relative",
}
const box = {
width: 100,
height: 100,
backgroundColor: "#0cdcf7",
borderRadius: 10,
}
const button = {
backgroundColor: "#0cdcf7",
borderRadius: 10,
padding: "10px 20px",
color: "#0f1115",
position: "absolute",
bottom: 0,
left: 0,
right: 0,
}
return (
<div style={container}>
<AnimatePresence initial={false}>
{isVisible && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
style={box}
key="box"
/>
)}
</AnimatePresence>
<motion.button
style={button}
onClick={() => setIsVisible(!isVisible)}
whileTap={{ scale: 0.95 }}
>
{isVisible ? "Hide" : "Show"}
</motion.button>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,101 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
...props
}) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props} />
);
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props} />
);
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props} />
);
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props} />
);
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
function CardContent({
className,
...props
}) {
return (<div data-slot="card-content" className={cn("px-6", className)} {...props} />);
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({
className,
type,
...props
}) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props} />
);
}
export { Input }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props} />
);
}
export { Label }

View File

@@ -0,0 +1,132 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,121 @@
@font-face {
font-family: 'Vibur';
src: url('./assets/fonts/vibur.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fabulous';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./assets/fonts/Fabulous.ttf') format('truetype');
}
@font-face {
font-family: 'Carry-You';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./assets/fonts/Carry-You.ttf') format('truetype');
}
/*-- Sign Styles --*/
.sign {
font-family: "Carry-You", cursive;
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span {
font-size: 5.6rem;
text-align: center;
line-height: 1;
color: #c6e2ff;
animation: neon .08s ease-in-out infinite alternate;
}
}
.sign-pink {
font-family: "Carry-You", cursive;
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span {
font-size: 5.6rem;
text-align: center;
line-height: 1;
color: #ffc5ec;
animation: neon-pink .08s ease-in-out infinite alternate;
}
}
@keyframes neon {
from {
text-shadow:
0 0 6px rgba(202,228,225,0.92),
0 0 30px rgba(202,228,225,0.34),
0 0 12px rgba(30,132,242,0.52),
0 0 21px rgba(30,132,242,0.92),
0 0 34px rgba(30,132,242,0.78),
0 0 54px rgba(30,132,242,0.92);
}
to {
text-shadow:
0 0 6px rgba(202,228,225,0.98),
0 0 30px rgba(202,228,225,0.42),
0 0 12px rgba(30,132,242,0.58),
0 0 22px rgba(30,132,242,0.84),
0 0 38px rgba(30,132,242,0.88),
0 0 60px rgba(30,132,242,1);
}
}
@keyframes neon-pink {
from {
text-shadow:
0 0 6px rgba(255,182,193,0.92),
0 0 30px rgba(255,182,193,0.34),
0 0 12px rgba(255,20,147,0.52),
0 0 21px rgba(255,20,147,0.92),
0 0 34px rgba(255,20,147,0.78),
0 0 54px rgba(255,20,147,0.92);
}
to {
text-shadow:
0 0 6px rgba(255,182,193,0.98),
0 0 30px rgba(255,182,193,0.42),
0 0 12px rgba(255,20,147,0.58),
0 0 22px rgba(255,20,147,0.84),
0 0 38px rgba(255,20,147,0.88),
0 0 60px rgba(255,20,147,1);
}
}
.link {
position: absolute;
bottom: 10px; left: 10px;
color: #828282;
text-decoration: none;
&:focus,
&:hover {
color: #c6e2ff;
text-shadow:
0 0 2px rgba(202,228,225,0.92),
0 0 10px rgba(202,228,225,0.34),
0 0 4px rgba(30,132,242,0.52),
0 0 7px rgba(30,132,242,0.92),
0 0 11px rgba(30,132,242,0.78),
0 0 16px rgba(30,132,242,0.92);
}
}

View File

@@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function LoginPage({ className }) {
return (
<div className={className}>
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your username below to login to your account
</CardDescription>
<CardAction>
<Button variant="link">Sign Up</Button>
</CardAction>
</CardHeader>
<CardContent>
<form>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="username"
placeholder="username"
required
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" required />
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button type="submit" className="w-full">
Login
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

0
tests/__init__.py Normal file
View File

77
tests/conftest.py Normal file
View File

@@ -0,0 +1,77 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import NullPool, insert
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.api.dependacies.db_dep import get_db
from src.core.auth_manager import AuthManager
from src.core.database import Base
from src.core.db_manager import DBManager
from src.core.settings import settings
from src.main import app
from src.models import * # noqa: F403
engine_null_pool = create_async_engine(
"sqlite+aiosqlite:///tests/test_db.db", poolclass=NullPool
)
test_session_maker = async_sessionmaker(engine_null_pool, expire_on_commit=False)
class TestDBManager(DBManager):
def __init__(self):
self.session_factory = test_session_maker
async def get_test_db():
async with TestDBManager() as db:
yield db
@pytest.fixture(scope="function")
async def db():
async for db in get_test_db():
yield db
@pytest.fixture(scope="function")
async def ac():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides[get_db] = get_test_db
@pytest.fixture(scope="session", autouse=True)
async def setup_database():
async with engine_null_pool.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
@pytest.fixture(scope="session", autouse=True)
async def add_admin(setup_database):
hashed_pass = AuthManager.get_password_hash("admin")
user_admin = {
"username": "admin",
"hashed_password": hashed_pass,
"is_superuser": True,
}
async with test_session_maker() as conn:
result = await conn.execute(
insert(UsersORM).values(user_admin).returning(UsersORM) # noqa: F405
)
await conn.commit()
admin = result.scalar_one()
assert admin.is_superuser is True
@pytest.fixture
def auth_token(ac, add_admin):
response = ac.post(
f"{settings.api.v1_login_url}/login",
data={"username": "admin", "password": "admin"},
)
return response.json()["access_token"]

View File

View File

@@ -0,0 +1,45 @@
import pytest
from httpx import AsyncClient
from src.core.settings import settings
from src.schemas.users import User
async def test_registration(ac):
user = {"username": "kot", "email": "super@kot.ru", "password": "P@ssw0rd"}
result = await ac.post(
f"{settings.api.v1_login_url}/signup",
json=user,
)
assert result.status_code == 200
assert User.model_validate(result.json())
assert result.json()["is_active"]
@pytest.mark.parametrize(
"fingerprint, username,password,expected_status",
[("string", "kot", "P@ssw0rd", 200), ("", "kot", "P@ssw0rd", 422)],
)
async def test_login(
ac: AsyncClient,
fingerprint: str,
username: str,
password: str,
expected_status: int,
):
result = await ac.post(
f"{settings.api.v1_login_url}/login",
data={
"fingerprint": fingerprint,
"grant_type": "password",
"username": username,
"password": password,
},
)
assert result.status_code == expected_status
if expected_status == 200:
assert result.json().get("access_token") is not None
else:
json_response = result.json()
if expected_status == 422:
assert "detail" in json_response

38
tests/mock_tasks.json Normal file
View File

@@ -0,0 +1,38 @@
[
{
"title": "test1",
"description": "test1",
"due_date": "2026-06-01",
"status": "open",
"priority": "medium"
},
{
"title": "test2",
"description": "test2",
"due_date": "2026-06-01",
"status": "open",
"priority": "high"
},
{
"title": "test3",
"description": "test3",
"due_date": "2026-06-02",
"status": "todo",
"priority": "medium"
},
{
"title": "test4",
"description": "test4",
"due_date": "2026-06-02",
"status": "open",
"priority": "high"
},
{
"title": "test5",
"description": "test5",
"due_date": "2026-06-02",
"status": "todo",
"priority": "low"
}
]

View File

View File

@@ -0,0 +1,10 @@
from src.core.auth_manager import AuthManager
async def test_jwt():
token = AuthManager.create_access_token(
data={"id": 1, "sub": "testuser", "is_active": "True"}
)
assert token
encode_token = AuthManager.decode_access_token(token=token)
assert encode_token["id"] == 1 and encode_token["sub"] == "testuser"

View File

@@ -0,0 +1,86 @@
import json
from datetime import datetime
from typing import TYPE_CHECKING
from src.models.users import UsersORM
from src.schemas.users import User
if TYPE_CHECKING:
from tests.conftest import TestDBManager
async def test_user_crud(db: "TestDBManager"):
data = {
"username": "test",
"hashed_password": "hashed_pass",
"email": "test@mail.ru",
"is_active": True,
"is_superuser": False,
}
user = await db.user.create_one(data=data)
assert user.username == data["username"]
filtered_user = await db.user.get_filtered(username=data["username"])
assert filtered_user[0] == user
new_user = User.model_validate(user)
new_user.username = "Test2"
new_user.email = None
await db.user.update_one(id=new_user.id, data=User.model_dump(new_user))
updated_user = await db.user.get_one_or_none(id=new_user.id)
assert updated_user
assert updated_user.username == new_user.username
assert not updated_user.email
await db.user.delete_one(id=updated_user.id)
delete_user = await db.user.get_one_or_none(id=new_user.id)
assert not delete_user
async def test_tasks_user(db: "TestDBManager"):
with open("tests/mock_tasks.json") as jsonfile:
data = json.load(jsonfile)
admin_user: UsersORM | None = await db.user.get_one_or_none(id=1)
assert admin_user
data = [
{
**item,
"user_id": admin_user.id,
"due_date": datetime.strptime(item["due_date"], "%Y-%m-%d"),
}
for item in data
]
result = await db.task.create_bulk(data)
await db.commit()
assert result
tasks = await db.task.get_filtered(user_id=admin_user.id)
assert tasks
user_with_tasks = await db.user.get_one_with_load(user_id=admin_user.id)
assert user_with_tasks
assert user_with_tasks.tasks
async def test_tasks_crud(db: "TestDBManager"):
data = {
"title": "test_tasks_crud",
"description": "test",
"due_date": datetime.now(),
"status": "open",
"priority": "medium",
"user_id": 1,
}
task = await db.task.create_one(data)
assert task
assert task.title == data["title"]
assert task.description == data["description"]
assert task.due_date == data["due_date"].date()
assert task.status == data["status"]
assert task.priority == data["priority"]
assert task.user_id == data["user_id"]
assert task.created_at is not None
find_task = await db.task.get_filtered(title=data["title"])
assert find_task
assert find_task[0].title == task.title
data["title"] = "test2"
task = await db.task.update_one(id=task.id, data=data)
assert task.title == data["title"]
await db.task.delete_one(id=task.id)
task = await db.task.get_one_or_none(id=task.id)
assert not task