diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8c873f72..24e2d993 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest services: postgresql: - image: postgres:14 + image: postgis/postgis:14-3.4 env: POSTGRES_DB: test-qualicharge-api POSTGRES_USER: qualicharge diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf174fe..fb513e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to ## [Unreleased] +### Added + +- Implement static data database schemas + +### Fixed + +- Mark Static.id_pdc_itinerance field as required + ## [0.3.0] - 2024-04-11 ### Added diff --git a/Makefile b/Makefile index bc9ff031..fa419b92 100644 --- a/Makefile +++ b/Makefile @@ -68,6 +68,7 @@ stop: ## stop all servers 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." + @$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/$${QUALICHARGE_TEST_DB_NAME}" -c "create extension postgis;"' || echo "Duly noted, skipping extension creation." .PHONY: create-api-test-db drop-api-test-db: ## drop API test database @@ -85,6 +86,7 @@ migrate-api: ## run alembic database migrations for the api service @$(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." + @$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/$${QUALICHARGE_DB_NAME}" -c "create extension postgis;"' || echo "Duly noted, skipping extension creation." @echo "Running migrations for api service…" @bin/alembic upgrade head .PHONY: migrate-api diff --git a/docker-compose.yml b/docker-compose.yml index a200d6f9..17be9806 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: postgresql: - image: postgres:14 + image: postgis/postgis:14-3.4 env_file: - env.d/postgresql - env.d/api diff --git a/src/api/Pipfile b/src/api/Pipfile index 0582cdd0..4167bab3 100644 --- a/src/api/Pipfile +++ b/src/api/Pipfile @@ -8,6 +8,7 @@ alembic = "==1.13.1" annotated-types = "==0.6.0" email-validator = "==2.1.1" fastapi = "==0.110.2" +geoalchemy2 = {extras = ["shapely"], version = "==0.14.7"} httpx = "==0.27.0" psycopg2-binary = "==2.9.9" pydantic-extra-types = {extras = ["all"], version = "==2.6.0"} @@ -19,6 +20,7 @@ uvicorn = {extras = ["standard"] } [dev-packages] black = "==24.4.0" +csvkit = "==1.5.0" honcho = "==1.1.0" mypy = "==1.9.0" polyfactory = "==2.15.0" @@ -27,7 +29,6 @@ pytest-cov = "==5.0.0" pytest-httpx = "==0.30.0" ruff = "==0.4.1" types-python-jose = "==3.3.4.20240106" -csvkit = "==1.5.0" [requires] python_version = "3.12" diff --git a/src/api/Pipfile.lock b/src/api/Pipfile.lock index 49fd142a..e68db4b0 100644 --- a/src/api/Pipfile.lock +++ b/src/api/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d4bbf2a758bdfe2fe24ab04bb5e5caac10c67afb6c16ec449e6a93fbedd96354" + "sha256": "d0a396d5aedcc0f42d9cc73b5ee46686830940df2a6a3fccce416664497be8eb" }, "pipfile-spec": 6, "requires": { @@ -187,6 +187,17 @@ "markers": "python_version >= '3.8'", "version": "==0.110.2" }, + "geoalchemy2": { + "extras": [ + "shapely" + ], + "hashes": [ + "sha256:9ff731c4e5b955525cb128ee7173222365e7f084d4b99f6f68e491bac6b44d21", + "sha256:af592892c58da0b98aa284d400f207f08a643aeee4523f025cef74991f76af14" + ], + "markers": "python_version >= '3.7'", + "version": "==0.14.7" + }, "greenlet": { "hashes": [ "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67", @@ -399,6 +410,56 @@ "markers": "python_version >= '3.7'", "version": "==2.1.5" }, + "numpy": { + "hashes": [ + "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", + "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", + "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", + "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", + "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", + "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", + "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea", + "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c", + "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", + "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", + "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be", + "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", + "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", + "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", + "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", + "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd", + "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c", + "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", + "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0", + "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c", + "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", + "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", + "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", + "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6", + "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", + "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", + "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30", + "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", + "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", + "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", + "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", + "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", + "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764", + "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", + "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3", + "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f" + ], + "markers": "python_version >= '3.9'", + "version": "==1.26.4" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, "pendulum": { "hashes": [ "sha256:04a1094a5aa1daa34a6b57c865b25f691848c61583fb22722a4df5699f6bf74c", @@ -598,96 +659,96 @@ }, "pydantic": { "hashes": [ - "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352", - "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383" + "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5", + "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc" ], "markers": "python_version >= '3.8'", - "version": "==2.7.0" + "version": "==2.7.1" }, "pydantic-core": { "hashes": [ - "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6", - "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb", - "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0", - "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6", - "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47", - "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a", - "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a", - "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac", - "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88", - "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db", - "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d", - "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d", - "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9", - "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e", - "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b", - "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d", - "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649", - "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c", - "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1", - "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09", - "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0", - "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90", - "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d", - "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294", - "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144", - "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b", - "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1", - "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b", - "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2", - "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad", - "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622", - "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17", - "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06", - "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc", - "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50", - "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d", - "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59", - "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539", - "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a", - "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b", - "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5", - "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9", - "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278", - "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6", - "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44", - "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0", - "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb", - "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80", - "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5", - "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570", - "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b", - "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de", - "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6", - "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8", - "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203", - "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7", - "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048", - "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae", - "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89", - "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f", - "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926", - "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2", - "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76", - "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d", - "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411", - "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9", - "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2", - "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586", - "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35", - "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c", - "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143", - "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6", - "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60", - "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b", - "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226", - "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519", - "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31", - "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7", - "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b" + "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b", + "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a", + "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90", + "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d", + "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e", + "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d", + "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027", + "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804", + "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347", + "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400", + "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3", + "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399", + "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349", + "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd", + "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c", + "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e", + "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413", + "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3", + "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e", + "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3", + "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91", + "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce", + "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c", + "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb", + "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664", + "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6", + "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd", + "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3", + "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af", + "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043", + "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350", + "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7", + "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0", + "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563", + "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761", + "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72", + "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3", + "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb", + "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788", + "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b", + "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c", + "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038", + "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250", + "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec", + "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c", + "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74", + "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81", + "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439", + "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75", + "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0", + "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8", + "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150", + "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438", + "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae", + "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857", + "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038", + "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374", + "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f", + "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241", + "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592", + "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4", + "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d", + "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b", + "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b", + "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182", + "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e", + "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641", + "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70", + "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9", + "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a", + "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543", + "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b", + "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f", + "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38", + "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845", + "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2", + "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0", + "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4", + "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242" ], "markers": "python_version >= '3.8'", - "version": "==2.18.1" + "version": "==2.18.2" }, "pydantic-extra-types": { "extras": [ @@ -815,6 +876,52 @@ "markers": "python_version >= '3.8'", "version": "==69.5.1" }, + "shapely": { + "hashes": [ + "sha256:011b77153906030b795791f2fdfa2d68f1a8d7e40bce78b029782ade3afe4f2f", + "sha256:03152442d311a5e85ac73b39680dd64a9892fa42bb08fd83b3bab4fe6999bfa0", + "sha256:05ffd6491e9e8958b742b0e2e7c346635033d0a5f1a0ea083547fcc854e5d5cf", + "sha256:0776c92d584f72f1e584d2e43cfc5542c2f3dd19d53f70df0900fda643f4bae6", + "sha256:263bcf0c24d7a57c80991e64ab57cba7a3906e31d2e21b455f493d4aab534aaa", + "sha256:2fbdc1140a7d08faa748256438291394967aa54b40009f54e8d9825e75ef6113", + "sha256:30982f79f21bb0ff7d7d4a4e531e3fcaa39b778584c2ce81a147f95be1cd58c9", + "sha256:31c19a668b5a1eadab82ff070b5a260478ac6ddad3a5b62295095174a8d26398", + "sha256:3f9103abd1678cb1b5f7e8e1af565a652e036844166c91ec031eeb25c5ca8af0", + "sha256:41388321a73ba1a84edd90d86ecc8bfed55e6a1e51882eafb019f45895ec0f65", + "sha256:4310b5494271e18580d61022c0857eb85d30510d88606fa3b8314790df7f367d", + "sha256:464157509ce4efa5ff285c646a38b49f8c5ef8d4b340f722685b09bb033c5ccf", + "sha256:485246fcdb93336105c29a5cfbff8a226949db37b7473c89caa26c9bae52a242", + "sha256:489c19152ec1f0e5c5e525356bcbf7e532f311bff630c9b6bc2db6f04da6a8b9", + "sha256:4f2ab0faf8188b9f99e6a273b24b97662194160cc8ca17cf9d1fb6f18d7fb93f", + "sha256:55a38dcd1cee2f298d8c2ebc60fc7d39f3b4535684a1e9e2f39a80ae88b0cea7", + "sha256:58b0ecc505bbe49a99551eea3f2e8a9b3b24b3edd2a4de1ac0dc17bc75c9ec07", + "sha256:5af4cd0d8cf2912bd95f33586600cac9c4b7c5053a036422b97cfe4728d2eb53", + "sha256:5bbd974193e2cc274312da16b189b38f5f128410f3377721cadb76b1e8ca5328", + "sha256:5c4849916f71dc44e19ed370421518c0d86cf73b26e8656192fcfcda08218fbd", + "sha256:5dc736127fac70009b8d309a0eeb74f3e08979e530cf7017f2f507ef62e6cfb8", + "sha256:63f3a80daf4f867bd80f5c97fbe03314348ac1b3b70fb1c0ad255a69e3749879", + "sha256:674d7baf0015a6037d5758496d550fc1946f34bfc89c1bf247cabdc415d7747e", + "sha256:6cd4ccecc5ea5abd06deeaab52fcdba372f649728050c6143cc405ee0c166679", + "sha256:790a168a808bd00ee42786b8ba883307c0e3684ebb292e0e20009588c426da47", + "sha256:7d56ce3e2a6a556b59a288771cf9d091470116867e578bebced8bfc4147fbfd7", + "sha256:841f93a0e31e4c64d62ea570d81c35de0f6cea224568b2430d832967536308e6", + "sha256:8de4578e838a9409b5b134a18ee820730e507b2d21700c14b71a2b0757396acc", + "sha256:92a41d936f7d6743f343be265ace93b7c57f5b231e21b9605716f5a47c2879e7", + "sha256:9831816a5d34d5170aa9ed32a64982c3d6f4332e7ecfe62dc97767e163cb0b17", + "sha256:994c244e004bc3cfbea96257b883c90a86e8cbd76e069718eb4c6b222a56f78b", + "sha256:9dab4c98acfb5fb85f5a20548b5c0abe9b163ad3525ee28822ffecb5c40e724c", + "sha256:b79bbd648664aa6f44ef018474ff958b6b296fed5c2d42db60078de3cffbc8aa", + "sha256:c3e700abf4a37b7b8b90532fa6ed5c38a9bfc777098bc9fbae5ec8e618ac8f30", + "sha256:c52ed79f683f721b69a10fb9e3d940a468203f5054927215586c5d49a072de8d", + "sha256:c75c98380b1ede1cae9a252c6dc247e6279403fae38c77060a5e6186c95073ac", + "sha256:d2b4431f522b277c79c34b65da128029a9955e4481462cbf7ebec23aab61fc58", + "sha256:ddf4a9bfaac643e62702ed662afc36f6abed2a88a21270e891038f9a19bc08fc", + "sha256:de0205cb21ad5ddaef607cda9a3191eadd1e7a62a756ea3a356369675230ac35", + "sha256:ec555c9d0db12d7fd777ba3f8b75044c73e576c720a851667432fabb7057da6c", + "sha256:fb5cdcbbe3080181498931b52a91a21a781a35dcb859da741c0345c6402bf00c" + ], + "version": "==2.0.4" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", diff --git a/src/api/pyproject.toml b/src/api/pyproject.toml index 1ea91c2d..53736999 100644 --- a/src/api/pyproject.toml +++ b/src/api/pyproject.toml @@ -6,6 +6,11 @@ name = "qualicharge" version = "0.3.0" # Third party packages configuration +[tool.coverage.run] +omit = [ + "qualicharge/migrations/*" +] + [tool.pytest.ini_options] addopts = "-v --cov-report term-missing --cov=qualicharge" python_files = [ @@ -15,6 +20,11 @@ python_files = [ testpaths = [ "tests", ] +[tool.ruff] +# Exclude a variety of commonly ignored directories. +exclude = [ + "migrations", +] [tool.ruff.lint] select = [ @@ -45,13 +55,22 @@ select = [ convention = "google" [tool.ruff.lint.flake8-bugbear] -extend-immutable-calls = ["fastapi.Depends", "fastapi.params.Depends", "fastapi.Query", "fastapi.params.Query"] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.params.Depends", + "fastapi.params.Query", + "fastapi.Query", +] [tool.mypy] plugins = "pydantic.mypy" files = "./**/*.py" -exclude = ["/tests/"] +exclude = [ + "qualicharge/migrations/" +] [[tool.mypy.overrides]] -module = [] +module = [ + "shapely.*", +] ignore_missing_imports = true diff --git a/src/api/qualicharge/factories/static.py b/src/api/qualicharge/factories/static.py index 1b8f7d2d..0d2e6369 100644 --- a/src/api/qualicharge/factories/static.py +++ b/src/api/qualicharge/factories/static.py @@ -1,9 +1,113 @@ """QualiCharge static factories.""" +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, Generic, TypeVar +from uuid import uuid4 + +from faker import Faker +from geoalchemy2.types import Geometry +from polyfactory import Use +from polyfactory.factories.dataclass_factory import DataclassFactory from polyfactory.factories.pydantic_factory import ModelFactory +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory +from pydantic_extra_types.coordinate import Coordinate from ..models.static import Statique +from ..schemas.static import ( + Amenageur, + Enseigne, + Localisation, + Operateur, + PointDeCharge, + Station, +) + +T = TypeVar("T") class StatiqueFactory(ModelFactory[Statique]): """Statique model factory.""" + + +class FrenchDataclassFactory(Generic[T], DataclassFactory[T]): + """Dataclass factory using the french locale.""" + + __faker__ = Faker(locale="fr_FR") + __is_base_factory__ = True + + +class TimestampedSQLModelFactory(Generic[T], SQLAlchemyFactory[T]): + """A base factory for timestamped SQLModel. + + We expect SQLModel to define the following fields: + + - id: UUID + - created_at: datetime + - updated_at: datetime + """ + + __is_base_factory__ = True + + id = Use(uuid4) + created_at = Use(lambda: datetime.now(timezone.utc) - timedelta(hours=1)) + updated_at = Use(datetime.now, timezone.utc) + + +class AmenageurFactory(TimestampedSQLModelFactory[Amenageur]): + """Amenageur schema factory.""" + + contact_amenageur = Use(FrenchDataclassFactory.__faker__.ascii_company_email) + + +class EnseigneFactory(TimestampedSQLModelFactory[Enseigne]): + """Enseigne schema factory.""" + + +class CoordinateFactory(DataclassFactory[Coordinate]): + """Coordinate factory.""" + + longitude = Use(DataclassFactory.__faker__.pyfloat, min_value=-180, max_value=180) + latitude = Use(DataclassFactory.__faker__.pyfloat, min_value=-90, max_value=90) + + +class LocalisationFactory(TimestampedSQLModelFactory[Localisation]): + """Localisation schema factory.""" + + @classmethod + def get_sqlalchemy_types(cls) -> Dict[Any, Callable[[], Any]]: + """Add support for Geometry fields.""" + types = super().get_sqlalchemy_types() + return { + Geometry: lambda: CoordinateFactory.build(), + **types, + } + + +class OperateurFactory(TimestampedSQLModelFactory[Operateur]): + """Operateur schema factory.""" + + contact_operateur = Use(FrenchDataclassFactory.__faker__.ascii_company_email) + telephone_operateur = Use(FrenchDataclassFactory.__faker__.phone_number) + + +class PointDeChargeFactory(TimestampedSQLModelFactory[PointDeCharge]): + """PointDeCharge schema factory.""" + + puissance_nominale = Use( + DataclassFactory.__faker__.pyfloat, + right_digits=2, + min_value=2.0, + max_value=100.0, + ) + + +class StationFactory(TimestampedSQLModelFactory[Station]): + """Station schema factory.""" + + date_maj = Use(DataclassFactory.__faker__.past_date) + date_mise_en_service = Use(DataclassFactory.__faker__.past_date) + + amenageur_id = None + operateur_id = None + enseigne_id = None + localisation_id = None diff --git a/src/api/qualicharge/migrations/env.py b/src/api/qualicharge/migrations/env.py index 3d433a11..55ca2c45 100644 --- a/src/api/qualicharge/migrations/env.py +++ b/src/api/qualicharge/migrations/env.py @@ -8,6 +8,16 @@ from qualicharge.conf import settings +# Nota bene: be sure to import all models that need to be migrated here +from qualicharge.schemas.static import ( # noqa: F401 + Amenageur, + Enseigne, + Localisation, + Operateur, + PointDeCharge, + Station, +) + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -70,7 +80,10 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + ) with context.begin_transaction(): context.run_migrations() diff --git a/src/api/qualicharge/migrations/versions/2664a0b4ce11_add_static_schema.py b/src/api/qualicharge/migrations/versions/2664a0b4ce11_add_static_schema.py new file mode 100644 index 00000000..3ad0fc96 --- /dev/null +++ b/src/api/qualicharge/migrations/versions/2664a0b4ce11_add_static_schema.py @@ -0,0 +1,230 @@ +"""add static schema + +Revision ID: 2664a0b4ce11 +Revises: +Create Date: 2024-04-23 14:51:04.993896 + +""" + +from typing import Sequence, Union + +import geoalchemy2 +import sqlalchemy as sa +import sqlmodel +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "2664a0b4ce11" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "amenageur", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("nom_amenageur", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("siren_amenageur", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("contact_amenageur", sa.String(), nullable=True), + sa.CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("nom_amenageur", "siren_amenageur", "contact_amenageur"), + ) + op.create_table( + "enseigne", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("nom_enseigne", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("nom_enseigne"), + ) + op.create_table( + "localisation", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column( + "adresse_station", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column( + "code_insee_commune", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column( + "coordonneesXY", + geoalchemy2.types.Geometry( + geometry_type="POINT", + srid=4326, + from_text="ST_GeomFromEWKT", + name="geometry", + nullable=False, + ), + nullable=False, + ), + sa.CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("adresse_station", "coordonneesXY"), + ) + op.create_table( + "operateur", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("nom_operateur", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("contact_operateur", sa.String(), nullable=False), + sa.Column( + "telephone_operateur", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "nom_operateur", "contact_operateur", "telephone_operateur" + ), + ) + op.create_table( + "station", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column( + "id_station_itinerance", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column( + "id_station_local", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + sa.Column("nom_station", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "implantation_station", + sa.Enum( + "VOIRIE", + "PARKING_PUBLIC", + "PARKING_PRIVE_USAGE_PUBLIC", + "PARKING_PRIVE_CLIENTELE", + "STATION_RECHARGE_RAPIDE", + name="implantationstationenum", + ), + nullable=False, + ), + sa.Column("nbre_pdc", sa.Integer(), nullable=False), + sa.Column( + "condition_acces", + sa.Enum("ACCESS_LIBRE", "ACCESS_RESERVE", name="conditionaccesenum"), + nullable=False, + ), + sa.Column("horaires", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("station_deux_roues", sa.Boolean(), nullable=False), + sa.Column( + "raccordement", + sa.Enum("DIRECT", "INDIRECT", name="raccordementemum"), + nullable=True, + ), + sa.Column("num_pdl", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("date_maj", sa.DateTime(), nullable=False), + sa.Column("date_mise_en_service", sa.DateTime(), nullable=True), + sa.Column("amenageur_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.Column("operateur_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.Column("enseigne_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.Column("localisation_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + sa.ForeignKeyConstraint( + ["amenageur_id"], + ["amenageur.id"], + ), + sa.ForeignKeyConstraint( + ["enseigne_id"], + ["enseigne.id"], + ), + sa.ForeignKeyConstraint( + ["localisation_id"], + ["localisation.id"], + ), + sa.ForeignKeyConstraint( + ["operateur_id"], + ["operateur.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_station_id_station_itinerance"), + "station", + ["id_station_itinerance"], + unique=True, + ) + op.create_table( + "pointdecharge", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column( + "id_pdc_itinerance", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column("id_pdc_local", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("puissance_nominale", sa.Float(), nullable=False), + sa.Column("prise_type_ef", sa.Boolean(), nullable=False), + sa.Column("prise_type_2", sa.Boolean(), nullable=False), + sa.Column("prise_type_combo_ccs", sa.Boolean(), nullable=False), + sa.Column("prise_type_chademo", sa.Boolean(), nullable=False), + sa.Column("prise_type_autre", sa.Boolean(), nullable=False), + sa.Column("gratuit", sa.Boolean(), nullable=True), + sa.Column("paiement_acte", sa.Boolean(), nullable=False), + sa.Column("paiement_cb", sa.Boolean(), nullable=True), + sa.Column("paiement_autre", sa.Boolean(), nullable=True), + sa.Column("tarification", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("reservation", sa.Boolean(), nullable=False), + sa.Column( + "accessibilite_pmr", + sa.Enum( + "RESERVE_PMR", + "NON_RESERVE", + "NON_ACCESSIBLE", + "INCONNUE", + name="accessibilitepmrenum", + ), + nullable=False, + ), + sa.Column( + "restriction_gabarit", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column("observations", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("cable_t2_attache", sa.Boolean(), nullable=True), + sa.Column("station_id", sqlmodel.sql.sqltypes.GUID(), nullable=True), + sa.CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + sa.ForeignKeyConstraint( + ["station_id"], + ["station.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_pointdecharge_id_pdc_itinerance"), + "pointdecharge", + ["id_pdc_itinerance"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_pointdecharge_id_pdc_itinerance"), table_name="pointdecharge" + ) + op.drop_table("pointdecharge") + op.drop_index(op.f("ix_station_id_station_itinerance"), table_name="station") + op.drop_table("station") + op.drop_table("operateur") + op.drop_index( + "idx_localisation_coordonneesXY", + table_name="localisation", + postgresql_using="gist", + ) + op.drop_table("localisation") + op.drop_table("enseigne") + op.drop_table("amenageur") + # ### end Alembic commands ### diff --git a/src/api/qualicharge/models/static.py b/src/api/qualicharge/models/static.py index 5d8c6360..158e93ce 100644 --- a/src/api/qualicharge/models/static.py +++ b/src/api/qualicharge/models/static.py @@ -109,10 +109,8 @@ class Statique(BaseModel): ] coordonneesXY: DataGouvCoordinate nbre_pdc: PositiveInt - id_pdc_itinerance: Optional[ - Annotated[ - str, Field(pattern="(?:(?:^|,)(^[A-Z]{2}[A-Z0-9]{4,33}$|Non concerné))+$") - ] + id_pdc_itinerance: Annotated[ + str, Field(pattern="(?:(?:^|,)(^[A-Z]{2}[A-Z0-9]{4,33}$|Non concerné))+$") ] id_pdc_local: Optional[str] puissance_nominale: PositiveFloat diff --git a/src/api/qualicharge/schemas/__init__.py b/src/api/qualicharge/schemas/__init__.py new file mode 100644 index 00000000..e4329564 --- /dev/null +++ b/src/api/qualicharge/schemas/__init__.py @@ -0,0 +1,35 @@ +"""QualiCharge schemas.""" + +from datetime import datetime, timezone +from typing import Tuple + +from pydantic.types import PastDatetime +from sqlalchemy import CheckConstraint, Constraint +from sqlalchemy.types import DateTime +from sqlmodel import Field, SQLModel + + +class BaseTimestampedSQLModel(SQLModel): + """A base class for SQL models with timestamp fields. + + This class provides two timestamp fields, `created_at` and `updated_at`, which are + automatically managed. The `created_at` field is set to the current UTC time when + a new record is created, and the `updated_at` field is updated to the current UTC + time whenever the record is modified. + """ + + __table_args__: Tuple[Constraint, ...] = ( + CheckConstraint("created_at <= updated_at", name="pre-creation-update"), + ) + + created_at: PastDatetime = Field( + sa_type=DateTime(timezone=True), + default_factory=lambda: datetime.now(timezone.utc), + description="The timestamp indicating when the record was created.", + ) # type: ignore + updated_at: PastDatetime = Field( + sa_type=DateTime(timezone=True), + sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)}, + default_factory=lambda: datetime.now(timezone.utc), + description="The timestamp indicating when the record was last updated.", + ) # type: ignore diff --git a/src/api/qualicharge/schemas/static.py b/src/api/qualicharge/schemas/static.py new file mode 100644 index 00000000..90199d30 --- /dev/null +++ b/src/api/qualicharge/schemas/static.py @@ -0,0 +1,189 @@ +"""QualiCharge static schemas.""" + +from typing import List, Optional +from uuid import UUID, uuid4 + +from geoalchemy2.shape import to_shape +from geoalchemy2.types import Geometry +from pydantic import ( + EmailStr, + PositiveFloat, + PositiveInt, + ValidationInfo, + field_serializer, + field_validator, +) +from pydantic.types import PastDate +from pydantic_extra_types.coordinate import Coordinate +from shapely.geometry import mapping +from sqlalchemy.types import DateTime, String +from sqlmodel import Field, Relationship, UniqueConstraint +from sqlmodel.main import SQLModelConfig + +from ..models.static import ( + AccessibilitePMREnum, + ConditionAccesEnum, + FrenchPhoneNumber, + ImplantationStationEnum, + RaccordementEmum, +) +from . import BaseTimestampedSQLModel + + +class Amenageur(BaseTimestampedSQLModel, table=True): + """Amenageur table.""" + + __table_args__ = BaseTimestampedSQLModel.__table_args__ + ( + UniqueConstraint("nom_amenageur", "siren_amenageur", "contact_amenageur"), + ) + + model_config = SQLModelConfig(validate_assignment=True) + + id: Optional[UUID] = Field(default_factory=lambda: uuid4().hex, primary_key=True) + nom_amenageur: Optional[str] + siren_amenageur: Optional[str] = Field(regex=r"^\d{9}$") + contact_amenageur: Optional[EmailStr] = Field(sa_type=String) + + # Relationships + stations: List["Station"] = Relationship(back_populates="amenageur") + + +class Operateur(BaseTimestampedSQLModel, table=True): + """Operateur table.""" + + __table_args__ = BaseTimestampedSQLModel.__table_args__ + ( + UniqueConstraint("nom_operateur", "contact_operateur", "telephone_operateur"), + ) + + model_config = SQLModelConfig(validate_assignment=True) + + id: Optional[UUID] = Field(default_factory=lambda: uuid4().hex, primary_key=True) + nom_operateur: Optional[str] + contact_operateur: EmailStr = Field(sa_type=String) + telephone_operateur: Optional[FrenchPhoneNumber] + + # Relationships + stations: List["Station"] = Relationship(back_populates="operateur") + + +class Enseigne(BaseTimestampedSQLModel, table=True): + """Enseigne table.""" + + model_config = SQLModelConfig(validate_assignment=True) + + id: Optional[UUID] = Field(default_factory=lambda: uuid4().hex, primary_key=True) + nom_enseigne: str = Field(unique=True) + + # Relationships + stations: List["Station"] = Relationship(back_populates="enseigne") + + +class Localisation(BaseTimestampedSQLModel, table=True): + """Localisation table.""" + + __table_args__ = BaseTimestampedSQLModel.__table_args__ + ( + UniqueConstraint("adresse_station", "coordonneesXY"), + ) + + model_config = SQLModelConfig( + validate_assignment=True, arbitrary_types_allowed=True + ) + + id: Optional[UUID] = Field(default_factory=lambda: uuid4().hex, primary_key=True) + adresse_station: str + code_insee_commune: Optional[str] = Field(regex=r"^([013-9]\d|2[AB1-9])\d{3}$") + coordonneesXY: Coordinate = Field( + sa_type=Geometry( + geometry_type="POINT", + # WGS84 coordinates system + srid=4326, + spatial_index=True, + ), + ) # type: ignore + + # Relationships + stations: List["Station"] = Relationship(back_populates="localisation") + + @field_validator("coordonneesXY") + @classmethod + def set_geometry_point(cls, value: Coordinate, info: ValidationInfo) -> str: + """Set coordonneesXY geometry from Coordinate type.""" + return f"POINT({value.longitude} {value.latitude})" + + @field_serializer("coordonneesXY") + def serialize_wkb_point(self, wkb, _): + """Serialize WKB element (Point type geometry) field to coordinates.""" + # Coordinate type expects a (latitude, longitude) tuple as input, so we need to + # reverse the original tuple as the standard is (longitude, latitude). + return Coordinate(*reversed(mapping(to_shape(wkb)).get("coordinates"))) + + +class Station(BaseTimestampedSQLModel, table=True): + """Station table.""" + + model_config = SQLModelConfig(validate_assignment=True) + + id: Optional[UUID] = Field(default_factory=lambda: uuid4().hex, primary_key=True) + id_station_itinerance: str = Field( + regex="(?:(?:^|,)(^[A-Z]{2}[A-Z0-9]{4,33}$|Non concerné))+$", + index=True, + unique=True, + ) + id_station_local: Optional[str] + nom_station: str + implantation_station: ImplantationStationEnum + nbre_pdc: PositiveInt + condition_acces: ConditionAccesEnum + horaires: str = Field(regex=r"(.*?)((\d{1,2}:\d{2})-(\d{1,2}:\d{2})|24/7)") + station_deux_roues: bool + raccordement: Optional[RaccordementEmum] + num_pdl: Optional[str] = Field(regex=r"^\d{14}$") + date_maj: PastDate = Field(sa_type=DateTime) + date_mise_en_service: Optional[PastDate] = Field(sa_type=DateTime) + + # Relationships + amenageur_id: Optional[UUID] = Field(default=None, foreign_key="amenageur.id") + amenageur: Amenageur = Relationship(back_populates="stations") + + operateur_id: Optional[UUID] = Field(default=None, foreign_key="operateur.id") + operateur: Operateur = Relationship(back_populates="stations") + + enseigne_id: Optional[UUID] = Field(default=None, foreign_key="enseigne.id") + enseigne: Enseigne = Relationship(back_populates="stations") + + localisation_id: Optional[UUID] = Field(default=None, foreign_key="localisation.id") + localisation: Localisation = Relationship(back_populates="stations") + + points_de_charge: List["PointDeCharge"] = Relationship(back_populates="station") + + +class PointDeCharge(BaseTimestampedSQLModel, table=True): + """PointDeCharge table.""" + + model_config = SQLModelConfig(validate_assignment=True) + + id: Optional[UUID] = Field(default_factory=lambda: uuid4().hex, primary_key=True) + id_pdc_itinerance: str = Field( + regex="(?:(?:^|,)(^[A-Z]{2}[A-Z0-9]{4,33}$|Non concerné))+$", index=True + ) + id_pdc_local: Optional[str] + puissance_nominale: PositiveFloat + prise_type_ef: bool + prise_type_2: bool + prise_type_combo_ccs: bool + prise_type_chademo: bool + prise_type_autre: bool + gratuit: Optional[bool] + paiement_acte: bool + paiement_cb: Optional[bool] + paiement_autre: Optional[bool] + tarification: Optional[str] + reservation: bool + accessibilite_pmr: AccessibilitePMREnum + restriction_gabarit: str + observations: Optional[str] + cable_t2_attache: Optional[bool] + + # Relationships + station_id: Optional[UUID] = Field(default=None, foreign_key="station.id") + station: Station = Relationship(back_populates="points_de_charge") diff --git a/src/api/start.sh b/src/api/start.sh index 739432a3..1f3795b4 100755 --- a/src/api/start.sh +++ b/src/api/start.sh @@ -24,6 +24,9 @@ else ) fi +echo "🗃️ Running database migrations..." +alembic -c qualicharge/alembic.ini upgrade head + # shellcheck disable=SC2068 uvicorn \ qualicharge.api:app \ diff --git a/src/api/tests/schemas/__init__.py b/src/api/tests/schemas/__init__.py new file mode 100644 index 00000000..ad334cb2 --- /dev/null +++ b/src/api/tests/schemas/__init__.py @@ -0,0 +1 @@ +"""QualiCharge schemas tests.""" diff --git a/src/api/tests/schemas/test_static.py b/src/api/tests/schemas/test_static.py new file mode 100644 index 00000000..f873a018 --- /dev/null +++ b/src/api/tests/schemas/test_static.py @@ -0,0 +1,182 @@ +"""QualiCharge static schemas tests.""" + +import re +from datetime import datetime, timedelta, timezone + +import pytest +from geoalchemy2.shape import to_shape +from pydantic_extra_types.coordinate import Coordinate +from shapely.geometry import mapping +from sqlalchemy.exc import IntegrityError +from sqlmodel import select + +from qualicharge.factories.static import ( + AmenageurFactory, + EnseigneFactory, + LocalisationFactory, + OperateurFactory, + PointDeChargeFactory, + StationFactory, +) +from qualicharge.schemas.static import Amenageur, Localisation + + +def test_localisation_schema_set_geometry_point_validator(db_session): + """Test the Localisation schema `set_geometry_point` validator.""" + # Create and save a new location + loc = Localisation( + adresse_station="4 baker street 75000 Tatooine", + coordonneesXY=Coordinate(longitude=3.129447, latitude=45.700327), + ) + db_session.add(loc) + db_session.commit() + + # Query newly created location + db_loc = db_session.exec(select(Localisation)).one() + + assert mapping(to_shape(db_loc.coordonneesXY)) == { + "type": "Point", + "coordinates": ( + 3.129447, + 45.700327, + ), + } + + +def test_localisation_schema_coordonneesXY_serializer(db_session): + """Test the Localisation schema `serialize_wkb_point` serializer.""" + # Create and save a new location + loc = Localisation( + adresse_station="4 baker street 75000 Tatooine", + coordonneesXY=Coordinate(longitude=3.129447, latitude=45.700327), + ) + db_session.add(loc) + db_session.commit() + + # Query newly created location + db_loc = db_session.exec(select(Localisation)).one() + + assert db_loc.model_dump(include={"adresse_station", "coordonneesXY"}) == { + "adresse_station": "4 baker street 75000 Tatooine", + "coordonneesXY": {"latitude": 45.700327, "longitude": 3.129447}, + } + + +def test_localisation_factory(): + """Test the localisation factory.""" + localisation = LocalisationFactory.build() + assert localisation.coordonneesXY is not None + assert ( + re.match(r"^POINT\(-?\d+\.\d+ -?\d+\.\d+\)$", localisation.coordonneesXY) + is not None + ) + + +def test_timestamped_model_defaults(db_session): + """Test timestamped model defaults.""" + amenageur = Amenageur( + nom_amenageur="ACME Inc.", + siren_amenageur="130025265", + contact_amenageur="john.doe@acme.com", + ) + + db_session.add(amenageur) + db_session.commit() + + # Query newly created location + db_amenageur = db_session.exec(select(Amenageur)).one() + + now = datetime.now(timezone.utc) + assert db_amenageur.created_at < now + assert db_amenageur.updated_at < now + assert db_amenageur.created_at < db_amenageur.updated_at + + +def test_timestamped_model_constraints(db_session): + """Test timestamped model constraints.""" + now = datetime.now(timezone.utc) + amenageur = Amenageur( + nom_amenageur="ACME Inc.", + siren_amenageur="130025265", + contact_amenageur="john.doe@acme.com", + ) + amenageur.updated_at = now - timedelta(hours=1) + + db_session.add(amenageur) + + with pytest.raises( + IntegrityError, + match=( + 'new row for relation "amenageur" violates check ' + 'constraint "pre-creation-update"' + ), + ): + db_session.commit() + + +def test_timestamped_model_constraints_composition(db_session): + """Test timestamped model constraints composition.""" + # Create the original amenageur + original = Amenageur( + nom_amenageur="ACME Inc.", + siren_amenageur="130025265", + contact_amenageur="john.doe@acme.com", + ) + db_session.add(original) + db_session.commit() + + # Now try to duplicate this entry + duplicate = Amenageur( + nom_amenageur="ACME Inc.", + siren_amenageur="130025265", + contact_amenageur="john.doe@acme.com", + ) + db_session.add(duplicate) + with pytest.raises( + IntegrityError, + match="duplicate key value violates unique constraint", + ): + db_session.commit() + + +@pytest.mark.parametrize( + "related_factory,related_field", + [ + [AmenageurFactory, "amenageur"], + [OperateurFactory, "operateur"], + [EnseigneFactory, "enseigne"], + [LocalisationFactory, "localisation"], + ], +) +def test_relationships_for_stations(db_session, related_factory, related_field): + """Test Schema.stations one-to-many relationship.""" + related_factory.__session__ = db_session + StationFactory.__session__ = db_session + + related = related_factory.create_sync() + assert related.id is not None + + size = 2 + stations = StationFactory.create_batch_sync( + size, **{f"{related_field}_id": related.id} + ) + assert len(related.stations) == size + assert ( + getattr(stations[0], related_field).id + == getattr(stations[1], related_field).id + == related.id + ) + + +def test_relationships_for_point_de_charge(db_session): + """Test PointDeCharge-Station many-to-one relationship.""" + PointDeChargeFactory.__session__ = db_session + StationFactory.__session__ = db_session + + station = StationFactory.create_sync() + size = 10 + points_de_charge = PointDeChargeFactory.create_batch_sync( + size, station_id=station.id + ) + assert len(station.points_de_charge) == size + assert all(pdc.station_id == station.id for pdc in points_de_charge)