diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 14952712..a52d34d9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,71 +6,67 @@ on: paths-ignore: - "codeforlife/version.py" - "CHANGELOG.md" + workflow_dispatch: env: PYTHON_VERSION: 3.8 jobs: test: - name: Test Code runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.8] steps: - - uses: actions/checkout@v3 + - name: ๐Ÿ›ซ Checkout + uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} + - name: ๐Ÿ Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install Python Packages + - name: ๐Ÿ›  Install Dependencies run: | python -m pip install --upgrade pip python -m pip install pipenv pipenv install --dev - - name: Check Code Format + - name: ๐Ÿ”Ž Check Code Format run: if ! pipenv run black --check .; then exit 1; fi - - name: Check Migrations + - name: ๐Ÿ”Ž Check Migrations run: pipenv run python manage.py makemigrations --check --dry-run # TODO: assert code coverage target. - - name: Test Code Units + - name: ๐Ÿงช Test Code Units run: pipenv run pytest - release: - name: Publish Release - concurrency: release + sync: runs-on: ubuntu-latest needs: [test] - if: github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v3 - with: - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - fetch-depth: 0 + - name: ๐Ÿ›ซ Checkout + uses: actions/checkout@v3 - - name: Set up Python + - name: ๐Ÿ Set up Python uses: actions/setup-python@v4 with: python-version: ${{ env.PYTHON_VERSION }} - - name: Install Dependencies + - name: ๐Ÿ›  Install Dependencies run: | python -m pip install --upgrade pip # pipenv-setup requires downgraded vistir: https://github.com/Madoshakalaka/pipenv-setup/issues/138 - python -m pip install python-semantic-release~=7.33 pipenv-setup[black]==3.2.0 vistir==0.6.1 + python -m pip install pipenv-setup[black]==3.2.0 vistir==0.6.1 - - name: Setup Git + - name: โš™๏ธ Configure Git run: | - git config --local user.name github-actions - git config --local user.email github-actions@github.com + git config --local user.name cfl-bot + git config --local user.email codeforlife-bot@ocado.com - - name: Sync Setup Dependencies + - name: ๐Ÿ”„ Sync Setup Dependencies run: | pipenv-setup sync git add setup.py @@ -81,7 +77,37 @@ jobs: git push fi - - name: Publish Semantic Release + release: + concurrency: release + runs-on: ubuntu-latest + needs: [sync] + if: github.ref_name == 'main' + steps: + - name: ๐Ÿ›ซ Checkout + uses: actions/checkout@v3 + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + fetch-depth: 0 + + - name: ๐Ÿ”„ Sync Setup Dependencies + run: git pull + + - name: ๐Ÿ Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: ๐Ÿ›  Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install python-semantic-release~=7.33 + + - name: โš™๏ธ Configure Git + run: | + git config --local user.name cfl-bot + git config --local user.email codeforlife-bot@ocado.com + + - name: ๐Ÿš€ Publish Semantic Release env: GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} run: semantic-release publish --verbosity=INFO diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b388533..962594af 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,11 @@ { + "black-formatter.args": [ + "--config", + "pyproject.toml" + ], "python.testing.pytestArgs": [ - "tests" + "-c=pyproject.toml", + "." ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/CHANGELOG.md b/CHANGELOG.md index c5fcc8c2..a19de983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ +## v0.8.0 (2023-10-02) + +### Feature + +* Otp ([#12](https://github.com/ocadotechnology/codeforlife-package-python/issues/12)) ([`4923f02`](https://github.com/ocadotechnology/codeforlife-package-python/commit/4923f0294a176ab6228568446234397a7bc63ddf)) + ## v0.7.14 (2023-09-21) ### Fix diff --git a/Pipfile b/Pipfile index e2507e3b..13d9a95f 100644 --- a/Pipfile +++ b/Pipfile @@ -11,11 +11,16 @@ django-two-factor-auth = "==1.13.2" django-cors-headers = "==4.1.0" pydantic = "==1.10.7" flask = "==2.2.3" +pyotp = "==2.9.0" importlib-metadata = "==4.13.0" # TODO: remove. needed by old portal django-formtools = "==2.2" # TODO: remove. needed by old portal django-otp = "==1.0.2" # TODO: remove. needed by old portal # https://pypi.org/user/codeforlife/ -cfl-common = "==6.36.2" # TODO: remove +cfl-common = "==6.37.1" # TODO: remove +codeforlife-portal = "==6.37.1" # TODO: remove +aimmo = "==2.10.6" # TODO: remove +rapid-router = "==5.11.3" # TODO: remove +phonenumbers = "==8.12.12" # TODO: remove [dev-packages] black = "==23.1.0" @@ -24,6 +29,7 @@ pytest-django = "==4.5.2" django-extensions = "==3.2.1" pyparsing = "==3.0.9" pydot = "==1.4.2" +pytest-env = "==0.8.1" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index ec096f59..c43f6514 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6865358979031ec674053110c2e57b4644fa155fc3dab80de3e09e702e5d86b1" + "sha256": "aa3e3eedfa674929a89e7496ab35a21a63986025b3ade8f19b29004d0afffda4" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "aimmo": { + "hashes": [ + "sha256:b89f83586412320b147ea61b4277599732c10e7668fba5b2d0a383db6a173145", + "sha256:bd2841b24d7830096b7cc81bdf7548377d30602f1a1b3d9e9084a58b11557413" + ], + "index": "pypi", + "version": "==2.10.6" + }, "asgiref": { "hashes": [ "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", @@ -24,6 +32,22 @@ "markers": "python_version >= '3.7'", "version": "==3.7.2" }, + "attrs": { + "hashes": [ + "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", + "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1.0" + }, + "cachetools": { + "hashes": [ + "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", + "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b" + ], + "markers": "python_version >= '3.7'", + "version": "==5.3.1" + }, "certifi": { "hashes": [ "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", @@ -34,11 +58,11 @@ }, "cfl-common": { "hashes": [ - "sha256:171e5607e7704e7f979d947226c681c1672c1f038116c0ed69b1fe57c2eb1eac", - "sha256:8fd9b61f1f6b70a1f8957ed65c5ff42fa6c6b6fedbf1b34cdeae0df563844d9f" + "sha256:24045d5550c741249a1d466cfb90149883454833accb48bbdb1aceec51e885c1", + "sha256:e28553af70dc4388fc73a6dfb4ece08e8518f21531d63213b448d24a5c0abef3" ], "index": "pypi", - "version": "==6.36.2" + "version": "==6.37.1" }, "charset-normalizer": { "hashes": [ @@ -129,6 +153,30 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "codeforlife-portal": { + "hashes": [ + "sha256:3c31ac0135af0cd78ec39e1b3e32ba98e514c05d944447e504002c000ce6b334", + "sha256:63a234390da9728139de7fbe8a4da9f4ca4b4b24d37c4bed248e5d49af563e53" + ], + "index": "pypi", + "version": "==6.37.1" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" + }, + "diff-match-patch": { + "hashes": [ + "sha256:953019cdb9c9d2c9e47b5b12bcff3cf4746fc4598eb406076fa1fc27e6a1f15c", + "sha256:dce43505fb7b1b317de7195579388df0746d90db07015ed47a85e5e44930ef93" + ], + "markers": "python_version >= '3.7'", + "version": "==20230430" + }, "django": { "hashes": [ "sha256:a477ab326ae7d8807dc25c186b951ab8c7648a3a23f9497763c37307a2b5ef87", @@ -137,6 +185,13 @@ "index": "pypi", "version": "==3.2.20" }, + "django-classy-tags": { + "hashes": [ + "sha256:25eb4f95afee396148683bfb4811b83b3f5729218d73ad0a3399271a6f9fcc49", + "sha256:d59d98bdf96a764dcf7a2929a86439d023b283a9152492811c7e44fc47555bc9" + ], + "version": "==2.0.0" + }, "django-cors-headers": { "hashes": [ "sha256:36a8d7a6dee6a85f872fe5916cc878a36d0812043866355438dfeda0b20b6b78", @@ -153,6 +208,13 @@ "index": "pypi", "version": "==7.3.1" }, + "django-csp": { + "hashes": [ + "sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a", + "sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727" + ], + "version": "==3.7" + }, "django-formtools": { "hashes": [ "sha256:304fa777b8ef9e0693ce7833f885cb89ba46b0e46fc23b01176900a93f46742f", @@ -161,6 +223,21 @@ "index": "pypi", "version": "==2.2" }, + "django-import-export": { + "hashes": [ + "sha256:88ecaf06be06bd95d97cf34f3c911c56c012a7a81712a8956740e5bfc2465162", + "sha256:d02e31908c965d512cc6f7ef6e72935177647b15d3846050d0f094177fca0d86" + ], + "markers": "python_version >= '3.8'", + "version": "==3.3.1" + }, + "django-js-reverse": { + "hashes": [ + "sha256:2a392d169f44e30b883c30dfcfd917a14167ce8fe196c99d2385b31c90d77aa0", + "sha256:8134c2ab6307c945edfa90671ca65e85d6c1754d48566bdd6464be259cc80c30" + ], + "version": "==0.9.1" + }, "django-otp": { "hashes": [ "sha256:8ba5ab9bd2738c7321376c349d7cce49cf4404e79f6804e0a3cc462a91728e18", @@ -177,6 +254,46 @@ "markers": "python_version >= '3.7'", "version": "==6.4.0" }, + "django-pipeline": { + "hashes": [ + "sha256:26f1d344a7bf39bc92c9dc520093471d912de53abd7d22ac715e77d779a831c8", + "sha256:56c299cec0e644e77d5f928f4cebfff804b919cc10ff5c0bfaa070ff57e8da44" + ], + "version": "==2.0.8" + }, + "django-preventconcurrentlogins": { + "hashes": [ + "sha256:9cb45fcd63edeec55e5ac29bbd2ee96974dc2a72d74ab88088dbf6a1f52978e9" + ], + "version": "==0.8.2" + }, + "django-ratelimit": { + "hashes": [ + "sha256:73223d860abd5c5d7b9a807fabb39a6220068129b514be8d78044b52607ab154", + "sha256:857e797f23de948b204a31dba9d88aea3ce731b7a5d926d0240c772e19b5486f" + ], + "markers": "python_version >= '3.4'", + "version": "==3.0.1" + }, + "django-recaptcha": { + "hashes": [ + "sha256:567784963fd5400feaf92e8951d8dbbbdb4b4c48a76e225d4baa63a2c9d2cd8c" + ], + "version": "==2.0.6" + }, + "django-sekizai": { + "hashes": [ + "sha256:5c5e16845d37ce822fc655ce79ec02715191b3d03330b550997bcb842cf24fdf", + "sha256:e829f09b0d6bf01ee5cde05de1fb3faf2fbc5df66dc4dc280fbaac224ca4336f" + ], + "version": "==2.0.0" + }, + "django-treebeard": { + "hashes": [ + "sha256:83aebc34a9f06de7daaec330d858d1c47887e81be3da77e3541fe7368196dd8a" + ], + "version": "==4.3.1" + }, "django-two-factor-auth": { "hashes": [ "sha256:3fac266d12472ac66475dd737bb18f2992484313bf56acf5a2eea5e824291ee6", @@ -193,6 +310,29 @@ "index": "pypi", "version": "==3.13.1" }, + "dnspython": { + "hashes": [ + "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", + "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "et-xmlfile": { + "hashes": [ + "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", + "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" + ], + "markers": "python_version >= '3.6'", + "version": "==1.1.0" + }, + "eventlet": { + "hashes": [ + "sha256:27ae41fad9deed9bbf4166f3e3b65acc15d524d42210a518e5877da85a6b8c5d", + "sha256:b36ec2ecc003de87fc87b93197d77fea528aa0f9204a34fdf3b2f8d0f01e017b" + ], + "version": "==0.31.0" + }, "flask": { "hashes": [ "sha256:7eb373984bf1c770023fce9db164ed0c3353cd0b53f130f4693da0ca756a2e6d", @@ -201,6 +341,92 @@ "index": "pypi", "version": "==2.2.3" }, + "google-auth": { + "hashes": [ + "sha256:9800802266366a2a87890fb2d04923fc0c0d4368af0b86db18edd94a62386ea1", + "sha256:d38bdf4fa1e7c5a35e574861bce55784fd08afadb4e48f99f284f1e487ce702d" + ], + "markers": "python_version >= '3.7'", + "version": "==2.23.1" + }, + "greenlet": { + "hashes": [ + "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", + "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a", + "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1", + "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43", + "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33", + "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", + "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088", + "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca", + "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343", + "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645", + "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db", + "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df", + "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3", + "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86", + "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2", + "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a", + "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf", + "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7", + "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394", + "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40", + "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3", + "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6", + "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74", + "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0", + "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3", + "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", + "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", + "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", + "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417", + "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", + "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b", + "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6", + "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb", + "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73", + "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b", + "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df", + "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9", + "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f", + "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0", + "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857", + "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a", + "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249", + "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30", + "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292", + "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b", + "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d", + "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b", + "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c", + "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca", + "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7", + "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75", + "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae", + "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47", + "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b", + "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", + "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c", + "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", + "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9", + "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099", + "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0", + "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5", + "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19", + "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1", + "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.0.2" + }, + "hypothesis": { + "hashes": [ + "sha256:6a3471ff74864ab04a0650c75500ef15f2f4a901d49ccbb7cbec668365736688", + "sha256:989162a9e0715c624b99ad9b2b4206765879b40eb51eef17b1e37de3e898370a" + ], + "markers": "python_version >= '3.6'", + "version": "==5.41.3" + }, "idna": { "hashes": [ "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", @@ -233,6 +459,31 @@ "markers": "python_version >= '3.7'", "version": "==3.1.2" }, + "kubernetes": { + "hashes": [ + "sha256:5854b0c508e8d217ca205591384ab58389abdae608576f9c9afc35a3c76a366c", + "sha256:e3db6800abf7e36c38d2629b5cb6b74d10988ee0cba6fba45595a7cbe60c0042" + ], + "markers": "python_version >= '3.6'", + "version": "==26.1.0" + }, + "libsass": { + "hashes": [ + "sha256:081e256ab3c5f3f09c7b8dea3bf3bf5e64a97c6995fd9eea880639b3f93a9f9a", + "sha256:3ab5ad18e47db560f4f0c09e3d28cf3bb1a44711257488ac2adad69f4f7f8425", + "sha256:65455a2728b696b62100eb5932604aa13a29f4ac9a305d95773c14aaa7200aaf", + "sha256:89c5ce497fcf3aba1dd1b19aae93b99f68257e5f2026b731b00a872f13324c7f", + "sha256:f1efc1b612299c88aec9e39d6ca0c266d360daa5b19d9430bdeaffffa86993f9" + ], + "markers": "python_version >= '3.6'", + "version": "==0.22.0" + }, + "markuppy": { + "hashes": [ + "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f" + ], + "version": "==1.14" + }, "markupsafe": { "hashes": [ "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", @@ -299,6 +550,14 @@ "markers": "python_version >= '3.7'", "version": "==2.1.3" }, + "more-itertools": { + "hashes": [ + "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced", + "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713" + ], + "markers": "python_version >= '3.5'", + "version": "==8.7.0" + }, "numpy": { "hashes": [ "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f", @@ -333,6 +592,28 @@ "markers": "python_version >= '3.8'", "version": "==1.24.4" }, + "oauthlib": { + "hashes": [ + "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", + "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918" + ], + "markers": "python_version >= '3.6'", + "version": "==3.2.2" + }, + "odfpy": { + "hashes": [ + "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec", + "sha256:fc3b8d1bc098eba4a0fda865a76d9d1e577c4ceec771426bcb169a82c5e9dfe0" + ], + "version": "==1.4.1" + }, + "openpyxl": { + "hashes": [ + "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184", + "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5" + ], + "version": "==3.1.2" + }, "pandas": { "hashes": [ "sha256:04dbdbaf2e4d46ca8da896e1805bc04eb85caa9a82e259e8eed00254d5e0c682", @@ -372,6 +653,90 @@ "markers": "python_version >= '3.8'", "version": "==0.4.0" }, + "phonenumbers": { + "hashes": [ + "sha256:23944f9e628f32a975d3b221b6d76e6ba8ae618d53cb3d82fc23d9e100a59b29", + "sha256:70aa98a50ba7bc7f6bf17851f806c927107e7c44e7d21eb46bdbec07b99d23ae" + ], + "index": "pypi", + "version": "==8.12.12" + }, + "pillow": { + "hashes": [ + "sha256:0462b1496505a3462d0f35dc1c4d7b54069747d65d00ef48e736acda2c8cbdff", + "sha256:186f7e04248103482ea6354af6d5bcedb62941ee08f7f788a1c7707bc720c66f", + "sha256:19e9adb3f22d4c416e7cd79b01375b17159d6990003633ff1d8377e21b7f1b21", + "sha256:28444cb6ad49726127d6b340217f0627abc8732f1194fd5352dec5e6a0105635", + "sha256:2872f2d7846cf39b3dbff64bc1104cc48c76145854256451d33c5faa55c04d1a", + "sha256:2cc6b86ece42a11f16f55fe8903595eff2b25e0358dec635d0a701ac9586588f", + "sha256:2d7e91b4379f7a76b31c2dda84ab9e20c6220488e50f7822e59dac36b0cd92b1", + "sha256:2fa6dd2661838c66f1a5473f3b49ab610c98a128fc08afbe81b91a1f0bf8c51d", + "sha256:32bec7423cdf25c9038fef614a853c9d25c07590e1a870ed471f47fb80b244db", + "sha256:3855447d98cced8670aaa63683808df905e956f00348732448b5a6df67ee5849", + "sha256:3a04359f308ebee571a3127fdb1bd01f88ba6f6fb6d087f8dd2e0d9bff43f2a7", + "sha256:3a0d3e54ab1df9df51b914b2233cf779a5a10dfd1ce339d0421748232cea9876", + "sha256:44e7e4587392953e5e251190a964675f61e4dae88d1e6edbe9f36d6243547ff3", + "sha256:459307cacdd4138edee3875bbe22a2492519e060660eaf378ba3b405d1c66317", + "sha256:4ce90f8a24e1c15465048959f1e94309dfef93af272633e8f37361b824532e91", + "sha256:50bd5f1ebafe9362ad622072a1d2f5850ecfa44303531ff14353a4059113b12d", + "sha256:522ff4ac3aaf839242c6f4e5b406634bfea002469656ae8358644fc6c4856a3b", + "sha256:552912dbca585b74d75279a7570dd29fa43b6d93594abb494ebb31ac19ace6bd", + "sha256:5d6c9049c6274c1bb565021367431ad04481ebb54872edecfcd6088d27edd6ed", + "sha256:697a06bdcedd473b35e50a7e7506b1d8ceb832dc238a336bd6f4f5aa91a4b500", + "sha256:71671503e3015da1b50bd18951e2f9daf5b6ffe36d16f1eb2c45711a301521a7", + "sha256:723bd25051454cea9990203405fa6b74e043ea76d4968166dfd2569b0210886a", + "sha256:764d2c0daf9c4d40ad12fbc0abd5da3af7f8aa11daf87e4fa1b834000f4b6b0a", + "sha256:787bb0169d2385a798888e1122c980c6eff26bf941a8ea79747d35d8f9210ca0", + "sha256:7f771e7219ff04b79e231d099c0a28ed83aa82af91fd5fa9fdb28f5b8d5addaf", + "sha256:847e8d1017c741c735d3cd1883fa7b03ded4f825a6e5fcb9378fd813edee995f", + "sha256:84efb46e8d881bb06b35d1d541aa87f574b58e87f781cbba8d200daa835b42e1", + "sha256:898f1d306298ff40dc1b9ca24824f0488f6f039bc0e25cfb549d3195ffa17088", + "sha256:8b451d6ead6e3500b6ce5c7916a43d8d8d25ad74b9102a629baccc0808c54971", + "sha256:8f06be50669087250f319b706decf69ca71fdecd829091a37cc89398ca4dc17a", + "sha256:92a23b0431941a33242b1f0ce6c88a952e09feeea9af4e8be48236a68ffe2205", + "sha256:93139acd8109edcdeffd85e3af8ae7d88b258b3a1e13a038f542b79b6d255c54", + "sha256:98533fd7fa764e5f85eebe56c8e4094db912ccbe6fbf3a58778d543cadd0db08", + "sha256:9f665d1e6474af9f9da5e86c2a3a2d2d6204e04d5af9c06b9d42afa6ebde3f21", + "sha256:b059ac2c4c7a97daafa7dc850b43b2d3667def858a4f112d1aa082e5c3d6cf7d", + "sha256:b1be1c872b9b5fcc229adeadbeb51422a9633abd847c0ff87dc4ef9bb184ae08", + "sha256:b7cf63d2c6928b51d35dfdbda6f2c1fddbe51a6bc4a9d4ee6ea0e11670dd981e", + "sha256:bc2e3069569ea9dbe88d6b8ea38f439a6aad8f6e7a6283a38edf61ddefb3a9bf", + "sha256:bcf1207e2f2385a576832af02702de104be71301c2696d0012b1b93fe34aaa5b", + "sha256:ca26ba5767888c84bf5a0c1a32f069e8204ce8c21d00a49c90dabeba00ce0145", + "sha256:cbe68deb8580462ca0d9eb56a81912f59eb4542e1ef8f987405e35a0179f4ea2", + "sha256:d6caf3cd38449ec3cd8a68b375e0c6fe4b6fd04edb6c9766b55ef84a6e8ddf2d", + "sha256:d72967b06be9300fed5cfbc8b5bafceec48bf7cdc7dab66b1d2549035287191d", + "sha256:d889b53ae2f030f756e61a7bff13684dcd77e9af8b10c6048fb2c559d6ed6eaf", + "sha256:de596695a75496deb3b499c8c4f8e60376e0516e1a774e7bc046f0f48cd620ad", + "sha256:e6a90167bcca1216606223a05e2cf991bb25b14695c518bc65639463d7db722d", + "sha256:ed2d9c0704f2dc4fa980b99d565c0c9a543fe5101c25b3d60488b8ba80f0cce1", + "sha256:ee7810cf7c83fa227ba9125de6084e5e8b08c59038a7b2c9045ef4dde61663b4", + "sha256:f0b4b06da13275bc02adfeb82643c4a6385bd08d26f03068c2796f60d125f6f2", + "sha256:f11c9102c56ffb9ca87134bd025a43d2aba3f1155f508eff88f694b33a9c6d19", + "sha256:f5bb289bb835f9fe1a1e9300d011eef4d69661bb9b34d5e196e5e82c4cb09b37", + "sha256:f6d3d4c905e26354e8f9d82548475c46d8e0889538cb0657aa9c6f0872a37aa4", + "sha256:fcb59711009b0168d6ee0bd8fb5eb259c4ab1717b2f538bbf36bacf207ef7a68", + "sha256:fd2a5403a75b54661182b75ec6132437a181209b901446ee5724b589af8edef1" + ], + "markers": "python_version >= '3.8'", + "version": "==10.0.1" + }, + "pyasn1": { + "hashes": [ + "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57", + "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==0.5.0" + }, + "pyasn1-modules": { + "hashes": [ + "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c", + "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==0.3.0" + }, "pydantic": { "hashes": [ "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e", @@ -414,6 +779,14 @@ "index": "pypi", "version": "==1.10.7" }, + "pyhamcrest": { + "hashes": [ + "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", + "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" + ], + "markers": "python_version >= '3.5'", + "version": "==2.0.2" + }, "pyjwt": { "hashes": [ "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd", @@ -422,6 +795,14 @@ "markers": "python_version >= '3.7'", "version": "==2.6.0" }, + "pyotp": { + "hashes": [ + "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", + "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612" + ], + "index": "pypi", + "version": "==2.9.0" + }, "pypng": { "hashes": [ "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", @@ -444,6 +825,41 @@ ], "version": "==2023.3.post1" }, + "pyyaml": { + "hashes": [ + "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", + "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", + "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", + "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", + "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", + "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", + "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", + "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", + "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", + "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", + "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", + "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", + "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", + "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", + "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", + "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", + "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", + "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", + "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", + "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", + "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", + "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", + "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==5.4.1" + }, "qrcode": { "hashes": [ "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", @@ -452,6 +868,65 @@ "markers": "python_version >= '3.7'", "version": "==7.4.2" }, + "rapid-router": { + "hashes": [ + "sha256:b3b0b9dd449775aaac8b6dcc05601f8e0d3d1805100ba80a086cc8a3c2661526", + "sha256:bb88bb75e0f743ebedcfd44fbbfba5e1f63e5464c2d7d7bd02d3c838486f78ac" + ], + "index": "pypi", + "version": "==5.11.3" + }, + "reportlab": { + "hashes": [ + "sha256:0b94e4f65a5f77a631cc010c9a7892d69e33f3251b760639dcc76420e138ce95", + "sha256:11a71c314183532d889ad4b3941f61c3fe4bfdda769c768a7f02d93cb69dd1bb", + "sha256:149718c3eaee937f28094325f0dd9ae1add3172c2dacbb93ff5403f37c9d3c57", + "sha256:21d6b6bcdecee9c7ce047156d0553a30d736b8172629e4c0fcacab35ba261f3b", + "sha256:269c59e508df08be498ab9e5278addb2cc16989677a03f800b17f8a31f8c5cc7", + "sha256:36568d3cb4101a210c4d821d9101635c2ef6e06bd649335938c01eb197f50c5d", + "sha256:3cb0da4975dbade6cc2ea6b0b0b17578af266dc3f669e959648f3306af993369", + "sha256:48eadd93237c7e2739525c74cf6615dd6c1a767c839f4b0d7c12167dc0b09911", + "sha256:57add04824bca89a130f9d428ace1b003cce4061386e0ec2a1b45b554ffe7aa3", + "sha256:58ea3471b9b4b8e7952bd357e8487789da11213470be328ffb3e5b7d7690c2c7", + "sha256:5a460f4c0c30bdf9d7bef46a816671a4386a9253670a53d35c694c666544261f", + "sha256:6172481e8acffcf72042653e977281fbd807a41705a39456d92d2606d8b8c5e2", + "sha256:65b441e22d8fe93154567a30662d8539e639b78142815afcaf92b388846eb3c1", + "sha256:6ea46fef07c588fef84d1164d4788fef322b39feb2bfb2df0a0706181dff79b8", + "sha256:6f75d33f7a3720cf47371ab63ced0f0ebd1aeb6db19386ae92f8977a09be9611", + "sha256:6fdac930dfdc6227720545ec44fdb396e92d53ec227a6f5ae58cc8cb9a6cbe89", + "sha256:701290747662d2b3be49fc0de33898ecc9ce3fafe0e2887d406e24693465e5ae", + "sha256:753485bb2b18cbd11340e227e4aaf9bde3bb64f83406dfa011e92ad0231a42c9", + "sha256:7b690bc30f58931b0abd47635d93a43a82d67972e83a6511cc8adbcd7da25310", + "sha256:7efdf68e97e8fea8683bfc17f25747fefbda729b9018bc2e3221658ac41ee0bd", + "sha256:7ff89011b5ee30209b3106641e3b7b4959f10aa6e9d6f3030205123c178f605d", + "sha256:8260c002e4845a5af65908d5ee2099bcc25a16c7646c5c417fa27f1e4b844bc1", + "sha256:8e4983486d419daa45cade40874bb869976e27ba11f77fb4b9ae32417284ade7", + "sha256:8f00175f8e12e6f7d3a01309de6d7008fac94a2cdce6837ad066f0961472c9e5", + "sha256:9f869286fcefa7f8e89e38448309891ff110ad74f58a7317ec204f3d4b8ad5f5", + "sha256:a0330322c6c8123745ac7667fcc6ae3e0de3b73c15bdfaa28c788a9eaa0f50da", + "sha256:a043cff1781ddb2a0ba0e8e760a79fc5be2430957c4f2a1f51bd4528cc53178f", + "sha256:a477f652e6c417ad40387a8498d9ad827421006f156aab16f67adc9b81699a72", + "sha256:a4dbc28fede7f504b9ac65ce9cbea35585e999d63f9fa68bc73f5a75b4929302", + "sha256:afb418409e0d323c6cb5e3be7ea4d14dfbf8a07eb03ab0b0062904cacf819878", + "sha256:b0d91663d450c11404ec189ebc5a4abdf20f7c4eca5954a920427cdbf5601525", + "sha256:ba6f533b262f4ee1636b754992bb2fb349df0500d765ac9be014a375c047f4db", + "sha256:bbdbba1ec3498b17eefca14d424ee90bb95b53e1423ecb22f1c17733c3406559", + "sha256:ca8eb7a6607f8a664187a330bab9f8d11c9f81ed885e063dfbb29a130944a72a", + "sha256:cca2d4c783f985b91b98e80d09ac79b6ed3f317a729cba5ba86edfe5eb9a2d9c", + "sha256:d59e62faa03003be81aa14d37ac34ea110e5ac59c8678fd4c0daa7d8b8f42096", + "sha256:d95fc8bc177a009053548c6d851a513b2147c465a5e8fea82287ea22d6825c4e", + "sha256:dbddadca6f08212732e83a60e30a42cfc7d2695892cedea208b3c3e7131c9993", + "sha256:e13a4e81761636591f5b60104f6e1eec70832ffd9aa781db68d7ebb576970d4b", + "sha256:e28a8d9cf462e2b4c9e71abd0630f9ec245d88b976b283b0dbb4602c9ddb3938", + "sha256:e5949f3b4e207fa7901c0cc3b49470b2a3372617a47dfbc892db31c2b56af296", + "sha256:e98965c6e60d76ff63989d9400ae8e65efd67c665d785b377f438f166a57c053", + "sha256:f1993a68c0edc45895d3df350d01b0456efe79aaf309cef777762742be501f2a", + "sha256:faeebde62f0f6ad86985bec5685411260393d2eb7ba907972da56af586b644e8", + "sha256:ff09a0a1e5cef05309ac09dfc5185e8151d927bcf45470d2f540c96260f8a355" + ], + "markers": "python_version >= '3.7' and python_version < '4'", + "version": "==3.6.13" + }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", @@ -460,6 +935,30 @@ "markers": "python_version >= '3.7'", "version": "==2.31.0" }, + "requests-oauthlib": { + "hashes": [ + "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", + "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.3.1" + }, + "rsa": { + "hashes": [ + "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", + "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21" + ], + "markers": "python_version >= '3.6' and python_version < '4'", + "version": "==4.9" + }, + "setuptools": { + "hashes": [ + "sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8", + "sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592" + ], + "markers": "python_version >= '3.7'", + "version": "==62.1.0" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", @@ -468,6 +967,13 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "sortedcontainers": { + "hashes": [ + "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", + "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" + ], + "version": "==2.4.0" + }, "sqlparse": { "hashes": [ "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", @@ -476,6 +982,21 @@ "markers": "python_version >= '3.5'", "version": "==0.4.4" }, + "tablib": { + "extras": [ + "html", + "ods", + "xls", + "xlsx", + "yaml" + ], + "hashes": [ + "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9", + "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33" + ], + "markers": "python_version >= '3.8'", + "version": "==3.5.0" + }, "typing-extensions": { "hashes": [ "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", @@ -494,11 +1015,19 @@ }, "urllib3": { "hashes": [ - "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", - "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4" + "sha256:13abf37382ea2ce6fb744d4dad67838eec857c9f4f57009891805e0b5e123594", + "sha256:ef16afa8ba34a1f989db38e1dbbe0c302e4289a47856990d0682e374563ce35e" ], "markers": "python_version >= '3.7'", - "version": "==2.0.4" + "version": "==2.0.5" + }, + "websocket-client": { + "hashes": [ + "sha256:3aad25d31284266bcfcfd1fd8a743f63282305a364b8d0948a43bd606acc652f", + "sha256:6cfc30d051ebabb73a5fa246efdcc14c8fbebbd0330f8984ac3bb6d9edd2ad03" + ], + "markers": "python_version >= '3.8'", + "version": "==1.6.3" }, "werkzeug": { "hashes": [ @@ -508,6 +1037,20 @@ "markers": "python_version >= '3.8'", "version": "==2.3.7" }, + "xlrd": { + "hashes": [ + "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", + "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88" + ], + "version": "==2.0.1" + }, + "xlwt": { + "hashes": [ + "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e", + "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88" + ], + "version": "==1.3.0" + }, "zipp": { "hashes": [ "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31", @@ -677,6 +1220,14 @@ "index": "pypi", "version": "==4.5.2" }, + "pytest-env": { + "hashes": [ + "sha256:8c0605ae09a5b7e41c20ebcc44f2c906eea9654095b4b0c342b3814bcc3a8492", + "sha256:d7b2f5273ec6d1e221757998bc2f50d2474ed7d0b9331b92556011fadc4e9abf" + ], + "index": "pypi", + "version": "==0.8.1" + }, "pytz": { "hashes": [ "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b", diff --git a/README.md b/README.md index 2756e8bb..61289393 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,49 @@ This repo contains CFL's python package. This will be installed into all backend ## Installation -This package requires Python 3.7 to be installed. See the package's [setup](setup.py). +To install this package, do one of the following options. + +*Ensure you're installing the package with the required python version. See [setup.py](setup.py).* *Remember to replace the version number ("0.0.0") with your [desired version](https://github.com/ocadotechnology/codeforlife-package-python/releases).* -Install via pipenv: +**Option 1:** Run `pipenv install` command: ```bash pipenv install git+https://github.com/ocadotechnology/codeforlife-package-python.git@v0.0.0#egg=codeforlife ``` -Or add a row to `[packages]` in Pipfile: +**Option 2:** Add a row to `[packages]` in `Pipfile`: ```toml [packages] codeforlife = {ref = "v0.0.0", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} ``` +## Making Changes + +To make changes, you must: + +1. Branch off of main. +1. Push your changes on your branch. +1. Ensure the pipeline runs successfully on your branch. +1. Have your changes reviewed and approved by a peer. +1. Merge your branch into the `main` branch. +1. [Manually trigger](https://github.com/ocadotechnology/codeforlife-package-python/actions/workflows/main.yml) +the `Main` pipeline for the `main` branch. + +### Installing your branch + +You may wish to install and integrate your changes into a CFL backend before it's been peer-reviewed. + +*Remember to replace the branch name ("my-branch") with your +[branch](https://github.com/ocadotechnology/codeforlife-package-python/branches)*. + +```toml +[packages] +codeforlife = {ref = "my-branch", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} +``` + ## Version Release New versions of this package are automatically created via a GitHub Actions [workflow](.github/workflows/python-package.yml). Versions are determined using the [semantic-release commit message format](https://semantic-release.gitbook.io/semantic-release/#commit-message-format). diff --git a/codeforlife/request.py b/codeforlife/request.py new file mode 100644 index 00000000..7ed4ee38 --- /dev/null +++ b/codeforlife/request.py @@ -0,0 +1,18 @@ +import typing as t + +from django.contrib.auth.models import AnonymousUser +from django.core.handlers.wsgi import WSGIRequest as _WSGIRequest +from django.http import HttpRequest as _HttpRequest + +from .user.models import User +from .user.models.session import SessionStore + + +class WSGIRequest(_WSGIRequest): + session: SessionStore + user: t.Union[User, AnonymousUser] + + +class HttpRequest(_HttpRequest): + session: SessionStore + user: t.Union[User, AnonymousUser] diff --git a/codeforlife/settings/django.py b/codeforlife/settings/django.py index bc352d9c..fa5821eb 100644 --- a/codeforlife/settings/django.py +++ b/codeforlife/settings/django.py @@ -25,6 +25,8 @@ AUTHENTICATION_BACKENDS = [ "codeforlife.user.auth.backends.EmailAndPasswordBackend", + "codeforlife.user.auth.backends.OtpBackend", + "codeforlife.user.auth.backends.OtpBypassTokenBackend", "codeforlife.user.auth.backends.UserIdAndLoginIdBackend", "codeforlife.user.auth.backends.UsernameAndPasswordAndClassIdBackend", ] @@ -32,6 +34,7 @@ # Sessions # https://docs.djangoproject.com/en/3.2/topics/http/sessions/ +SESSION_ENGINE = "codeforlife.user.models.session" SESSION_COOKIE_AGE = 60 * 60 SESSION_SAVE_EVERY_REQUEST = True SESSION_EXPIRE_AT_BROWSER_CLOSE = True diff --git a/codeforlife/settings/third_party.py b/codeforlife/settings/third_party.py index e9ea2cd7..4da01a78 100644 --- a/codeforlife/settings/third_party.py +++ b/codeforlife/settings/third_party.py @@ -9,4 +9,4 @@ CORS_ALLOW_ALL_ORIGINS = DEBUG CORS_ALLOW_CREDENTIALS = True -CORS_ALLOWED_ORIGINS = ["https://codeforlife.education"] +CORS_ALLOWED_ORIGINS = ["https://www.codeforlife.education"] diff --git a/codeforlife/user/auth/backends/__init__.py b/codeforlife/user/auth/backends/__init__.py index 5fef85c7..cbe607b0 100644 --- a/codeforlife/user/auth/backends/__init__.py +++ b/codeforlife/user/auth/backends/__init__.py @@ -1,4 +1,6 @@ from .email_and_password import EmailAndPasswordBackend +from .otp import OtpBackend +from .otp_bypass_token import OtpBypassTokenBackend from .user_id_and_login_id import UserIdAndLoginIdBackend from .username_and_password_and_class_id import ( UsernameAndPasswordAndClassIdBackend, diff --git a/codeforlife/user/auth/backends/email_and_password.py b/codeforlife/user/auth/backends/email_and_password.py index d2c53118..934fa29b 100644 --- a/codeforlife/user/auth/backends/email_and_password.py +++ b/codeforlife/user/auth/backends/email_and_password.py @@ -1,11 +1,9 @@ import typing as t -from django.contrib.auth import get_user_model from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.base_user import AbstractBaseUser -from django.core.handlers.wsgi import WSGIRequest -User = get_user_model() +from ....request import WSGIRequest +from ...models import User class EmailAndPasswordBackend(BaseBackend): @@ -15,20 +13,18 @@ def authenticate( email: t.Optional[str] = None, password: t.Optional[str] = None, **kwargs - ) -> t.Optional[AbstractBaseUser]: + ): if email is None or password is None: return try: user = User.objects.get(email=email) - if getattr(user, "is_active", True) and user.check_password( - password - ): + if user.check_password(password): return user except User.DoesNotExist: return - def get_user(self, user_id: int) -> t.Optional[AbstractBaseUser]: + def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: diff --git a/codeforlife/user/auth/backends/otp.py b/codeforlife/user/auth/backends/otp.py new file mode 100644 index 00000000..40c2f565 --- /dev/null +++ b/codeforlife/user/auth/backends/otp.py @@ -0,0 +1,53 @@ +import typing as t + +import pyotp +from django.contrib.auth.backends import BaseBackend +from django.utils import timezone + +from ....request import WSGIRequest +from ...models import AuthFactor, User + + +class OtpBackend(BaseBackend): + def authenticate( + self, + request: WSGIRequest, + otp: t.Optional[str] = None, + **kwargs, + ): + # Avoid near misses by getting the timestamp before any processing. + now = timezone.now() + + if ( + otp is None + or not isinstance(request.user, User) + or not request.user.userprofile.otp_secret + or not request.user.session.session_auth_factors.filter( + auth_factor__type=AuthFactor.Type.OTP + ).exists() + ): + return + + totp = pyotp.TOTP(request.user.userprofile.otp_secret) + + # Verify the otp is valid for now. + if totp.verify(otp, for_time=now): + # Deny replay attacks by rejecting the otp for last time. + last_otp_for_time = request.user.userprofile.last_otp_for_time + if last_otp_for_time and totp.verify(otp, last_otp_for_time): + return + request.user.userprofile.last_otp_for_time = now + request.user.userprofile.save() + + # Delete OTP auth factor from session. + request.user.session.session_auth_factors.filter( + auth_factor__type=AuthFactor.Type.OTP + ).delete() + + return request.user + + def get_user(self, user_id: int): + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return diff --git a/codeforlife/user/auth/backends/otp_bypass_token.py b/codeforlife/user/auth/backends/otp_bypass_token.py new file mode 100644 index 00000000..3e20cfde --- /dev/null +++ b/codeforlife/user/auth/backends/otp_bypass_token.py @@ -0,0 +1,38 @@ +import typing as t + +from django.contrib.auth.backends import BaseBackend + +from ....request import WSGIRequest +from ...models import AuthFactor, User + + +class OtpBypassTokenBackend(BaseBackend): + def authenticate( + self, + request: WSGIRequest, + token: t.Optional[str] = None, + **kwargs, + ): + if ( + token is None + or not isinstance(request.user, User) + or not request.user.session.session_auth_factors.filter( + auth_factor__type=AuthFactor.Type.OTP + ).exists() + ): + return + + for otp_bypass_token in request.user.otp_bypass_tokens.all(): + if otp_bypass_token.check_token(token): + # Delete OTP auth factor from session. + request.user.session.session_auth_factors.filter( + auth_factor__type=AuthFactor.Type.OTP + ).delete() + + return request.user + + def get_user(self, user_id: int): + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return diff --git a/codeforlife/user/auth/backends/user_id_and_login_id.py b/codeforlife/user/auth/backends/user_id_and_login_id.py index 9048bf0b..15da47ca 100644 --- a/codeforlife/user/auth/backends/user_id_and_login_id.py +++ b/codeforlife/user/auth/backends/user_id_and_login_id.py @@ -2,12 +2,10 @@ from common.helpers.generators import get_hashed_login_id from common.models import Student -from django.contrib.auth import get_user_model from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.base_user import AbstractBaseUser -from django.core.handlers.wsgi import WSGIRequest -User = get_user_model() +from ....request import WSGIRequest +from ...models import User class UserIdAndLoginIdBackend(BaseBackend): @@ -17,12 +15,12 @@ def authenticate( user_id: t.Optional[int] = None, login_id: t.Optional[str] = None, **kwargs - ) -> t.Optional[AbstractBaseUser]: + ): if user_id is None or login_id is None: return user = self.get_user(user_id) - if user and getattr(user, "is_active", True): + if user: # Check the url against the student's stored hash. student = Student.objects.get(new_user=user) if ( @@ -32,7 +30,7 @@ def authenticate( ): return user - def get_user(self, user_id: int) -> t.Optional[AbstractBaseUser]: + def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: diff --git a/codeforlife/user/auth/backends/username_and_password_and_class_id.py b/codeforlife/user/auth/backends/username_and_password_and_class_id.py index 0162f6c4..0a1c8cd1 100644 --- a/codeforlife/user/auth/backends/username_and_password_and_class_id.py +++ b/codeforlife/user/auth/backends/username_and_password_and_class_id.py @@ -1,11 +1,9 @@ import typing as t -from django.contrib.auth import get_user_model from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.base_user import AbstractBaseUser -from django.core.handlers.wsgi import WSGIRequest -User = get_user_model() +from ....request import WSGIRequest +from ...models import User class UsernameAndPasswordAndClassIdBackend(BaseBackend): @@ -16,7 +14,7 @@ def authenticate( password: t.Optional[str] = None, class_id: t.Optional[str] = None, **kwargs - ) -> t.Optional[AbstractBaseUser]: + ): if username is None or password is None or class_id is None: return @@ -25,14 +23,12 @@ def authenticate( username=username, new_student__class_field__access_code=class_id, ) - if getattr(user, "is_active", True) and user.check_password( - password - ): + if user.check_password(password): return user except User.DoesNotExist: return - def get_user(self, user_id: int) -> t.Optional[AbstractBaseUser]: + def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py new file mode 100644 index 00000000..034c7cbe --- /dev/null +++ b/codeforlife/user/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 3.2.20 on 2023-09-29 17:53 + +import django.contrib.auth.models +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Session', + fields=[ + ('session_key', models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name='session key')), + ('session_data', models.TextField(verbose_name='session data')), + ('expire_date', models.DateTimeField(db_index=True, verbose_name='expire date')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='user.user')), + ], + options={ + 'verbose_name': 'session', + 'verbose_name_plural': 'sessions', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AuthFactor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.TextField(choices=[('otp', 'one-time password')])), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_factors', to='user.user')), + ], + options={ + 'unique_together': {('user', 'type')}, + }, + ), + migrations.CreateModel( + name='SessionAuthFactor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('auth_factor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.authfactor')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='session_auth_factors', to='user.session')), + ], + options={ + 'unique_together': {('session', 'auth_factor')}, + }, + ), + migrations.CreateModel( + name='OtpBypassToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to='user.user')), + ], + options={ + 'unique_together': {('user', 'token')}, + }, + ), + ] diff --git a/codeforlife/user/models/__init__.py b/codeforlife/user/models/__init__.py index e3f1351e..a8b856db 100644 --- a/codeforlife/user/models/__init__.py +++ b/codeforlife/user/models/__init__.py @@ -6,4 +6,8 @@ # from .student import Student # from .teacher_invitation import SchoolTeacherInvitation # from .teacher import Teacher -# from .user import User +from .auth_factor import AuthFactor +from .otp_bypass_token import OtpBypassToken +from .session import Session +from .session_auth_factor import SessionAuthFactor +from .user import User diff --git a/codeforlife/user/models/auth_factor.py b/codeforlife/user/models/auth_factor.py new file mode 100644 index 00000000..bec978e4 --- /dev/null +++ b/codeforlife/user/models/auth_factor.py @@ -0,0 +1,23 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from . import user + + +class AuthFactor(models.Model): + class Type(models.TextChoices): + OTP = "otp", _("one-time password") + + user: "user.User" = models.ForeignKey( + "user.User", + related_name="auth_factors", + on_delete=models.CASCADE, + ) + + type = models.TextField(choices=Type.choices) + + class Meta: + unique_together = ["user", "type"] + + def __str__(self): + return self.type diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py new file mode 100644 index 00000000..fbd22ad8 --- /dev/null +++ b/codeforlife/user/models/otp_bypass_token.py @@ -0,0 +1,75 @@ +import typing as t +from itertools import groupby + +from django.contrib.auth.hashers import check_password, make_password +from django.core.exceptions import ValidationError +from django.core.validators import MinLengthValidator +from django.db import models + +from . import user + + +class OtpBypassToken(models.Model): + max_count = 10 + max_count_validation_error = ValidationError( + f"Exceeded max count of {max_count}" + ) + + class Manager(models.Manager["OtpBypassToken"]): + def create(self, token: str, **kwargs): + return super().create(token=make_password(token), **kwargs) + + def bulk_create( + self, + otp_bypass_tokens: t.List["OtpBypassToken"], + *args, + **kwargs, + ): + def key(otp_bypass_token: OtpBypassToken): + return otp_bypass_token.user.id + + otp_bypass_tokens.sort(key=key) + for user_id, group in groupby(otp_bypass_tokens, key=key): + if ( + len(list(group)) + + OtpBypassToken.objects.filter(user_id=user_id).count() + > OtpBypassToken.max_count + ): + raise OtpBypassToken.max_count_validation_error + + for otp_bypass_token in otp_bypass_tokens: + otp_bypass_token.token = make_password(otp_bypass_token.token) + + return super().bulk_create(otp_bypass_tokens, *args, **kwargs) + + objects: Manager = Manager() + + user: "user.User" = models.ForeignKey( + "user.User", + related_name="otp_bypass_tokens", + on_delete=models.CASCADE, + ) + + token = models.CharField( + max_length=8, + validators=[MinLengthValidator(8)], + ) + + class Meta: + unique_together = ["user", "token"] + + def save(self, *args, **kwargs): + if self.id is None: + if ( + OtpBypassToken.objects.filter(user=self.user).count() + >= OtpBypassToken.max_count + ): + raise OtpBypassToken.max_count_validation_error + + return super().save(*args, **kwargs) + + def check_token(self, token: str): + if check_password(token, self.token): + self.delete() + return True + return False diff --git a/codeforlife/user/models/session.py b/codeforlife/user/models/session.py index 31b22f37..9becb473 100644 --- a/codeforlife/user/models/session.py +++ b/codeforlife/user/models/session.py @@ -1,19 +1,112 @@ +# from django.db import models +# from django.utils import timezone + +# from .classroom import Class +# from .school import School +# from .user import User + + +# class UserSession(models.Model): +# user = models.ForeignKey(User, on_delete=models.CASCADE) +# login_time = models.DateTimeField(default=timezone.now) +# school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) +# class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) +# login_type = models.CharField( +# max_length=100, null=True +# ) # for student login + +# def __str__(self): +# return f"{self.user} login: {self.login_time} type: {self.login_type}" + +import typing as t + +from django.contrib.auth import SESSION_KEY +from django.contrib.sessions.backends.db import SessionStore as DBStore +from django.contrib.sessions.base_session import AbstractBaseSession from django.db import models +from django.db.models.query import QuerySet from django.utils import timezone -from .user import User -from .school import School -from .classroom import Class +from . import session_auth_factor, user + + +class Session(AbstractBaseSession): + """ + A custom session model to support querying a user's session. + https://docs.djangoproject.com/en/3.2/topics/http/sessions/#example + """ + + session_auth_factors: QuerySet["session_auth_factor.SessionAuthFactor"] + + user: "user.User" = models.OneToOneField( + "user.User", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + + @property + def is_expired(self): + return self.expire_date < timezone.now() + + @property + def store(self): + return self.get_session_store_class()(self.session_key) + + @classmethod + def get_session_store_class(cls): + return SessionStore + + +class SessionStore(DBStore): + """ + A custom session store interface to support: + 1. creating only one session per user; + 2. setting a session's auth factors; + 3. clearing a user's expired sessions. + https://docs.djangoproject.com/en/3.2/topics/http/sessions/#example + """ + + @classmethod + def get_model_class(cls): + return Session + + def create_model_instance(self, data): + Session = self.get_model_class() + session: Session + + try: + user_id = int(data.get(SESSION_KEY)) + + try: + session = Session.objects.get(user_id=user_id) + except Session.DoesNotExist: + # Associate session to user. + session = Session.objects.get(session_key=self.session_key) + session.user = user.User.objects.get(id=user_id) + session_auth_factor.SessionAuthFactor.objects.bulk_create( + [ + session_auth_factor.SessionAuthFactor( + session=session, + auth_factor=auth_factor, + ) + for auth_factor in session.user.auth_factors.all() + ] + ) + + session.session_data = self.encode(data) + except (ValueError, TypeError): + # Create an anon session. + session = super().create_model_instance(data) -class UserSession(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - login_time = models.DateTimeField(default=timezone.now) - school = models.ForeignKey(School, null=True, on_delete=models.SET_NULL) - class_field = models.ForeignKey(Class, null=True, on_delete=models.SET_NULL) - login_type = models.CharField( - max_length=100, null=True - ) # for student login + return session - def __str__(self): - return f"{self.user} login: {self.login_time} type: {self.login_type}" + @classmethod + def clear_expired(cls, user_id: t.Optional[int] = None): + session_query = cls.get_model_class().objects.filter( + expire_date__lt=timezone.now() + ) + if user_id: + session_query = session_query.filter(user_id=user_id) + session_query.delete() diff --git a/codeforlife/user/models/session_auth_factor.py b/codeforlife/user/models/session_auth_factor.py new file mode 100644 index 00000000..d7e43e5f --- /dev/null +++ b/codeforlife/user/models/session_auth_factor.py @@ -0,0 +1,23 @@ +from django.db import models + +from . import auth_factor, session + + +class SessionAuthFactor(models.Model): + session: "session.Session" = models.ForeignKey( + "user.Session", + related_name="session_auth_factors", + on_delete=models.CASCADE, + ) + + auth_factor: "auth_factor.AuthFactor" = models.ForeignKey( + "user.AuthFactor", + related_name="session_auth_factors", + on_delete=models.CASCADE, + ) + + class Meta: + unique_together = ["session", "auth_factor"] + + def __str__(self): + return str(self.auth_factor) diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index f14a0105..881fd94e 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -1,38 +1,66 @@ -from datetime import timedelta -from enum import Enum +# from datetime import timedelta +# from enum import Enum -from django.contrib.auth.models import AbstractUser -from django.contrib.auth.models import UserManager as AbstractUserManager -from django.db import models -from django.utils import timezone +# from django.contrib.auth.models import AbstractUser +# from django.contrib.auth.models import UserManager as AbstractUserManager +# from django.db import models +# from django.utils import timezone -class UserManager(AbstractUserManager): - def create_user(self, username, email=None, password=None, **extra_fields): - return super().create_user(username, email, password, **extra_fields) +# class UserManager(AbstractUserManager): +# def create_user(self, username, email=None, password=None, **extra_fields): +# return super().create_user(username, email, password, **extra_fields) - def create_superuser( - self, username, email=None, password=None, **extra_fields - ): - return super().create_superuser( - username, email, password, **extra_fields - ) +# def create_superuser( +# self, username, email=None, password=None, **extra_fields +# ): +# return super().create_superuser( +# username, email, password, **extra_fields +# ) -class User(AbstractUser): - class Type(str, Enum): - TEACHER = "teacher" - DEP_STUDENT = "dependent-student" - INDEP_STUDENT = "independent-student" +# class User(AbstractUser): +# class Type(str, Enum): +# TEACHER = "teacher" +# DEP_STUDENT = "dependent-student" +# INDEP_STUDENT = "independent-student" - developer = models.BooleanField(default=False) - is_verified = models.BooleanField(default=False) +# developer = models.BooleanField(default=False) +# is_verified = models.BooleanField(default=False) - objects: UserManager = UserManager() +# objects: UserManager = UserManager() - def __str__(self): - return self.get_full_name() +# def __str__(self): +# return self.get_full_name() + +# @property +# def joined_recently(self): +# return timezone.now() - timedelta(days=7) <= self.date_joined + +from common.models import UserProfile +from django.contrib.auth.models import User as _User +from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ + +from . import auth_factor, otp_bypass_token, session + + +class User(_User): + auth_factors: QuerySet["auth_factor.AuthFactor"] + otp_bypass_tokens: QuerySet["otp_bypass_token.OtpBypassToken"] + session: "session.Session" + userprofile: UserProfile + + class Meta: + proxy = True @property - def joined_recently(self): - return timezone.now() - timedelta(days=7) <= self.date_joined + def is_authenticated(self): + """ + Check if the user has any pending auth factors. + """ + + try: + return not self.session.session_auth_factors + except session.Session.DoesNotExist: + return False diff --git a/codeforlife/user/tests/auth/__init__.py b/codeforlife/user/tests/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codeforlife/user/tests/auth/backends/__init__.py b/codeforlife/user/tests/auth/backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codeforlife/user/tests/auth/backends/test_otp_bypass_token.py b/codeforlife/user/tests/auth/backends/test_otp_bypass_token.py new file mode 100644 index 00000000..4c944c67 --- /dev/null +++ b/codeforlife/user/tests/auth/backends/test_otp_bypass_token.py @@ -0,0 +1,58 @@ +from datetime import timedelta + +from django.test import RequestFactory, TestCase +from django.utils import timezone +from django.utils.crypto import get_random_string + +from ....auth.backends import OtpBypassTokenBackend +from ....models import ( + AuthFactor, + OtpBypassToken, + Session, + SessionAuthFactor, + User, +) + + +class TestTokenBackend(TestCase): + def setUp(self): + self.backend = OtpBypassTokenBackend() + self.request_factory = RequestFactory() + + self.user = User.objects.get(id=2) + + self.auth_factor = AuthFactor.objects.create( + user=self.user, + type=AuthFactor.Type.OTP, + ) + + self.session = Session.objects.create( + session_key="a", + session_data="", + expire_date=timezone.now() + timedelta(hours=24), + user=self.user, + ) + + self.session_auth_factor = SessionAuthFactor.objects.create( + session=self.session, + auth_factor=self.auth_factor, + ) + + self.tokens = [ + get_random_string(8) for _ in range(OtpBypassToken.max_count) + ] + self.otp_bypass_tokens = OtpBypassToken.objects.bulk_create( + [ + OtpBypassToken(user=self.user, token=token) + for token in self.tokens + ] + ) + + def test_authenticate(self): + request = self.request_factory.post("/") + request.user = self.user + + user = self.backend.authenticate(request, token=self.tokens[0]) + + assert user == self.user + assert self.otp_bypass_tokens[0].id is None diff --git a/codeforlife/user/tests/models/__init__.py b/codeforlife/user/tests/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codeforlife/user/tests/models/test_otp_bypass_token.py b/codeforlife/user/tests/models/test_otp_bypass_token.py new file mode 100644 index 00000000..a6683d5c --- /dev/null +++ b/codeforlife/user/tests/models/test_otp_bypass_token.py @@ -0,0 +1,67 @@ +from django.contrib.auth.hashers import check_password +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils.crypto import get_random_string + +from ...models import OtpBypassToken, User + + +class TestOtpBypassToken(TestCase): + def setUp(self): + self.user = User.objects.get(id=2) + + def test_bulk_create(self): + token = get_random_string(8) + otp_bypass_tokens = OtpBypassToken.objects.bulk_create( + [OtpBypassToken(user=self.user, token=token)] + ) + + assert check_password(token, otp_bypass_tokens[0].token) + with self.assertRaises(ValidationError): + OtpBypassToken.objects.bulk_create( + [ + OtpBypassToken( + user=self.user, + token=get_random_string(8), + ) + for _ in range(OtpBypassToken.max_count) + ] + ) + + def test_create(self): + token = get_random_string(8) + otp_bypass_token = OtpBypassToken.objects.create( + user=self.user, token=token + ) + + assert check_password(token, otp_bypass_token.token) + + OtpBypassToken.objects.bulk_create( + [ + OtpBypassToken( + user=self.user, + token=get_random_string(8), + ) + for _ in range(OtpBypassToken.max_count - 1) + ] + ) + + with self.assertRaises(ValidationError): + OtpBypassToken.objects.create( + user=self.user, + token=get_random_string(8), + ) + + def test_check_token(self): + token = get_random_string(8) + otp_bypass_token = OtpBypassToken.objects.create( + user=self.user, token=token + ) + + assert otp_bypass_token.check_token(token) + assert otp_bypass_token.id is None + with self.assertRaises(OtpBypassToken.DoesNotExist): + OtpBypassToken.objects.get( + user=otp_bypass_token.user, + token=otp_bypass_token.token, + ) diff --git a/codeforlife/version.py b/codeforlife/version.py index 5dcd2107..0f039df4 100644 --- a/codeforlife/version.py +++ b/codeforlife/version.py @@ -1,3 +1,3 @@ # Do NOT set manually! # This is auto-updated by python-semantic-release in the pipeline. -__version__ = "0.7.14" +__version__ = "0.8.0" diff --git a/manage.py b/manage.py index f12f9295..a317e0a8 100644 --- a/manage.py +++ b/manage.py @@ -1,26 +1,9 @@ from pathlib import Path +from codeforlife.settings import * -# Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = ( - "django-insecure-!s&7h44ae3y6+_*o*i)zf7#3gs1)mk%g@1h#2xzk1c&o2&y4$o" -) - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", @@ -29,18 +12,17 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sites", - "codeforlife.user.apps.UserConfig", - "django_extensions", + "codeforlife.user", + "aimmo", # TODO: remove this + "game", # TODO: remove this + "common", # TODO: remove this + "portal", # TODO: remove this ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "codeforlife.user.urls" @@ -61,12 +43,6 @@ }, ] -WSGI_APPLICATION = "codeforlife.user.wsgi.application" - - -# Database -# https://docs.djangoproject.com/en/3.2/ref/settings/#databases - DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", @@ -74,56 +50,11 @@ } } - -# Password validation -# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - -# AUTH_USER_MODEL = "user.User" - -# Internationalization -# https://docs.djangoproject.com/en/3.2/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.2/howto/static-files/ - -STATIC_URL = "/static/" - -# Default primary key field type -# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - - if __name__ == "__main__": - from django.core.management import execute_from_command_line - import sys import os + import sys + + from django.core.management import execute_from_command_line os.environ.setdefault("DJANGO_SETTINGS_MODULE", Path(__file__).stem) diff --git a/pyproject.toml b/pyproject.toml index 6a1b96d6..17a1a0d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,4 @@ line-length = 80 extend-exclude = "^/setup.py|^/codeforlife/user/migrations/" [tool.pytest.ini_options] -# env = ["DJANGO_SETTINGS_MODULE=example_project.settings"] -DJANGO_SETTINGS_MODULE = "manage" -python_files = "tests.py test_*.py" +env = ["DJANGO_SETTINGS_MODULE=manage"] diff --git a/setup.py b/setup.py index 70403047..fbbc09e0 100644 --- a/setup.py +++ b/setup.py @@ -29,41 +29,87 @@ # These will be synced with Pipfile by the pipeline. # DO NOT edit these manually. Instead, update the Pipfile. install_requires=[ + "aimmo==2.10.6", "asgiref==3.7.2; python_version >= '3.7'", + "attrs==23.1.0; python_version >= '3.7'", + "cachetools==5.3.1; python_version >= '3.7'", "certifi==2023.7.22; python_version >= '3.6'", - "cfl-common==6.36.2", + "cfl-common==6.37.1", "charset-normalizer==3.2.0; python_full_version >= '3.7.0'", "click==8.1.7; python_version >= '3.7'", + "codeforlife-portal==6.37.1", + "defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "diff-match-patch==20230430; python_version >= '3.7'", "django==3.2.20", + "django-classy-tags==2.0.0", "django-cors-headers==4.1.0", "django-countries==7.3.1", + "django-csp==3.7", "django-formtools==2.2", + "django-import-export==3.3.1; python_version >= '3.8'", + "django-js-reverse==0.9.1", "django-otp==1.0.2", "django-phonenumber-field==6.4.0; python_version >= '3.7'", + "django-pipeline==2.0.8", + "django-preventconcurrentlogins==0.8.2", + "django-ratelimit==3.0.1; python_version >= '3.4'", + "django-recaptcha==2.0.6", + "django-sekizai==2.0.0", + "django-treebeard==4.3.1", "django-two-factor-auth==1.13.2", "djangorestframework==3.13.1", + "dnspython==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "et-xmlfile==1.1.0; python_version >= '3.6'", + "eventlet==0.31.0", "flask==2.2.3", + "google-auth==2.23.1; python_version >= '3.7'", + "greenlet==2.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "hypothesis==5.41.3; python_version >= '3.6'", "idna==3.4; python_version >= '3.5'", "importlib-metadata==4.13.0", "itsdangerous==2.1.2; python_version >= '3.7'", "jinja2==3.1.2; python_version >= '3.7'", + "kubernetes==26.1.0; python_version >= '3.6'", + "libsass==0.22.0; python_version >= '3.6'", + "markuppy==1.14", "markupsafe==2.1.3; python_version >= '3.7'", + "more-itertools==8.7.0; python_version >= '3.5'", "numpy==1.24.4; python_version >= '3.8'", + "oauthlib==3.2.2; python_version >= '3.6'", + "odfpy==1.4.1", + "openpyxl==3.1.2", "pandas==2.0.3; python_version >= '3.8'", "pgeocode==0.4.0; python_version >= '3.8'", + "phonenumbers==8.12.12", + "pillow==10.0.1; python_version >= '3.8'", + "pyasn1==0.5.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "pyasn1-modules==0.3.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "pydantic==1.10.7", + "pyhamcrest==2.0.2; python_version >= '3.5'", "pyjwt==2.6.0; python_version >= '3.7'", + "pyotp==2.9.0", "pypng==0.20220715.0", "python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "pytz==2023.3.post1", + "pyyaml==5.4.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "qrcode==7.4.2; python_version >= '3.7'", + "rapid-router==5.11.3", + "reportlab==3.6.13; python_version >= '3.7' and python_version < '4'", "requests==2.31.0; python_version >= '3.7'", + "requests-oauthlib==1.3.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "rsa==4.9; python_version >= '3.6' and python_version < '4'", + "setuptools==62.1.0; python_version >= '3.7'", "six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "sortedcontainers==2.4.0", "sqlparse==0.4.4; python_version >= '3.5'", + "tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'", "typing-extensions==4.8.0; python_version >= '3.8'", "tzdata==2023.3; python_version >= '2'", - "urllib3==2.0.4; python_version >= '3.7'", + "urllib3==2.0.5; python_version >= '3.7'", + "websocket-client==1.6.3; python_version >= '3.8'", "werkzeug==2.3.7; python_version >= '3.8'", + "xlrd==2.0.1", + "xlwt==1.3.0", "zipp==3.17.0; python_version >= '3.8'", ], dependency_links=[],