From dcd44fa6f234aadf6cf1235f356acb2a66592c2c Mon Sep 17 00:00:00 2001 From: Nicolas Poyiadjis Date: Tue, 17 May 2022 15:49:17 +0300 Subject: [PATCH] Letter Tracking System using a Postal Service API - with fully automated testing --- .gitignore | 28 + Pipfile | 22 + Pipfile.lock | 655 ++++++++++++++++++ README.md | 30 + app/__init__.py | 19 + app/config.py | 26 + app/la_poste_nicpoyia.db | Bin 0 -> 45056 bytes app/models/__init__.py | 1 + app/models/letter.py | 31 + app/models/status_update.py | 22 + app/tracking_service/__init__.py | 1 + app/tracking_service/tracking_exception.py | 17 + app/tracking_service/tracking_response_dto.py | 75 ++ app/tracking_service/tracking_service.py | 194 ++++++ app/views/__init__.py | 40 ++ app/views/batch_tracking_api_result_dto.py | 5 + app/views/tracking_api_result_dto.py | 5 + migrations/README | 1 + migrations/alembic.ini | 50 ++ migrations/script.py.mako | 24 + .../6297642bcbc4_initial_migration.py | 50 ++ postman_demo.json | 72 ++ pytest.ini | 2 + .../test_get_all_letters_statuses_e2e.py | 42 ++ tests/api_e2e/test_get_letter_status_e2e.py | 18 + .../test_get_letter_status_updated_within.py | 59 ++ tests/conftest.py | 1 + tests/model_unit/test_letter.py | 28 + tests/model_unit/test_status_update.py | 21 + tests/test_fixtures.py | 70 ++ .../tracking_service/test_tracking_service.py | 105 +++ 31 files changed, 1714 insertions(+) create mode 100644 .gitignore create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/la_poste_nicpoyia.db create mode 100644 app/models/__init__.py create mode 100644 app/models/letter.py create mode 100644 app/models/status_update.py create mode 100644 app/tracking_service/__init__.py create mode 100644 app/tracking_service/tracking_exception.py create mode 100644 app/tracking_service/tracking_response_dto.py create mode 100644 app/tracking_service/tracking_service.py create mode 100644 app/views/__init__.py create mode 100644 app/views/batch_tracking_api_result_dto.py create mode 100644 app/views/tracking_api_result_dto.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/6297642bcbc4_initial_migration.py create mode 100644 postman_demo.json create mode 100644 pytest.ini create mode 100644 tests/api_e2e/test_get_all_letters_statuses_e2e.py create mode 100644 tests/api_e2e/test_get_letter_status_e2e.py create mode 100644 tests/api_e2e/test_get_letter_status_updated_within.py create mode 100644 tests/conftest.py create mode 100644 tests/model_unit/test_letter.py create mode 100644 tests/model_unit/test_status_update.py create mode 100644 tests/test_fixtures.py create mode 100644 tests/tracking_service/test_tracking_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfd4bdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +.DS_Store +.env +.flaskenv +*.pyc +*.pyo +env/ +env* +dist/ +build/ +*.egg +*.egg-info/ +_mailinglist +.tox/ +.cache/ +.pytest_cache/ +.idea/ +docs/_build/ +.vscode + +# Coverage reports +htmlcov/ +.coverage +.coverage.* +*,cover + +# Local testing +local_testing/ +!local_testing/.gitkeep \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..faf77de --- /dev/null +++ b/Pipfile @@ -0,0 +1,22 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +pytest = "*" +pytest-httpserver = "*" +flask_alembic = "*" +pytest-asyncio = "*" + +[packages] +flask = "*" +flask-sqlalchemy = "*" +flask-cors = "*" +python-dateutil = "*" +requests = ">=2.27.1" +SQLAlchemy = ">=1.3.12" +flask_migrate = "*" + +[requires] +python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..6694d13 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,655 @@ +{ + "_meta": { + "hash": { + "sha256": "abb811841bfee2f3c7984a2a81002fedd9c4af3cbac7b4fc63a9a33e59cd5807" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "alembic": { + "hashes": [ + "sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b", + "sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58" + ], + "markers": "python_version >= '3.6'", + "version": "==1.7.7" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "click": { + "hashes": [ + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.3" + }, + "flask": { + "hashes": [ + "sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477", + "sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe" + ], + "index": "pypi", + "version": "==2.1.2" + }, + "flask-cors": { + "hashes": [ + "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438", + "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de" + ], + "index": "pypi", + "version": "==3.0.10" + }, + "flask-migrate": { + "hashes": [ + "sha256:57d6060839e3a7f150eaab6fe4e726d9e3e7cffe2150fb223d73f92421c6d1d9", + "sha256:a6498706241aba6be7a251078de9cf166d74307bca41a4ca3e403c9d39e2f897" + ], + "index": "pypi", + "version": "==3.1.0" + }, + "flask-sqlalchemy": { + "hashes": [ + "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", + "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390" + ], + "index": "pypi", + "version": "==2.5.1" + }, + "greenlet": { + "hashes": [ + "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3", + "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711", + "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd", + "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073", + "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708", + "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67", + "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23", + "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1", + "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08", + "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd", + "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2", + "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa", + "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8", + "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40", + "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab", + "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6", + "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc", + "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b", + "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e", + "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963", + "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3", + "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d", + "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d", + "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe", + "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28", + "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3", + "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e", + "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c", + "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d", + "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0", + "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497", + "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee", + "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713", + "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58", + "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a", + "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06", + "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88", + "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965", + "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f", + "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4", + "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5", + "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c", + "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a", + "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1", + "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43", + "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627", + "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b", + "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168", + "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d", + "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5", + "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478", + "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf", + "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce", + "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c", + "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b" + ], + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==1.1.2" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "importlib-metadata": { + "hashes": [ + "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6", + "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539" + ], + "markers": "python_version < '3.10'", + "version": "==4.11.3" + }, + "importlib-resources": { + "hashes": [ + "sha256:b6062987dfc51f0fcb809187cffbd60f35df7acb4589091f154214af6d0d49d3", + "sha256:e447dc01619b1e951286f3929be820029d48c75eb25d265c28b92a16548212b8" + ], + "markers": "python_version < '3.9'", + "version": "==5.7.1" + }, + "itsdangerous": { + "hashes": [ + "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", + "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "mako": { + "hashes": [ + "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba", + "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "markupsafe": { + "hashes": [ + "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", + "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", + "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", + "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", + "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", + "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", + "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", + "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", + "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", + "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", + "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", + "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", + "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", + "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", + "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", + "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", + "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", + "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", + "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", + "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", + "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", + "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", + "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", + "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", + "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", + "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", + "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", + "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", + "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", + "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", + "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", + "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", + "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", + "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", + "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", + "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", + "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", + "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", + "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", + "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.1" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "version": "==2.8.2" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "index": "pypi", + "version": "==2.27.1" + }, + "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" + }, + "sqlalchemy": { + "hashes": [ + "sha256:09c606d8238feae2f360b8742ffbe67741937eb0a05b57f536948d198a3def96", + "sha256:166a3887ec355f7d2f12738f7fa25dc8ac541867147a255f790f2f41f614cb44", + "sha256:16abf35af37a3d5af92725fc9ec507dd9e9183d261c2069b6606d60981ed1c6e", + "sha256:2e885548da361aa3f8a9433db4cfb335b2107e533bf314359ae3952821d84b3e", + "sha256:2ec89bf98cc6a0f5d1e28e3ad28e9be6f3b4bdbd521a4053c7ae8d5e1289a8a1", + "sha256:2ecac4db8c1aa4a269f5829df7e706639a24b780d2ac46b3e485cbbd27ec0028", + "sha256:316c7e5304dda3e3ad711569ac5d02698bbc71299b168ac56a7076b86259f7ea", + "sha256:5041474dcab7973baa91ec1f3112049a9dd4652898d6a95a6a895ff5c58beb6b", + "sha256:53d2d9ee93970c969bc4e3c78b1277d7129554642f6ffea039c282c7dc4577bc", + "sha256:5864a83bd345871ad9699ce466388f836db7572003d67d9392a71998092210e3", + "sha256:5c90ef955d429966d84326d772eb34333178737ebb669845f1d529eb00c75e72", + "sha256:5d50cb71c1dbed70646d521a0975fb0f92b7c3f84c61fa59e07be23a1aaeecfc", + "sha256:64678ac321d64a45901ef2e24725ec5e783f1f4a588305e196431447e7ace243", + "sha256:64d796e9af522162f7f2bf7a3c5531a0a550764c426782797bbeed809d0646c5", + "sha256:6cb4c4f57a20710cea277edf720d249d514e587f796b75785ad2c25e1c0fed26", + "sha256:6e1fe00ee85c768807f2a139b83469c1e52a9ffd58a6eb51aa7aeb524325ab18", + "sha256:6e859fa96605027bd50d8e966db1c4e1b03e7b3267abbc4b89ae658c99393c58", + "sha256:7a052bd9f53004f8993c624c452dfad8ec600f572dd0ed0445fbe64b22f5570e", + "sha256:81e53bd383c2c33de9d578bfcc243f559bd3801a0e57f2bcc9a943c790662e0c", + "sha256:83cf3077712be9f65c9aaa0b5bc47bc1a44789fd45053e2e3ecd59ff17c63fe9", + "sha256:8b20c4178ead9bc398be479428568ff31b6c296eb22e75776273781a6551973f", + "sha256:8d07fe2de0325d06e7e73281e9a9b5e259fbd7cbfbe398a0433cbb0082ad8fa7", + "sha256:a0ae3aa2e86a4613f2d4c49eb7da23da536e6ce80b2bfd60bbb2f55fc02b0b32", + "sha256:af2587ae11400157753115612d6c6ad255143efba791406ad8a0cbcccf2edcb3", + "sha256:b3db741beaa983d4cbf9087558620e7787106319f7e63a066990a70657dd6b35", + "sha256:be094460930087e50fd08297db9d7aadaed8408ad896baf758e9190c335632da", + "sha256:cb441ca461bf97d00877b607f132772644b623518b39ced54da433215adce691", + "sha256:ce20f5da141f8af26c123ebaa1b7771835ca6c161225ce728962a79054f528c3", + "sha256:d57ac32f8dc731fddeb6f5d1358b4ca5456e72594e664769f0a9163f13df2a31", + "sha256:dce3468bf1fc12374a1a732c9efd146ce034f91bb0482b602a9311cb6166a920", + "sha256:e12532c4d3f614678623da5d852f038ace1f01869b89f003ed6fe8c793f0c6a3", + "sha256:e74ce103b81c375c3853b436297952ef8d7863d801dcffb6728d01544e5191b5", + "sha256:f0394a3acfb8925db178f7728adb38c027ed7e303665b225906bfa8099dc1ce8", + "sha256:f522214f6749bc073262529c056f7dfd660f3b5ec4180c5354d985eb7219801e", + "sha256:fbf8c09fe9728168f8cc1b40c239eab10baf9c422c18be7f53213d70434dea43", + "sha256:fca8322e04b2dde722fcb0558682740eebd3bd239bea7a0d0febbc190e99dc15" + ], + "index": "pypi", + "version": "==1.4.36" + }, + "urllib3": { + "hashes": [ + "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", + "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.9" + }, + "werkzeug": { + "hashes": [ + "sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6", + "sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "zipp": { + "hashes": [ + "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad", + "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099" + ], + "markers": "python_version >= '3.7'", + "version": "==3.8.0" + } + }, + "develop": { + "alembic": { + "hashes": [ + "sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b", + "sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58" + ], + "markers": "python_version >= '3.6'", + "version": "==1.7.7" + }, + "attrs": { + "hashes": [ + "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", + "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.4.0" + }, + "click": { + "hashes": [ + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.3" + }, + "flask": { + "hashes": [ + "sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477", + "sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe" + ], + "index": "pypi", + "version": "==2.1.2" + }, + "flask-alembic": { + "hashes": [ + "sha256:05a1e6f4148dbfcc9280a393373bfbd250af6f9f4f0ca9f744ef8f7376a3deec", + "sha256:7e67740b0b08d58dcae0c701d56b56e60f5fa4af907bb82b4cb0469229ba94ff" + ], + "index": "pypi", + "version": "==2.0.1" + }, + "flask-sqlalchemy": { + "hashes": [ + "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", + "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390" + ], + "index": "pypi", + "version": "==2.5.1" + }, + "greenlet": { + "hashes": [ + "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3", + "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711", + "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd", + "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073", + "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708", + "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67", + "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23", + "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1", + "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08", + "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd", + "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2", + "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa", + "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8", + "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40", + "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab", + "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6", + "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc", + "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b", + "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e", + "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963", + "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3", + "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d", + "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d", + "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe", + "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28", + "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3", + "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e", + "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c", + "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d", + "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0", + "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497", + "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee", + "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713", + "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58", + "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a", + "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06", + "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88", + "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965", + "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f", + "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4", + "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5", + "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c", + "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a", + "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1", + "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43", + "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627", + "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b", + "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168", + "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d", + "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5", + "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478", + "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf", + "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce", + "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c", + "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b" + ], + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==1.1.2" + }, + "importlib-metadata": { + "hashes": [ + "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6", + "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539" + ], + "markers": "python_version < '3.10'", + "version": "==4.11.3" + }, + "importlib-resources": { + "hashes": [ + "sha256:b6062987dfc51f0fcb809187cffbd60f35df7acb4589091f154214af6d0d49d3", + "sha256:e447dc01619b1e951286f3929be820029d48c75eb25d265c28b92a16548212b8" + ], + "markers": "python_version < '3.9'", + "version": "==5.7.1" + }, + "iniconfig": { + "hashes": [ + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" + ], + "version": "==1.1.1" + }, + "itsdangerous": { + "hashes": [ + "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", + "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "mako": { + "hashes": [ + "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba", + "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "markupsafe": { + "hashes": [ + "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", + "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", + "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", + "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", + "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", + "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", + "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", + "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", + "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", + "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", + "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", + "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", + "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", + "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", + "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", + "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", + "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", + "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", + "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", + "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", + "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", + "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", + "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", + "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", + "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", + "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", + "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", + "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", + "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", + "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", + "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", + "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", + "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", + "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", + "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", + "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", + "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", + "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", + "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", + "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.1" + }, + "packaging": { + "hashes": [ + "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", + "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" + ], + "markers": "python_version >= '3.6'", + "version": "==21.3" + }, + "pluggy": { + "hashes": [ + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.0" + }, + "py": { + "hashes": [ + "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", + "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.11.0" + }, + "pyparsing": { + "hashes": [ + "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", + "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" + ], + "markers": "python_full_version >= '3.6.8'", + "version": "==3.0.9" + }, + "pytest": { + "hashes": [ + "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c", + "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45" + ], + "index": "pypi", + "version": "==7.1.2" + }, + "pytest-asyncio": { + "hashes": [ + "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213", + "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91", + "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84" + ], + "index": "pypi", + "version": "==0.18.3" + }, + "pytest-httpserver": { + "hashes": [ + "sha256:17396350d7c0dec067bc8c5ceca7e1137aa0e3395468a9c179bdfc5ede9027a5", + "sha256:6de464ba5f74628d6182ebbdcb56783edf2c9b0caf598dc35c11f014f24a3f0d" + ], + "index": "pypi", + "version": "==1.0.4" + }, + "sqlalchemy": { + "hashes": [ + "sha256:09c606d8238feae2f360b8742ffbe67741937eb0a05b57f536948d198a3def96", + "sha256:166a3887ec355f7d2f12738f7fa25dc8ac541867147a255f790f2f41f614cb44", + "sha256:16abf35af37a3d5af92725fc9ec507dd9e9183d261c2069b6606d60981ed1c6e", + "sha256:2e885548da361aa3f8a9433db4cfb335b2107e533bf314359ae3952821d84b3e", + "sha256:2ec89bf98cc6a0f5d1e28e3ad28e9be6f3b4bdbd521a4053c7ae8d5e1289a8a1", + "sha256:2ecac4db8c1aa4a269f5829df7e706639a24b780d2ac46b3e485cbbd27ec0028", + "sha256:316c7e5304dda3e3ad711569ac5d02698bbc71299b168ac56a7076b86259f7ea", + "sha256:5041474dcab7973baa91ec1f3112049a9dd4652898d6a95a6a895ff5c58beb6b", + "sha256:53d2d9ee93970c969bc4e3c78b1277d7129554642f6ffea039c282c7dc4577bc", + "sha256:5864a83bd345871ad9699ce466388f836db7572003d67d9392a71998092210e3", + "sha256:5c90ef955d429966d84326d772eb34333178737ebb669845f1d529eb00c75e72", + "sha256:5d50cb71c1dbed70646d521a0975fb0f92b7c3f84c61fa59e07be23a1aaeecfc", + "sha256:64678ac321d64a45901ef2e24725ec5e783f1f4a588305e196431447e7ace243", + "sha256:64d796e9af522162f7f2bf7a3c5531a0a550764c426782797bbeed809d0646c5", + "sha256:6cb4c4f57a20710cea277edf720d249d514e587f796b75785ad2c25e1c0fed26", + "sha256:6e1fe00ee85c768807f2a139b83469c1e52a9ffd58a6eb51aa7aeb524325ab18", + "sha256:6e859fa96605027bd50d8e966db1c4e1b03e7b3267abbc4b89ae658c99393c58", + "sha256:7a052bd9f53004f8993c624c452dfad8ec600f572dd0ed0445fbe64b22f5570e", + "sha256:81e53bd383c2c33de9d578bfcc243f559bd3801a0e57f2bcc9a943c790662e0c", + "sha256:83cf3077712be9f65c9aaa0b5bc47bc1a44789fd45053e2e3ecd59ff17c63fe9", + "sha256:8b20c4178ead9bc398be479428568ff31b6c296eb22e75776273781a6551973f", + "sha256:8d07fe2de0325d06e7e73281e9a9b5e259fbd7cbfbe398a0433cbb0082ad8fa7", + "sha256:a0ae3aa2e86a4613f2d4c49eb7da23da536e6ce80b2bfd60bbb2f55fc02b0b32", + "sha256:af2587ae11400157753115612d6c6ad255143efba791406ad8a0cbcccf2edcb3", + "sha256:b3db741beaa983d4cbf9087558620e7787106319f7e63a066990a70657dd6b35", + "sha256:be094460930087e50fd08297db9d7aadaed8408ad896baf758e9190c335632da", + "sha256:cb441ca461bf97d00877b607f132772644b623518b39ced54da433215adce691", + "sha256:ce20f5da141f8af26c123ebaa1b7771835ca6c161225ce728962a79054f528c3", + "sha256:d57ac32f8dc731fddeb6f5d1358b4ca5456e72594e664769f0a9163f13df2a31", + "sha256:dce3468bf1fc12374a1a732c9efd146ce034f91bb0482b602a9311cb6166a920", + "sha256:e12532c4d3f614678623da5d852f038ace1f01869b89f003ed6fe8c793f0c6a3", + "sha256:e74ce103b81c375c3853b436297952ef8d7863d801dcffb6728d01544e5191b5", + "sha256:f0394a3acfb8925db178f7728adb38c027ed7e303665b225906bfa8099dc1ce8", + "sha256:f522214f6749bc073262529c056f7dfd660f3b5ec4180c5354d985eb7219801e", + "sha256:fbf8c09fe9728168f8cc1b40c239eab10baf9c422c18be7f53213d70434dea43", + "sha256:fca8322e04b2dde722fcb0558682740eebd3bd239bea7a0d0febbc190e99dc15" + ], + "index": "pypi", + "version": "==1.4.36" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.1" + }, + "werkzeug": { + "hashes": [ + "sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6", + "sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "zipp": { + "hashes": [ + "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad", + "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099" + ], + "markers": "python_version >= '3.7'", + "version": "==3.8.0" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0674dd1 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Letter Tracking System using a Postal Service API + +### By Nicolas Poyiadjis +https://www.linkedin.com/in/nicpoyia/ + +## How to run and test + +### Environment Setup +- Install python 3.7 & pipenv +- `pipenv install` to install project's requirements +- Before running the API, export the environment variable called LA_POSTE_API_KEY, which is the authorization key for La Poste API `export LA_POSTE_API_KEY=LA_POSTE_API_KEY_HERE` +- Working tracking IDs are already stored in the sample SQLite database, therefore by retrieving statuses of all letters should return results. +- Execute command `flask run` to run the application's API +- You can use postman_demo.json for a demo of the API + +### Automated Tests +- Execute command `pipenv install -d` to install all dependencies required for development environment +- Execute command `python -m pytest tests/` to run all automated tests + +There have been implemented 3 types of automated tests: +- Unit tests, in which the model and data classes are being tested independently (the smallest units - the lowest layer) +- Module tests (or service tests, or integration tests), in which the service containing the logic is tested by integrating a mock implementation of each external dependency, which in this case is La Poste API +- End-to-end tests, in which the application is tested as a whole by running its HTTP API using mock dependencies + +In order to run the tests independently of production infrastructure, an independent SQLite database is generated on demand for testing purposes, i.e. before running the tests it is automatically created (if not yet) and the schema is initialized according to the application's migrations + +### Database Setup +The database is already initialized with the updated schema and sample data to allow observing the application in action + +The command `flask db upgrade` is used to initialize the configured database with the appropriate schema (no data will be lost if it is already initialized). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..59baaa4 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,19 @@ +import os + +from flask import Flask +from flask_cors import CORS +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +from app.config import config + +app = Flask(__name__) +CORS(app, origins="*", supports_credentials=True) +config_name = os.getenv("FLASK_CONFIG") or "default" +app.config.from_object(config[config_name]) + +db = SQLAlchemy(app) +Migrate(app, db) + +from .models import * +from .views import * diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..203cbcf --- /dev/null +++ b/app/config.py @@ -0,0 +1,26 @@ +import os + + +class Config: + SQLALCHEMY_DATABASE_URI = "sqlite:///la_poste_nicpoyia.db" + SQLALCHEMY_TRACK_MODIFICATIONS = False + + +class DevelopmentConfig(Config): + ENV_TYPE = "development" + LA_POSTE_API_BASE_URL = "https://api.laposte.fr/ssu/v1" + LA_POSTE_API_KEY = os.environ.get('LA_POSTE_API_KEY') + APP_DEBUG = True + + +class ProductionConfig(Config): + ENV_TYPE = "production" + LA_POSTE_API_BASE_URL = "https://api.laposte.fr/ssu/v1" + LA_POSTE_API_KEY = os.environ.get('LA_POSTE_API_KEY') + + +config = { + "development": DevelopmentConfig, + "production": ProductionConfig, + "default": DevelopmentConfig, +} diff --git a/app/la_poste_nicpoyia.db b/app/la_poste_nicpoyia.db new file mode 100644 index 0000000000000000000000000000000000000000..aab599d611371964df8ea24ccf21e7b9ecaab23d GIT binary patch literal 45056 zcmeI(Pi)&%90zbaasD`M@;Z$Xl?(4Otx>lU+fC90O^|izR+^@3njppwQJT1|SsG`K zv$YjFWE?o}%xODu*@;t6T##T42_bPpLYyW6(!|68IB?)SC+(V`RTH9(+I_P*wV(Zd ze!tIqbz{YEX)$ZqI$bfX^|DPhVMGu`;cZFATmQcbsQ-6|Q?-g0?5pGg-obT)S}^B&!&8ePcI-l}wLH)iCH z-{%WQBH~ALc6qs`_a4~0H-0fUyI9=Qx!u$B)!BQxzFm<8$r~OY6^*{Lt{C-ltrN}joLrnk_J(!}BJD-D zFFZOb-ns3JlunG!yVNr>I%Rb1xg(;lsk@^^tbJEmE&u_!@LT;&$ zPqSYX+83eJxL%<>!+1};NFZ|F6i-Oak%^Nj#7hfv3Y}45;^#@b>3f$~6>*tr+ zAX`N8RAB${f&c^{009U<00Izz00bZa0SG|gm$7HK!eA%Q9}VCa}OW zR=Hl$dm^c+sFs>k+ddc1Xx^_)Ux>$5o=`N_&1g@o1I{UtQj+bE$Xjxa*%sqdQB_f* z$=FOjTc%eSlunH%we)h8S(+@pI-6!fH+S48Xu3(8rdg-V->~_yt1NdWYSLEoEI*J| z%5^rEzg9DEu_KzaVVSFz-fYq}-O?xMF0#T_Q3LSd!%KRecyNul&&WMb9(3;i?+N4= z@*TN{`~M?37SL!2KmY;|fB*y_009U<00Iy=`T}9MChk7B|7>!E)L-76>fq@wXOk)T z!~@r>ZcW~e^X&Qmp!|w3;y)#j+e8d~9C|1CWpHlztKsa>!=d@W*MUp&BYDw(*MEu~ zf)@lJ009U<00IXr;Q!!&Pq1J3trK2BY|m@1{@IoXIYTm$iYci{r7etj1-X4qJM1}; z?6Xz335@3aemwi`+X!*HSxs{64rRV9`+TRS)XA;jKzfZ$*VPhjVR(SO#+wjt3qyl# zf}(5%2G9fSga2eoWj!DdEd6d1w)}%iUtyOG-(b^cz3&}V`q5}xV3Xwsbk%h3JY&f> zC66F3YM%bT880+Lf5Xk~4)v<(G+T*pxdzi~T1sJ;EOD^Cro;l|fiObu^ZEae$q(dP z@&);vd`kWxKauarL$Xcolc(e}@+)~jk}vvi-@lL82>}Q|00Izz00bZa0SG_<0uVSl z0^b~7Q$21`be-U{_D9%By@J3Cq>xh_ bool: + return self.tracking_number + + def get_status_text(self) -> str: + return self.status + + def is_final(self) -> bool: + return self.final + + def get_last_update_timestamp(self) -> bool: + return self.updated + + def make_final(self) -> None: + self.final = True diff --git a/app/models/status_update.py b/app/models/status_update.py new file mode 100644 index 0000000..4d4df5b --- /dev/null +++ b/app/models/status_update.py @@ -0,0 +1,22 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.sql import func + +from app import db + + +class StatusUpdate(db.Model): + __tablename__ = "status_history" + + id = db.Column(db.Integer, primary_key=True) + letter_id = db.Column(db.Integer, ForeignKey('letter.id'), index=True) + status = db.Column(db.String(191)) + timestamp_tracked = db.Column(db.DateTime(timezone=True), server_default=func.now()) + + def get_letter_id(self) -> str: + return self.letter_id + + def get_status_text(self) -> str: + return self.status + + def get_tracking_timestamp(self) -> bool: + return self.timestamp_tracked diff --git a/app/tracking_service/__init__.py b/app/tracking_service/__init__.py new file mode 100644 index 0000000..78c0177 --- /dev/null +++ b/app/tracking_service/__init__.py @@ -0,0 +1 @@ +__all__ = ["tracking_service", "tracking_response_dto", "tracking_exception"] diff --git a/app/tracking_service/tracking_exception.py b/app/tracking_service/tracking_exception.py new file mode 100644 index 0000000..2b2c2ed --- /dev/null +++ b/app/tracking_service/tracking_exception.py @@ -0,0 +1,17 @@ +class CannotTrackLetterException(Exception): + def __init__(self, log_message: str): + self.log_message = log_message + + +class CannotUpdateLetterTrackingException(Exception): + def __init__(self, log_message: str): + self.log_message = log_message + + +class InvalidTrackingResponseException(Exception): + def __init__(self, invalid_object: str): + self.invalid_object = invalid_object + + +class NoTrackingEventException(Exception): + pass diff --git a/app/tracking_service/tracking_response_dto.py b/app/tracking_service/tracking_response_dto.py new file mode 100644 index 0000000..034c079 --- /dev/null +++ b/app/tracking_service/tracking_response_dto.py @@ -0,0 +1,75 @@ +from datetime import datetime +from typing import List + +from .tracking_exception import InvalidTrackingResponseException, NoTrackingEventException + + +class _TrackingEventDto: + date: datetime + label: str + + def __init__(self, date: datetime, label: str) -> None: + super().__init__() + self.date = date + self.label = label + + @staticmethod + def from_json_dict(json_dict): + """ + Factory method which generates an object of the class by using the received raw JSON data + :param json_dict: Dictionary with JSON data + :return: _TrackingEventDto + :raises: + InvalidTrackingResponseException: In case of invalid payload received in the response + """ + date = json_dict.get('date') + if not date: + raise InvalidTrackingResponseException("event.date") + label = json_dict.get('label') + if not label: + raise InvalidTrackingResponseException("event.label") + return _TrackingEventDto(date, label) + + +class TrackingResponseDto: + # Whether the tracking is final, i.e. no further changes will apply + is_final: bool + # Tracking events in anti-chronological order + events: List[_TrackingEventDto] + + def __init__(self, is_final: bool, events: List[_TrackingEventDto]) -> None: + super().__init__() + self.is_final = is_final + self.events = events + self.events.sort(key=lambda ev: ev.date, reverse=True) + + @staticmethod + def from_json_dict(json_dict): + """ + Factory method which generates an object of the class by using the received raw JSON data + :param json_dict: Dictionary with JSON data + :return: TrackingResponseDto + :raises: + InvalidTrackingResponseException: In case of invalid payload received in the response + """ + shipment_obj = json_dict.get('shipment') + if not shipment_obj: + raise InvalidTrackingResponseException("shipment") + if 'event' not in shipment_obj: + raise InvalidTrackingResponseException("event") + events_data_list = shipment_obj.get('event') + events_list = [_TrackingEventDto.from_json_dict(event_dict) for event_dict in events_data_list] + return TrackingResponseDto( + is_final=shipment_obj.get('isFinal'), + events=events_list + ) + + def get_last_event_status(self) -> str: + """ + :return: Status label of last tracking event + :raises: + NoTrackingEventException If no event is available + """ + if not self.events: + raise NoTrackingEventException() + return self.events[0].label diff --git a/app/tracking_service/tracking_service.py b/app/tracking_service/tracking_service.py new file mode 100644 index 0000000..cd8f05a --- /dev/null +++ b/app/tracking_service/tracking_service.py @@ -0,0 +1,194 @@ +import logging +from datetime import datetime +from threading import Thread + +import requests +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import false + +from app import app, db +from app.models.letter import Letter +from app.models.status_update import StatusUpdate +from .tracking_exception import ( + CannotTrackLetterException, + CannotUpdateLetterTrackingException, + InvalidTrackingResponseException +) +from .tracking_response_dto import TrackingResponseDto + + +class TrackingService: + # Page size used for paginated retrieval of tracked letters + __PAGE_SIZE = 100 + + # Base URL of tracking API + api_base_url: str + # Authorization key for tracking API + api_key: str + + # Active database session + db_session: Session + # Whether the application is running in debug mode + is_debug: bool + + def __init__(self) -> None: + super().__init__() + self.api_base_url = app.config.get('LA_POSTE_API_BASE_URL') + self.api_key = app.config.get('LA_POSTE_API_KEY') + self.db_session = db.session + self.is_debug = app.config.get('APP_DEBUG') + + def track_letter(self, shipment_d: str) -> str: + """ + Tracks a letter, updates tracking status in database, and returns the latest tracked status + :param shipment_d: Shipment id of letter + :return: Latest tracked status of letter + :raises: + CannotTrackLetterException: In case of unexpected tracking error + """ + # Call API and return tracking status + url = '{b_url}/suivi-unifie/idship/{sh_id}?lang=en_GB'.format( + b_url=self.api_base_url, + sh_id=shipment_d + ) + try: + response = requests.get(url, headers={'X-Okapi-Key': self.api_key, 'Accept': 'application/json'}) + if response.status_code != 200: + raise CannotTrackLetterException( + "API call unsuccessful with status {resp_code} - \"{resp_mess}\"".format( + resp_code=response.status_code, + resp_mess=response.text + ) + ) + try: + trackingResponse = TrackingResponseDto.from_json_dict(response.json()) + except InvalidTrackingResponseException: + raise CannotTrackLetterException("Invalid response from API") + letter_status = trackingResponse.get_last_event_status() + try: + self.__save_letter_tracking_info(shipment_d, letter_status, trackingResponse.is_final) + except CannotUpdateLetterTrackingException as UpdateException: + # Log tracking update error for future reference/audit + logging.error(UpdateException.log_message) + return letter_status + except requests.exceptions.ConnectionError as e: + # Connection exception handling + if not self.is_debug: + # While running in testing environment, + # there may be some API calls without a handling process in place, which is expected + error_text = str(e) + logging.error(error_text) + raise CannotTrackLetterException(error_text) + except Exception as e: + if not self.is_debug: + # Uncaught exception handling + error_text = str(e) + logging.error(error_text) + raise CannotTrackLetterException(error_text) + + def track_all_registered_letters(self) -> dict: + """ + Tracks all letters that are registered in the database asynchronously, + i.e. returns the current known status of each letter and then asynchronously updates every letter's status + :return: Dictionary containing the latest tracked status of each letter + """ + # Find letters in database that are not final, i.e. there is a potential change of tracking status + # Tracking the status of only non-final letters is pivotal when it comes to scalability, + # Since final letters will be piled up more and more in the database, without any potential change in status + thread = Thread(target=self.track_all_registered_letters_in_database) + # Return current tracking status + letter_statuses = {} + letter_results = Letter.query.order_by(Letter.updated.desc()) + for letter in letter_results: + letter_statuses[letter.tracking_number] = letter.status + # Start asynchronous task on return + thread.start() + return letter_statuses + + def track_all_registered_letters_in_database(self): + for batch in self.__get_letter_tracking_batches(): + self.__process_letter_tracking_batch(batch) + + def track_letters_updated_between(self, from_update: datetime, to_update: datetime): + """ + Tracks letters updated within a date/time range in the database asynchronously, + i.e. returns the current known status of each letter and then asynchronously updates every letter's status + :param from_update: Optional update timestamp to filter letters from + :param to_update: Optional update timestamp to filter letters until + :return: Dictionary containing the latest tracked status of each letter + """ + # Find letters in database that are not final, i.e. there is a potential change of tracking status + # Tracking the status of only non-final letters is pivotal when it comes to scalability, + # Since final letters will be piled up more and more in the database, without any potential change in status + thread = Thread(target=self.track_letters_in_range, args=(from_update, to_update)) + # Return current tracking status + letter_statuses = {} + letter_results = Letter.query.order_by(Letter.updated.desc()) \ + .filter(Letter.updated >= from_update).filter(Letter.updated <= to_update) + for letter in letter_results: + letter_statuses[letter.tracking_number] = letter.status + # Start asynchronous task on return + thread.start() + return letter_statuses + + def track_letters_in_range(self, from_update: datetime, to_update: datetime): + for batch in self.__get_letter_tracking_batches(from_update, to_update): + self.__process_letter_tracking_batch(batch) + + def __get_letter_tracking_batches(self, from_update: datetime = None, to_update: datetime = None): + """ + # Find letters in database that are not final, i.e. there is a potential change of tracking status + # Tracking the status of only non-final letters is pivotal when it comes to scalability, + # Since final letters will be piled up more and more in the database, without any potential change in status + :param from_update: Optional update timestamp to filter letters from + :param to_update: Optional update timestamp to filter letters until + :return: Batches of non-final letters to be tracked + """ + cur_page = 1 + res_count = self.__PAGE_SIZE + while res_count > 0: + letterQuery = Letter.query.filter(Letter.final == false()) + if from_update: + letterQuery = letterQuery.filter(Letter.updated >= from_update) + if to_update: + letterQuery = letterQuery.filter(Letter.updated <= to_update) + next_page = letterQuery.order_by(Letter.id.asc()).paginate(page=cur_page, per_page=self.__PAGE_SIZE) + res_count = len(next_page.items) + cur_page += 1 + if res_count > 0: + yield next_page + if res_count < self.__PAGE_SIZE: + break + + def __process_letter_tracking_batch(self, batch): + for letter in batch.items: + self.track_letter(letter.tracking_number) + + def __save_letter_tracking_info(self, shipment_id: str, status: str, is_final: bool) -> None: + """ + Updates the tracking status of a letter in the database + :param shipment_id: Shipment id of letter + :param status: Latest tracked status of letter + :raises: + CannotUpdateLetterTrackingException: In case of error while updating the tracking status in database + """ + # Update letter status (and register letter in database if it does not exist) + existing_letter = Letter.query.filter(Letter.tracking_number == shipment_id).first() + if not existing_letter: + letter = Letter(tracking_number=shipment_id, status=status) + else: + letter = existing_letter + letter.status = status + if is_final: + letter.make_final() + # Reload letter entity to get id of letter in database + self.db_session.add(letter) + self.db_session.commit() + self.db_session.refresh(letter) + # Save status update record (immutable) + if not letter.id: + raise CannotUpdateLetterTrackingException("Error while registering letter for tracking") + new_status_update = StatusUpdate(letter_id=letter.id, status=status) + self.db_session.add(new_status_update) + self.db_session.commit() + StatusUpdate.query.filter(StatusUpdate.letter_id == letter.id) diff --git a/app/views/__init__.py b/app/views/__init__.py new file mode 100644 index 0000000..b0f8c46 --- /dev/null +++ b/app/views/__init__.py @@ -0,0 +1,40 @@ +from dateutil import parser +from dateutil.parser import ParserError + +from app import app +from app.tracking_service.tracking_exception import CannotTrackLetterException +from app.tracking_service.tracking_service import TrackingService +from app.views.batch_tracking_api_result_dto import BatchTrackingApiResultDto +from app.views.tracking_api_result_dto import TrackingApiResultDto + + +@app.route("/letters/all", methods=["GET"]) +def get_all_letters_statuses(): + trackingService = TrackingService() + tracking_statuses = trackingService.track_all_registered_letters() + return BatchTrackingApiResultDto(tracking_statuses).__dict__ + + +@app.route("/letters/by_ship_id/", methods=["GET"]) +def get_letter_status(shipment_id: str): + trackingService = TrackingService() + try: + tracking_status = trackingService.track_letter(shipment_id) + except CannotTrackLetterException as e: + return f"Cannot track letter due to \"{e.log_message}\"", 422 + return TrackingApiResultDto(tracking_status).__dict__ + + +@app.route("/letters/by_update//", methods=["GET"]) +def get_letter_status_updated_within(from_date: str, to_date: str): + try: + from_date = parser.parse(from_date) + except ParserError: + return "Invalid from-date", 400 + try: + to_date = parser.parse(to_date) + except ParserError: + return "Invalid to-date", 400 + trackingService = TrackingService() + tracking_statuses = trackingService.track_letters_updated_between(from_date, to_date) + return BatchTrackingApiResultDto(tracking_statuses).__dict__ diff --git a/app/views/batch_tracking_api_result_dto.py b/app/views/batch_tracking_api_result_dto.py new file mode 100644 index 0000000..4d76cb2 --- /dev/null +++ b/app/views/batch_tracking_api_result_dto.py @@ -0,0 +1,5 @@ +class BatchTrackingApiResultDto: + status_per_ship_id: dict + + def __init__(self, status_per_ship_id: dict) -> None: + self.status_per_ship_id = status_per_ship_id diff --git a/app/views/tracking_api_result_dto.py b/app/views/tracking_api_result_dto.py new file mode 100644 index 0000000..7bd9ca0 --- /dev/null +++ b/app/views/tracking_api_result_dto.py @@ -0,0 +1,5 @@ +class TrackingApiResultDto: + status: str + + def __init__(self, status: str) -> None: + self.status = status diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/6297642bcbc4_initial_migration.py b/migrations/versions/6297642bcbc4_initial_migration.py new file mode 100644 index 0000000..99bca76 --- /dev/null +++ b/migrations/versions/6297642bcbc4_initial_migration.py @@ -0,0 +1,50 @@ +"""Initial migration + +Revision ID: 6297642bcbc4 +Revises: +Create Date: 2022-05-01 23:41:27.119735 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6297642bcbc4' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('letter', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tracking_number', sa.String(length=256), nullable=True), + sa.Column('status', sa.String(length=191), nullable=True), + sa.Column('final', sa.Boolean(), nullable=True), + sa.Column('updated', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_letter_final'), 'letter', ['final'], unique=False) + op.create_index(op.f('ix_letter_tracking_number'), 'letter', ['tracking_number'], unique=True) + op.create_index(op.f('ix_letter_updated'), 'letter', ['updated'], unique=False) + op.create_table('status_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('letter_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=191), nullable=True), + sa.Column('timestamp_tracked', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.ForeignKeyConstraint(['letter_id'], ['letter.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_status_history_letter_id'), 'status_history', ['letter_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_index(op.f('ix_status_history_letter_id'), table_name='status_history') + op.drop_table('status_history') + op.drop_index(op.f('ix_letter_updated'), table_name='letter') + op.drop_index(op.f('ix_letter_tracking_number'), table_name='letter') + op.drop_index(op.f('ix_letter_final'), table_name='letter') + op.drop_table('letter') + # ### end Alembic commands ### diff --git a/postman_demo.json b/postman_demo.json new file mode 100644 index 0000000..fb0e7f1 --- /dev/null +++ b/postman_demo.json @@ -0,0 +1,72 @@ +{ + "info": { + "_postman_id": "ccfce4a2-759a-46c8-bdf7-afbb025077cc", + "name": "Demo of Letter Tracking API - Nicolas Poyiadjis", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Track specific letter", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5000/letters/by_ship_id/LU680211095FR", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5000", + "path": [ + "letters", + "by_ship_id", + "LU680211095FR" + ] + } + }, + "response": [] + }, + { + "name": "Track all letters in DB asynchronously", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5000/letters/all", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5000", + "path": [ + "letters", + "all" + ] + } + }, + "response": [] + }, + { + "name": "Track letters updated between period", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5000/letters/by_update/2022-05-01T09:58:10/2022-05-02T11:59:15", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "5000", + "path": [ + "letters", + "by_update", + "2022-05-01T09:58:10", + "2022-05-02T11:59:15" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d0f9fcc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = strict \ No newline at end of file diff --git a/tests/api_e2e/test_get_all_letters_statuses_e2e.py b/tests/api_e2e/test_get_all_letters_statuses_e2e.py new file mode 100644 index 0000000..b3c77a5 --- /dev/null +++ b/tests/api_e2e/test_get_all_letters_statuses_e2e.py @@ -0,0 +1,42 @@ +import asyncio +import uuid + +import pytest +from flask.testing import FlaskClient +from flask_sqlalchemy import SQLAlchemy +from pytest_httpserver import HTTPServer + +from tests.test_fixtures import prepare_mock_la_poste_api, DEFAULT_TRACKING_STATUS_FOR_TESTING + + +@pytest.mark.asyncio +async def test_get_all_letters_statuses_e2e( + test_db: SQLAlchemy, + test_http_server: HTTPServer, + test_api_client: FlaskClient +): + # Setup mock server behaviour in response to La Poste API requests (use a mock server) + # Register letter and update its status in the system + shipment_id = str(uuid.uuid4()) + first_status = f"Letter status {uuid.uuid4()}" + prepare_mock_la_poste_api(test_http_server, shipment_id, first_status) + test_api_client.get(f"/letters/by_ship_id/{shipment_id}") + # Test the full application using mock dependencies via its HTTP API + # Setup mock status update when La Poste API is called again + second_status = DEFAULT_TRACKING_STATUS_FOR_TESTING + prepare_mock_la_poste_api(test_http_server, shipment_id, second_status) + # At this point the system does not know about the second status + # We will use this to evaluate whether the operation is asynchronous + # The first status (the outdated one) should be returned, + # because the update operation will begin after the API has been responded + response_object = test_api_client.get("/letters/all").json + assert 'status_per_ship_id' in response_object + status_per_ship_id = response_object['status_per_ship_id'] + assert shipment_id in status_per_ship_id + assert status_per_ship_id[shipment_id] == first_status + # Wait for a few seconds and check again + # Here we intentionally do not check the internal database state, since we are performing end-to-end testing + await asyncio.sleep(3) + # Check updated tracking status + status_per_ship_id = test_api_client.get("/letters/all").json['status_per_ship_id'] + assert status_per_ship_id[shipment_id] == second_status diff --git a/tests/api_e2e/test_get_letter_status_e2e.py b/tests/api_e2e/test_get_letter_status_e2e.py new file mode 100644 index 0000000..7266dcb --- /dev/null +++ b/tests/api_e2e/test_get_letter_status_e2e.py @@ -0,0 +1,18 @@ +import uuid + +from flask.testing import FlaskClient +from flask_sqlalchemy import SQLAlchemy +from pytest_httpserver import HTTPServer + +from tests.test_fixtures import prepare_mock_la_poste_api + + +def test_get_letter_status_e2e(test_db: SQLAlchemy, httpserver: HTTPServer, test_api_client: FlaskClient): + # Setup mock server behaviour in response to La Poste API requests (use a mock server) + shipment_id = str(uuid.uuid4()) + latest_status = f"Letter status {uuid.uuid4()}" + prepare_mock_la_poste_api(httpserver, shipment_id, latest_status) + # Test the full application using mock dependencies via its HTTP API + response = test_api_client.get(f"/letters/by_ship_id/{shipment_id}") + response_object = response.json + assert response_object['status'] == latest_status diff --git a/tests/api_e2e/test_get_letter_status_updated_within.py b/tests/api_e2e/test_get_letter_status_updated_within.py new file mode 100644 index 0000000..588ba6e --- /dev/null +++ b/tests/api_e2e/test_get_letter_status_updated_within.py @@ -0,0 +1,59 @@ +import asyncio +import uuid +from datetime import datetime, timedelta + +import pytest +from flask.testing import FlaskClient +from flask_sqlalchemy import SQLAlchemy +from pytest_httpserver import HTTPServer + +from tests.test_fixtures import prepare_mock_la_poste_api, DEFAULT_TRACKING_STATUS_FOR_TESTING + + +def test_get_letter_status_updated_within_bad_request(test_api_client: FlaskClient): + response = test_api_client.get("/letters/by_update/invalid_timestamp1/invalid_timestamp2") + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_letter_status_updated_within( + test_db: SQLAlchemy, + test_http_server: HTTPServer, + test_api_client: FlaskClient +): + # Setup mock server behaviour in response to La Poste API requests (use a mock server) + # Register letter and update its status in the system + shipment_id = str(uuid.uuid4()) + first_status = f"Letter status {uuid.uuid4()}" + prepare_mock_la_poste_api(test_http_server, shipment_id, first_status) + test_api_client.get(f"/letters/by_ship_id/{shipment_id}") + # Test the full application using mock dependencies via its HTTP API + # Setup mock status update when La Poste API is called again + second_status = DEFAULT_TRACKING_STATUS_FOR_TESTING + prepare_mock_la_poste_api(test_http_server, shipment_id, second_status) + # At this point the system does not know about the second status + # We will use this to evaluate whether the operation is asynchronous + # The first status (the outdated one) should be returned, + # because the update operation will begin after the API has been responded + from_update1 = datetime.utcnow() - timedelta(hours=4) + to_update1 = datetime.utcnow() + timedelta(minutes=10) + response_object = test_api_client.get( + f"/letters/by_update/{from_update1.isoformat()}/{to_update1.isoformat()}").json + assert 'status_per_ship_id' in response_object + status_per_ship_id = response_object['status_per_ship_id'] + assert shipment_id in status_per_ship_id + assert status_per_ship_id[shipment_id] == first_status + # Wait for a few seconds and check again + # Here we intentionally do not check the internal database state, since we are performing end-to-end testing + await asyncio.sleep(3) + # Check updated tracking status + status_per_ship_id = test_api_client.get( + f"/letters/by_update/{from_update1.isoformat()}/{to_update1.isoformat()}").json['status_per_ship_id'] + assert status_per_ship_id[shipment_id] == second_status + # Check that the letter is not included in the response if out of the timestamp range specified + from_update2 = datetime.utcnow() + timedelta(minutes=10) + to_update2 = datetime.utcnow() + timedelta(minutes=20) + response_object = test_api_client.get( + f"/letters/by_update/{from_update2.isoformat()}/{to_update2.isoformat()}").json + assert 'status_per_ship_id' in response_object + assert not response_object['status_per_ship_id'] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cb9b0bd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +from tests.test_fixtures import * diff --git a/tests/model_unit/test_letter.py b/tests/model_unit/test_letter.py new file mode 100644 index 0000000..4b1ead9 --- /dev/null +++ b/tests/model_unit/test_letter.py @@ -0,0 +1,28 @@ +import unittest +import uuid +from datetime import datetime + +from app.models.letter import Letter + + +class LetterUnitTest(unittest.TestCase): + + def test_getters(self): + tracking_number = str(uuid.uuid4()) + status = str(uuid.uuid4()) + final = True + updated = datetime.now() + letter = Letter(tracking_number=tracking_number, status=status, final=final, updated=updated) + self.assertEqual(letter.get_status_text(), status) + self.assertTrue(letter.is_final()) + self.assertEqual(letter.get_last_update_timestamp(), updated) + + def test_make_final(self): + letter = Letter() + self.assertFalse(letter.is_final()) + letter.make_final() + self.assertTrue(letter.is_final()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/model_unit/test_status_update.py b/tests/model_unit/test_status_update.py new file mode 100644 index 0000000..762526a --- /dev/null +++ b/tests/model_unit/test_status_update.py @@ -0,0 +1,21 @@ +import unittest +import uuid +from datetime import datetime + +from app.models.status_update import StatusUpdate + + +class StatusUpdateUnitTest(unittest.TestCase): + + def test_getters(self): + letter_id = str(uuid.uuid4()) + status = str(uuid.uuid4()) + timestamp_tracked = datetime.now() + status_update = StatusUpdate(letter_id=letter_id, status=status, timestamp_tracked=timestamp_tracked) + self.assertTrue(status_update.get_letter_id(), letter_id) + self.assertEqual(status_update.get_status_text(), status) + self.assertEqual(status_update.get_tracking_timestamp(), timestamp_tracked) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..d71f9ce --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,70 @@ +import os.path +import re +import uuid +from datetime import datetime + +import pytest +from alembic import command +from alembic.config import Config +from pytest_httpserver import HTTPServer + +from app import app, db + +DEFAULT_TRACKING_STATUS_FOR_TESTING = 'THIS IS A DEFAULT TRACKING STATUS FOR TESTING' + + +@pytest.fixture() +def test_app(): + app.config.update({ + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///../local_testing/testing.db", + "LA_POSTE_API_BASE_URL": "http://localhost:12312/mock-la-poste-api", + "LA_POSTE_API_KEY": "mock_api_key" + }) + yield app + + +@pytest.fixture(scope="session") +def httpserver_listen_address(): + # Configure mock HTTP server + return "127.0.0.1", 12312 + + +@pytest.fixture() +def test_api_client(test_app): + return test_app.test_client() + + +@pytest.fixture() +def test_db(test_app): + # Run database migrations on testing database (prepare schema) + with test_app.app_context(): + config = Config(os.path.dirname(os.path.abspath(__file__)) + "/../migrations/alembic.ini") + config.set_main_option("script_location", os.path.dirname(os.path.abspath(__file__)) + "/../migrations") + command.upgrade(config, "head") + # Provide testing database + yield db + # Close connection after tests are finshed + db.session.close() + + +@pytest.fixture() +def test_http_server(httpserver: HTTPServer) -> HTTPServer: + # Set up a default request handling for testing purposes + default_response_object = {'shipment': {'isFinal': False, 'event': [ + {'date': datetime.now().isoformat(), 'label': DEFAULT_TRACKING_STATUS_FOR_TESTING} + ]}} + httpserver.expect_request(re.compile("/mock-la-poste-api/suivi-unifie/idship/.+")) \ + .respond_with_json(default_response_object) + return httpserver + + +def prepare_mock_la_poste_api(httpserver: HTTPServer, + shipment_id: str, + latest_status: str = None, + latest_event_date: str = None): + latest_status = latest_status or f"Letter status {uuid.uuid4()}" + latest_event_date = latest_event_date or datetime.now().isoformat() + responseObject = {'shipment': {'isFinal': False, 'event': [{'date': latest_event_date, 'label': latest_status}]}} + httpserver.clear() + httpserver.expect_request(f"/mock-la-poste-api/suivi-unifie/idship/{shipment_id}").respond_with_json(responseObject) diff --git a/tests/tracking_service/test_tracking_service.py b/tests/tracking_service/test_tracking_service.py new file mode 100644 index 0000000..ab4a045 --- /dev/null +++ b/tests/tracking_service/test_tracking_service.py @@ -0,0 +1,105 @@ +import time +import uuid +from datetime import datetime, timedelta + +import pytest +from flask_sqlalchemy import SQLAlchemy +from pytest_httpserver import HTTPServer + +import app +from app.models.letter import Letter +from app.models.status_update import StatusUpdate +from app.tracking_service.tracking_service import TrackingService +from tests.test_fixtures import prepare_mock_la_poste_api, DEFAULT_TRACKING_STATUS_FOR_TESTING + + +def get_is_final(): + return [False, True] + + +@pytest.mark.parametrize("is_final", get_is_final()) +def test_track_letter(test_db: SQLAlchemy, httpserver: HTTPServer, is_final: bool): + # Setup mock server behaviour in response to La Poste API requests (use a mock server) + shipment_id = str(uuid.uuid4()) + latest_status = f"Letter status {uuid.uuid4()}" + latest_event_date = datetime.now().isoformat() + prepare_mock_la_poste_api(httpserver, shipment_id, latest_status, latest_event_date) + # Track a letter using the service under test + test_tracking_service = TrackingService() + returned_status = test_tracking_service.track_letter(shipment_id) + assert returned_status == latest_status + # Check if the letter has been registered and the status was updated + letter_results = test_db.session.query(Letter).filter_by(tracking_number=shipment_id).all() + assert len(letter_results) == 1 + assert letter_results[0].status == returned_status + assert not letter_results[0].final + # Check if a record has been added in the status-update history + status_update_results = test_db.session.query(StatusUpdate).filter_by(letter_id=letter_results[0].id).all() + assert len(status_update_results) == 1 + assert status_update_results[0].status == returned_status + + +def test_track_all_registered_letters(test_db: SQLAlchemy, httpserver: HTTPServer): + # Setup mock server behaviour in response to La Poste API requests (use a mock server) + # Register letter and update its status in the system + shipment_id = str(uuid.uuid4()) + letter_first_status = f"Letter status {uuid.uuid4()}" + latest_event_date = datetime.now().isoformat() + prepare_mock_la_poste_api(httpserver, shipment_id, letter_first_status, latest_event_date) + test_tracking_service = TrackingService() + returned_status = test_tracking_service.track_letter(shipment_id) + assert returned_status == letter_first_status + # Setup mock status update when La Poste API is called again + letter_second_status = DEFAULT_TRACKING_STATUS_FOR_TESTING + prepare_mock_la_poste_api(httpserver, shipment_id, letter_second_status) + # At this point the system does not know about the second status + # We will use this to evaluate whether the operation is asynchronous + # The first status (the outdated one) should be returned, + # because the update operation will begin after the service has been responded + all_returned_statuses = test_tracking_service.track_all_registered_letters() + assert shipment_id in all_returned_statuses + assert all_returned_statuses[shipment_id] == letter_first_status + # Wait until the letter is updated in the database and check the updated status + new_letter_status = __detect_status_change_in_database(shipment_id, letter_first_status) + assert new_letter_status == letter_second_status + + +def test_track_letters_updated_between(test_db: SQLAlchemy, httpserver: HTTPServer): + # Setup mock server behaviour in response to La Poste API requests (use a mock server) + # Register letter and update its status in the system + shipment_id = str(uuid.uuid4()) + letter_first_status = f"Letter status {uuid.uuid4()}" + latest_event_date = datetime.now().isoformat() + prepare_mock_la_poste_api(httpserver, shipment_id, letter_first_status, latest_event_date) + test_tracking_service = TrackingService() + returned_status = test_tracking_service.track_letter(shipment_id) + assert returned_status == letter_first_status + # Setup mock status update when La Poste API is called again + letter_second_status = DEFAULT_TRACKING_STATUS_FOR_TESTING + prepare_mock_la_poste_api(httpserver, shipment_id, letter_second_status) + # At this point the system does not know about the second status + # We will use this to evaluate whether the operation is asynchronous + # The first status (the outdated one) should be returned, + # because the update operation will begin after the service has been responded + from_update1 = datetime.utcnow() - timedelta(hours=4) + to_update1 = datetime.utcnow() + timedelta(minutes=10) + all_returned_statuses = test_tracking_service.track_letters_updated_between(from_update1, to_update1) + assert shipment_id in all_returned_statuses + assert all_returned_statuses[shipment_id] == letter_first_status + # Wait until the letter is updated in the database and check the updated status + new_letter_status = __detect_status_change_in_database(shipment_id, letter_first_status) + assert new_letter_status == letter_second_status + # Check that the letter is not included in the results if out of the timestamp range specified + from_update2 = datetime.utcnow() + timedelta(minutes=10) + to_update2 = datetime.utcnow() + timedelta(minutes=20) + assert not test_tracking_service.track_letters_updated_between(from_update2, to_update2) + + +def __detect_status_change_in_database(shipment_id: str, previous_status: str, timeout=3): + try_until = time.time() + timeout + while time.time() < try_until: + letter = Letter.query.filter(Letter.tracking_number == shipment_id)[0] + app.db.session.refresh(letter) + if letter.status != previous_status: + return letter.status + time.sleep(0.1)