From 1a68a01ae188ae85b7b4aea5d664fd501f2061f5 Mon Sep 17 00:00:00 2001 From: Julien Maupetit Date: Wed, 27 Mar 2024 19:17:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(api)=20integrate=20postgresql=20datab?= =?UTF-8?q?ase=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are now able to use a PostgreSQL database to persist data. --- .github/workflows/python-app.yml | 14 + CHANGELOG.md | 4 + Makefile | 36 +- bin/alembic | 13 + bin/pipenv | 2 +- docker-compose.yml | 18 +- env.d/api | 9 + env.d/postgresql | 3 + src/api/Pipfile | 8 +- src/api/Pipfile.lock | 335 +++++++++++++++++- src/api/pyproject.toml | 4 +- src/api/qualicharge/alembic.ini | 110 ++++++ src/api/qualicharge/api/__init__.py | 14 +- src/api/qualicharge/conf.py | 38 ++ src/api/qualicharge/db.py | 77 ++++ src/api/qualicharge/migrations/__init__.py | 1 + src/api/qualicharge/migrations/env.py | 82 +++++ src/api/qualicharge/migrations/script.py.mako | 26 ++ .../migrations/versions/__init__.py | 1 + src/api/tests/conftest.py | 10 + src/api/tests/fixtures/__init__.py | 1 + src/api/tests/fixtures/db.py | 61 ++++ src/api/tests/test_api.py | 11 + 23 files changed, 865 insertions(+), 13 deletions(-) create mode 100755 bin/alembic create mode 100644 env.d/postgresql create mode 100644 src/api/qualicharge/alembic.ini create mode 100644 src/api/qualicharge/db.py create mode 100644 src/api/qualicharge/migrations/__init__.py create mode 100644 src/api/qualicharge/migrations/env.py create mode 100644 src/api/qualicharge/migrations/script.py.mako create mode 100644 src/api/qualicharge/migrations/versions/__init__.py create mode 100644 src/api/tests/conftest.py create mode 100644 src/api/tests/fixtures/__init__.py create mode 100644 src/api/tests/fixtures/db.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 36076e3c..9267b903 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -54,6 +54,20 @@ jobs: test-api: needs: build-api runs-on: ubuntu-latest + services: + postgres: + image: postgres:14 + env: + POSTGRES_DB: test-qualicharge-api + POSTGRES_USER: qualicharge + POSTGRES_PASSWORD: pass + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 defaults: run: working-directory: ./src/api diff --git a/CHANGELOG.md b/CHANGELOG.md index c6679bce..9a4869eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +## Added + +- Integrate PostgreSQL database persistency in an asynchronous context + ## [0.1.0] - 2024-03-26 ### Added diff --git a/Makefile b/Makefile index 48b0231b..e14fc42c 100644 --- a/Makefile +++ b/Makefile @@ -15,17 +15,27 @@ default: help # -- Docker/compose bootstrap: ## bootstrap the project for development bootstrap: \ - build + build \ + migrate-api \ + create-api-test-db .PHONY: bootstrap build: ## build the app container(s) $(COMPOSE) build .PHONY: build -logs: ## display OCPI server logs (follow mode) +down: ## stop and remove all containers + @$(COMPOSE) down +.PHONY: down + +logs: ## display all services logs (follow mode) @$(COMPOSE) logs -f .PHONY: logs +logs-api: ## display API server logs (follow mode) + @$(COMPOSE) logs -f api +.PHONY: logs-api + run: ## run the whole stack $(COMPOSE) up -d .PHONY: run @@ -38,7 +48,27 @@ stop: ## stop all servers @$(COMPOSE) stop .PHONY: stop -# -- OCPI +# -- Provisioning +create-api-test-db: ## create API test database + @echo "Creating api service test database…" + @$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/postgres" -c "create database \"$${QUALICHARGE_TEST_DB_NAME}\";"' || echo "Duly noted, skipping database creation." +.PHONY: create-api-test-db + +drop-api-test-db: ## drop API test database + @echo "Droping api service test database…" + @$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/postgres" -c "drop database \"$${QUALICHARGE_TEST_DB_NAME}\";"' || echo "Duly noted, skipping database deletion." +.PHONY: drop-api-test-db + +migrate-api: ## run alembic database migrations for the api service + @echo "Running api service database engine…" + @$(COMPOSE) up -d --wait postgresql + @echo "Creating api service database…" + @$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/postgres" -c "create database \"$${QUALICHARGE_DB_NAME}\";"' || echo "Duly noted, skipping database creation." + @echo "Running migrations for api service…" + @bin/alembic upgrade head +.PHONY: migrate-api + +# -- API lint: ## lint api python sources lint: \ lint-black \ diff --git a/bin/alembic b/bin/alembic new file mode 100755 index 00000000..b7262bb2 --- /dev/null +++ b/bin/alembic @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -eo pipefail + +declare DOCKER_USER +DOCKER_UID="$(id -u)" +DOCKER_GID="$(id -g)" +DOCKER_USER="${DOCKER_UID}:${DOCKER_GID}" + +DOCKER_USER=${DOCKER_USER} \ + DOCKER_UID=${DOCKER_UID} \ + DOCKER_GID=${DOCKER_GID} \ + docker compose run --rm api pipenv run alembic -c qualicharge/alembic.ini "$@" diff --git a/bin/pipenv b/bin/pipenv index 4e357e0b..4e66a70a 100755 --- a/bin/pipenv +++ b/bin/pipenv @@ -2,5 +2,5 @@ set -eo pipefail -docker compose run --rm -u "pipenv:pipenv" api pipenv "$@" +docker compose run --rm --no-deps -u "pipenv:pipenv" api pipenv "$@" docker compose build api diff --git a/docker-compose.yml b/docker-compose.yml index 604e0355..9cc19003 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,20 @@ -version: '3.8' +version: "3.8" services: + postgresql: + image: postgres:14 + env_file: + - env.d/postgresql + - env.d/api + healthcheck: + test: + - "CMD-SHELL" + - "pg_isready" + - "-d" + - "$${QUALICHARGE_DB_NAME}" + interval: 10s + timeout: 5s + retries: 5 api: build: @@ -16,3 +30,5 @@ services: - env.d/api volumes: - ./src/api:/app + depends_on: + - postgresql diff --git a/env.d/api b/env.d/api index 3646d14f..70109200 100644 --- a/env.d/api +++ b/env.d/api @@ -1 +1,10 @@ PORT=8000 +QUALICHARGE_ALLOWED_HOSTS=["http://localhost:8010"] +QUALICHARGE_DB_ENGINE=postgresql +QUALICHARGE_DB_HOST=postgresql +QUALICHARGE_DB_NAME=qualicharge-api +QUALICHARGE_DB_PASSWORD=pass +QUALICHARGE_DB_PORT=5432 +QUALICHARGE_DB_USER=qualicharge +QUALICHARGE_DEBUG=1 +QUALICHARGE_TEST_DB_NAME=test-qualicharge-api diff --git a/env.d/postgresql b/env.d/postgresql new file mode 100644 index 00000000..b36b783c --- /dev/null +++ b/env.d/postgresql @@ -0,0 +1,3 @@ +POSTGRES_DB=qualicharge-api +POSTGRES_USER=qualicharge +POSTGRES_PASSWORD=pass diff --git a/src/api/Pipfile b/src/api/Pipfile index 92a1f9b4..26aed4d2 100644 --- a/src/api/Pipfile +++ b/src/api/Pipfile @@ -4,17 +4,21 @@ verify_ssl = true name = "pypi" [packages] +alembic = "==1.13.1" fastapi = "==0.110.0" +psycopg2-binary = "==2.9.9" pydantic-settings = "==2.2.1" setuptools = "==69.2.0" -uvicorn = {extras = ["standard"], version = "==0.29.0"} +sqlmodel = "==0.0.16" +uvicorn = {extras = ["standard"] } [dev-packages] black = "==24.3.0" honcho = "==1.1.0" -httpx = "==0.27.0" mypy = "==1.9.0" +polyfactory = "==2.15.0" pytest = "==8.1.1" +pytest-httpx = "==0.30.0" ruff = "==0.3.4" [requires] diff --git a/src/api/Pipfile.lock b/src/api/Pipfile.lock index e387e0ec..fba4255a 100644 --- a/src/api/Pipfile.lock +++ b/src/api/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ba86ef0a1860c7b649d67496518650fadea9c44c79e002ab93906f279aae90d0" + "sha256": "459eb5f9afaa486e224cbcb8bc8088f69bc39dd3046e133bf2ca9e424ae6903b" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,15 @@ ] }, "default": { + "alembic": { + "hashes": [ + "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43", + "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.13.1" + }, "annotated-types": { "hashes": [ "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", @@ -49,6 +58,70 @@ "markers": "python_version >= '3.8'", "version": "==0.110.0" }, + "greenlet": { + "hashes": [ + "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67", + "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6", + "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257", + "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4", + "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676", + "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61", + "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc", + "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca", + "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7", + "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728", + "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305", + "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6", + "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379", + "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414", + "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04", + "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a", + "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf", + "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491", + "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559", + "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e", + "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274", + "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb", + "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b", + "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9", + "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b", + "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be", + "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506", + "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405", + "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113", + "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f", + "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5", + "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230", + "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d", + "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f", + "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a", + "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e", + "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61", + "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6", + "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d", + "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71", + "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22", + "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2", + "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3", + "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067", + "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc", + "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881", + "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3", + "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e", + "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac", + "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53", + "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0", + "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b", + "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83", + "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41", + "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c", + "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf", + "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da", + "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33" + ], + "markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==3.0.3" + }, "h11": { "hashes": [ "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", @@ -106,6 +179,159 @@ "markers": "python_version >= '3.5'", "version": "==3.6" }, + "mako": { + "hashes": [ + "sha256:2a0c8ad7f6274271b3bb7467dd37cf9cc6dab4bc19cb69a4ef10669402de698e", + "sha256:32a99d70754dfce237019d17ffe4a282d2d3351b9c476e90d8a60e63f133b80c" + ], + "markers": "python_version >= '3.8'", + "version": "==1.3.2" + }, + "markupsafe": { + "hashes": [ + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.5" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9", + "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77", + "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e", + "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84", + "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3", + "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2", + "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67", + "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876", + "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152", + "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f", + "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a", + "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6", + "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503", + "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f", + "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493", + "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996", + "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f", + "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e", + "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59", + "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94", + "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7", + "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682", + "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420", + "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae", + "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291", + "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe", + "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980", + "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93", + "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692", + "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119", + "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716", + "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472", + "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b", + "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2", + "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc", + "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", + "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5", + "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", + "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984", + "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9", + "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf", + "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0", + "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f", + "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212", + "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb", + "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be", + "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90", + "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041", + "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7", + "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860", + "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d", + "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245", + "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27", + "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417", + "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359", + "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202", + "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0", + "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7", + "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba", + "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1", + "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd", + "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07", + "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98", + "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55", + "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d", + "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972", + "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f", + "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e", + "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26", + "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957", + "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53", + "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.9.9" + }, "pydantic": { "hashes": [ "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6", @@ -289,6 +515,70 @@ "markers": "python_version >= '3.7'", "version": "==1.3.1" }, + "sqlalchemy": { + "hashes": [ + "sha256:01d10638a37460616708062a40c7b55f73e4d35eaa146781c683e0fa7f6c43fb", + "sha256:04c487305ab035a9548f573763915189fc0fe0824d9ba28433196f8436f1449c", + "sha256:0dfefdb3e54cd15f5d56fd5ae32f1da2d95d78319c1f6dfb9bcd0eb15d603d5d", + "sha256:0f3ca96af060a5250a8ad5a63699180bc780c2edf8abf96c58af175921df847a", + "sha256:205f5a2b39d7c380cbc3b5dcc8f2762fb5bcb716838e2d26ccbc54330775b003", + "sha256:25664e18bef6dc45015b08f99c63952a53a0a61f61f2e48a9e70cec27e55f699", + "sha256:296195df68326a48385e7a96e877bc19aa210e485fa381c5246bc0234c36c78e", + "sha256:2a0732dffe32333211801b28339d2a0babc1971bc90a983e3035e7b0d6f06b93", + "sha256:3071ad498896907a5ef756206b9dc750f8e57352113c19272bdfdc429c7bd7de", + "sha256:308ef9cb41d099099fffc9d35781638986870b29f744382904bf9c7dadd08513", + "sha256:334184d1ab8f4c87f9652b048af3f7abea1c809dfe526fb0435348a6fef3d380", + "sha256:38b624e5cf02a69b113c8047cf7f66b5dfe4a2ca07ff8b8716da4f1b3ae81567", + "sha256:471fcb39c6adf37f820350c28aac4a7df9d3940c6548b624a642852e727ea586", + "sha256:4c142852ae192e9fe5aad5c350ea6befe9db14370b34047e1f0f7cf99e63c63b", + "sha256:4f6d971255d9ddbd3189e2e79d743ff4845c07f0633adfd1de3f63d930dbe673", + "sha256:52c8011088305476691b8750c60e03b87910a123cfd9ad48576d6414b6ec2a1d", + "sha256:52de4736404e53c5c6a91ef2698c01e52333988ebdc218f14c833237a0804f1b", + "sha256:5c7b02525ede2a164c5fa5014915ba3591730f2cc831f5be9ff3b7fd3e30958e", + "sha256:5ef3fbccb4058355053c51b82fd3501a6e13dd808c8d8cd2561e610c5456013c", + "sha256:5f20cb0a63a3e0ec4e169aa8890e32b949c8145983afa13a708bc4b0a1f30e03", + "sha256:61405ea2d563407d316c63a7b5271ae5d274a2a9fbcd01b0aa5503635699fa1e", + "sha256:77d29cb6c34b14af8a484e831ab530c0f7188f8efed1c6a833a2c674bf3c26ec", + "sha256:7b184e3de58009cc0bf32e20f137f1ec75a32470f5fede06c58f6c355ed42a72", + "sha256:7e614d7a25a43a9f54fcce4675c12761b248547f3d41b195e8010ca7297c369c", + "sha256:8197d6f7a3d2b468861ebb4c9f998b9df9e358d6e1cf9c2a01061cb9b6cf4e41", + "sha256:87a1d53a5382cdbbf4b7619f107cc862c1b0a4feb29000922db72e5a66a5ffc0", + "sha256:8c37f1050feb91f3d6c32f864d8e114ff5545a4a7afe56778d76a9aec62638ba", + "sha256:90453597a753322d6aa770c5935887ab1fc49cc4c4fdd436901308383d698b4b", + "sha256:988569c8732f54ad3234cf9c561364221a9e943b78dc7a4aaf35ccc2265f1930", + "sha256:99a1e69d4e26f71e750e9ad6fdc8614fbddb67cfe2173a3628a2566034e223c7", + "sha256:9b19836ccca0d321e237560e475fd99c3d8655d03da80c845c4da20dda31b6e1", + "sha256:9d6753305936eddc8ed190e006b7bb33a8f50b9854823485eed3a886857ab8d1", + "sha256:a13b917b4ffe5a0a31b83d051d60477819ddf18276852ea68037a144a506efb9", + "sha256:a88913000da9205b13f6f195f0813b6ffd8a0c0c2bd58d499e00a30eb508870c", + "sha256:b2a0e3cf0caac2085ff172c3faacd1e00c376e6884b5bc4dd5b6b84623e29e4f", + "sha256:b5d7ed79df55a731749ce65ec20d666d82b185fa4898430b17cb90c892741520", + "sha256:bab41acf151cd68bc2b466deae5deeb9e8ae9c50ad113444151ad965d5bf685b", + "sha256:bd9566b8e58cabd700bc367b60e90d9349cd16f0984973f98a9a09f9c64e86f0", + "sha256:bda7ce59b06d0f09afe22c56714c65c957b1068dee3d5e74d743edec7daba552", + "sha256:c2f9c762a2735600654c654bf48dad388b888f8ce387b095806480e6e4ff6907", + "sha256:c4520047006b1d3f0d89e0532978c0688219857eb2fee7c48052560ae76aca1e", + "sha256:d96710d834a6fb31e21381c6d7b76ec729bd08c75a25a5184b1089141356171f", + "sha256:dba622396a3170974f81bad49aacebd243455ec3cc70615aeaef9e9613b5bca5", + "sha256:dc4ee2d4ee43251905f88637d5281a8d52e916a021384ec10758826f5cbae305", + "sha256:dddaae9b81c88083e6437de95c41e86823d150f4ee94bf24e158a4526cbead01", + "sha256:de7202ffe4d4a8c1e3cde1c03e01c1a3772c92858837e8f3879b497158e4cb44", + "sha256:e5bbe55e8552019c6463709b39634a5fc55e080d0827e2a3a11e18eb73f5cdbd", + "sha256:ea311d4ee9a8fa67f139c088ae9f905fcf0277d6cd75c310a21a88bf85e130f5", + "sha256:fecd5089c4be1bcc37c35e9aa678938d2888845a134dd016de457b942cf5a758" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.29" + }, + "sqlmodel": { + "hashes": [ + "sha256:966656f18a8e9a2d159eb215b07fb0cf5222acfae3362707ca611848a8a06bd1", + "sha256:b972f5d319580d6c37ecc417881f6ec4d1ad3ed3583d0ac0ed43234a28bf605a" + ], + "index": "pypi", + "markers": "python_version >= '3.7' and python_version < '4.0'", + "version": "==0.0.16" + }, "starlette": { "hashes": [ "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044", @@ -564,6 +854,14 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "faker": { + "hashes": [ + "sha256:998c29ee7d64429bd59204abffa9ba11f784fb26c7b9df4def78d1a70feb36a7", + "sha256:a5ddccbe97ab691fad6bd8036c31f5697cfaa550e62e000078d1935fa8a7ec2e" + ], + "markers": "python_version >= '3.8'", + "version": "==24.4.0" + }, "h11": { "hashes": [ "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", @@ -593,7 +891,6 @@ "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5", "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5" ], - "index": "pypi", "markers": "python_version >= '3.8'", "version": "==0.27.0" }, @@ -687,6 +984,15 @@ "markers": "python_version >= '3.8'", "version": "==1.4.0" }, + "polyfactory": { + "hashes": [ + "sha256:a3ff5263756ad74acf4001f04c1b6aab7d1197cbaa070352df79573a8dcd85ec", + "sha256:ff5b6a8742cbd6fbde9f81310b9732d5421fbec31916d6ede5a977753110fbe9" + ], + "index": "pypi", + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==2.15.0" + }, "pytest": { "hashes": [ "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7", @@ -696,6 +1002,23 @@ "markers": "python_version >= '3.8'", "version": "==8.1.1" }, + "pytest-httpx": { + "hashes": [ + "sha256:6d47849691faf11d2532565d0c8e0e02b9f4ee730da31687feae315581d7520c", + "sha256:755b8edca87c974dd4f3605c374fda11db84631de3d163b99c0df5807023a19a" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==0.30.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.9.0.post0" + }, "ruff": { "hashes": [ "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365", @@ -720,6 +1043,14 @@ "markers": "python_version >= '3.7'", "version": "==0.3.4" }, + "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:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", diff --git a/src/api/pyproject.toml b/src/api/pyproject.toml index 076d56ed..197d6d50 100644 --- a/src/api/pyproject.toml +++ b/src/api/pyproject.toml @@ -53,7 +53,5 @@ files = "./**/*.py" exclude = ["/tests/"] [[tool.mypy.overrides]] -module = [ - "py_ocpi.*", -] +module = [] ignore_missing_imports = true diff --git a/src/api/qualicharge/alembic.ini b/src/api/qualicharge/alembic.ini new file mode 100644 index 00000000..e0758834 --- /dev/null +++ b/src/api/qualicharge/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(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. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# 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 warren/migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:warren/migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# 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/src/api/qualicharge/api/__init__.py b/src/api/qualicharge/api/__init__.py index 19fe79fb..a811cc8a 100644 --- a/src/api/qualicharge/api/__init__.py +++ b/src/api/qualicharge/api/__init__.py @@ -1,12 +1,24 @@ """QualiCharge API root.""" +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from ..conf import settings +from ..db import get_engine from .v1 import app as v1 -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application life span.""" + engine = get_engine() + yield + engine.dispose() + + +app = FastAPI(lifespan=lifespan) app.add_middleware( diff --git a/src/api/qualicharge/conf.py b/src/api/qualicharge/conf.py index 421a09b9..91912437 100644 --- a/src/api/qualicharge/conf.py +++ b/src/api/qualicharge/conf.py @@ -1,7 +1,9 @@ """QualiCharge API settings.""" +from pathlib import Path from typing import List +from pydantic import computed_field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -15,6 +17,42 @@ class Settings(BaseSettings): "http://localhost:8010", ] + # API Core Root path + # (used at least by everything that is alembic-configuration-related) + ROOT_PATH: Path = Path(__file__).parent + + # Alembic + ALEMBIC_CFG_PATH: Path = ROOT_PATH / "alembic.ini" + + # Database + DB_ENGINE: str = "postgresql" + DB_HOST: str = "postgresql" + DB_NAME: str = "warren-api" + DB_USER: str = "fun" + DB_PASSWORD: str = "pass" + DB_PORT: int = 5432 + TEST_DB_NAME: str = "test-warren-api" + + @computed_field # type: ignore[misc] + @property + def DATABASE_URL(self) -> str: + """Get the database URL as required by SQLAlchemy.""" + return ( + f"{self.DB_ENGINE}://" + f"{self.DB_USER}:{self.DB_PASSWORD}@" + f"{self.DB_HOST}/{self.DB_NAME}" + ) + + @computed_field # type: ignore[misc] + @property + def TEST_DATABASE_URL(self) -> str: + """Get the database URL as required by SQLAlchemy.""" + return ( + f"{self.DB_ENGINE}://" + f"{self.DB_USER}:{self.DB_PASSWORD}@" + f"{self.DB_HOST}/{self.TEST_DB_NAME}" + ) + model_config = SettingsConfigDict( case_sensitive=True, env_nested_delimiter="__", env_prefix="QUALICHARGE_" ) diff --git a/src/api/qualicharge/db.py b/src/api/qualicharge/db.py new file mode 100644 index 00000000..46b90be7 --- /dev/null +++ b/src/api/qualicharge/db.py @@ -0,0 +1,77 @@ +"""QualiCharge database connection.""" + +import logging +from typing import Optional + +from sqlalchemy import Engine as SAEngine +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlmodel import Session as SMSession +from sqlmodel import create_engine + +from .conf import settings + +logger = logging.getLogger(__name__) + + +class Singleton(type): + """Singleton pattern metaclass.""" + + _instances: dict = {} + + def __call__(cls, *args, **kwargs): + """Store instances in a private class property.""" + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class Engine(metaclass=Singleton): + """Database engine singleton.""" + + _engine: Optional[SAEngine] = None + + def get_engine(self, url, echo=False) -> SAEngine: + """Get created engine or create a new one.""" + if self._engine is None: + logger.debug("Create a new engine") + self._engine = create_engine(url, echo=echo) + logger.debug("Getting database engine %s", self._engine) + return self._engine + + +class Session(metaclass=Singleton): + """Database session singleton.""" + + _session: Optional[SMSession] = None + + def get_session(self, engine) -> SMSession: + """Get active session or create a new one.""" + if self._session is None: + logger.debug("Create new session") + self._session = SMSession(bind=engine) + logger.debug("Getting database session %s", self._session) + return self._session + + +def get_engine() -> SAEngine: + """Get database engine.""" + return Engine().get_engine(url=settings.DATABASE_URL, echo=settings.DEBUG) + + +def get_session() -> SMSession: + """Get database session.""" + session = Session().get_session(get_engine()) + logger.debug("Getting session %s", session) + return session + + +def is_alive() -> bool: + """Check if database connection is alive.""" + session = get_session() + try: + session.execute(text("SELECT 1 as is_alive")) + return True + except OperationalError as err: + logger.debug("Exception: %s", err) + return False diff --git a/src/api/qualicharge/migrations/__init__.py b/src/api/qualicharge/migrations/__init__.py new file mode 100644 index 00000000..ed95e85b --- /dev/null +++ b/src/api/qualicharge/migrations/__init__.py @@ -0,0 +1 @@ +"""Qualicharge migrations.""" diff --git a/src/api/qualicharge/migrations/env.py b/src/api/qualicharge/migrations/env.py new file mode 100644 index 00000000..a2a28c07 --- /dev/null +++ b/src/api/qualicharge/migrations/env.py @@ -0,0 +1,82 @@ +"""Alembic configuration.""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from qualicharge.conf import settings + +# 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. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Set Database URL from QualiCharge API's configuration +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = SQLModel.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 run_migrations_offline() -> None: + """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 = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/api/qualicharge/migrations/script.py.mako b/src/api/qualicharge/migrations/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/src/api/qualicharge/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/api/qualicharge/migrations/versions/__init__.py b/src/api/qualicharge/migrations/versions/__init__.py new file mode 100644 index 00000000..7f841d27 --- /dev/null +++ b/src/api/qualicharge/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""QualiCharge migrations versions.""" diff --git a/src/api/tests/conftest.py b/src/api/tests/conftest.py new file mode 100644 index 00000000..9018b65b --- /dev/null +++ b/src/api/tests/conftest.py @@ -0,0 +1,10 @@ +"""Fixtures for pytest.""" + +# pylint: disable=unused-import +# ruff: noqa: F401 + +from .fixtures.db import ( + db_engine, + db_session, + override_db_test_session, +) diff --git a/src/api/tests/fixtures/__init__.py b/src/api/tests/fixtures/__init__.py new file mode 100644 index 00000000..432f98ab --- /dev/null +++ b/src/api/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Fixtures for QualiCharge API.""" diff --git a/src/api/tests/fixtures/db.py b/src/api/tests/fixtures/db.py new file mode 100644 index 00000000..70f2d4f8 --- /dev/null +++ b/src/api/tests/fixtures/db.py @@ -0,0 +1,61 @@ +"""Fixtures for QualiCharge API database.""" + +import pytest +from alembic import command +from alembic.config import Config +from sqlmodel import Session, SQLModel, create_engine + +from qualicharge.api.v1 import app as v1 +from qualicharge.conf import settings +from qualicharge.db import get_session + + +@pytest.fixture(scope="session") +def db_engine(): + """Test database engine fixture.""" + engine = create_engine(settings.TEST_DATABASE_URL, echo=False) + + # Create database and tables + SQLModel.metadata.create_all(engine) + + # Pretend to have all migrations applied + alembic_cfg = Config(settings.ALEMBIC_CFG_PATH) + command.stamp(alembic_cfg, "head") + + yield engine + SQLModel.metadata.drop_all(engine) + engine.dispose() + + +@pytest.fixture(scope="function") +def db_session(db_engine): + """Test session fixture.""" + # Setup + # + # Connect to the database and create a non-ORM transaction. Our connection + # is bound to the test session. + connection = db_engine.connect() + transaction = connection.begin() + session = Session(bind=connection) + + yield session + + # Teardown + # + # Rollback everything that happened with the Session above (including + # explicit commits). + session.close() + transaction.rollback() + connection.close() + + +@pytest.fixture(autouse=True) +def override_db_test_session(db_session): + """Use test database along with a test session by default.""" + + def get_session_override(): + return db_session + + v1.dependency_overrides[get_session] = get_session_override + + yield diff --git a/src/api/tests/test_api.py b/src/api/tests/test_api.py index 78d8cb18..bc26c5ec 100644 --- a/src/api/tests/test_api.py +++ b/src/api/tests/test_api.py @@ -1,7 +1,10 @@ """Tests for the QualiCharge API.""" +import pytest from fastapi import status from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.exc import OperationalError from qualicharge.api import app @@ -13,3 +16,11 @@ def test_hello(): response = client.get("/api/v1/") assert response.status_code == status.HTTP_200_OK assert response.json() == {"message": "Hello world."} + + +def test_database_connection(db_session): + """Test the PostgreSQL database connection.""" + try: + db_session.execute(text("SELECT 42 as life")) + except OperationalError: + pytest.fail("Cannot connect to configured database")