From 863c04650aaa40e9982b8f6315a0b8926c89d76a Mon Sep 17 00:00:00 2001 From: Manuel Holtgrewe Date: Wed, 27 Sep 2023 16:16:37 +0200 Subject: [PATCH] feat: create initial database setup for project (#49) (#80) --- backend/.gitignore | 2 + backend/Makefile | 17 + backend/Pipfile | 22 +- backend/Pipfile.lock | 633 +++++++++++++----- backend/alembic.ini | 73 ++ backend/alembic/env.py | 94 +++ backend/alembic/script.py.mako | 26 + backend/alembic/versions/315675882512_.py | 43 ++ backend/app/api/__init__.py | 0 backend/app/api/api_v1/__init__.py | 0 backend/app/api/api_v1/api.py | 5 + backend/app/api/api_v1/endpoints/__init__.py | 0 backend/app/api/api_v1/endpoints/adminmsgs.py | 20 + backend/app/api/deps.py | 11 + backend/app/api/internal/__init__.py | 0 backend/app/api/internal/api.py | 20 + .../app/api/internal/endpoints/__init__.py | 0 backend/app/api/internal/endpoints/proxy.py | 51 ++ backend/app/api/internal/endpoints/remote.py | 101 +++ backend/app/backend_pre_start.py | 37 + backend/app/core/__init__.py | 0 backend/app/core/config.py | 163 +++++ backend/app/crud/__init__.py | 7 + backend/app/crud/base.py | 58 ++ backend/app/db/__init__.py | 0 backend/app/db/base.py | 1 + backend/app/db/init_db.py | 25 + backend/app/db/session.py | 8 + backend/app/initial_data.py | 22 + backend/app/main.py | 219 +----- backend/app/models/__init__.py | 1 + backend/app/models/adminmsg.py | 34 + backend/app/models/utils/__init__.py | 0 backend/app/models/utils/guid.py | 50 ++ backend/app/models/utils/helpers.py | 12 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/adminmsg.py | 36 + backend/env.dev | 12 + backend/prestart.sh | 12 + backend/setup.cfg | 7 + backend/tests/api/__init__.py | 0 backend/tests/api/api_v1/__init__.py | 0 backend/tests/api/api_v1/test_adminmsgs.py | 12 + backend/tests/api/internal/__init__.py | 0 backend/tests/api/internal/test_proxy.py | 80 +++ backend/tests/api/internal/test_remote.py | 49 ++ backend/tests/conftest.py | 63 ++ backend/tests/crud/__init__.py | 0 backend/tests/crud/test_adminmsg.py | 49 ++ backend/tests/test_main.py | 132 +--- frontend/Makefile | 2 +- frontend/src/api/__tests__/common.spec.ts | 19 +- frontend/src/api/common.ts | 15 +- .../VariantDetails/VariantValidator.vue | 2 +- frontend/src/stores/variantAcmgRating.ts | 2 +- utils/docker/Dockerfile | 3 +- utils/docker/entrypoint.sh | 79 +-- 57 files changed, 1750 insertions(+), 580 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/315675882512_.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/api_v1/__init__.py create mode 100644 backend/app/api/api_v1/api.py create mode 100644 backend/app/api/api_v1/endpoints/__init__.py create mode 100644 backend/app/api/api_v1/endpoints/adminmsgs.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/internal/__init__.py create mode 100644 backend/app/api/internal/api.py create mode 100644 backend/app/api/internal/endpoints/__init__.py create mode 100644 backend/app/api/internal/endpoints/proxy.py create mode 100644 backend/app/api/internal/endpoints/remote.py create mode 100644 backend/app/backend_pre_start.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/crud/__init__.py create mode 100644 backend/app/crud/base.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/init_db.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/initial_data.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/adminmsg.py create mode 100644 backend/app/models/utils/__init__.py create mode 100644 backend/app/models/utils/guid.py create mode 100644 backend/app/models/utils/helpers.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/adminmsg.py create mode 100644 backend/env.dev create mode 100644 backend/prestart.sh create mode 100644 backend/tests/api/__init__.py create mode 100644 backend/tests/api/api_v1/__init__.py create mode 100644 backend/tests/api/api_v1/test_adminmsgs.py create mode 100644 backend/tests/api/internal/__init__.py create mode 100644 backend/tests/api/internal/test_proxy.py create mode 100644 backend/tests/api/internal/test_remote.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/crud/__init__.py create mode 100644 backend/tests/crud/test_adminmsg.py diff --git a/backend/.gitignore b/backend/.gitignore index ad4a1f17..c0a0ddbf 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,5 @@ +.env + # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python diff --git a/backend/Makefile b/backend/Makefile index 59a442d4..1a93a33a 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -13,6 +13,11 @@ help: @echo " ci Install dependencies, run lints and tests" @echo " docs Generate the documentation" @echo " serve Run the (development) server" + @echo " migrate Create alembic versions and upgrade" + @echO "" + @echo " alembic-check Run alembic check" + @echo " alembic-autogenerate Autogenerte alembic version" + @echo " alembic-upgrade Upgrade database to head" .PHONY: deps deps: @@ -75,3 +80,15 @@ docs: .PHONY: serve serve: pipenv run uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload + +.PHONY: alembic-check +alembic-check: + alembic check + +.PHONY: alembic-autogenerate +alembic-autogenerate: + alembic revision --autogenerate + +.PHONY: alembic-upgrade +alembic-upgrade: + alembic upgrade head diff --git a/backend/Pipfile b/backend/Pipfile index 1885c004..e2a39618 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -5,17 +5,28 @@ name = "pypi" [packages] fastapi = "*" -pydantic = "*" -uvicorn = "*" -python-dotenv = "*" httpx = "*" +install = "*" +pip = "*" +pydantic = {extras = ["email"], version = "*"} +pydantic-settings = "*" +python-dotenv = "*" requests-mock = "*" +tenacity = "*" +uvicorn = "*" +jose = {extras = ["cryptography"], version = "*"} +passlib = {extras = ["bcrypt"], version = "*"} +psycopg2-binary = "*" +python-dateutil = "*" +sqlalchemy = "*" [dev-packages] black = "*" flake8 = "*" +install = "*" isort = "*" mypy = "*" +pip = "*" pytest = "*" pytest-asyncio = "*" pytest-cov = "*" @@ -24,6 +35,11 @@ pytest-subprocess = "*" sphinx = "*" sphinx-rtd-theme = "*" starlette = "*" +types-python-jose = "*" +types-passlib = "*" +sqlalchemy-stubs = "*" +faker = "*" +pytest-faker = "*" [requires] python_version = "3.10" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 4d830b0a..1007a962 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "75758868d20e4fa7fb7618ab07ef00f73cd6e0215275661cfb4fb4e8932ad72f" + "sha256": "e56f9e48c3cd92dc5d05f5b05088faee6fcb6d31f5cea6d87985626f8578e5dc" }, "pipfile-spec": 6, "requires": { @@ -32,6 +32,32 @@ "markers": "python_version >= '3.7'", "version": "==3.7.1" }, + "bcrypt": { + "hashes": [ + "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", + "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", + "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", + "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", + "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665", + "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", + "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71", + "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215", + "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b", + "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda", + "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", + "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", + "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", + "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", + "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d", + "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c", + "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c", + "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", + "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d", + "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", + "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3" + ], + "version": "==4.0.1" + }, "certifi": { "hashes": [ "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", @@ -129,6 +155,21 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "dnspython": { + "hashes": [ + "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8", + "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984" + ], + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==2.4.2" + }, + "email-validator": { + "hashes": [ + "sha256:1ff6e86044200c56ae23595695c54e9614f4a9551e0e393614f764860b3d7900", + "sha256:2466ba57cda361fb7309fd3d5a225723c788ca4bbad32a0ebd5373b99730285c" + ], + "version": "==2.0.0.post2" + }, "exceptiongroup": { "hashes": [ "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", @@ -143,6 +184,7 @@ "sha256:5e5f17e826dbd9e9b5a5145976c5cd90bcaa61f2bf9a69aca423f2bcebe44d83" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==0.103.1" }, "h11": { @@ -155,19 +197,20 @@ }, "httpcore": { "hashes": [ - "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", - "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87" + "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9", + "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced" ], - "markers": "python_version >= '3.7'", - "version": "==0.17.3" + "markers": "python_version >= '3.8'", + "version": "==0.18.0" }, "httpx": { "hashes": [ - "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", - "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd" + "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100", + "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875" ], "index": "pypi", - "version": "==0.24.1" + "markers": "python_version >= '3.8'", + "version": "==0.25.0" }, "idna": { "hashes": [ @@ -177,125 +220,250 @@ "markers": "python_version >= '3.5'", "version": "==3.4" }, - "pydantic": { + "install": { "hashes": [ - "sha256:1607cc106602284cd4a00882986570472f193fde9cb1259bceeaedb26aa79a6d", - "sha256:45b5e446c6dfaad9444819a293b921a40e1db1aa61ea08aede0522529ce90e81" + "sha256:0d3fadf4aa62c95efe8d34757c8507eb46177f86c016c21c6551eafc6a53d5a9", + "sha256:e67c8a0be5ccf8cb4ffa17d090f3a61b6e820e6a7e21cd1d2c0f7bc59b18e647" + ], + "index": "pypi", + "markers": "python_version >= '2.7'", + "version": "==1.3.5" + }, + "jose": { + "extras": [ + "cryptography" + ], + "hashes": [ + "sha256:8436c3617cd94e1ba97828fbb1ce27c129f66c78fb855b4bb47e122b5f345fba" + ], + "version": "==1.0.0" + }, + "passlib": { + "extras": [ + "bcrypt" + ], + "hashes": [ + "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", + "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" + ], + "version": "==1.7.4" + }, + "pip": { + "hashes": [ + "sha256:7ccf472345f20d35bdc9d1841ff5f313260c2c33fe417f48c30ac46cccabf5be", + "sha256:fb0bd5435b3200c602b5bf61d2d43c2f13c02e29c1707567ae7fbc514eb9faf2" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2.1" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:00d8db270afb76f48a499f7bb8fa70297e66da67288471ca873db88382850bf4", + "sha256:024eaeb2a08c9a65cd5f94b31ace1ee3bb3f978cd4d079406aef85169ba01f08", + "sha256:094af2e77a1976efd4956a031028774b827029729725e136514aae3cdf49b87b", + "sha256:1011eeb0c51e5b9ea1016f0f45fa23aca63966a4c0afcf0340ccabe85a9f65bd", + "sha256:11abdbfc6f7f7dea4a524b5f4117369b0d757725798f1593796be6ece20266cb", + "sha256:122641b7fab18ef76b18860dd0c772290566b6fb30cc08e923ad73d17461dc63", + "sha256:17cc17a70dfb295a240db7f65b6d8153c3d81efb145d76da1e4a096e9c5c0e63", + "sha256:18f12632ab516c47c1ac4841a78fddea6508a8284c7cf0f292cb1a523f2e2379", + "sha256:1b918f64a51ffe19cd2e230b3240ba481330ce1d4b7875ae67305bd1d37b041c", + "sha256:1c31c2606ac500dbd26381145684d87730a2fac9a62ebcfbaa2b119f8d6c19f4", + "sha256:26484e913d472ecb6b45937ea55ce29c57c662066d222fb0fbdc1fab457f18c5", + "sha256:2993ccb2b7e80844d534e55e0f12534c2871952f78e0da33c35e648bf002bbff", + "sha256:2b04da24cbde33292ad34a40db9832a80ad12de26486ffeda883413c9e1b1d5e", + "sha256:2dec5a75a3a5d42b120e88e6ed3e3b37b46459202bb8e36cd67591b6e5feebc1", + "sha256:2df562bb2e4e00ee064779902d721223cfa9f8f58e7e52318c97d139cf7f012d", + "sha256:3fbb1184c7e9d28d67671992970718c05af5f77fc88e26fd7136613c4ece1f89", + "sha256:42a62ef0e5abb55bf6ffb050eb2b0fcd767261fa3faf943a4267539168807522", + "sha256:4ecc15666f16f97709106d87284c136cdc82647e1c3f8392a672616aed3c7151", + "sha256:4eec5d36dbcfc076caab61a2114c12094c0b7027d57e9e4387b634e8ab36fd44", + "sha256:4fe13712357d802080cfccbf8c6266a3121dc0e27e2144819029095ccf708372", + "sha256:51d1b42d44f4ffb93188f9b39e6d1c82aa758fdb8d9de65e1ddfe7a7d250d7ad", + "sha256:59f7e9109a59dfa31efa022e94a244736ae401526682de504e87bd11ce870c22", + "sha256:62cb6de84d7767164a87ca97e22e5e0a134856ebcb08f21b621c6125baf61f16", + "sha256:642df77484b2dcaf87d4237792246d8068653f9e0f5c025e2c692fc56b0dda70", + "sha256:6822c9c63308d650db201ba22fe6648bd6786ca6d14fdaf273b17e15608d0852", + "sha256:692df8763b71d42eb8343f54091368f6f6c9cfc56dc391858cdb3c3ef1e3e584", + "sha256:6d92e139ca388ccfe8c04aacc163756e55ba4c623c6ba13d5d1595ed97523e4b", + "sha256:7952807f95c8eba6a8ccb14e00bf170bb700cafcec3924d565235dffc7dc4ae8", + "sha256:7db7b9b701974c96a88997d458b38ccb110eba8f805d4b4f74944aac48639b42", + "sha256:81d5dd2dd9ab78d31a451e357315f201d976c131ca7d43870a0e8063b6b7a1ec", + "sha256:8a136c8aaf6615653450817a7abe0fc01e4ea720ae41dfb2823eccae4b9062a3", + "sha256:8a7968fd20bd550431837656872c19575b687f3f6f98120046228e451e4064df", + "sha256:8c721ee464e45ecf609ff8c0a555018764974114f671815a0a7152aedb9f3343", + "sha256:8f309b77a7c716e6ed9891b9b42953c3ff7d533dc548c1e33fddc73d2f5e21f9", + "sha256:8f94cb12150d57ea433e3e02aabd072205648e86f1d5a0a692d60242f7809b15", + "sha256:95a7a747bdc3b010bb6a980f053233e7610276d55f3ca506afff4ad7749ab58a", + "sha256:9b0c2b466b2f4d89ccc33784c4ebb1627989bd84a39b79092e560e937a11d4ac", + "sha256:9dcfd5d37e027ec393a303cc0a216be564b96c80ba532f3d1e0d2b5e5e4b1e6e", + "sha256:a5ee89587696d808c9a00876065d725d4ae606f5f7853b961cdbc348b0f7c9a1", + "sha256:a6a8b575ac45af1eaccbbcdcf710ab984fd50af048fe130672377f78aaff6fc1", + "sha256:ac83ab05e25354dad798401babaa6daa9577462136ba215694865394840e31f8", + "sha256:ad26d4eeaa0d722b25814cce97335ecf1b707630258f14ac4d2ed3d1d8415265", + "sha256:ad5ec10b53cbb57e9a2e77b67e4e4368df56b54d6b00cc86398578f1c635f329", + "sha256:c82986635a16fb1fa15cd5436035c88bc65c3d5ced1cfaac7f357ee9e9deddd4", + "sha256:ced63c054bdaf0298f62681d5dcae3afe60cbae332390bfb1acf0e23dcd25fc8", + "sha256:d0b16e5bb0ab78583f0ed7ab16378a0f8a89a27256bb5560402749dbe8a164d7", + "sha256:dbbc3c5d15ed76b0d9db7753c0db40899136ecfe97d50cbde918f630c5eb857a", + "sha256:ded8e15f7550db9e75c60b3d9fcbc7737fea258a0f10032cdb7edc26c2a671fd", + "sha256:e02bc4f2966475a7393bd0f098e1165d470d3fa816264054359ed4f10f6914ea", + "sha256:e5666632ba2b0d9757b38fc17337d84bdf932d38563c5234f5f8c54fd01349c9", + "sha256:ea5f8ee87f1eddc818fc04649d952c526db4426d26bab16efbe5a0c52b27d6ab", + "sha256:eb1c0e682138f9067a58fc3c9a9bf1c83d8e08cfbee380d858e63196466d5c86", + "sha256:eb3b8d55924a6058a26db69fb1d3e7e32695ff8b491835ba9f479537e14dcf9f", + "sha256:ee919b676da28f78f91b464fb3e12238bd7474483352a59c8a16c39dfc59f0c5", + "sha256:f02f4a72cc3ab2565c6d9720f0343cb840fb2dc01a2e9ecb8bc58ccf95dc5c06", + "sha256:f4f37bbc6588d402980ffbd1f3338c871368fb4b1cfa091debe13c68bb3852b3", + "sha256:f8651cf1f144f9ee0fa7d1a1df61a9184ab72962531ca99f077bbdcba3947c58", + "sha256:f955aa50d7d5220fcb6e38f69ea126eafecd812d96aeed5d5f3597f33fad43bb", + "sha256:fc10da7e7df3380426521e8c1ed975d22df678639da2ed0ec3244c3dc2ab54c8", + "sha256:fdca0511458d26cf39b827a663d7d87db6f32b93efc22442a742035728603d5f" ], "index": "pypi", - "version": "==2.3.0" + "markers": "python_version >= '3.6'", + "version": "==2.9.7" + }, + "pydantic": { + "extras": [ + "email" + ], + "hashes": [ + "sha256:2b2240c8d54bb8f84b88e061fac1bdfa1761c2859c367f9d3afe0ec2966deddc", + "sha256:b172505886028e4356868d617d2d1a776d7af1625d1313450fd51bdd19d9d61f" + ], + "markers": "python_version >= '3.7'", + "version": "==2.4.1" }, "pydantic-core": { "hashes": [ - "sha256:002d0ea50e17ed982c2d65b480bd975fc41086a5a2f9c924ef8fc54419d1dea3", - "sha256:02e1c385095efbd997311d85c6021d32369675c09bcbfff3b69d84e59dc103f6", - "sha256:046af9cfb5384f3684eeb3f58a48698ddab8dd870b4b3f67f825353a14441418", - "sha256:04fe5c0a43dec39aedba0ec9579001061d4653a9b53a1366b113aca4a3c05ca7", - "sha256:07a1aec07333bf5adebd8264047d3dc518563d92aca6f2f5b36f505132399efc", - "sha256:1480fa4682e8202b560dcdc9eeec1005f62a15742b813c88cdc01d44e85308e5", - "sha256:1508f37ba9e3ddc0189e6ff4e2228bd2d3c3a4641cbe8c07177162f76ed696c7", - "sha256:171a4718860790f66d6c2eda1d95dd1edf64f864d2e9f9115840840cf5b5713f", - "sha256:19e20f8baedd7d987bd3f8005c146e6bcbda7cdeefc36fad50c66adb2dd2da48", - "sha256:1a0ddaa723c48af27d19f27f1c73bdc615c73686d763388c8683fe34ae777bad", - "sha256:1aa712ba150d5105814e53cb141412217146fedc22621e9acff9236d77d2a5ef", - "sha256:1ac1750df1b4339b543531ce793b8fd5c16660a95d13aecaab26b44ce11775e9", - "sha256:1c721bfc575d57305dd922e6a40a8fe3f762905851d694245807a351ad255c58", - "sha256:1ce8c84051fa292a5dc54018a40e2a1926fd17980a9422c973e3ebea017aa8da", - "sha256:1fa1f6312fb84e8c281f32b39affe81984ccd484da6e9d65b3d18c202c666149", - "sha256:22134a4453bd59b7d1e895c455fe277af9d9d9fbbcb9dc3f4a97b8693e7e2c9b", - "sha256:23470a23614c701b37252618e7851e595060a96a23016f9a084f3f92f5ed5881", - "sha256:240a015102a0c0cc8114f1cba6444499a8a4d0333e178bc504a5c2196defd456", - "sha256:252851b38bad3bfda47b104ffd077d4f9604a10cb06fe09d020016a25107bf98", - "sha256:2a20c533cb80466c1d42a43a4521669ccad7cf2967830ac62c2c2f9cece63e7e", - "sha256:2dd50d6a1aef0426a1d0199190c6c43ec89812b1f409e7fe44cb0fbf6dfa733c", - "sha256:340e96c08de1069f3d022a85c2a8c63529fd88709468373b418f4cf2c949fb0e", - "sha256:3796a6152c545339d3b1652183e786df648ecdf7c4f9347e1d30e6750907f5bb", - "sha256:37a822f630712817b6ecc09ccc378192ef5ff12e2c9bae97eb5968a6cdf3b862", - "sha256:3a750a83b2728299ca12e003d73d1264ad0440f60f4fc9cee54acc489249b728", - "sha256:3c8945a105f1589ce8a693753b908815e0748f6279959a4530f6742e1994dcb6", - "sha256:3ccc13afee44b9006a73d2046068d4df96dc5b333bf3509d9a06d1b42db6d8bf", - "sha256:3f90e5e3afb11268628c89f378f7a1ea3f2fe502a28af4192e30a6cdea1e7d5e", - "sha256:4292ca56751aebbe63a84bbfc3b5717abb09b14d4b4442cc43fd7c49a1529efd", - "sha256:430ddd965ffd068dd70ef4e4d74f2c489c3a313adc28e829dd7262cc0d2dd1e8", - "sha256:439a0de139556745ae53f9cc9668c6c2053444af940d3ef3ecad95b079bc9987", - "sha256:44b4f937b992394a2e81a5c5ce716f3dcc1237281e81b80c748b2da6dd5cf29a", - "sha256:48c1ed8b02ffea4d5c9c220eda27af02b8149fe58526359b3c07eb391cb353a2", - "sha256:4ef724a059396751aef71e847178d66ad7fc3fc969a1a40c29f5aac1aa5f8784", - "sha256:50555ba3cb58f9861b7a48c493636b996a617db1a72c18da4d7f16d7b1b9952b", - "sha256:522a9c4a4d1924facce7270c84b5134c5cabcb01513213662a2e89cf28c1d309", - "sha256:5493a7027bfc6b108e17c3383959485087d5942e87eb62bbac69829eae9bc1f7", - "sha256:56ea80269077003eaa59723bac1d8bacd2cd15ae30456f2890811efc1e3d4413", - "sha256:5a2a3c9ef904dcdadb550eedf3291ec3f229431b0084666e2c2aa8ff99a103a2", - "sha256:5cfde4fab34dd1e3a3f7f3db38182ab6c95e4ea91cf322242ee0be5c2f7e3d2f", - "sha256:5e4a2cf8c4543f37f5dc881de6c190de08096c53986381daebb56a355be5dfe6", - "sha256:5e9c068f36b9f396399d43bfb6defd4cc99c36215f6ff33ac8b9c14ba15bdf6b", - "sha256:5ed7ceca6aba5331ece96c0e328cd52f0dcf942b8895a1ed2642de50800b79d3", - "sha256:5fa159b902d22b283b680ef52b532b29554ea2a7fc39bf354064751369e9dbd7", - "sha256:615a31b1629e12445c0e9fc8339b41aaa6cc60bd53bf802d5fe3d2c0cda2ae8d", - "sha256:621afe25cc2b3c4ba05fff53525156d5100eb35c6e5a7cf31d66cc9e1963e378", - "sha256:6656a0ae383d8cd7cc94e91de4e526407b3726049ce8d7939049cbfa426518c8", - "sha256:672174480a85386dd2e681cadd7d951471ad0bb028ed744c895f11f9d51b9ebe", - "sha256:692b4ff5c4e828a38716cfa92667661a39886e71136c97b7dac26edef18767f7", - "sha256:6bcc1ad776fffe25ea5c187a028991c031a00ff92d012ca1cc4714087e575973", - "sha256:6bf7d610ac8f0065a286002a23bcce241ea8248c71988bda538edcc90e0c39ad", - "sha256:75c0ebbebae71ed1e385f7dfd9b74c1cff09fed24a6df43d326dd7f12339ec34", - "sha256:788be9844a6e5c4612b74512a76b2153f1877cd845410d756841f6c3420230eb", - "sha256:7dc2ce039c7290b4ef64334ec7e6ca6494de6eecc81e21cb4f73b9b39991408c", - "sha256:813aab5bfb19c98ae370952b6f7190f1e28e565909bfc219a0909db168783465", - "sha256:8421cf496e746cf8d6b677502ed9a0d1e4e956586cd8b221e1312e0841c002d5", - "sha256:84e87c16f582f5c753b7f39a71bd6647255512191be2d2dbf49458c4ef024588", - "sha256:84f8bb34fe76c68c9d96b77c60cef093f5e660ef8e43a6cbfcd991017d375950", - "sha256:85cc4d105747d2aa3c5cf3e37dac50141bff779545ba59a095f4a96b0a460e70", - "sha256:883daa467865e5766931e07eb20f3e8152324f0adf52658f4d302242c12e2c32", - "sha256:8b2b1bfed698fa410ab81982f681f5b1996d3d994ae8073286515ac4d165c2e7", - "sha256:8ecbac050856eb6c3046dea655b39216597e373aa8e50e134c0e202f9c47efec", - "sha256:930bfe73e665ebce3f0da2c6d64455098aaa67e1a00323c74dc752627879fc67", - "sha256:9616567800bdc83ce136e5847d41008a1d602213d024207b0ff6cab6753fe645", - "sha256:9680dd23055dd874173a3a63a44e7f5a13885a4cfd7e84814be71be24fba83db", - "sha256:99faba727727b2e59129c59542284efebbddade4f0ae6a29c8b8d3e1f437beb7", - "sha256:9a718d56c4d55efcfc63f680f207c9f19c8376e5a8a67773535e6f7e80e93170", - "sha256:9b33bf9658cb29ac1a517c11e865112316d09687d767d7a0e4a63d5c640d1b17", - "sha256:9e8b374ef41ad5c461efb7a140ce4730661aadf85958b5c6a3e9cf4e040ff4bb", - "sha256:9e9b65a55bbabda7fccd3500192a79f6e474d8d36e78d1685496aad5f9dbd92c", - "sha256:a0b7486d85293f7f0bbc39b34e1d8aa26210b450bbd3d245ec3d732864009819", - "sha256:a53e3195f134bde03620d87a7e2b2f2046e0e5a8195e66d0f244d6d5b2f6d31b", - "sha256:a87c54e72aa2ef30189dc74427421e074ab4561cf2bf314589f6af5b37f45e6d", - "sha256:a892b5b1871b301ce20d40b037ffbe33d1407a39639c2b05356acfef5536d26a", - "sha256:a8acc9dedd304da161eb071cc7ff1326aa5b66aadec9622b2574ad3ffe225525", - "sha256:aaafc776e5edc72b3cad1ccedb5fd869cc5c9a591f1213aa9eba31a781be9ac1", - "sha256:acafc4368b289a9f291e204d2c4c75908557d4f36bd3ae937914d4529bf62a76", - "sha256:b0a5d7edb76c1c57b95df719af703e796fc8e796447a1da939f97bfa8a918d60", - "sha256:b25afe9d5c4f60dcbbe2b277a79be114e2e65a16598db8abee2a2dcde24f162b", - "sha256:b44c42edc07a50a081672e25dfe6022554b47f91e793066a7b601ca290f71e42", - "sha256:b594b64e8568cf09ee5c9501ede37066b9fc41d83d58f55b9952e32141256acd", - "sha256:b962700962f6e7a6bd77e5f37320cabac24b4c0f76afeac05e9f93cf0c620014", - "sha256:bb128c30cf1df0ab78166ded1ecf876620fb9aac84d2413e8ea1594b588c735d", - "sha256:bf9d42a71a4d7a7c1f14f629e5c30eac451a6fc81827d2beefd57d014c006c4a", - "sha256:c6595b0d8c8711e8e1dc389d52648b923b809f68ac1c6f0baa525c6440aa0daa", - "sha256:c8c6660089a25d45333cb9db56bb9e347241a6d7509838dbbd1931d0e19dbc7f", - "sha256:c9d469204abcca28926cbc28ce98f28e50e488767b084fb3fbdf21af11d3de26", - "sha256:d38bbcef58220f9c81e42c255ef0bf99735d8f11edef69ab0b499da77105158a", - "sha256:d4eb77df2964b64ba190eee00b2312a1fd7a862af8918ec70fc2d6308f76ac64", - "sha256:d63b7545d489422d417a0cae6f9898618669608750fc5e62156957e609e728a5", - "sha256:d7050899026e708fb185e174c63ebc2c4ee7a0c17b0a96ebc50e1f76a231c057", - "sha256:d79f1f2f7ebdb9b741296b69049ff44aedd95976bfee38eb4848820628a99b50", - "sha256:d85463560c67fc65cd86153a4975d0b720b6d7725cf7ee0b2d291288433fc21b", - "sha256:d9140ded382a5b04a1c030b593ed9bf3088243a0a8b7fa9f071a5736498c5483", - "sha256:d9b4916b21931b08096efed090327f8fe78e09ae8f5ad44e07f5c72a7eedb51b", - "sha256:df14f6332834444b4a37685810216cc8fe1fe91f447332cd56294c984ecbff1c", - "sha256:e49ce7dc9f925e1fb010fc3d555250139df61fa6e5a0a95ce356329602c11ea9", - "sha256:e61eae9b31799c32c5f9b7be906be3380e699e74b2db26c227c50a5fc7988698", - "sha256:ea053cefa008fda40f92aab937fb9f183cf8752e41dbc7bc68917884454c6362", - "sha256:f06e21ad0b504658a3a9edd3d8530e8cea5723f6ea5d280e8db8efc625b47e49", - "sha256:f14546403c2a1d11a130b537dda28f07eb6c1805a43dae4617448074fd49c282", - "sha256:f1a5d8f18877474c80b7711d870db0eeef9442691fcdb00adabfc97e183ee0b0", - "sha256:f2969e8f72c6236c51f91fbb79c33821d12a811e2a94b7aa59c65f8dbdfad34a", - "sha256:f468d520f47807d1eb5d27648393519655eadc578d5dd862d06873cce04c4d1b", - "sha256:f70dc00a91311a1aea124e5f64569ea44c011b58433981313202c46bccbec0e1", - "sha256:f93255b3e4d64785554e544c1c76cd32f4a354fa79e2eeca5d16ac2e7fdd57aa" - ], - "markers": "python_version >= '3.7'", - "version": "==2.6.3" + "sha256:042462d8d6ba707fd3ce9649e7bf268633a41018d6a998fb5fbacb7e928a183e", + "sha256:0523aeb76e03f753b58be33b26540880bac5aa54422e4462404c432230543f33", + "sha256:05560ab976012bf40f25d5225a58bfa649bb897b87192a36c6fef1ab132540d7", + "sha256:0675ba5d22de54d07bccde38997e780044dcfa9a71aac9fd7d4d7a1d2e3e65f7", + "sha256:073d4a470b195d2b2245d0343569aac7e979d3a0dcce6c7d2af6d8a920ad0bea", + "sha256:07ec6d7d929ae9c68f716195ce15e745b3e8fa122fc67698ac6498d802ed0fa4", + "sha256:0880e239827b4b5b3e2ce05e6b766a7414e5f5aedc4523be6b68cfbc7f61c5d0", + "sha256:0c27f38dc4fbf07b358b2bc90edf35e82d1703e22ff2efa4af4ad5de1b3833e7", + "sha256:0d8a8adef23d86d8eceed3e32e9cca8879c7481c183f84ed1a8edc7df073af94", + "sha256:0e2a35baa428181cb2270a15864ec6286822d3576f2ed0f4cd7f0c1708472aff", + "sha256:0f8682dbdd2f67f8e1edddcbffcc29f60a6182b4901c367fc8c1c40d30bb0a82", + "sha256:0fa467fd300a6f046bdb248d40cd015b21b7576c168a6bb20aa22e595c8ffcdd", + "sha256:128552af70a64660f21cb0eb4876cbdadf1a1f9d5de820fed6421fa8de07c893", + "sha256:1396e81b83516b9d5c9e26a924fa69164156c148c717131f54f586485ac3c15e", + "sha256:149b8a07712f45b332faee1a2258d8ef1fb4a36f88c0c17cb687f205c5dc6e7d", + "sha256:14ac492c686defc8e6133e3a2d9eaf5261b3df26b8ae97450c1647286750b901", + "sha256:14cfbb00959259e15d684505263d5a21732b31248a5dd4941f73a3be233865b9", + "sha256:14e09ff0b8fe6e46b93d36a878f6e4a3a98ba5303c76bb8e716f4878a3bee92c", + "sha256:154ea7c52e32dce13065dbb20a4a6f0cc012b4f667ac90d648d36b12007fa9f7", + "sha256:15d6bca84ffc966cc9976b09a18cf9543ed4d4ecbd97e7086f9ce9327ea48891", + "sha256:1d40f55222b233e98e3921df7811c27567f0e1a4411b93d4c5c0f4ce131bc42f", + "sha256:25bd966103890ccfa028841a8f30cebcf5875eeac8c4bde4fe221364c92f0c9a", + "sha256:2cf5bb4dd67f20f3bbc1209ef572a259027c49e5ff694fa56bed62959b41e1f9", + "sha256:2e0e2959ef5d5b8dc9ef21e1a305a21a36e254e6a34432d00c72a92fdc5ecda5", + "sha256:320f14bd4542a04ab23747ff2c8a778bde727158b606e2661349557f0770711e", + "sha256:3625578b6010c65964d177626fde80cf60d7f2e297d56b925cb5cdeda6e9925a", + "sha256:39215d809470f4c8d1881758575b2abfb80174a9e8daf8f33b1d4379357e417c", + "sha256:3f0ac9fb8608dbc6eaf17956bf623c9119b4db7dbb511650910a82e261e6600f", + "sha256:417243bf599ba1f1fef2bb8c543ceb918676954734e2dcb82bf162ae9d7bd514", + "sha256:420a692b547736a8d8703c39ea935ab5d8f0d2573f8f123b0a294e49a73f214b", + "sha256:443fed67d33aa85357464f297e3d26e570267d1af6fef1c21ca50921d2976302", + "sha256:48525933fea744a3e7464c19bfede85df4aba79ce90c60b94d8b6e1eddd67096", + "sha256:485a91abe3a07c3a8d1e082ba29254eea3e2bb13cbbd4351ea4e5a21912cc9b0", + "sha256:4a5be350f922430997f240d25f8219f93b0c81e15f7b30b868b2fddfc2d05f27", + "sha256:4d966c47f9dd73c2d32a809d2be529112d509321c5310ebf54076812e6ecd884", + "sha256:524ff0ca3baea164d6d93a32c58ac79eca9f6cf713586fdc0adb66a8cdeab96a", + "sha256:53df009d1e1ba40f696f8995683e067e3967101d4bb4ea6f667931b7d4a01357", + "sha256:5994985da903d0b8a08e4935c46ed8daf5be1cf217489e673910951dc533d430", + "sha256:5cabb9710f09d5d2e9e2748c3e3e20d991a4c5f96ed8f1132518f54ab2967221", + "sha256:5fdb39f67c779b183b0c853cd6b45f7db84b84e0571b3ef1c89cdb1dfc367325", + "sha256:600d04a7b342363058b9190d4e929a8e2e715c5682a70cc37d5ded1e0dd370b4", + "sha256:631cb7415225954fdcc2a024119101946793e5923f6c4d73a5914d27eb3d3a05", + "sha256:63974d168b6233b4ed6a0046296803cb13c56637a7b8106564ab575926572a55", + "sha256:64322bfa13e44c6c30c518729ef08fda6026b96d5c0be724b3c4ae4da939f875", + "sha256:655f8f4c8d6a5963c9a0687793da37b9b681d9ad06f29438a3b2326d4e6b7970", + "sha256:6835451b57c1b467b95ffb03a38bb75b52fb4dc2762bb1d9dbed8de31ea7d0fc", + "sha256:6db2eb9654a85ada248afa5a6db5ff1cf0f7b16043a6b070adc4a5be68c716d6", + "sha256:7c4d1894fe112b0864c1fa75dffa045720a194b227bed12f4be7f6045b25209f", + "sha256:7eb037106f5c6b3b0b864ad226b0b7ab58157124161d48e4b30c4a43fef8bc4b", + "sha256:8282bab177a9a3081fd3d0a0175a07a1e2bfb7fcbbd949519ea0980f8a07144d", + "sha256:82f55187a5bebae7d81d35b1e9aaea5e169d44819789837cdd4720d768c55d15", + "sha256:8572cadbf4cfa95fb4187775b5ade2eaa93511f07947b38f4cd67cf10783b118", + "sha256:8cdbbd92154db2fec4ec973d45c565e767ddc20aa6dbaf50142676484cbff8ee", + "sha256:8f6e6aed5818c264412ac0598b581a002a9f050cb2637a84979859e70197aa9e", + "sha256:92f675fefa977625105708492850bcbc1182bfc3e997f8eecb866d1927c98ae6", + "sha256:962ed72424bf1f72334e2f1e61b68f16c0e596f024ca7ac5daf229f7c26e4208", + "sha256:9badf8d45171d92387410b04639d73811b785b5161ecadabf056ea14d62d4ede", + "sha256:9c120c9ce3b163b985a3b966bb701114beb1da4b0468b9b236fc754783d85aa3", + "sha256:9f6f3e2598604956480f6c8aa24a3384dbf6509fe995d97f6ca6103bb8c2534e", + "sha256:a1254357f7e4c82e77c348dabf2d55f1d14d19d91ff025004775e70a6ef40ada", + "sha256:a1392e0638af203cee360495fd2cfdd6054711f2db5175b6e9c3c461b76f5175", + "sha256:a1c311fd06ab3b10805abb72109f01a134019739bd3286b8ae1bc2fc4e50c07a", + "sha256:a5cb87bdc2e5f620693148b5f8f842d293cae46c5f15a1b1bf7ceeed324a740c", + "sha256:a7a7902bf75779bc12ccfc508bfb7a4c47063f748ea3de87135d433a4cca7a2f", + "sha256:aad7bd686363d1ce4ee930ad39f14e1673248373f4a9d74d2b9554f06199fb58", + "sha256:aafdb89fdeb5fe165043896817eccd6434aee124d5ee9b354f92cd574ba5e78f", + "sha256:ae8a8843b11dc0b03b57b52793e391f0122e740de3df1474814c700d2622950a", + "sha256:b00bc4619f60c853556b35f83731bd817f989cba3e97dc792bb8c97941b8053a", + "sha256:b1f22a9ab44de5f082216270552aa54259db20189e68fc12484873d926426921", + "sha256:b3c01c2fb081fced3bbb3da78510693dc7121bb893a1f0f5f4b48013201f362e", + "sha256:b3dcd587b69bbf54fc04ca157c2323b8911033e827fffaecf0cafa5a892a0904", + "sha256:b4a6db486ac8e99ae696e09efc8b2b9fea67b63c8f88ba7a1a16c24a057a0776", + "sha256:bec7dd208a4182e99c5b6c501ce0b1f49de2802448d4056091f8e630b28e9a52", + "sha256:c0877239307b7e69d025b73774e88e86ce82f6ba6adf98f41069d5b0b78bd1bf", + "sha256:caa48fc31fc7243e50188197b5f0c4228956f97b954f76da157aae7f67269ae8", + "sha256:cfe1090245c078720d250d19cb05d67e21a9cd7c257698ef139bc41cf6c27b4f", + "sha256:d43002441932f9a9ea5d6f9efaa2e21458221a3a4b417a14027a1d530201ef1b", + "sha256:d64728ee14e667ba27c66314b7d880b8eeb050e58ffc5fec3b7a109f8cddbd63", + "sha256:d6495008733c7521a89422d7a68efa0a0122c99a5861f06020ef5b1f51f9ba7c", + "sha256:d8f1ebca515a03e5654f88411420fea6380fc841d1bea08effb28184e3d4899f", + "sha256:d99277877daf2efe074eae6338453a4ed54a2d93fb4678ddfe1209a0c93a2468", + "sha256:da01bec0a26befab4898ed83b362993c844b9a607a86add78604186297eb047e", + "sha256:db9a28c063c7c00844ae42a80203eb6d2d6bbb97070cfa00194dff40e6f545ab", + "sha256:dda81e5ec82485155a19d9624cfcca9be88a405e2857354e5b089c2a982144b2", + "sha256:e357571bb0efd65fd55f18db0a2fb0ed89d0bb1d41d906b138f088933ae618bb", + "sha256:e544246b859f17373bed915182ab841b80849ed9cf23f1f07b73b7c58baee5fb", + "sha256:e562617a45b5a9da5be4abe72b971d4f00bf8555eb29bb91ec2ef2be348cd132", + "sha256:e570ffeb2170e116a5b17e83f19911020ac79d19c96f320cbfa1fa96b470185b", + "sha256:e6f31a17acede6a8cd1ae2d123ce04d8cca74056c9d456075f4f6f85de055607", + "sha256:e9121b4009339b0f751955baf4543a0bfd6bc3f8188f8056b1a25a2d45099934", + "sha256:ebedb45b9feb7258fac0a268a3f6bec0a2ea4d9558f3d6f813f02ff3a6dc6698", + "sha256:ecaac27da855b8d73f92123e5f03612b04c5632fd0a476e469dfc47cd37d6b2e", + "sha256:ecdbde46235f3d560b18be0cb706c8e8ad1b965e5c13bbba7450c86064e96561", + "sha256:ed550ed05540c03f0e69e6d74ad58d026de61b9eaebebbaaf8873e585cbb18de", + "sha256:eeb3d3d6b399ffe55f9a04e09e635554012f1980696d6b0aca3e6cf42a17a03b", + "sha256:ef337945bbd76cce390d1b2496ccf9f90b1c1242a3a7bc242ca4a9fc5993427a", + "sha256:f1365e032a477c1430cfe0cf2856679529a2331426f8081172c4a74186f1d595", + "sha256:f23b55eb5464468f9e0e9a9935ce3ed2a870608d5f534025cd5536bca25b1402", + "sha256:f2e9072d71c1f6cfc79a36d4484c82823c560e6f5599c43c1ca6b5cdbd54f881", + "sha256:f323306d0556351735b54acbf82904fe30a27b6a7147153cbe6e19aaaa2aa429", + "sha256:f36a3489d9e28fe4b67be9992a23029c3cec0babc3bd9afb39f49844a8c721c5", + "sha256:f64f82cc3443149292b32387086d02a6c7fb39b8781563e0ca7b8d7d9cf72bd7", + "sha256:f6defd966ca3b187ec6c366604e9296f585021d922e666b99c47e78738b5666c", + "sha256:f7c2b8eb9fc872e68b46eeaf835e86bccc3a58ba57d0eedc109cbb14177be531", + "sha256:fa7db7558607afeccb33c0e4bf1c9a9a835e26599e76af6fe2fcea45904083a6", + "sha256:fcb83175cc4936a5425dde3356f079ae03c0802bbdf8ff82c035f8a54b333521" + ], + "markers": "python_version >= '3.7'", + "version": "==2.10.1" + }, + "pydantic-settings": { + "hashes": [ + "sha256:962dc3672495aad6ae96a4390fac7e593591e144625e5112d359f8f67fb75945", + "sha256:ddd907b066622bd67603b75e2ff791875540dc485b7307c4fffc015719da8625" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.0.3" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" }, "python-dotenv": { "hashes": [ @@ -303,6 +471,7 @@ "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==1.0.0" }, "requests": { @@ -345,21 +514,30 @@ "markers": "python_version >= '3.7'", "version": "==0.27.0" }, - "typing-extensions": { + "tenacity": { "hashes": [ - "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", - "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a", + "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c" ], + "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==4.7.1" + "version": "==8.2.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", + "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" + ], + "markers": "python_version >= '3.8'", + "version": "==4.8.0" }, "urllib3": { "hashes": [ - "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", - "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4" + "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594", + "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e" ], "markers": "python_version >= '3.7'", - "version": "==2.0.4" + "version": "==2.0.5" }, "uvicorn": { "hashes": [ @@ -367,6 +545,7 @@ "sha256:4d3cc12d7727ba72b64d12d3cc7743124074c0a69f7b201512fc50c3e3f1569a" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==0.23.2" } }, @@ -397,31 +576,32 @@ }, "black": { "hashes": [ - "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3", - "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb", - "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087", - "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320", - "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6", - "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3", - "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc", - "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f", - "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587", - "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91", - "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a", - "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad", - "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926", - "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9", - "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be", - "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd", - "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96", - "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491", - "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2", - "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a", - "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f", - "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995" + "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f", + "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7", + "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100", + "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573", + "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d", + "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f", + "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9", + "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300", + "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948", + "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325", + "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9", + "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71", + "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186", + "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f", + "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe", + "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855", + "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80", + "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393", + "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c", + "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204", + "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377", + "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301" ], "index": "pypi", - "version": "==23.7.0" + "markers": "python_version >= '3.8'", + "version": "==23.9.1" }, "certifi": { "hashes": [ @@ -597,12 +777,22 @@ "markers": "python_version < '3.11'", "version": "==1.1.3" }, + "faker": { + "hashes": [ + "sha256:8fba91068dc26e3159c1ac9f22444a2338704b0991d86605322e454bda420092", + "sha256:d5d5953556b0fb428a46019e03fc2d40eab2980135ddef5a9eb3d054947fdf83" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==19.6.2" + }, "flake8": { "hashes": [ "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23", "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5" ], "index": "pypi", + "markers": "python_full_version >= '3.8.1'", "version": "==6.1.0" }, "h11": { @@ -615,19 +805,20 @@ }, "httpcore": { "hashes": [ - "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", - "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87" + "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9", + "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced" ], - "markers": "python_version >= '3.7'", - "version": "==0.17.3" + "markers": "python_version >= '3.8'", + "version": "==0.18.0" }, "httpx": { "hashes": [ - "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", - "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd" + "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100", + "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875" ], "index": "pypi", - "version": "==0.24.1" + "markers": "python_version >= '3.8'", + "version": "==0.25.0" }, "idna": { "hashes": [ @@ -653,12 +844,22 @@ "markers": "python_version >= '3.7'", "version": "==2.0.0" }, + "install": { + "hashes": [ + "sha256:0d3fadf4aa62c95efe8d34757c8507eb46177f86c016c21c6551eafc6a53d5a9", + "sha256:e67c8a0be5ccf8cb4ffa17d090f3a61b6e820e6a7e21cd1d2c0f7bc59b18e647" + ], + "index": "pypi", + "markers": "python_version >= '2.7'", + "version": "==1.3.5" + }, "isort": { "hashes": [ "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" ], "index": "pypi", + "markers": "python_full_version >= '3.8.0'", "version": "==5.12.0" }, "jinja2": { @@ -675,8 +876,11 @@ "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", + "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c", "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", + "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", + "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939", "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", @@ -684,6 +888,7 @@ "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", + "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd", "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", @@ -692,6 +897,7 @@ "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", + "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007", "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", @@ -699,9 +905,12 @@ "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", + "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1", "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", + "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c", "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", + "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823", "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", @@ -720,7 +929,9 @@ "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", - "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2" + "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc", + "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2", + "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11" ], "markers": "python_version >= '3.7'", "version": "==2.1.3" @@ -764,6 +975,7 @@ "sha256:ff0cedc84184115202475bbb46dd99f8dcb87fe24d5d0ddfc0fe6b8575c88d2f" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==1.5.1" }, "mypy-extensions": { @@ -790,6 +1002,15 @@ "markers": "python_version >= '3.7'", "version": "==0.11.2" }, + "pip": { + "hashes": [ + "sha256:7ccf472345f20d35bdc9d1841ff5f313260c2c33fe417f48c30ac46cccabf5be", + "sha256:fb0bd5435b3200c602b5bf61d2d43c2f13c02e29c1707567ae7fbc514eb9faf2" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2.1" + }, "platformdirs": { "hashes": [ "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", @@ -832,11 +1053,12 @@ }, "pytest": { "hashes": [ - "sha256:2f2301e797521b23e4d2585a0a3d7b5e50fdddaaf7e7d6773ea26ddb17c213ab", - "sha256:460c9a59b14e27c602eb5ece2e47bec99dc5fc5f6513cf924a7d03a578991b1f" + "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002", + "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069" ], "index": "pypi", - "version": "==7.4.1" + "markers": "python_version >= '3.7'", + "version": "==7.4.2" }, "pytest-asyncio": { "hashes": [ @@ -844,6 +1066,7 @@ "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==0.21.1" }, "pytest-cov": { @@ -852,15 +1075,24 @@ "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==4.1.0" }, + "pytest-faker": { + "hashes": [ + "sha256:6b37bb89d94f96552bfa51f8e8b89d32addded8ddb58a331488299ef0137d9b6" + ], + "index": "pypi", + "version": "==2.0.0" + }, "pytest-httpx": { "hashes": [ - "sha256:193cecb57a005eb15288f68986f328d4c8d06c0b7c4ef1ce512e024cbb1d5961", - "sha256:259e6266cf3e04eb8fcc18dff262657ad96f6b8668dc2171fb353eaec5571889" + "sha256:b489c5a7bb847551943eaee601bc35053b35dc4f5961c944305120f14a1d770a", + "sha256:ca372b94c569c0aca2f06240f6f78cc223dfbc3ab97b5700d4e14c9a73eab17a" ], "index": "pypi", - "version": "==0.24.0" + "markers": "python_version >= '3.9'", + "version": "==0.26.0" }, "pytest-subprocess": { "hashes": [ @@ -868,8 +1100,18 @@ "sha256:dfd75b10af6800a89a9b758f2e2eceff9de082a27bd1388521271b6e8bde298b" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==1.5.0" }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", @@ -878,6 +1120,14 @@ "markers": "python_version >= '3.7'", "version": "==2.31.0" }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, "sniffio": { "hashes": [ "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", @@ -895,11 +1145,12 @@ }, "sphinx": { "hashes": [ - "sha256:1a9290001b75c497fd087e92b0334f1bbfa1a1ae7fddc084990c4b7bd1130b88", - "sha256:9269f9ed2821c9ebd30e4204f5c2339f5d4980e377bc89cb2cb6f9b17409c20a" + "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560", + "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5" ], "index": "pypi", - "version": "==7.2.5" + "markers": "python_version >= '3.9'", + "version": "==7.2.6" }, "sphinx-rtd-theme": { "hashes": [ @@ -907,6 +1158,7 @@ "sha256:590b030c7abb9cf038ec053b95e5380b5c70d61591eb0b552063fbe7c41f0931" ], "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.3.0" }, "sphinxcontrib-applehelp": { @@ -965,6 +1217,14 @@ "markers": "python_version >= '3.9'", "version": "==1.1.9" }, + "sqlalchemy-stubs": { + "hashes": [ + "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5", + "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae" + ], + "index": "pypi", + "version": "==0.4" + }, "starlette": { "hashes": [ "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", @@ -981,21 +1241,44 @@ "markers": "python_version < '3.11'", "version": "==2.0.1" }, + "types-passlib": { + "hashes": [ + "sha256:414b5ee9c88313357c9261cfcf816509b1e8e4673f0796bd61e9ef249f6fe076", + "sha256:f152639f1f2103d7f59a56e2aec5f9398a75a80830991d0d68aac5c2b9c32a77" + ], + "index": "pypi", + "version": "==1.7.7.13" + }, + "types-pyasn1": { + "hashes": [ + "sha256:8f1965d0b79152f9d1efc89f9aa9a8cdda7cd28b2619df6737c095cbedeff98b", + "sha256:dd5fc818864e63a66cd714be0a7a59a493f4a81b87ee9ac978c41f1eaa9a0cef" + ], + "version": "==0.4.0.6" + }, + "types-python-jose": { + "hashes": [ + "sha256:3c316675c3cee059ccb9aff87358254344915239fa7f19cee2787155a7db14ac", + "sha256:95592273443b45dc5cc88f7c56aa5a97725428753fb738b794e63ccb4904954e" + ], + "index": "pypi", + "version": "==3.3.4.8" + }, "typing-extensions": { "hashes": [ - "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", - "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2" + "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", + "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" ], - "markers": "python_version >= '3.7'", - "version": "==4.7.1" + "markers": "python_version >= '3.8'", + "version": "==4.8.0" }, "urllib3": { "hashes": [ - "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", - "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4" + "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594", + "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e" ], "markers": "python_version >= '3.7'", - "version": "==2.0.4" + "version": "==2.0.5" } } } diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..aed96885 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,73 @@ +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +timezone = Europe/Berlin + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..857bc509 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,94 @@ +from __future__ import with_statement + +import os + +from alembic import context +from dotenv import load_dotenv +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +# Load environment +env = os.environ +load_dotenv() + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None + +from app.db.base import Base # noqa +import app.models # noqa + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_url(): + user = os.getenv("POSTGRES_USER", "postgres") + password = os.getenv("POSTGRES_PASSWORD", "") + server = os.getenv("POSTGRES_HOST", "db") + port = os.getenv("POSTGRES_PORT", 5432) + db = os.getenv("POSTGRES_DB", "app") + return f"postgresql://{user}:{password}@{server}:{port}/{db}" + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 00000000..72303dfc --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +import app.models.guid # noqa + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/315675882512_.py b/backend/alembic/versions/315675882512_.py new file mode 100644 index 00000000..dc826881 --- /dev/null +++ b/backend/alembic/versions/315675882512_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 315675882512 +Revises: +Create Date: 2023-09-27 14:55:20.332132+02:00 + +""" +from alembic import op +import sqlalchemy as sa + + +import app.models.guid # noqa + +# revision identifiers, used by Alembic. +revision = '315675882512' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('adminmessages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('uuid', app.models.guid.GUID(length=36), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('text', sa.Text(), nullable=True), + sa.Column('active_start', sa.DateTime(), nullable=False), + sa.Column('active_stop', sa.DateTime(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_adminmessages_id'), 'adminmessages', ['id'], unique=False) + op.create_index(op.f('ix_adminmessages_uuid'), 'adminmessages', ['uuid'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_adminmessages_uuid'), table_name='adminmessages') + op.drop_index(op.f('ix_adminmessages_id'), table_name='adminmessages') + op.drop_table('adminmessages') + # ### end Alembic commands ### diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/api_v1/__init__.py b/backend/app/api/api_v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py new file mode 100644 index 00000000..59e4572f --- /dev/null +++ b/backend/app/api/api_v1/api.py @@ -0,0 +1,5 @@ +from app.api.api_v1.endpoints import adminmsgs +from fastapi import APIRouter + +api_router = APIRouter() +api_router.include_router(adminmsgs.router, prefix="/adminmsgs", tags=["adminmsgs"]) diff --git a/backend/app/api/api_v1/endpoints/__init__.py b/backend/app/api/api_v1/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/api_v1/endpoints/adminmsgs.py b/backend/app/api/api_v1/endpoints/adminmsgs.py new file mode 100644 index 00000000..dfc4f9b7 --- /dev/null +++ b/backend/app/api/api_v1/endpoints/adminmsgs.py @@ -0,0 +1,20 @@ +from app import crud, models, schemas +from app.api import deps +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +router = APIRouter() + + +@router.get("/", response_model=list[schemas.AdminMessage]) +def read_adminmsgs( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, +) -> list[schemas.AdminMessage]: + """Retrieve all admin messages""" + users = [ + schemas.AdminMessage.model_validate(db_obj) + for db_obj in crud.adminmessage.get_multi(db, skip=skip, limit=limit) + ] + return users diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 00000000..9917a1d0 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,11 @@ +from typing import Iterator + +from app.db.session import SessionLocal + + +def get_db() -> Iterator[SessionLocal]: # type: ignore[valid-type] + try: + db = SessionLocal() + yield db + finally: + db.close() diff --git a/backend/app/api/internal/__init__.py b/backend/app/api/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/internal/api.py b/backend/app/api/internal/api.py new file mode 100644 index 00000000..0b490fd3 --- /dev/null +++ b/backend/app/api/internal/api.py @@ -0,0 +1,20 @@ +import subprocess + +from app.api.internal.endpoints import proxy, remote +from app.core.config import settings +from fastapi import APIRouter, Response + +api_router = APIRouter() + +api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"]) +api_router.include_router(remote.router, prefix="/remote", tags=["remote"]) + + +@api_router.get("/version") +async def version(): + """Return REEV software version""" + if settings.REEV_VERSION: + version = settings.REEV_VERSION + else: + version = subprocess.check_output(["git", "describe", "--tags", "--dirty"]).strip() + return Response(content=version) diff --git a/backend/app/api/internal/endpoints/__init__.py b/backend/app/api/internal/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/internal/endpoints/proxy.py b/backend/app/api/internal/endpoints/proxy.py new file mode 100644 index 00000000..c616510a --- /dev/null +++ b/backend/app/api/internal/endpoints/proxy.py @@ -0,0 +1,51 @@ +"""Reverse proxies to internal services.""" + +import httpx +from app.core.config import settings +from fastapi import APIRouter, BackgroundTasks, Request, Response +from fastapi.responses import StreamingResponse +from starlette.background import BackgroundTask + +router = APIRouter() + + +@router.get("/{path:path}") +@router.post("/{path:path}") +async def reverse_proxy(request: Request) -> Response: + """Implement reverse proxy for internal backend services.""" + url = request.url + backend_url = None + + if url.path.startswith(f"{settings.INTERNAL_STR}/proxy/annonars"): + backend_url = settings.BACKEND_PREFIX_ANNONARS + url.path.replace( + "/internal/proxy/annonars", "" + ) + elif url.path.startswith(f"{settings.INTERNAL_STR}/proxy/mehari"): + backend_url = settings.BACKEND_PREFIX_MEHARI + url.path.replace( + "/internal/proxy/mehari", "" + ) + elif url.path.startswith(f"{settings.INTERNAL_STR}/proxy/viguno"): + backend_url = settings.BACKEND_PREFIX_VIGUNO + url.path.replace( + "/internal/proxy/viguno", "" + ) + elif url.path.startswith(f"{settings.INTERNAL_STR}/proxy/nginx"): + backend_url = settings.BACKEND_PREFIX_NGINX + url.path.replace("/internal/proxy/nginx", "") + + if backend_url: + client = httpx.AsyncClient() + backend_url = backend_url + (f"?{url.query}" if url.query else "") + backend_req = client.build_request( + method=request.method, + url=backend_url, + headers=request.headers.raw, + content=await request.body(), + ) + backend_resp = await client.send(backend_req, stream=True) + return StreamingResponse( + backend_resp.aiter_raw(), + status_code=backend_resp.status_code, + headers=backend_resp.headers, + background=BackgroundTasks([BackgroundTask(backend_resp.aclose)]), + ) + else: + return Response(status_code=404, content="Reverse proxy route not found") diff --git a/backend/app/api/internal/endpoints/remote.py b/backend/app/api/internal/endpoints/remote.py new file mode 100644 index 00000000..316ec044 --- /dev/null +++ b/backend/app/api/internal/endpoints/remote.py @@ -0,0 +1,101 @@ +"""Reverse proxies to external/remote services.""" + +import httpx +from fastapi import APIRouter, BackgroundTasks, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse +from starlette.background import BackgroundTask + +#: Keys for the ACMG rating +ACMG_RATING_KEYS: tuple[str, ...] = ( + "pvs1", + "ps1", + "ps2", + "ps3", + "ps4", + "pm1", + "pm2", + "pm3", + "pm4", + "pm5", + "pm6", + "pp1", + "pp2", + "pp3", + "pp4", + "pp5", + "ba1", + "bs1", + "bs2", + "bs3", + "bs4", + "bp1", + "bp2", + "bp3", + "bp4", + "bp5", + "bp6", + "bp7", +) + + +def default_acmg_rating() -> dict[str, bool]: + return {k: False for k in ACMG_RATING_KEYS} + + +router = APIRouter() + + +@router.get("/variantvalidator/{path:path}") +async def variantvalidator(request: Request, path: str): + """Implement reverse proxy for variantvalidator.org.""" + url = request.url + + # change grch to GRCh and strip "chr" prefixes + path = path.replace("grch", "GRCh").replace("chr", "") + backend_url = "https://rest.variantvalidator.org/VariantValidator/variantvalidator/" + path + + backend_url = backend_url + (f"?{url.query}" if url.query else "") + client = httpx.AsyncClient() + backend_req = client.build_request( + method=request.method, + url=backend_url, + content=await request.body(), + ) + backend_resp = await client.send(backend_req, stream=True) + return StreamingResponse( + backend_resp.aiter_raw(), + status_code=backend_resp.status_code, + headers=backend_resp.headers, + background=BackgroundTasks([BackgroundTask(backend_resp.aclose)]), + ) + + +@router.get("/acmg/{path:path}") +async def acmg(request: Request): + """Implement searching for ACMG classification.""" + query_params = request.query_params + chromosome = query_params.get("chromosome") + position = query_params.get("position") + reference = query_params.get("reference") + alternative = query_params.get("alternative") + build = query_params.get("release") + + if not chromosome or not position or not reference or not alternative or not build: + return Response(status_code=400, content="Missing query parameters") + + url = ( + f"http://wintervar.wglab.org/api_new.php?" + f"queryType=position&chr={chromosome}&pos={position}" + f"&ref={reference}&alt={alternative}&build={build}" + ) + client = httpx.AsyncClient() + backend_req = client.build_request(method="GET", url=url) + backend_resp = await client.send(backend_req) + if backend_resp.status_code != 200: + return Response(status_code=backend_resp.status_code, content=backend_resp.content) + + acmg_rating = default_acmg_rating() + for key, value in backend_resp.json().items(): + if key.lower() in acmg_rating: + acmg_rating[key.lower()] = value == 1 + return JSONResponse(acmg_rating) diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py new file mode 100644 index 00000000..0cee7ff0 --- /dev/null +++ b/backend/app/backend_pre_start.py @@ -0,0 +1,37 @@ +import logging + +from app.db.session import SessionLocal +from sqlalchemy import text +from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +max_tries = 60 * 5 # 5 minutes +wait_seconds = 1 + + +@retry( + stop=stop_after_attempt(max_tries), + wait=wait_fixed(wait_seconds), + before=before_log(logger, logging.INFO), + after=after_log(logger, logging.WARN), +) +def init() -> None: + try: + db = SessionLocal() + # Try to create session to check if DB is awake + db.execute(text("SELECT 1")) + except Exception as e: + logger.error(e) + raise e + + +def main() -> None: + logger.info("Initializing service") + init() + logger.info("Service finished initializing") + + +if __name__ == "__main__": + main() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 00000000..7a383573 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,163 @@ +import os +import secrets +from typing import Any + +from pydantic import AnyHttpUrl, EmailStr, HttpUrl, PostgresDsn, field_validator +from pydantic_core.core_schema import ValidationInfo +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", case_sensitive=True + ) + + # -- app-specific settings ----------------------------------------------- + + # == deployment-related settings == + + #: Enable debug mode (makes all APIs appear in openapi.json) + DEBUG: bool = False + #: Project name + PROJECT_NAME: str = "REEV" + #: Path to frontend build, if any. + SERVE_FRONTEND: str | None = "" + #: Path to REEV version file. + VERSION_FILE: str = "/VERSION" + #: The REEV version from the file (``None`` if to load dynamically from git) + REEV_VERSION: str | None = None + + @field_validator("REEV_VERSION", mode="before") + def assemble_reev_version(cls, v: str | None, info: ValidationInfo) -> str | None: + if isinstance(v, str): # pragma: no cover + return v + version_file: str | None = info.data.get("VERSION_FILE") + if version_file and os.path.exists(version_file): # pragma: no cover + with open(version_file, "rt") as f: + return f.read().strip() or None + else: + return None + + # == API-related settings == + + #: URL prefix for internal API + INTERNAL_STR: str = "/internal" + #: URL prefix for V1 URLs + API_V1_STR: str = "/api/v1" + + # == security-related settings == + + #: Secret key + SECRET_KEY: str = secrets.token_urlsafe(32) + #: Expiry of access token (60 minutes * 24 hours * 8 days = 8 days) + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + #: Server hostname + SERVER_NAME: str = "localhost" + #: HTTP to server + SERVER_HOST: AnyHttpUrl | str = "http://localhost:8080" + #: BACKEND_CORS_ORIGINS is a JSON-formatted list of origins + #: e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \ + #: "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]' + BACKEND_CORS_ORIGINS: list[AnyHttpUrl | str] = [] + + @field_validator("BACKEND_CORS_ORIGINS", mode="before") + def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str: # pragma: no cover + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + # == backend-related settings == + + #: Prefix for the backend of annonars service, default is for dev. + BACKEND_PREFIX_ANNONARS: str = "http://localhost:3001" + #: Prefix for the backend of mehari service, default is for dev. + BACKEND_PREFIX_MEHARI: str = "http://localhost:3002" + #: Prefix for the backend of viguno service, default is for dev. + BACKEND_PREFIX_VIGUNO: str = "http://localhost:3003" + #: Prefix for the backend of nginx service, default is for dev. + BACKEND_PREFIX_NGINX: str = "http://localhost:3004" + + # -- User-Related Configuration --------------------------------------------- + + # FIRST_SUPERUSER: EmailStr + # FIRST_SUPERUSER_PASSWORD: str + # USERS_OPEN_REGISTRATION: bool = False + # EMAIL_TEST_USER: EmailStr = "test@example.com" # type: ignore + + # -- Database Configuration ---------------------------------------------- + + # Note that when os.environ["CI"] is "true" then we will use an in-memory + # sqlite database (test use only). + + #: Postgres hostname + POSTGRES_HOST: str | None = None + #: Postgres port + POSTGRES_PORT: int = 5432 + #: Postgres user + POSTGRES_USER: str | None = None + #: Postgres password + POSTGRES_PASSWORD: str | None = None + #: Postgres database name + POSTGRES_DB: str | None = None + #: SQLAlchemy Postgres DSN + SQLALCHEMY_DATABASE_URI: PostgresDsn | str | None = None + + @field_validator("SQLALCHEMY_DATABASE_URI", mode="before") + def assemble_db_connection(cls, v: str | None, info: ValidationInfo) -> Any: + if os.environ.get("CI") == "true": # pragma: no cover + return "sqlite://" + elif isinstance(v, str): # pragma: no cover + return v + else: + return PostgresDsn.build( + scheme="postgresql", + username=info.data.get("POSTGRES_USER"), + password=info.data.get("POSTGRES_PASSWORD"), + host=info.data.get("POSTGRES_HOST"), + port=info.data.get("POSTGRES_PORT"), + path=f"{info.data.get('POSTGRES_DB') or ''}", + ) + + # -- Email Sending Configuration ----------------------------------------- + + # SMTP_TLS: bool = True + # SMTP_PORT: int | None = None + # SMTP_HOST: str | None = None + # SMTP_USER: str | None = None + # SMTP_PASSWORD: str | None = None + # EMAILS_FROM_EMAIL: EmailStr | None = None + # EMAILS_FROM_NAME: str | None = None + + # @validator("EMAILS_FROM_NAME") + # def get_project_name(cls, v: str | None, values: Dict[str, Any]) -> str: + # if not v: + # return values["PROJECT_NAME"] + # return v + + # EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 + # EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build" + # EMAILS_ENABLED: bool = False + + # @validator("EMAILS_ENABLED", pre=True) + # def get_emails_enabled(cls, v: bool, values: Dict[str, Any]) -> bool: + # return bool( + # values.get("SMTP_HOST") + # and values.get("SMTP_PORT") + # and values.get("EMAILS_FROM_EMAIL") + # ) + + # -- Sentry Configuration ------------------------------------------------ + + #: Sentry DSN + SENTRY_DSN: HttpUrl | None = None + + @field_validator("SENTRY_DSN", mode="before") + def sentry_dsn_can_be_blank(cls, v: str | None) -> str | None: # pragma: no cover + if not v: + return None + return v + + +settings = Settings(_env_file=".env", _env_file_encoding="utf-8") # type: ignore[call-arg] diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py new file mode 100644 index 00000000..52d8227b --- /dev/null +++ b/backend/app/crud/__init__.py @@ -0,0 +1,7 @@ +from typing import Any + +from app.crud.base import CrudBase +from app.models.adminmsg import AdminMessage +from app.schemas.adminmsg import AdminMessageCreate, AdminMessageUpdate + +adminmessage = CrudBase[AdminMessage, AdminMessageCreate, AdminMessageUpdate](AdminMessage) diff --git a/backend/app/crud/base.py b/backend/app/crud/base.py new file mode 100644 index 00000000..61dcc2ca --- /dev/null +++ b/backend/app/crud/base.py @@ -0,0 +1,58 @@ +from typing import Any, Generic, Type, TypeVar + +from app.models.utils.helpers import ModelType, sa_model_to_dict +from pydantic import BaseModel +from sqlalchemy.orm import Session + +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + + +class CrudBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + def __init__(self, model: Type[ModelType]): + """ + CRUD object with default methods to Create, Read, Update, Delete (CRUD). + + **Parameters** + + * `model`: A SQLAlchemy model class + * `schema`: A Pydantic model (schema) class + """ + self.model = model + + def get(self, db: Session, id: Any) -> ModelType | None: + return db.query(self.model).filter(self.model.id == id).first() + + def get_multi(self, db: Session, *, skip: int = 0, limit: int = 100) -> list[ModelType]: + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + obj_in_data = obj_in.model_dump() + db_obj = self.model(**obj_in_data) # type: ignore + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, db: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType | dict[str, Any] + ) -> ModelType: + obj_data = sa_model_to_dict(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, id: int) -> ModelType | None: + obj = db.query(self.model).get(id) + if obj: + db.delete(obj) + db.commit() + return obj diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 00000000..9acb6047 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1 @@ +from app.db.session import Base # noqa # pragma: no cover diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 00000000..452679c4 --- /dev/null +++ b/backend/app/db/init_db.py @@ -0,0 +1,25 @@ +from app import crud, schemas +from app.core.config import settings +from app.db import base # noqa: F401 +from sqlalchemy.orm import Session + +# make sure all SQL Alchemy models are imported (app.db.base) before initializing DB +# otherwise, SQL Alchemy might fail to initialize relationships properly +# for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 + + +def init_db(db: Session) -> None: + # Tables should be created with Alembic migrations + # But if you don't want to use migrations, create + # the tables un-commenting the next line + # Base.metadata.create_all(bind=engine) + + pass + # user = crud.user.get_by_email(db, email=settings.FIRST_SUPERUSER) + # if not user: + # user_in = schemas.UserCreate( + # email=settings.FIRST_SUPERUSER, + # password=settings.FIRST_SUPERUSER_PASSWORD, + # is_superuser=True, + # ) + # user = crud.user.create(db, obj_in=user_in) # noqa: F841 diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 00000000..f2ee1f87 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,8 @@ +from app.core.config import settings +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker # type: ignore[attr-defined] + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI), pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/backend/app/initial_data.py b/backend/app/initial_data.py new file mode 100644 index 00000000..c50646d2 --- /dev/null +++ b/backend/app/initial_data.py @@ -0,0 +1,22 @@ +import logging + +from app.db.init_db import init_db +from app.db.session import SessionLocal + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def init() -> None: + db = SessionLocal() + init_db(db) + + +def main() -> None: + logger.info("Creating initial data") + init() + logger.info("Initial data created") + + +if __name__ == "__main__": + main() diff --git a/backend/app/main.py b/backend/app/main.py index 397eab18..68f551c3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,213 +1,50 @@ -import os +import logging import pathlib -import subprocess -import sys -import httpx -from dotenv import load_dotenv +from app.api.api_v1.api import api_router as api_v1_router +from app.api.internal.api import api_router as internal_router +from app.core.config import settings from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from starlette.background import BackgroundTask +from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request -from starlette.responses import FileResponse, JSONResponse, Response, StreamingResponse - -# Load environment -env = os.environ -load_dotenv() - -#: Path to frontend build, if any. -SERVE_FRONTEND = env.get("REEV_SERVE_FRONTEND") -#: Debug mode -DEBUG = env.get("REEV_DEBUG", "false").lower() in ("true", "1") -#: Prefix for the backend of annonars service -BACKEND_PREFIX_ANNONARS = env.get("REEV_BACKEND_PREFIX_ANNONARS", "http://annonars:8080") -#: Prefix for the backend of mehari service -BACKEND_PREFIX_MEHARI = env.get("REEV_BACKEND_PREFIX_MEHARI", "http://mehari:8080") -#: Prefix for the backend of viguno service -BACKEND_PREFIX_VIGUNO = env.get("REEV_BACKEND_PREFIX_VIGUNO", "http://viguno:8080") -#: Prefix for the backend of nginx service -BACKEND_PREFIX_NGINX = env.get("REEV_BACKEND_PREFIX_NGINX", "http://nginx:8080") -#: Path to REEV version file. -VERSION_FILE = env.get("REEV_VERSION_FILE", "/VERSION") -#: The REEV version from the file (``None`` if to load dynamically from git) -REEV_VERSION = None -# Try to obtain version from file, otherwise keep it at ``None`` -if os.path.exists(VERSION_FILE): # pragma: no cover - with open(VERSION_FILE) as f: - REEV_VERSION = f.read().strip() or None -#: Template for ACMG rating -ACMG_RATING: dict = { - "pvs1": False, - "ps1": False, - "ps2": False, - "ps3": False, - "ps4": False, - "pm1": False, - "pm2": False, - "pm3": False, - "pm4": False, - "pm5": False, - "pm6": False, - "pp1": False, - "pp2": False, - "pp3": False, - "pp4": False, - "pp5": False, - "ba1": False, - "bs1": False, - "bs2": False, - "bs3": False, - "bs4": False, - "bp1": False, - "bp2": False, - "bp3": False, - "bp4": False, - "bp5": False, - "bp6": False, - "bp7": False, -} - -app = FastAPI() - -# Configure CORS settings -origins = [ - "http://localhost", # Update with the actual frontend URL - "http://localhost:8081", # Update with the actual frontend URL -] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Reverse proxy implementation -client = httpx.AsyncClient() - - -async def reverse_proxy(request: Request) -> Response: - """Implement reverse proxy for backend services.""" - url = request.url - backend_url = None - - if url.path.startswith("/proxy/annonars"): - backend_url = BACKEND_PREFIX_ANNONARS + url.path.replace("/proxy/annonars", "") - elif url.path.startswith("/proxy/mehari"): - backend_url = BACKEND_PREFIX_MEHARI + url.path.replace("/proxy/mehari", "") - elif url.path.startswith("/proxy/viguno"): - backend_url = BACKEND_PREFIX_VIGUNO + url.path.replace("/proxy/viguno", "") - elif url.path.startswith("/proxy/nginx"): - backend_url = BACKEND_PREFIX_NGINX + url.path.replace("/proxy/nginx", "") - - if backend_url: - backend_url = backend_url + (f"?{url.query}" if url.query else "") - backend_req = client.build_request( - method=request.method, - url=backend_url, - headers=request.headers.raw, - content=await request.body(), - ) - backend_resp = await client.send(backend_req, stream=True) - return StreamingResponse( - backend_resp.aiter_raw(), - status_code=backend_resp.status_code, - headers=backend_resp.headers, - background=BackgroundTask(backend_resp.aclose), - ) - else: - return Response(status_code=404, content="Reverse proxy route not found") - - -# Register reverse proxy route -app.add_route("/proxy/{path:path}", reverse_proxy, methods=["GET", "POST"]) - - -# Register app for returning REEV version. -@app.get("/version") -async def version(): - if REEV_VERSION: - version = REEV_VERSION - else: - version = subprocess.check_output(["git", "describe", "--tags", "--dirty"]).strip() - return Response(content=version) - - -# Register app for returning proxy for variantvalidator.org. -@app.get("/variantvalidator/{path:path}") -async def variantvalidator(request: Request, path: str): - """Implement reverse proxy for variantvalidator.org.""" - url = request.url - # Change grch to GRCh and chr to nothing in path - path = path.replace("grch", "GRCh").replace("chr", "") - backend_url = "https://rest.variantvalidator.org/VariantValidator/variantvalidator/" + path - - backend_url = backend_url + (f"?{url.query}" if url.query else "") - backend_req = client.build_request( - method=request.method, - url=backend_url, - content=await request.body(), - ) - backend_resp = await client.send(backend_req, stream=True) - return StreamingResponse( - backend_resp.aiter_raw(), - status_code=backend_resp.status_code, - headers=backend_resp.headers, - background=BackgroundTask(backend_resp.aclose), - ) - - -# Register app for retrieving ACMG classification. -@app.get("/acmg/{path:path}") -async def acmg(request: Request): - """Implement searching for ACMG classification.""" - query_params = request.query_params - chromosome = query_params.get("chromosome") - position = query_params.get("position") - reference = query_params.get("reference") - alternative = query_params.get("alternative") - build = query_params.get("release") - - if not chromosome or not position or not reference or not alternative or not build: - return Response(status_code=400, content="Missing query parameters") - - url = ( - f"http://wintervar.wglab.org/api_new.php?" - f"queryType=position&chr={chromosome}&pos={position}" - f"&ref={reference}&alt={alternative}&build={build}" +from starlette.responses import FileResponse + +app = FastAPI(title="REEV", openapi_url=f"{settings.API_V1_STR}/openapi.json") + +# Set all CORS enabled origins +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) - backend_req = client.build_request(method="GET", url=url) - backend_resp = await client.send(backend_req) - if backend_resp.status_code != 200: - return Response(status_code=backend_resp.status_code, content=backend_resp.content) - acmg_rating = ACMG_RATING.copy() - for key, value in backend_resp.json().items(): - if key.lower() in acmg_rating: - acmg_rating[key.lower()] = True if value == 1 else False - return JSONResponse(acmg_rating) +# Add internal API to router but excluded from docs +app.include_router(internal_router, prefix=settings.INTERNAL_STR, include_in_schema=settings.DEBUG) +# Add V1 API to router +app.include_router(api_v1_router, prefix=settings.API_V1_STR) -# Register route for favicon. -@app.get("/favicon.ico") +@app.get("/favicon.ico", include_in_schema=False) async def favicon(): + """Serve favicon""" return FileResponse(pathlib.Path(__file__).parent / "assets/favicon.ico") -# Server front-end (assets directory and index file for root ("/") entrypoint) when configured -if SERVE_FRONTEND: # pragma: no cover - print(f"serving front-end from {SERVE_FRONTEND}", file=sys.stderr) - app.mount("/assets", StaticFiles(directory=f"{SERVE_FRONTEND}/assets"), name="ui") +if settings.SERVE_FRONTEND: # pragma: no cover + logging.info(f"serving front-end from {settings.SERVE_FRONTEND}") + app.mount("/assets", StaticFiles(directory=f"{settings.SERVE_FRONTEND}/assets"), name="ui") @app.get("/") async def index(): """Render the index.html page at the root URL""" - return FileResponse(f"{SERVE_FRONTEND}/index.html") + return FileResponse(f"{settings.SERVE_FRONTEND}/index.html") @app.api_route("/{path_name:path}", methods=["GET"]) async def catch_all(request: Request, path_name: str): """Catch-all route forwarding to frontend.""" _, _ = request, path_name - return FileResponse(f"{SERVE_FRONTEND}/index.html") + return FileResponse(f"{settings.SERVE_FRONTEND}/index.html") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 00000000..67bc0542 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +from app.models.adminmsg import AdminMessage # noqa diff --git a/backend/app/models/adminmsg.py b/backend/app/models/adminmsg.py new file mode 100644 index 00000000..43623d5d --- /dev/null +++ b/backend/app/models/adminmsg.py @@ -0,0 +1,34 @@ +"""Models for admin messages.""" + +import uuid +from typing import TYPE_CHECKING + +from app.db.session import Base +from app.models.utils.guid import GUID +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text + + +class AdminMessage(Base): + """Message to be set by administrators. + + We currently only support read-only access, must be created via admin interface. + """ + + __tablename__ = "adminmessages" + + #: Primary key. + id = Column(Integer, primary_key=True, index=True) + #: UUID for external reference. + uuid = Column( + GUID(36), default=lambda: str(uuid.uuid4()), nullable=False, unique=True, index=True + ) + #: Message title. + title = Column(String(255), nullable=False) + #: The message's text. + text = Column(Text, nullable=True) + #: When to start displaying the message. + active_start = Column(DateTime, nullable=False) + #: When to stop displaying the message. + active_stop = Column(DateTime, nullable=False) + #: Whether the message is enabled at all. + enabled = Column(Boolean, default=True, nullable=False) diff --git a/backend/app/models/utils/__init__.py b/backend/app/models/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/models/utils/guid.py b/backend/app/models/utils/guid.py new file mode 100644 index 00000000..26b27d3c --- /dev/null +++ b/backend/app/models/utils/guid.py @@ -0,0 +1,50 @@ +"""Helper to provide UUIDs that also work with SQLite + +Source: https://gist.github.com/gmolveau/7caeeefe637679005a7bb9ae1b5e421e +""" + +import typing +import uuid + +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.sql.type_api import TypeEngine +from sqlalchemy.types import CHAR, TypeDecorator + + +class GUID(TypeDecorator): + """Platform-independent GUID type. + + Uses PostgreSQL's UUID type, otherwise uses + CHAR(32), storing as stringified hex values. + + """ + + impl = CHAR + + def load_dialect_impl(self, dialect: typing.Any) -> TypeEngine: + if dialect.name == "postgresql": + return dialect.type_descriptor(UUID()) + else: + return dialect.type_descriptor(CHAR(32)) + + def process_bind_param(self, value: typing.Any, dialect: typing.Any) -> str | None: + if value is None: + return value + elif dialect.name == "postgresql": + return str(value) + else: + if not isinstance(value, uuid.UUID): + return "%.32x" % uuid.UUID(value).int + else: + # hexstring + return "%.32x" % value.int + + def process_result_value(self, value: typing.Any, dialect: typing.Any) -> uuid.UUID | None: + _ = dialect + if value is None: + return value + else: + if not isinstance(value, uuid.UUID): + value = uuid.UUID(value) + return value diff --git a/backend/app/models/utils/helpers.py b/backend/app/models/utils/helpers.py new file mode 100644 index 00000000..070c2c09 --- /dev/null +++ b/backend/app/models/utils/helpers.py @@ -0,0 +1,12 @@ +from typing import Any, TypeVar + +from app.db.session import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +def sa_model_to_dict(db_obj: ModelType) -> dict[str, Any]: + """Convert database model to dict.""" + result = dict(db_obj.__dict__) + result.pop("_sa_instance_state") + return result diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 00000000..2d164f06 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +from app.schemas.adminmsg import AdminMessage, AdminMessageCreate, AdminMessageUpdate # noqa diff --git a/backend/app/schemas/adminmsg.py b/backend/app/schemas/adminmsg.py new file mode 100644 index 00000000..ee49623c --- /dev/null +++ b/backend/app/schemas/adminmsg.py @@ -0,0 +1,36 @@ +import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class AdminMessageBase(BaseModel): + title: str | None = None + text: str | None = None + active_start: datetime.datetime | None = None + active_stop: datetime.datetime | None = None + enabled: bool | None = None + + +class AdminMessageCreate(AdminMessageBase): + pass + + +class AdminMessageUpdate(AdminMessageBase): + pass + + +class AdminMessageInDbBase(AdminMessageBase): + id: int + uuid: UUID + + class Config: + from_attributes = True + + +class AdminMessage(AdminMessageInDbBase): + pass + + +class AdminMessageInDb(AdminMessageInDbBase): + pass diff --git a/backend/env.dev b/backend/env.dev new file mode 100644 index 00000000..d34ca25c --- /dev/null +++ b/backend/env.dev @@ -0,0 +1,12 @@ +# Application configuration +SERVER_NAME=localhost +SERVER_HOST=http://localhost:8080 +BACKEND_CORS_ORIGINS=["*"] +DEBUG=1 + +# Postgres configuration for dev, defaults as in reev-docker-compose +POSTGRES_USER=reev +POSTGRES_PASSWORD=db-password +POSTGRES_HOST=localhost +POSTGRES_PORT=3020 +POSTGRES_DB=reev diff --git a/backend/prestart.sh b/backend/prestart.sh new file mode 100644 index 00000000..61e01b29 --- /dev/null +++ b/backend/prestart.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +# Let the DB start +python $SCRIPT_DIR/app/backend_pre_start.py + +# Run migrations +alembic upgrade head + +# Create initial data in DB +python $SCRIPT_DIR/app/initial_data.py diff --git a/backend/setup.cfg b/backend/setup.cfg index 454cdec4..d1bb5a37 100644 --- a/backend/setup.cfg +++ b/backend/setup.cfg @@ -2,6 +2,13 @@ testpaths = tests +[coverage:run] +omit = + app/backend_pre_start.py + app/init_db.py + app/initial_data.py + app/tests_pre_start.py + [flake8] exclude = .*.py diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/api/api_v1/__init__.py b/backend/tests/api/api_v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/api/api_v1/test_adminmsgs.py b/backend/tests/api/api_v1/test_adminmsgs.py new file mode 100644 index 00000000..21833610 --- /dev/null +++ b/backend/tests/api/api_v1/test_adminmsgs.py @@ -0,0 +1,12 @@ +import pytest +from app.core.config import settings +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + + +@pytest.mark.asyncio +async def test_adminmsgs_list(db: Session, client: TestClient): + """Test proxying to annonars backend.""" + response = client.get(f"{settings.API_V1_STR}/adminmsgs/") + assert response.status_code == 200 + assert response.json() == [] diff --git a/backend/tests/api/internal/__init__.py b/backend/tests/api/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/api/internal/test_proxy.py b/backend/tests/api/internal/test_proxy.py new file mode 100644 index 00000000..08a60a57 --- /dev/null +++ b/backend/tests/api/internal/test_proxy.py @@ -0,0 +1,80 @@ +import pytest +from _pytest.monkeypatch import MonkeyPatch +from app.api.internal.endpoints.remote import default_acmg_rating +from app.core.config import settings +from fastapi.testclient import TestClient +from pytest_httpx._httpx_mock import HTTPXMock + +#: Host name to use for the mocked backend. +MOCKED_BACKEND_HOST = "mocked-backend" + +#: A "token" to be used in test URLs, does not carry a meaning as it is mocked. +MOCKED_URL_TOKEN = "xXTeStXxx" + + +@pytest.mark.asyncio +async def test_proxy_annonars(monkeypatch: MonkeyPatch, httpx_mock: HTTPXMock, client: TestClient): + """Test proxying to annonars backend.""" + monkeypatch.setattr(settings, "BACKEND_PREFIX_ANNONARS", f"http://{MOCKED_BACKEND_HOST}") + httpx_mock.add_response( + url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", + method="GET", + text="Mocked response", + ) + + response = client.get(f"/internal/proxy/annonars/{MOCKED_URL_TOKEN}") + assert response.status_code == 200 + assert response.text == "Mocked response" + + +@pytest.mark.asyncio +async def test_proxy_mehari(monkeypatch: MonkeyPatch, httpx_mock: HTTPXMock, client: TestClient): + """Test proxying to mehari backend.""" + monkeypatch.setattr(settings, "BACKEND_PREFIX_MEHARI", f"http://{MOCKED_BACKEND_HOST}") + httpx_mock.add_response( + url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", + method="GET", + text="Mocked response", + ) + + response = client.get(f"/internal/proxy/mehari/{MOCKED_URL_TOKEN}") + assert response.status_code == 200 + assert response.text == "Mocked response" + + +@pytest.mark.asyncio +async def test_proxy_viguno(monkeypatch: MonkeyPatch, httpx_mock: HTTPXMock, client: TestClient): + """Test proxying to viguno backend.""" + monkeypatch.setattr(settings, "BACKEND_PREFIX_VIGUNO", f"http://{MOCKED_BACKEND_HOST}") + httpx_mock.add_response( + url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", + method="GET", + text="Mocked response", + ) + + response = client.get(f"/internal/proxy/viguno/{MOCKED_URL_TOKEN}") + assert response.status_code == 200 + assert response.text == "Mocked response" + + +@pytest.mark.asyncio +async def test_proxy_nginx(monkeypatch: MonkeyPatch, httpx_mock: HTTPXMock, client: TestClient): + """Test proxying to nginx backend.""" + monkeypatch.setattr(settings, "BACKEND_PREFIX_NGINX", f"http://{MOCKED_BACKEND_HOST}") + httpx_mock.add_response( + url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", + method="GET", + text="Mocked response", + ) + + response = client.get(f"/internal/proxy/nginx/{MOCKED_URL_TOKEN}") + assert response.status_code == 200 + assert response.text == "Mocked response" + + +@pytest.mark.asyncio +async def test_invalid_proxy_route(client: TestClient): + """Test invalid proxy route.""" + response = client.get("/internal/proxy/some-other-path") + assert response.status_code == 404 + assert response.text == "Reverse proxy route not found" diff --git a/backend/tests/api/internal/test_remote.py b/backend/tests/api/internal/test_remote.py new file mode 100644 index 00000000..c92ac328 --- /dev/null +++ b/backend/tests/api/internal/test_remote.py @@ -0,0 +1,49 @@ +import pytest +from app.api.internal.endpoints.remote import default_acmg_rating +from fastapi.testclient import TestClient +from pytest_httpx._httpx_mock import HTTPXMock + +#: Host name to use for the mocked backend. +MOCKED_BACKEND_HOST = "mocked-backend" + +#: A "token" to be used in test URLs, does not carry a meaning as it is mocked. +MOCKED_URL_TOKEN = "xXTeStXxx" + + +@pytest.mark.asyncio +async def test_variantvalidator(httpx_mock: HTTPXMock, client: TestClient): + """Test variant validator endpoint.""" + variantvalidator_url = "https://rest.variantvalidator.org/VariantValidator/variantvalidator" + httpx_mock.add_response( + url=f"{variantvalidator_url}/{MOCKED_URL_TOKEN}", + method="GET", + text="Mocked response", + ) + + response = client.get(f"/internal/remote/variantvalidator/{MOCKED_URL_TOKEN}") + assert response.status_code == 200 + assert response.text == "Mocked response" + + +@pytest.mark.asyncio +async def test_acmg(httpx_mock: HTTPXMock, client: TestClient): + """Test ACMG endpoint.""" + acmg_url = "http://wintervar.wglab.org/api_new.php" + acmg_qury_params = "?chromosome=1&position=123&reference=A&alternative=T&release=hg19" + httpx_mock.add_response( + url=f"{acmg_url}?queryType=position&chr=1&pos=123&ref=A&alt=T&build=hg19", + method="GET", + json={"acmg": "Mocked response"}, + ) + + response = client.get(f"/internal/remote/acmg/{acmg_qury_params}") + assert response.status_code == 200 + assert response.json() == default_acmg_rating() + + +@pytest.mark.asyncio +async def test_acmg_missing_query_params(client: TestClient): + """Test ACMG endpoint with missing query parameters.""" + response = client.get("/internal/remote/acmg") + assert response.status_code == 400 + assert response.text == "Missing query parameters" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..10790e93 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,63 @@ +from typing import Iterator + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from app import models # noqa +from app.api import deps +from app.db import session +from app.db.base import Base +from app.main import app +from fastapi.testclient import TestClient +from sqlalchemy.engine import Engine, create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + + +@pytest.fixture() +def db_engine() -> Iterator[Engine]: + # setup engine with in-memory sqlite for testing + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + yield engine + + +@pytest.fixture() +def db(db_engine: Engine, monkeypatch: MonkeyPatch) -> Iterator: + """Create in-memory sqlite database for testing.""" + # create all tables + Base.metadata.create_all(bind=db_engine) + # create a session for testing + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=db_engine) + try: + monkeypatch.setattr(session, "engine", db_engine) + monkeypatch.setattr(session, "SessionLocal", TestingSessionLocal) + db = TestingSessionLocal() + + def override_get_db(): + yield db + + app.dependency_overrides[deps.get_db] = override_get_db + yield db + finally: + db.close() + # drop all tables + Base.metadata.drop_all(bind=db_engine) + + +@pytest.fixture(scope="module") +def client() -> Iterator[TestClient]: + """Fixture with a test client for the FastAPI app.""" + with TestClient(app) as c: + yield c + + +@pytest.fixture +def non_mocked_hosts(client: TestClient) -> list[str]: + """List of hosts that should not be mocked. + + We read the host from ``client``. + """ + return [client._base_url.host] diff --git a/backend/tests/crud/__init__.py b/backend/tests/crud/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/crud/test_adminmsg.py b/backend/tests/crud/test_adminmsg.py new file mode 100644 index 00000000..6cd3e2a9 --- /dev/null +++ b/backend/tests/crud/test_adminmsg.py @@ -0,0 +1,49 @@ +import typing + +import pytest +from app import crud +from app.schemas.adminmsg import AdminMessageCreate, AdminMessageUpdate +from sqlalchemy.orm import Session + + +@pytest.fixture +def adminmessage_create(faker: typing.Any) -> AdminMessageCreate: + return AdminMessageCreate( + title=faker.sentence(), + text=faker.paragraph(), + enabled=True, + active_start=faker.past_date(), + active_stop=faker.future_date(), + ) + + +def test_create_get_adminmessage(db: Session, adminmessage_create: AdminMessageCreate) -> None: + adminmessage_postcreate = crud.adminmessage.create(db=db, obj_in=adminmessage_create) + stored_item = crud.adminmessage.get(db=db, id=adminmessage_postcreate.id) + assert stored_item + assert adminmessage_postcreate.id == stored_item.id + assert adminmessage_postcreate.uuid == stored_item.uuid + assert adminmessage_postcreate.text == stored_item.text + assert adminmessage_postcreate.active_start == stored_item.active_start + assert adminmessage_postcreate.active_stop == stored_item.active_stop + + +def test_create_update_adminmessage( + db: Session, faker: typing.Any, adminmessage_create: AdminMessageCreate +) -> None: + adminmessage_update = AdminMessageUpdate( + title=faker.sentence(), + ) + adminmessage_postcreate = crud.adminmessage.create(db=db, obj_in=adminmessage_create) + adminmessage_postupdate = crud.adminmessage.update( + db=db, db_obj=adminmessage_postcreate, obj_in=adminmessage_update + ) + assert adminmessage_postupdate + assert adminmessage_postupdate.title == adminmessage_update.title + + +def test_delete_adminmessage(db: Session, adminmessage_create: AdminMessageCreate) -> None: + adminmessage_postcreate = crud.adminmessage.create(db=db, obj_in=adminmessage_create) + crud.adminmessage.remove(db=db, id=adminmessage_postcreate.id) + adminmessage_postdelete = crud.adminmessage.get(db=db, id=adminmessage_postcreate.id) + assert adminmessage_postdelete is None diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 65a00e2b..0a7e62a9 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -1,145 +1,33 @@ -import typing - import pytest -from app import main -from starlette.testclient import TestClient - -#: Host name to use for the mocked backend. -MOCKED_BACKEND_HOST = "mocked-backend" - -#: A "token" to be used in test URLs, does not carry a meaning as it is mocked. -MOCKED_URL_TOKEN = "xXTeStXxx" - -#: FastAPI/startlette test client. -client = TestClient(main.app) - - -@pytest.fixture -def non_mocked_hosts() -> typing.List[str]: - """List of hosts that should not be mocked. - - We read the host from ``client``. - """ - return [client._base_url.host] - - -@pytest.mark.asyncio -async def test_proxy_annonars(monkeypatch, httpx_mock): - """Test proxying to annonars backend.""" - monkeypatch.setattr(main, "BACKEND_PREFIX_ANNONARS", f"http://{MOCKED_BACKEND_HOST}") - httpx_mock.add_response( - url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", - method="GET", - text="Mocked response", - ) - - response = client.get(f"/proxy/annonars/{MOCKED_URL_TOKEN}") - assert response.status_code == 200 - assert response.text == "Mocked response" - - -@pytest.mark.asyncio -async def test_proxy_mehari(monkeypatch, httpx_mock): - """Test proxying to mehari backend.""" - monkeypatch.setattr(main, "BACKEND_PREFIX_MEHARI", f"http://{MOCKED_BACKEND_HOST}") - httpx_mock.add_response( - url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", - method="GET", - text="Mocked response", - ) - - response = client.get(f"/proxy/mehari/{MOCKED_URL_TOKEN}") - assert response.status_code == 200 - assert response.text == "Mocked response" - - -@pytest.mark.asyncio -async def test_proxy_viguno(monkeypatch, httpx_mock): - """Test proxying to viguno backend.""" - monkeypatch.setattr(main, "BACKEND_PREFIX_VIGUNO", f"http://{MOCKED_BACKEND_HOST}") - httpx_mock.add_response( - url=f"http://{MOCKED_BACKEND_HOST}/{MOCKED_URL_TOKEN}", - method="GET", - text="Mocked response", - ) - - response = client.get(f"/proxy/viguno/{MOCKED_URL_TOKEN}") - assert response.status_code == 200 - assert response.text == "Mocked response" - - -@pytest.mark.asyncio -async def test_invalid_proxy_route(monkeypatch, httpx_mock): - """Test invalid proxy route.""" - response = client.get("/proxy/some-other-path") - assert response.status_code == 404 - assert response.text == "Reverse proxy route not found" +from _pytest.monkeypatch import MonkeyPatch +from app.core.config import settings +from fastapi.testclient import TestClient @pytest.mark.asyncio -async def test_version(monkeypatch): +async def test_version(monkeypatch: MonkeyPatch, client: TestClient): """Test version endpoint.""" - monkeypatch.setattr(main, "REEV_VERSION", "1.2.3") - response = client.get("/version") + monkeypatch.setattr(settings, "REEV_VERSION", "1.2.3") + response = client.get("/internal/version") assert response.status_code == 200 assert response.text == "1.2.3" @pytest.mark.asyncio -async def test_version_no_version(monkeypatch, fp): +async def test_version_no_version(monkeypatch: MonkeyPatch, fp, client: TestClient): """Test version endpoint with no version.""" - monkeypatch.setattr(main, "REEV_VERSION", None) + monkeypatch.setattr(settings, "REEV_VERSION", None) # We mock the output of ``git describe`` as subprocesses will be triggered # internally. fp.register(["git", "describe", "--tags", "--dirty"], stdout="v0.0.0-16-g7a4205d-dirty") - response = client.get("/version") + response = client.get("/internal/version") assert response.status_code == 200 assert response.text == "v0.0.0-16-g7a4205d-dirty" @pytest.mark.asyncio -async def test_variantvalidator(httpx_mock): - """Test variant validator endpoint.""" - variantvalidator_url = "https://rest.variantvalidator.org/VariantValidator/variantvalidator" - httpx_mock.add_response( - url=f"{variantvalidator_url}/{MOCKED_URL_TOKEN}", - method="GET", - text="Mocked response", - ) - - response = client.get(f"/variantvalidator/{MOCKED_URL_TOKEN}") - assert response.status_code == 200 - assert response.text == "Mocked response" - - -@pytest.mark.asyncio -async def test_acmg(httpx_mock): - """Test ACMG endpoint.""" - acmg_url = "http://wintervar.wglab.org/api_new.php" - acmg_qury_params = "?chromosome=1&position=123&reference=A&alternative=T&release=hg19" - httpx_mock.add_response( - url=f"{acmg_url}?queryType=position&chr=1&pos=123&ref=A&alt=T&build=hg19", - method="GET", - json={"acmg": "Mocked response"}, - ) - - response = client.get(f"/acmg/{acmg_qury_params}") - assert response.status_code == 200 - print("Main", main.ACMG_RATING) - assert response.json() == main.ACMG_RATING - - -@pytest.mark.asyncio -async def test_acmg_missing_query_params(): - """Test ACMG endpoint with missing query parameters.""" - response = client.get("/acmg") - assert response.status_code == 400 - assert response.text == "Missing query parameters" - - -@pytest.mark.asyncio -async def test_favicon(): +async def test_favicon(client: TestClient): """Test favicon endpoint.""" response = client.get("/favicon.ico") assert response.status_code == 200 diff --git a/frontend/Makefile b/frontend/Makefile index e93eda42..b967d9c3 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -43,4 +43,4 @@ ci: \ .PHONY: serve serve: - npm run dev + MODE=development npm run dev diff --git a/frontend/src/api/__tests__/common.spec.ts b/frontend/src/api/__tests__/common.spec.ts index 3f648c58..8217a707 100644 --- a/frontend/src/api/__tests__/common.spec.ts +++ b/frontend/src/api/__tests__/common.spec.ts @@ -1,23 +1,34 @@ import { describe, it, expect } from 'vitest' -import { API_BASE_PREFIX, API_BASE_PREFIX_ANNONARS, API_BASE_PREFIX_MEHARI } from '../common' +import { + API_BASE_PREFIX, + API_BASE_PREFIX_ANNONARS, + API_BASE_PREFIX_MEHARI, + API_BASE_PREFIX_NGINX +} from '../common' describe.concurrent('API_BASE_PREFIX constants', () => { it('returns the correct API base prefix in production mode', () => { const originalMode = import.meta.env.MODE - expect(API_BASE_PREFIX).toBe('/') + expect(API_BASE_PREFIX).toBe('/internal/') import.meta.env.MODE = originalMode }) it('returns the correct API base prefix for annonars in production mode', () => { const originalMode = import.meta.env.MODE - expect(API_BASE_PREFIX_ANNONARS).toBe('/proxy/annonars') + expect(API_BASE_PREFIX_ANNONARS).toBe('/internal/proxy/annonars') import.meta.env.MODE = originalMode }) it('returns the correct API base prefix for mehari in production mode', () => { const originalMode = import.meta.env.MODE - expect(API_BASE_PREFIX_MEHARI).toBe('/proxy/mehari') + expect(API_BASE_PREFIX_MEHARI).toBe('/internal/proxy/mehari') + import.meta.env.MODE = originalMode + }) + + it('returns the correct API base prefix for nginx in production mode', () => { + const originalMode = import.meta.env.MODE + expect(API_BASE_PREFIX_NGINX).toBe('/internal/proxy/nginx') import.meta.env.MODE = originalMode }) }) diff --git a/frontend/src/api/common.ts b/frontend/src/api/common.ts index 91f96ca3..5c22d497 100644 --- a/frontend/src/api/common.ts +++ b/frontend/src/api/common.ts @@ -1,10 +1,17 @@ -export const API_BASE_PREFIX = import.meta.env.MODE == 'development' ? '//localhost:8080/' : '/' +export const API_BASE_PREFIX = + import.meta.env.MODE == 'development' ? '//localhost:8080/internal/' : '/internal/' export const API_BASE_PREFIX_ANNONARS = - import.meta.env.MODE == 'development' ? '//localhost:8080/proxy/annonars' : '/proxy/annonars' + import.meta.env.MODE == 'development' + ? `//localhost:8080/internal/proxy/annonars` + : '/internal/proxy/annonars' export const API_BASE_PREFIX_MEHARI = - import.meta.env.MODE == 'development' ? '//localhost:8080/proxy/mehari' : '/proxy/mehari' + import.meta.env.MODE == 'development' + ? '//localhost:8080/internal/proxy/mehari' + : '/internal/proxy/mehari' export const API_BASE_PREFIX_NGINX = - import.meta.env.MODE == 'development' ? '//localhost:8080/proxy/nginx' : '/proxy/nginx' + import.meta.env.MODE == 'development' + ? '//localhost:8080/internal/proxy/proxy/nginx' + : '/internal/proxy/nginx' diff --git a/frontend/src/components/VariantDetails/VariantValidator.vue b/frontend/src/components/VariantDetails/VariantValidator.vue index 4039ee10..9cb5b638 100644 --- a/frontend/src/components/VariantDetails/VariantValidator.vue +++ b/frontend/src/components/VariantDetails/VariantValidator.vue @@ -24,7 +24,7 @@ const queryVariantValidatorApi = async () => { variantValidatorState.value = VariantValidatorStates.Running const url = API_BASE_URL + - `variantvalidator/${props.smallVariant?.release}/` + + `/internal/remote/variantvalidator/${props.smallVariant?.release}/` + `${props.smallVariant?.chromosome}-${props.smallVariant?.start}-` + `${props.smallVariant?.reference}-${props.smallVariant?.alternative}` + `/all?content-type=application%2Fjson` diff --git a/frontend/src/stores/variantAcmgRating.ts b/frontend/src/stores/variantAcmgRating.ts index 01b3da97..9218b2d5 100644 --- a/frontend/src/stores/variantAcmgRating.ts +++ b/frontend/src/stores/variantAcmgRating.ts @@ -63,7 +63,7 @@ export const useVariantAcmgRatingStore = defineStore('variantAcmgRating', () => const ref = smallVar.reference const alt = smallVar.alternative const response = await fetch( - `${API_BASE_URL}acmg/?release=${release}&chromosome=${chromosome}` + + `${API_BASE_URL}internal/remote/acmg/?release=${release}&chromosome=${chromosome}` + `&position=${pos}&reference=${ref}&alternative=${alt}`, { method: 'GET' } ) diff --git a/utils/docker/Dockerfile b/utils/docker/Dockerfile index 5a5fb911..12bec6c2 100644 --- a/utils/docker/Dockerfile +++ b/utils/docker/Dockerfile @@ -64,6 +64,7 @@ ARG version_file=utils/docker/empty-file-dont-remove ENV REEV_SERVE_FRONTEND=/home/reev/ui COPY --from=backend-deps /.venv /.venv +COPY --chmod=a+rx utils/docker/entrypoint.sh /entrypoint.sh ENV PATH="/.venv/bin:$PATH" @@ -75,5 +76,5 @@ COPY ${version_file} /VERSION COPY backend/. . COPY --from=frontend-build /dist /home/reev/ui -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["/entrypoint.sh"] EXPOSE 8080 diff --git a/utils/docker/entrypoint.sh b/utils/docker/entrypoint.sh index c9812beb..8a1c20e5 100644 --- a/utils/docker/entrypoint.sh +++ b/utils/docker/entrypoint.sh @@ -5,89 +5,14 @@ set -euo pipefail # Interpreted environment variables. # -# PATH_DB_BASE -- base directory for database defaults -# default: /data/annonars # HTTP_HOST -- host to listen on # default: 0.0.0.0 # HTTP_PORT -- port # default: 8080 -# -# PATH_DB_CADD_37 -- path to CADD database for GRCh37, defaults to $PATH_DB_BASE/grch37/cadd/rocksdb -# PATH_DB_CADD_38 -- path to CADD database for GRCh38, defaults to $PATH_DB_BASE/grch38/cadd/rocksdb -# PATH_DB_DBSNP_37 -- path to dbSNP database for GRCh37, defaults to $PATH_DB_BASE/grch37/dbsnp/rocksdb -# PATH_DB_DBSNP_38 -- path to dbSNP database for GRCh38, defaults to $PATH_DB_BASE/grch38/dbsnp/rocksdb -# PATH_DB_DBNSFP_37 -- path to dbNSFP database for GRCh37, defaults to $PATH_DB_BASE/grch37/dbnsfp/rocksdb -# PATH_DB_DBNSFP_38 -- path to dbNSFP database for GRCh38, defaults to $PATH_DB_BASE/grch38/dbnsfp/rocksdb -# PATH_DB_DBSCSNV_37 -- path to dbscSNV database for GRCh37, defaults to $PATH_DB_BASE/grch37/dbscsnv/rocksdb -# PATH_DB_DBSCSNV_38 -- path to dbscSNV database for GRCh38, defaults to $PATH_DB_BASE/grch38/dbscsnv/rocksdb -# PATH_DB_GNOMAD_MTDNA_37 -- path to gnomAD mtDNA database for GRCh37, defaults to $PATH_DB_BASE/grch37/gnomad-mtdna/rocksdb -# PATH_DB_GNOMAD_MTDNA_38 -- path to gnomAD mtDNA database for GRCh38, defaults to $PATH_DB_BASE/grch38/gnomad-mtdna/rocksdb -# PATH_DB_GNOMAD_EXOMES_37 -- path to gnomAD exomes database for GRCh37, defaults to $PATH_DB_BASE/grch37/gnomad-exomes/rocksdb -# PATH_DB_GNOMAD_EXOMES_38 -- path to gnomAD exomes database for GRCh38, defaults to $PATH_DB_BASE/grch38/gnomad-exomes/rocksdb -# PATH_DB_GNOMAD_GENOMES_37 -- path to gnomAD genomes database for GRCh37, defaults to $PATH_DB_BASE/grch37/gnomad-genomes/rocksdb -# PATH_DB_GNOMAD_GENOMES_38 -- path to gnomAD genomes database for GRCh38, defaults to $PATH_DB_BASE/grch38/gnomad-genomes/rocksdb -# PATH_DB_HELIXMTDB_37 -- path to HelixMTdb database for GRCh37, defaults to $PATH_DB_BASE/grch37/helixmtdb/rocksdb -# PATH_DB_HELIXMTDB_38 -- path to HelixMTdb database for GRCh38, defaults to $PATH_DB_BASE/grch38/helixmtdb/rocksdb -# PATH_DB_CONS_37 -- path to UCSC conservation database for GRCh37, defaults to $PATH_DB_BASE/grch37/cons/rocksdb -# PATH_DB_CONS_38 -- path to UCSC conservation database for GRCh38, defaults to $PATH_DB_BASE/grch38/cons/rocksdb -# -# PATH_GENES -- path to the genes RocksDB, defaults to $PATH_DB_BASE/genes/rocksdb -PATH_DB_BASE=${PATH_HPO_DIR-/data/annonars} HTTP_HOST=${HTTP_HOST-0.0.0.0} HTTP_PORT=${HTTP_PORT-8080} -PATH_DB_CADD_37=${PATH_DB_CADD_37-$PATH_DB_BASE/grch37/cadd/rocksdb} -PATH_DB_CADD_38=${PATH_DB_CADD_38-$PATH_DB_BASE/grch38/cadd/rocksdb} -PATH_DB_DBSNP_37=${PATH_DB_DBSNP_37-$PATH_DB_BASE/grch37/dbsnp/rocksdb} -PATH_DB_DBSNP_38=${PATH_DB_DBSNP_38-$PATH_DB_BASE/grch38/dbsnp/rocksdb} -PATH_DB_DBNSFP_37=${PATH_DB_DBNSFP_37-$PATH_DB_BASE/grch37/dbnsfp/rocksdb} -PATH_DB_DBNSFP_38=${PATH_DB_DBNSFP_38-$PATH_DB_BASE/grch38/dbnsfp/rocksdb} -PATH_DB_DBSCSNV_37=${PATH_DB_DBSCSNV_37-$PATH_DB_BASE/grch37/dbscsnv/rocksdb} -PATH_DB_DBSCSNV_38=${PATH_DB_DBSCSNV_38-$PATH_DB_BASE/grch38/dbscsnv/rocksdb} -PATH_DB_GNOMAD_MTDNA_37=${PATH_DB_GNOMAD_MTDNA_37-$PATH_DB_BASE/grch37/gnomad-mtdna/rocksdb} -PATH_DB_GNOMAD_MTDNA_38=${PATH_DB_GNOMAD_MTDNA_38-$PATH_DB_BASE/grch38/gnomad-mtdna/rocksdb} -PATH_DB_GNOMAD_EXOMES_37=${PATH_DB_GNOMAD_EXOMES_37-$PATH_DB_BASE/grch37/gnomad-exomes/rocksdb} -PATH_DB_GNOMAD_EXOMES_38=${PATH_DB_GNOMAD_EXOMES_38-$PATH_DB_BASE/grch38/gnomad-exomes/rocksdb} -PATH_DB_GNOMAD_GENOMES_37=${PATH_DB_GNOMAD_GENOMES_37-$PATH_DB_BASE/grch37/gnomad-genomes/rocksdb} -PATH_DB_GNOMAD_GENOMES_38=${PATH_DB_GNOMAD_GENOMES_38-$PATH_DB_BASE/grch38/gnomad-genomes/rocksdb} -PATH_DB_HELIXMTDB_37=${PATH_DB_HELIXMTDB_37-$PATH_DB_BASE/grch37/helixmtdb/rocksdb} -PATH_DB_HELIXMTDB_38=${PATH_DB_HELIXMTDB_38-$PATH_DB_BASE/grch38/helixmtdb/rocksdb} -PATH_DB_CONS_37=${PATH_DB_CONS_37-$PATH_DB_BASE/grch37/cons/rocksdb} -PATH_DB_CONS_38=${PATH_DB_CONS_38-$PATH_DB_BASE/grch38/cons/rocksdb} -PATH_GENES=${PATH_GENES-$PATH_DB_BASE/genes/rocksdb} - -first=${1-} - -if [ "$first" == exec ]; then - shift - exec "$@" -else - exec \ - annonars \ - run-server \ - $(test -e $PATH_DB_CADD_37 && echo --path-cadd $PATH_DB_CADD_37) \ - $(test -e $PATH_DB_CADD_38 && echo --path-cadd $PATH_DB_CADD_38) \ - $(test -e $PATH_DB_DBSNP_37 && echo --path-dbsnp $PATH_DB_DBSNP_37) \ - $(test -e $PATH_DB_DBSNP_38 && echo --path-dbsnp $PATH_DB_DBSNP_38) \ - $(test -e $PATH_DB_DBNSFP_37 && echo --path-dbnsfp $PATH_DB_DBNSFP_37) \ - $(test -e $PATH_DB_DBNSFP_38 && echo --path-dbnsfp $PATH_DB_DBNSFP_38) \ - $(test -e $PATH_DB_DBSCSNV_37 && echo --path-dbscsnv $PATH_DB_DBSCSNV_37) \ - $(test -e $PATH_DB_DBSCSNV_38 && echo --path-dbscsnv $PATH_DB_DBSCSNV_38) \ - $(test -e $PATH_DB_GNOMAD_MTDNA_37 && echo --path-gnomad-mtdna $PATH_DB_GNOMAD_MTDNA_37) \ - $(test -e $PATH_DB_GNOMAD_MTDNA_38 && echo --path-gnomad-mtdna $PATH_DB_GNOMAD_MTDNA_38) \ - $(test -e $PATH_DB_GNOMAD_EXOMES_37 && echo --path-gnomad-exomes $PATH_DB_GNOMAD_EXOMES_37) \ - $(test -e $PATH_DB_GNOMAD_EXOMES_38 && echo --path-gnomad-exomes $PATH_DB_GNOMAD_EXOMES_38) \ - $(test -e $PATH_DB_GNOMAD_GENOMES_37 && echo --path-gnomad-genomes $PATH_DB_GNOMAD_GENOMES_37) \ - $(test -e $PATH_DB_GNOMAD_GENOMES_38 && echo --path-gnomad-genomes $PATH_DB_GNOMAD_GENOMES_38) \ - $(test -e $PATH_DB_HELIXMTDB_37 && echo --path-helixmtdb $PATH_DB_HELIXMTDB_37) \ - $(test -e $PATH_DB_HELIXMTDB_38 && echo --path-helixmtdb $PATH_DB_HELIXMTDB_38) \ - $(test -e $PATH_DB_CONS_37 && echo --path-ucsc-conservation $PATH_DB_CONS_37) \ - $(test -e $PATH_DB_CONS_38 && echo --path-ucsc-conservation $PATH_DB_CONS_38) \ - $(test -e $PATH_GENES && echo --path-ucsc-conservation $PATH_GENES) \ - \ - --listen-host "$HTTP_HOST" \ - --listen-port "$HTTP_PORT" -fi +bash -x /home/reev/backend_pre_start.sh -exit $? +uvicorn app.main:app --host $HTTP_HOST --port $HTTP_PORT