diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..18217f5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +*.tar +*.png +*.md +.env +.git/* +.gitignore +*.pyc +*.sh +tests/ +.vscode/ +__pycache__/ +.pytest_cache +*.env +.venv \ No newline at end of file diff --git a/.github/workflows/docker-image-tag.yml b/.github/workflows/docker-image-tag.yml new file mode 100644 index 0000000..8e78b43 --- /dev/null +++ b/.github/workflows/docker-image-tag.yml @@ -0,0 +1,25 @@ +name: Docker Image CI - tags + +on: + push: + tags: [ "*" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v3 + with: + push: true + tags: moralcode/classclockapi:${{ github.ref_name }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..6a2eb84 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,29 @@ +name: Docker Image CI + +on: + push: + branches: [ "main" ] +# tags: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: +# - +# name: Set up QEMU +# uses: docker/setup-qemu-action@v2 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build and push + uses: docker/build-push-action@v3 + with: + push: true + tags: moralcode/classclockapi:main diff --git a/.gitignore b/.gitignore index d773b90..ab7140e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,160 @@ -__pycache__ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + + + + /startserver.sh classclockapi_venv/ @@ -7,3 +162,4 @@ classclockapi_venv/ prodstart\.sh *.pyc +*.env \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..12e80af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +## 0.3.3 +- add optional sentry monitoring +- add TRUSTED_PROXY_COUNT environment variable to allow the app to run behind a proxy +- increase rate limits +- improve handling of 429 errors + +## 0.3.2 +- Improvements to the admin role checks +- Fix an issue where the token for the auth0management endpoint for checking user roles would expire and cause requests to return less data than they should + +## 0.3.1 +- Improvements to logging +- Improved interactions with Auth0 management API by using higher level HTTP libraries and correcting the URL path for accessing the API +- updated role checking for protected endpoints to be a little more robust and more reliably parse the data from the management API + +Known issues: +- Will not start up if environment variables are quoted. + +## 0.3.0 +- Introduce database migrations using flask-migrate. +- Introduce crude soft-deletion +- Add a new endpoint at `/bellschedules` for logged-in admins to list the schedules for every school that they are the owner of +- disable strict slashes on all endpoints (`.../endpoint/` and `.../endpoint` should now act the same.) +- Updates to CORS settings +- Improvements to generated API documententation +- update dependencies and connection string settings to fix some crashing on startup + + +### Updating to 0.3.0 +The recommended/easiest way to update databases created prior to 0.3.0 is to create a new database. However, this may not be possible in all cases. + +Alternatively, databases created prior to 0.3.0 should be upgraded as follows: +1. perform a backup +2. determine what commit the database was created from +3. apply any database schema changes (as indicated by changes to the `db_schema.py` file) to get your database up to the same schema as version 0.2.0 +4. run the following command against your database: `flask db stamp 93d6649210e1` to set up the versioning table +5. run the migrations using `flask db upgrade`. This will update your database schema so that it contains the latest changes + +## 0.2.0 +- improved logging +- dependency updates +- begin using sqlalchemy ORM for managing DB queries +- add a mehcanism for populating the database with demo data +- Switch from JSONAPI to a more regular JSON output format +- Create an automatically-generated documentation page with swagger +- add a basic HTML homepage to the API's main URL for any visitors that come looking +- add beta and staging site URLs to CORS origins +- add a /ping endpoint to allow the frontend to check connectivity +- Dockerize the app +## 0.1.0 +Initial tagged version \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1129ae8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing to the ClassClock API + + +Here are some things that may be helpful for maintainers or contributors + + +## making changes to the schema +If you make changes to the DB schema, generate a new migration to allow existing users to upgrade their databases. This can be done with the command `FLASK_APP=api.py pipenv run flask db migrate -m ""`. Use a short, descriptive message to describe what was changed. TO upgrade, run `FLASK_APP=api.py pipenv run flask db upgrade` to update your local db to the new schema. Don't forget to also document the changes in the changelog you made to the app and which app versions are compatible with which DB versions. + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cddf9c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.8-slim-buster + +WORKDIR /classclock-api + +RUN pip install pipenv + + +COPY Pipfile Pipfile.lock /classclock-api/ + +RUN pipenv install + +COPY . /classclock-api/ + +ENTRYPOINT pipenv run gunicorn --workers 1 --bind 0.0.0.0 api:app diff --git a/Pipfile b/Pipfile index 3fc0401..aa3f98b 100644 --- a/Pipfile +++ b/Pipfile @@ -4,22 +4,30 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] +flask-migrate = "*" [packages] -python-dotenv = "*" +six = "~=1.12.0" +flasgger = "~=0.9.3" +flask-marshmallow = "~=0.10.1" +Flask = "~=2.0.0" +Flask-Cors = "~=3.0.8" +Flask-Limiter = "~=3.2.0" +gunicorn = "~=22.0.0" +werkzeug = "~=2.0.0" +sqlalchemy = "~=1.3.13" +flask-sqlalchemy = "~=2.5.0" +marshmallow-sqlalchemy = "~=0.23" +apispec = "~=3.3.0" +apispec-webframeworks = "~=0.5.2" +mysql-connector-python = "8.0.24" python-jose = "*" -six = "*" -mysql-connector = "*" -dnspython = "*" -flasgger = "*" -flask-marshmallow = "*" -Flask = "*" -Flask-Cors = "*" -Flask-Limiter = "*" -gunicorn = "*" -sqlalchemy = "*" -json-api-doc = "*" -marshmallow-jsonapi = "*" +python-dotenv = "*" +flask-migrate = "~=3.0.0" +requests = "*" +sentry-sdk = "*" +psycopg2-binary = "*" +ordered-set = "*" [requires] -python_version = "3.6" +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index b72520a..669102b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "39eaf869ef189205d378a0bc9bb9a78dc8de0ca25a12a84b0aabf3826af0e3d6" + "sha256": "f2e8690265236431a4831c0145e3a83db4636b070b8373468c1bef2ca5a3d4f3" }, "pipfile-spec": 6, "requires": { - "python_version": "3.6" + "python_version": "3.8" }, "sources": [ { @@ -16,275 +16,1155 @@ ] }, "default": { + "alembic": { + "hashes": [ + "sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", + "sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213" + ], + "markers": "python_version >= '3.8'", + "version": "==1.14.1" + }, + "apispec": { + "hashes": [ + "sha256:a1df9ec6b2cd0edf45039ef025abd7f0660808fa2edf737d3ba1cf5ef1a4625b", + "sha256:d23ebd5b71e541e031b02a19db10b5e6d5ef8452c552833e3e1afc836b40b1ad" + ], + "index": "pypi", + "version": "==3.3.2" + }, + "apispec-webframeworks": { + "hashes": [ + "sha256:0db35b267914b3f8c562aca0261957dbcb4176f255eacc22520277010818dcf3", + "sha256:482c563abbcc2a261439476cb3f1a7c7284cc997c322c574d48c111643e9c04e" + ], + "index": "pypi", + "version": "==0.5.2" + }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", + "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" ], - "version": "==19.3.0" + "markers": "python_version >= '3.8'", + "version": "==25.3.0" + }, + "certifi": { + "hashes": [ + "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", + "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.8.3" + }, + "charset-normalizer": { + "hashes": [ + "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", + "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", + "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", + "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", + "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", + "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", + "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", + "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", + "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", + "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", + "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", + "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", + "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", + "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", + "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", + "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", + "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", + "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", + "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", + "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", + "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", + "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", + "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", + "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", + "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", + "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", + "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", + "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", + "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", + "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", + "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", + "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", + "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", + "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", + "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", + "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", + "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", + "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", + "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", + "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", + "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", + "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", + "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", + "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", + "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", + "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", + "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", + "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", + "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", + "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", + "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", + "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", + "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", + "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", + "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", + "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", + "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", + "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", + "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", + "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", + "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", + "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", + "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", + "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", + "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", + "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", + "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", + "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", + "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", + "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", + "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", + "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", + "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", + "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", + "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", + "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", + "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", + "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", + "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.3" }, "click": { "hashes": [ - "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", - "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" ], - "version": "==7.1.1" + "markers": "python_version >= '3.7'", + "version": "==8.1.8" }, - "dnspython": { + "deprecated": { "hashes": [ - "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", - "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d" + "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", + "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec" ], - "index": "pypi", - "version": "==1.16.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.2.18" }, "ecdsa": { "hashes": [ - "sha256:867ec9cf6df0b03addc8ef66b56359643cb5d0c1dc329df76ba7ecfe256c8061", - "sha256:8f12ac317f8a1318efa75757ef0a651abe12e51fc1af8838fb91079445227277" + "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", + "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61" ], - "version": "==0.15" + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==0.19.1" }, "flasgger": { "hashes": [ - "sha256:37137b3292738580c42e03662bfb8731656a11d636e76f76d30e572c1fa5bd0d", - "sha256:7c187f7a7caeb42645f7de652335b794375925467407e40322620fb9d401b38c" + "sha256:ca098e10bfbb12f047acc6299cc70a33851943a746e550d86e65e60d4df245fb" ], "index": "pypi", - "version": "==0.9.4" + "version": "==0.9.7.1" }, "flask": { "hashes": [ - "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", - "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" + "sha256:59da8a3170004800a2837844bfa84d49b022550616070f7cb1a659682b2e7c9f", + "sha256:e1120c228ca2f553b470df4a5fa927ab66258467526069981b3eb0a91902687d" ], "index": "pypi", - "version": "==1.1.2" + "version": "==2.0.3" }, "flask-cors": { "hashes": [ - "sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16", - "sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a" + "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438", + "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de" ], "index": "pypi", - "version": "==3.0.8" + "version": "==3.0.10" }, "flask-limiter": { "hashes": [ - "sha256:d984a57ef37acb6eee29edc864ff22cd4cf090845f06968c015093ffd91e96f1", - "sha256:db2a069402977927282b0fcf650753bfcb50488028def9f5b2398e1d525f2f9f" + "sha256:919b4af05b7d4162a699bfdc24357feeec876b70b1916150c3b4337ad711a995", + "sha256:e56140f2d48a3ee7d9d6e49bb81015e30cd1e99dc9e44adebb0c1127b89b3dc8" ], "index": "pypi", - "version": "==1.2.1" + "version": "==3.2.0" }, "flask-marshmallow": { "hashes": [ - "sha256:01520ef1851ccb64d4ffb33196cddff895cc1302ae1585bff1abf58684a8111a", - "sha256:28b969193958d9602ab5d6add6d280e0e360c8e373d3492c2f73b024ecd36374" + "sha256:4f507f883838b397638a3a36c7d36ee146b255a49db952f5d9de3f6f4522e8a8", + "sha256:69e99e3a123393894884a032ae2d11e6bdf4519a505819b66cec7eda32057741" ], "index": "pypi", - "version": "==0.11.0" + "version": "==0.10.1" + }, + "flask-migrate": { + "hashes": [ + "sha256:4d42e8f861d78cb6e9319afcba5bf76062e5efd7784184dd2a1cccd9de34a702", + "sha256:df9043d2050df3c0e0f6313f6b529b62c837b6033c20335e9d0b4acdf2c40e23" + ], + "index": "pypi", + "version": "==3.0.1" + }, + "flask-sqlalchemy": { + "hashes": [ + "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", + "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390" + ], + "index": "pypi", + "version": "==2.5.1" }, "gunicorn": { "hashes": [ - "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", - "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" + "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", + "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63" ], "index": "pypi", - "version": "==20.0.4" + "version": "==22.0.0" + }, + "idna": { + "hashes": [ + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + ], + "markers": "python_version >= '3.6'", + "version": "==3.10" }, "importlib-metadata": { "hashes": [ - "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", - "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" + "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", + "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7" ], - "markers": "python_version < '3.8'", - "version": "==1.6.0" + "markers": "python_version < '3.9'", + "version": "==8.5.0" + }, + "importlib-resources": { + "hashes": [ + "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065", + "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717" + ], + "markers": "python_version < '3.9'", + "version": "==6.4.5" }, "itsdangerous": { "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" + "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", + "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173" ], - "version": "==1.1.0" + "markers": "python_version >= '3.8'", + "version": "==2.2.0" }, "jinja2": { "hashes": [ - "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", - "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" + "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", + "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" ], - "version": "==2.11.1" + "markers": "python_version >= '3.7'", + "version": "==3.1.6" }, - "json-api-doc": { + "jsonschema": { "hashes": [ - "sha256:3a4724d9b86c5a52686a76f2c7538101f1a933fc1a08b85b20541f9885ec2433", - "sha256:a073be2309ec90fc33a02701a5f73a715cb6475a2dffa71d0e9df63e0fbe9182" + "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", + "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566" ], - "index": "pypi", - "version": "==0.11.0" + "markers": "python_version >= '3.8'", + "version": "==4.23.0" }, - "jsonschema": { + "jsonschema-specifications": { "hashes": [ - "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", - "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" + "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc", + "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c" ], - "version": "==3.2.0" + "markers": "python_version >= '3.8'", + "version": "==2023.12.1" }, "limits": { "hashes": [ - "sha256:0e5f8b10f18dd809eb2342f5046eb9aa5e4e69a0258567b5f4aa270647d438b3", - "sha256:f0c3319f032c4bfad68438ed1325c0fac86dac64582c7c25cddc87a0b658fa20" + "sha256:6571b0c567bfa175a35fed9f8a954c0c92f1c3200804282f1b8f1de4ad98a953", + "sha256:9767f7233da4255e9904b79908a728e8ec0984c0b086058b4cbbd309aea553f6" ], - "version": "==1.5.1" + "markers": "python_version >= '3.8'", + "version": "==3.13.0" + }, + "mako": { + "hashes": [ + "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", + "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59" + ], + "markers": "python_version >= '3.8'", + "version": "==1.3.10" + }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" }, "markupsafe": { "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "version": "==1.1.1" + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.5" }, "marshmallow": { "hashes": [ - "sha256:90854221bbb1498d003a0c3cc9d8390259137551917961c8b5258c64026b2f85", - "sha256:ac2e13b30165501b7d41fc0371b8df35944f5849769d136f20e2c5f6cdc6e665" + "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e", + "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9" ], - "version": "==3.5.1" + "markers": "python_version >= '3.8'", + "version": "==3.22.0" }, - "marshmallow-jsonapi": { + "marshmallow-sqlalchemy": { "hashes": [ - "sha256:0d5567ad98d834149e4329d244732bda0185dc51b5fef0ec7dc58e9d1d7c0493", - "sha256:289ac50cc65d9febfe432892345f44b99d4520d48538a86cf274549712fa6a82" + "sha256:2ab0f1280c793e5aec81deab3e63ec23688ddfe05e5f38ac960368a1079520a1", + "sha256:c31b3bdf794de1d78c53e1c495502cbb3eeb06ed216869980c71d6159e7e9e66" ], "index": "pypi", - "version": "==0.23.1" + "version": "==0.28.2" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" }, "mistune": { "hashes": [ - "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", - "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4" + "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", + "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164" ], - "version": "==0.8.4" + "markers": "python_version >= '3.8'", + "version": "==3.1.4" }, - "mysql-connector": { + "mysql-connector-python": { "hashes": [ - "sha256:1733e6ce52a049243de3264f1fbc22a852cb35458c4ad739ba88189285efdf32" + "sha256:016d81bb1499dee8b77c82464244e98f10d3671ceefb4023adc559267d1fad50", + "sha256:052058cf3dc0bf183ab522132f3b18a614a26f3e392ae886efcdab38d4f4fc42", + "sha256:134b71e439e2eafaee4c550365221ae2890dd54fb76227c64a87a94a07fe79b4", + "sha256:2a8f451c4d700802fdfe515890c14974766c322213df2ceed3b27752929dc70f", + "sha256:2dcf05355315e5c7c81e9eca34395d78f29c4da3662e869e42dd7b16380f92ce", + "sha256:38c229d76cd1dea8465357855f2b2842b7a9b201f17dea13b0eab7d3b9d6ad74", + "sha256:67fc2b2e67a63963c633fc884f285a8de5a626967a3cc5f5d48ac3e8d15b122d", + "sha256:6d92c58f71c691f86ad35bb2f3e13d7a9cc1c84ce0b04c146e5980e450faeff1", + "sha256:72bfd0213364c2bea0244f6432ababb2f204cff43f4f886c65dca2be11f536ee", + "sha256:7af7f68198f2aca3a520e1201fe2b329331e0ca19a481f3b3451cb0746f56c01", + "sha256:823190e7f2a9b4bcc574ab6bb72a33802933e1a8c171594faad90162d2d27758", + "sha256:853c5916d188ef2c357a474e15ac81cafae6085e599ceb9b2b0bcb9104118e63", + "sha256:8a404db37864acca43fd76222d1fbc7ff8d17d4ce02d803289c2141c2693ce9e", + "sha256:9199d6ecc81576602990178f0c2fb71737c53a598c8a2f51e1097a53fcfaee40", + "sha256:933c3e39d30cc6f9ff636d27d18aa3f1341b23d803ade4b57a76f91c26d14066", + "sha256:a48534b881c176557ddc78527c8c75b4c9402511e972670ad33c5e49d31eddfe", + "sha256:a688ea65b2ea771b9b69dc409377240a7cab7c1aafef46cd75219d5a94ba49e0", + "sha256:ac92b2f2a9307ac0c4aafdfcf7ecf01ec92dfebd9140f8c95353adfbf5822cd4", + "sha256:b267a6c000b7f98e6436a9acefa5582a9662e503b0632a2562e3093a677f6845", + "sha256:b8639d8aa381a7d19b92ca1a32448f09baaf80787e50187d1f7d072191430768", + "sha256:c01aad36f0c34ca3f642018be37fd0d55c546f088837cba88f1a1aff408c63dd", + "sha256:ca8349fe56ce39498d9b5ca8eabba744774e94d85775259f26a43a03e8825429", + "sha256:ced1fa55e653d28f66c4f3569ed524d4d92098119dcd80c2fa026872a30eba55", + "sha256:e90a7b96ce2c6a60f6e2609b0c83f45bd55e144cc7c2a9714e344938827da363", + "sha256:eacc353dcf6f39665d4ca3311ded5ddae0f5a117f03107991d4185ffa59fd890", + "sha256:f41cb8da8bb487ed60329ac31789c50621f0e6d2c26abc7d4ae2383838fb1b93" ], "index": "pypi", - "version": "==2.2.9" + "version": "==9.0.0" + }, + "ordered-set": { + "hashes": [ + "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", + "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8" + ], + "index": "pypi", + "version": "==4.1.0" + }, + "packaging": { + "hashes": [ + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + ], + "markers": "python_version >= '3.8'", + "version": "==24.2" + }, + "pkgutil-resolve-name": { + "hashes": [ + "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174", + "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e" + ], + "markers": "python_version < '3.9'", + "version": "==1.3.10" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", + "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5", + "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f", + "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", + "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", + "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c", + "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", + "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", + "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", + "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", + "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", + "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", + "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", + "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", + "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", + "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", + "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5", + "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8", + "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1", + "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", + "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", + "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1", + "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53", + "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", + "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906", + "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0", + "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", + "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", + "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", + "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44", + "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648", + "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", + "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", + "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa", + "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697", + "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d", + "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b", + "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", + "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4", + "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287", + "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", + "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", + "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", + "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30", + "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3", + "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", + "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92", + "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", + "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", + "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8", + "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", + "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", + "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864", + "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc", + "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", + "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", + "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", + "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b", + "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481", + "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5", + "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4", + "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", + "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", + "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", + "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", + "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", + "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", + "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863" + ], + "index": "pypi", + "version": "==2.9.10" }, "pyasn1": { "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" ], + "markers": "python_version >= '3.8'", "version": "==0.4.8" }, - "pyrsistent": { + "pygments": { "hashes": [ - "sha256:28669905fe725965daa16184933676547c5bb40a5153055a8dee2a4bd7933ad3" + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" ], - "version": "==0.16.0" + "markers": "python_version >= '3.8'", + "version": "==2.19.2" }, "python-dotenv": { "hashes": [ - "sha256:81822227f771e0cab235a2939f0f265954ac4763cafd806d845801c863bf372f", - "sha256:92b3123fb2d58a284f76cc92bfe4ee6c502c32ded73e8b051c4f6afc8b6751ed" + "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", + "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a" ], "index": "pypi", - "version": "==0.12.0" + "version": "==1.0.1" }, "python-jose": { "hashes": [ - "sha256:1ac4caf4bfebd5a70cf5bd82702ed850db69b0b6e1d0ae7368e5f99ac01c9571", - "sha256:8484b7fdb6962e9d242cce7680469ecf92bda95d10bbcbbeb560cacdff3abfce" + "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", + "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f" ], "index": "pypi", - "version": "==3.1.0" + "version": "==3.4.0" }, "pyyaml": { "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" + "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", + "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", + "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", + "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", + "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", + "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", + "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", + "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", + "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", + "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", + "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a", + "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", + "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", + "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", + "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", + "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", + "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", + "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a", + "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", + "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", + "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", + "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", + "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", + "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", + "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", + "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", + "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", + "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", + "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", + "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706", + "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", + "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", + "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", + "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083", + "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", + "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", + "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", + "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", + "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", + "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", + "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", + "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", + "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", + "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", + "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5", + "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d", + "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", + "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", + "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", + "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", + "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", + "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", + "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" + ], + "markers": "python_version >= '3.8'", + "version": "==6.0.2" + }, + "referencing": { + "hashes": [ + "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c", + "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de" + ], + "markers": "python_version >= '3.8'", + "version": "==0.35.1" + }, + "requests": { + "hashes": [ + "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", + "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" + ], + "index": "pypi", + "version": "==2.32.4" + }, + "rich": { + "hashes": [ + "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", + "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90" + ], + "markers": "python_version >= '3.8'", + "version": "==13.9.4" + }, + "rpds-py": { + "hashes": [ + "sha256:02a0629ec053fc013808a85178524e3cb63a61dbc35b22499870194a63578fb9", + "sha256:07924c1b938798797d60c6308fa8ad3b3f0201802f82e4a2c41bb3fafb44cc28", + "sha256:07f59760ef99f31422c49038964b31c4dfcfeb5d2384ebfc71058a7c9adae2d2", + "sha256:0a3a1e9ee9728b2c1734f65d6a1d376c6f2f6fdcc13bb007a08cc4b1ff576dc5", + "sha256:0a90c373ea2975519b58dece25853dbcb9779b05cc46b4819cb1917e3b3215b6", + "sha256:0ad56edabcdb428c2e33bbf24f255fe2b43253b7d13a2cdbf05de955217313e6", + "sha256:0b581f47257a9fce535c4567782a8976002d6b8afa2c39ff616edf87cbeff712", + "sha256:0f8f741b6292c86059ed175d80eefa80997125b7c478fb8769fd9ac8943a16c0", + "sha256:0fc212779bf8411667234b3cdd34d53de6c2b8b8b958e1e12cb473a5f367c338", + "sha256:13c56de6518e14b9bf6edde23c4c39dac5b48dcf04160ea7bce8fca8397cdf86", + "sha256:142c0a5124d9bd0e2976089484af5c74f47bd3298f2ed651ef54ea728d2ea42c", + "sha256:14511a539afee6f9ab492b543060c7491c99924314977a55c98bfa2ee29ce78c", + "sha256:15a842bb369e00295392e7ce192de9dcbf136954614124a667f9f9f17d6a216f", + "sha256:16d4477bcb9fbbd7b5b0e4a5d9b493e42026c0bf1f06f723a9353f5153e75d30", + "sha256:1791ff70bc975b098fe6ecf04356a10e9e2bd7dc21fa7351c1742fdeb9b4966f", + "sha256:19b73643c802f4eaf13d97f7855d0fb527fbc92ab7013c4ad0e13a6ae0ed23bd", + "sha256:200a23239781f46149e6a415f1e870c5ef1e712939fe8fa63035cd053ac2638e", + "sha256:2249280b870e6a42c0d972339e9cc22ee98730a99cd7f2f727549af80dd5a963", + "sha256:2b431c777c9653e569986ecf69ff4a5dba281cded16043d348bf9ba505486f36", + "sha256:2cc3712a4b0b76a1d45a9302dd2f53ff339614b1c29603a911318f2357b04dd2", + "sha256:2fbb0ffc754490aff6dabbf28064be47f0f9ca0b9755976f945214965b3ace7e", + "sha256:32b922e13d4c0080d03e7b62991ad7f5007d9cd74e239c4b16bc85ae8b70252d", + "sha256:36785be22066966a27348444b40389f8444671630063edfb1a2eb04318721e17", + "sha256:37fe0f12aebb6a0e3e17bb4cd356b1286d2d18d2e93b2d39fe647138458b4bcb", + "sha256:3aea7eed3e55119635a74bbeb80b35e776bafccb70d97e8ff838816c124539f1", + "sha256:3c6afcf2338e7f374e8edc765c79fbcb4061d02b15dd5f8f314a4af2bdc7feb5", + "sha256:3ccb8ac2d3c71cda472b75af42818981bdacf48d2e21c36331b50b4f16930163", + "sha256:3d089d0b88996df627693639d123c8158cff41c0651f646cd8fd292c7da90eaf", + "sha256:3dd645e2b0dcb0fd05bf58e2e54c13875847687d0b71941ad2e757e5d89d4356", + "sha256:3e310838a5801795207c66c73ea903deda321e6146d6f282e85fa7e3e4854804", + "sha256:42cbde7789f5c0bcd6816cb29808e36c01b960fb5d29f11e052215aa85497c93", + "sha256:483b29f6f7ffa6af845107d4efe2e3fa8fb2693de8657bc1849f674296ff6a5a", + "sha256:4888e117dd41b9d34194d9e31631af70d3d526efc363085e3089ab1a62c32ed1", + "sha256:49fe9b04b6fa685bd39237d45fad89ba19e9163a1ccaa16611a812e682913496", + "sha256:4a5a844f68776a7715ecb30843b453f07ac89bad393431efbf7accca3ef599c1", + "sha256:4a916087371afd9648e1962e67403c53f9c49ca47b9680adbeef79da3a7811b0", + "sha256:4f676e21db2f8c72ff0936f895271e7a700aa1f8d31b40e4e43442ba94973899", + "sha256:518d2ca43c358929bf08f9079b617f1c2ca6e8848f83c1225c88caeac46e6cbc", + "sha256:5265505b3d61a0f56618c9b941dc54dc334dc6e660f1592d112cd103d914a6db", + "sha256:55cd1fa4ecfa6d9f14fbd97ac24803e6f73e897c738f771a9fe038f2f11ff07c", + "sha256:58b1d5dd591973d426cbb2da5e27ba0339209832b2f3315928c9790e13f159e8", + "sha256:59240685e7da61fb78f65a9f07f8108e36a83317c53f7b276b4175dc44151684", + "sha256:5b48e790e0355865197ad0aca8cde3d8ede347831e1959e158369eb3493d2191", + "sha256:5d4eea0761e37485c9b81400437adb11c40e13ef513375bbd6973e34100aeb06", + "sha256:648386ddd1e19b4a6abab69139b002bc49ebf065b596119f8f37c38e9ecee8ff", + "sha256:653647b8838cf83b2e7e6a0364f49af96deec64d2a6578324db58380cff82aca", + "sha256:6740a3e8d43a32629bb9b009017ea5b9e713b7210ba48ac8d4cb6d99d86c8ee8", + "sha256:6889469bfdc1eddf489729b471303739bf04555bb151fe8875931f8564309afc", + "sha256:68cb0a499f2c4a088fd2f521453e22ed3527154136a855c62e148b7883b99f9a", + "sha256:6aa97af1558a9bef4025f8f5d8c60d712e0a3b13a2fe875511defc6ee77a1ab7", + "sha256:6b73c67850ca7cae0f6c56f71e356d7e9fa25958d3e18a64927c2d930859b8e4", + "sha256:6c8e9340ce5a52f95fa7d3b552b35c7e8f3874d74a03a8a69279fd5fca5dc751", + "sha256:6ca91093a4a8da4afae7fe6a222c3b53ee4eef433ebfee4d54978a103435159e", + "sha256:754bbed1a4ca48479e9d4182a561d001bbf81543876cdded6f695ec3d465846b", + "sha256:762703bdd2b30983c1d9e62b4c88664df4a8a4d5ec0e9253b0231171f18f6d75", + "sha256:78f0b6877bfce7a3d1ff150391354a410c55d3cdce386f862926a4958ad5ab7e", + "sha256:7a07ced2b22f0cf0b55a6a510078174c31b6d8544f3bc00c2bcee52b3d613f74", + "sha256:7dca7081e9a0c3b6490a145593f6fe3173a94197f2cb9891183ef75e9d64c425", + "sha256:7e21b7031e17c6b0e445f42ccc77f79a97e2687023c5746bfb7a9e45e0921b84", + "sha256:7f5179583d7a6cdb981151dd349786cbc318bab54963a192692d945dd3f6435d", + "sha256:83cba698cfb3c2c5a7c3c6bac12fe6c6a51aae69513726be6411076185a8b24a", + "sha256:842c19a6ce894493563c3bd00d81d5100e8e57d70209e84d5491940fdb8b9e3a", + "sha256:84b8382a90539910b53a6307f7c35697bc7e6ffb25d9c1d4e998a13e842a5e83", + "sha256:8ba6f89cac95c0900d932c9efb7f0fb6ca47f6687feec41abcb1bd5e2bd45535", + "sha256:8bbe951244a838a51289ee53a6bae3a07f26d4e179b96fc7ddd3301caf0518eb", + "sha256:925d176a549f4832c6f69fa6026071294ab5910e82a0fe6c6228fce17b0706bd", + "sha256:92b68b79c0da2a980b1c4197e56ac3dd0c8a149b4603747c4378914a68706979", + "sha256:93da1d3db08a827eda74356f9f58884adb254e59b6664f64cc04cdff2cc19b0d", + "sha256:95f3b65d2392e1c5cec27cff08fdc0080270d5a1a4b2ea1d51d5f4a2620ff08d", + "sha256:9c4cb04a16b0f199a8c9bf807269b2f63b7b5b11425e4a6bd44bd6961d28282c", + "sha256:a624cc00ef2158e04188df5e3016385b9353638139a06fb77057b3498f794782", + "sha256:a649dfd735fff086e8a9d0503a9f0c7d01b7912a333c7ae77e1515c08c146dad", + "sha256:a94e52537a0e0a85429eda9e49f272ada715506d3b2431f64b8a3e34eb5f3e75", + "sha256:aa7ac11e294304e615b43f8c441fee5d40094275ed7311f3420d805fde9b07b4", + "sha256:b41b6321805c472f66990c2849e152aff7bc359eb92f781e3f606609eac877ad", + "sha256:b71b8666eeea69d6363248822078c075bac6ed135faa9216aa85f295ff009b1e", + "sha256:b9c2fe36d1f758b28121bef29ed1dee9b7a2453e997528e7d1ac99b94892527c", + "sha256:bb63804105143c7e24cee7db89e37cb3f3941f8e80c4379a0b355c52a52b6780", + "sha256:be5ef2f1fc586a7372bfc355986226484e06d1dc4f9402539872c8bb99e34b01", + "sha256:c142b88039b92e7e0cb2552e8967077e3179b22359e945574f5e2764c3953dcf", + "sha256:c14937af98c4cc362a1d4374806204dd51b1e12dded1ae30645c298e5a5c4cb1", + "sha256:ca449520e7484534a2a44faf629362cae62b660601432d04c482283c47eaebab", + "sha256:cd945871335a639275eee904caef90041568ce3b42f402c6959b460d25ae8732", + "sha256:d0b937b2a1988f184a3e9e577adaa8aede21ec0b38320d6009e02bd026db04fa", + "sha256:d126b52e4a473d40232ec2052a8b232270ed1f8c9571aaf33f73a14cc298c24f", + "sha256:d8761c3c891cc51e90bc9926d6d2f59b27beaf86c74622c8979380a29cc23ac3", + "sha256:d9ecb51120de61e4604650666d1f2b68444d46ae18fd492245a08f53ad2b7711", + "sha256:da584ff96ec95e97925174eb8237e32f626e7a1a97888cdd27ee2f1f24dd0ad8", + "sha256:dbcf360c9e3399b056a238523146ea77eeb2a596ce263b8814c900263e46031a", + "sha256:dbddc10776ca7ebf2a299c41a4dde8ea0d8e3547bfd731cb87af2e8f5bf8962d", + "sha256:dc73505153798c6f74854aba69cc75953888cf9866465196889c7cdd351e720c", + "sha256:e13de156137b7095442b288e72f33503a469aa1980ed856b43c353ac86390519", + "sha256:e1791c4aabd117653530dccd24108fa03cc6baf21f58b950d0a73c3b3b29a350", + "sha256:e75ba609dba23f2c95b776efb9dd3f0b78a76a151e96f96cc5b6b1b0004de66f", + "sha256:e79059d67bea28b53d255c1437b25391653263f0e69cd7dec170d778fdbca95e", + "sha256:ecd27a66740ffd621d20b9a2f2b5ee4129a56e27bfb9458a3bcc2e45794c96cb", + "sha256:f009c69bc8c53db5dfab72ac760895dc1f2bc1b62ab7408b253c8d1ec52459fc", + "sha256:f16bc1334853e91ddaaa1217045dd7be166170beec337576818461268a3de67f", + "sha256:f19169781dddae7478a32301b499b2858bc52fc45a112955e798ee307e294977", + "sha256:fa3060d885657abc549b2a0f8e1b79699290e5d83845141717c6c90c2df38311", + "sha256:fa41a64ac5b08b292906e248549ab48b69c5428f3987b09689ab2441f267d04d", + "sha256:fbf15aff64a163db29a91ed0868af181d6f68ec1a3a7d5afcfe4501252840bad", + "sha256:fe00a9057d100e69b4ae4a094203a708d65b0f345ed546fdef86498bf5390982" ], - "version": "==5.3.1" + "markers": "python_version >= '3.8'", + "version": "==0.20.1" }, "rsa": { "hashes": [ - "sha256:14ba45700ff1ec9eeb206a2ce76b32814958a98e372006c8fb76ba820211be66", - "sha256:1a836406405730121ae9823e19c6e806c62bbad73f890574fff50efa4122c487" + "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", + "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75" ], - "version": "==4.0" + "markers": "python_version >= '3.6' and python_version < '4'", + "version": "==4.9.1" + }, + "sentry-sdk": { + "hashes": [ + "sha256:38c98e3cbb620dd3dd80a8d6e39c753d453dd41f8a9df581b0584c19a52bc926", + "sha256:e9e8f3c795044beb59f2c8f4c6b9b0f9779e5e604099882df05eec525e782cc6" + ], + "index": "pypi", + "version": "==2.35.2" }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" ], "index": "pypi", - "version": "==1.14.0" + "version": "==1.12.0" }, "sqlalchemy": { "hashes": [ - "sha256:7224e126c00b8178dfd227bc337ba5e754b197a3867d33b9f30dc0208f773d70" + "sha256:014ea143572fee1c18322b7908140ad23b3994036ef4c0d630110faf942652f8", + "sha256:0172423a27fbcae3751ef016663b72e1a516777de324a76e30efa170dbd3dd2d", + "sha256:01aa5f803db724447c1d423ed583e42bf5264c597fd55e4add4301f163b0be48", + "sha256:0352db1befcbed2f9282e72843f1963860bf0e0472a4fa5cf8ee084318e0e6ab", + "sha256:09083c2487ca3c0865dc588e07aeaa25416da3d95f7482c07e92f47e080aa17b", + "sha256:0d5d862b1cfbec5028ce1ecac06a3b42bc7703eb80e4b53fceb2738724311443", + "sha256:14f0eb5db872c231b20c18b1e5806352723a3a89fb4254af3b3e14f22eaaec75", + "sha256:1e2f89d2e5e3c7a88e25a3b0e43626dba8db2aa700253023b82e630d12b37109", + "sha256:26155ea7a243cbf23287f390dba13d7927ffa1586d3208e0e8d615d0c506f996", + "sha256:2ed6343b625b16bcb63c5b10523fd15ed8934e1ed0f772c534985e9f5e73d894", + "sha256:34fcec18f6e4b24b4a5f6185205a04f1eab1e56f8f1d028a2a03694ebcc2ddd4", + "sha256:4d0e3515ef98aa4f0dc289ff2eebb0ece6260bbf37c2ea2022aad63797eacf60", + "sha256:5de2464c254380d8a6c20a2746614d5a436260be1507491442cf1088e59430d2", + "sha256:6607ae6cd3a07f8a4c3198ffbf256c261661965742e2b5265a77cd5c679c9bba", + "sha256:8110e6c414d3efc574543109ee618fe2c1f96fa31833a1ff36cc34e968c4f233", + "sha256:816de75418ea0953b5eb7b8a74933ee5a46719491cd2b16f718afc4b291a9658", + "sha256:861e459b0e97673af6cc5e7f597035c2e3acdfb2608132665406cded25ba64c7", + "sha256:87a2725ad7d41cd7376373c15fd8bf674e9c33ca56d0b8036add2d634dba372e", + "sha256:a006d05d9aa052657ee3e4dc92544faae5fcbaafc6128217310945610d862d39", + "sha256:bce28277f308db43a6b4965734366f533b3ff009571ec7ffa583cb77539b84d6", + "sha256:c10ff6112d119f82b1618b6dc28126798481b9355d8748b64b9b55051eb4f01b", + "sha256:d375d8ccd3cebae8d90270f7aa8532fe05908f79e78ae489068f3b4eee5994e8", + "sha256:d37843fb8df90376e9e91336724d78a32b988d3d20ab6656da4eb8ee3a45b63c", + "sha256:e47e257ba5934550d7235665eee6c911dc7178419b614ba9e1fbb1ce6325b14f", + "sha256:e98d09f487267f1e8d1179bf3b9d7709b30a916491997137dd24d6ae44d18d79", + "sha256:ebbb777cbf9312359b897bf81ba00dae0f5cb69fba2a18265dcc18a6f5ef7519", + "sha256:ee5f5188edb20a29c1cc4a039b074fdc5575337c9a68f3063449ab47757bb064", + "sha256:f03bd97650d2e42710fbe4cf8a59fae657f191df851fc9fc683ecef10746a375", + "sha256:f1149d6e5c49d069163e58a3196865e4321bad1803d7886e07d8710de392c548", + "sha256:f3c5c52f7cb8b84bfaaf22d82cb9e6e9a8297f7c2ed14d806a0f5e4d22e83fb7", + "sha256:f597a243b8550a3a0b15122b14e49d8a7e622ba1c9d29776af741f1845478d79", + "sha256:fc1f2a5a5963e2e73bac4926bdaf7790c4d7d77e8fc0590817880e22dd9d0b8b", + "sha256:fc4cddb0b474b12ed7bdce6be1b9edc65352e8ce66bc10ff8cbbfb3d4047dbf4", + "sha256:fcb251305fa24a490b6a9ee2180e5f8252915fb778d3dafc70f9cc3f863827b9" ], "index": "pypi", - "version": "==1.3.16" + "version": "==1.3.24" + }, + "typing-extensions": { + "hashes": [ + "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", + "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" + ], + "markers": "python_version >= '3.8'", + "version": "==4.13.2" + }, + "urllib3": { + "hashes": [ + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + ], + "markers": "python_version >= '3.8'", + "version": "==2.2.3" }, "werkzeug": { "hashes": [ - "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", - "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" + "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8", + "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c" ], - "version": "==1.0.1" + "index": "pypi", + "version": "==2.0.3" + }, + "wrapt": { + "hashes": [ + "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56", + "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", + "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", + "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", + "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", + "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", + "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139", + "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", + "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", + "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f", + "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", + "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", + "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f", + "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", + "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", + "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc", + "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", + "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", + "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", + "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", + "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81", + "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", + "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", + "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b", + "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", + "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", + "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", + "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", + "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", + "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c", + "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df", + "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", + "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", + "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", + "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", + "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", + "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5", + "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9", + "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", + "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", + "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225", + "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", + "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", + "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", + "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", + "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00", + "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", + "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a", + "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", + "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", + "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", + "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", + "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", + "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", + "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d", + "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22", + "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", + "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2", + "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", + "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", + "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", + "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", + "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f", + "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", + "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", + "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", + "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", + "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a", + "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", + "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", + "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", + "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", + "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", + "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", + "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", + "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", + "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", + "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", + "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", + "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", + "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c" + ], + "markers": "python_version >= '3.8'", + "version": "==1.17.3" }, "zipp": { "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", + "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29" ], - "version": "==3.1.0" + "markers": "python_version < '3.10'", + "version": "==3.20.2" } }, - "develop": {} + "develop": { + "alembic": { + "hashes": [ + "sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", + "sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213" + ], + "markers": "python_version >= '3.8'", + "version": "==1.14.1" + }, + "click": { + "hashes": [ + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.8" + }, + "flask": { + "hashes": [ + "sha256:59da8a3170004800a2837844bfa84d49b022550616070f7cb1a659682b2e7c9f", + "sha256:e1120c228ca2f553b470df4a5fa927ab66258467526069981b3eb0a91902687d" + ], + "index": "pypi", + "version": "==2.0.3" + }, + "flask-migrate": { + "hashes": [ + "sha256:4d42e8f861d78cb6e9319afcba5bf76062e5efd7784184dd2a1cccd9de34a702", + "sha256:df9043d2050df3c0e0f6313f6b529b62c837b6033c20335e9d0b4acdf2c40e23" + ], + "index": "pypi", + "version": "==3.0.1" + }, + "flask-sqlalchemy": { + "hashes": [ + "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912", + "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390" + ], + "index": "pypi", + "version": "==2.5.1" + }, + "importlib-metadata": { + "hashes": [ + "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", + "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7" + ], + "markers": "python_version < '3.9'", + "version": "==8.5.0" + }, + "importlib-resources": { + "hashes": [ + "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065", + "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717" + ], + "markers": "python_version < '3.9'", + "version": "==6.4.5" + }, + "itsdangerous": { + "hashes": [ + "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", + "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173" + ], + "markers": "python_version >= '3.8'", + "version": "==2.2.0" + }, + "jinja2": { + "hashes": [ + "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", + "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.6" + }, + "mako": { + "hashes": [ + "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", + "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59" + ], + "markers": "python_version >= '3.8'", + "version": "==1.3.10" + }, + "markupsafe": { + "hashes": [ + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.5" + }, + "sqlalchemy": { + "hashes": [ + "sha256:014ea143572fee1c18322b7908140ad23b3994036ef4c0d630110faf942652f8", + "sha256:0172423a27fbcae3751ef016663b72e1a516777de324a76e30efa170dbd3dd2d", + "sha256:01aa5f803db724447c1d423ed583e42bf5264c597fd55e4add4301f163b0be48", + "sha256:0352db1befcbed2f9282e72843f1963860bf0e0472a4fa5cf8ee084318e0e6ab", + "sha256:09083c2487ca3c0865dc588e07aeaa25416da3d95f7482c07e92f47e080aa17b", + "sha256:0d5d862b1cfbec5028ce1ecac06a3b42bc7703eb80e4b53fceb2738724311443", + "sha256:14f0eb5db872c231b20c18b1e5806352723a3a89fb4254af3b3e14f22eaaec75", + "sha256:1e2f89d2e5e3c7a88e25a3b0e43626dba8db2aa700253023b82e630d12b37109", + "sha256:26155ea7a243cbf23287f390dba13d7927ffa1586d3208e0e8d615d0c506f996", + "sha256:2ed6343b625b16bcb63c5b10523fd15ed8934e1ed0f772c534985e9f5e73d894", + "sha256:34fcec18f6e4b24b4a5f6185205a04f1eab1e56f8f1d028a2a03694ebcc2ddd4", + "sha256:4d0e3515ef98aa4f0dc289ff2eebb0ece6260bbf37c2ea2022aad63797eacf60", + "sha256:5de2464c254380d8a6c20a2746614d5a436260be1507491442cf1088e59430d2", + "sha256:6607ae6cd3a07f8a4c3198ffbf256c261661965742e2b5265a77cd5c679c9bba", + "sha256:8110e6c414d3efc574543109ee618fe2c1f96fa31833a1ff36cc34e968c4f233", + "sha256:816de75418ea0953b5eb7b8a74933ee5a46719491cd2b16f718afc4b291a9658", + "sha256:861e459b0e97673af6cc5e7f597035c2e3acdfb2608132665406cded25ba64c7", + "sha256:87a2725ad7d41cd7376373c15fd8bf674e9c33ca56d0b8036add2d634dba372e", + "sha256:a006d05d9aa052657ee3e4dc92544faae5fcbaafc6128217310945610d862d39", + "sha256:bce28277f308db43a6b4965734366f533b3ff009571ec7ffa583cb77539b84d6", + "sha256:c10ff6112d119f82b1618b6dc28126798481b9355d8748b64b9b55051eb4f01b", + "sha256:d375d8ccd3cebae8d90270f7aa8532fe05908f79e78ae489068f3b4eee5994e8", + "sha256:d37843fb8df90376e9e91336724d78a32b988d3d20ab6656da4eb8ee3a45b63c", + "sha256:e47e257ba5934550d7235665eee6c911dc7178419b614ba9e1fbb1ce6325b14f", + "sha256:e98d09f487267f1e8d1179bf3b9d7709b30a916491997137dd24d6ae44d18d79", + "sha256:ebbb777cbf9312359b897bf81ba00dae0f5cb69fba2a18265dcc18a6f5ef7519", + "sha256:ee5f5188edb20a29c1cc4a039b074fdc5575337c9a68f3063449ab47757bb064", + "sha256:f03bd97650d2e42710fbe4cf8a59fae657f191df851fc9fc683ecef10746a375", + "sha256:f1149d6e5c49d069163e58a3196865e4321bad1803d7886e07d8710de392c548", + "sha256:f3c5c52f7cb8b84bfaaf22d82cb9e6e9a8297f7c2ed14d806a0f5e4d22e83fb7", + "sha256:f597a243b8550a3a0b15122b14e49d8a7e622ba1c9d29776af741f1845478d79", + "sha256:fc1f2a5a5963e2e73bac4926bdaf7790c4d7d77e8fc0590817880e22dd9d0b8b", + "sha256:fc4cddb0b474b12ed7bdce6be1b9edc65352e8ce66bc10ff8cbbfb3d4047dbf4", + "sha256:fcb251305fa24a490b6a9ee2180e5f8252915fb778d3dafc70f9cc3f863827b9" + ], + "index": "pypi", + "version": "==1.3.24" + }, + "typing-extensions": { + "hashes": [ + "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", + "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" + ], + "markers": "python_version >= '3.8'", + "version": "==4.13.2" + }, + "werkzeug": { + "hashes": [ + "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8", + "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c" + ], + "index": "pypi", + "version": "==2.0.3" + }, + "zipp": { + "hashes": [ + "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", + "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29" + ], + "markers": "python_version < '3.10'", + "version": "==3.20.2" + } + } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..20fd999 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# ClassClockAPI + +![Docker Pulls](https://img.shields.io/docker/pulls/moralcode/classclockapi) + +This is the backend that provides access to the ClassClock database. + +## Environment Variables + +| Environment Variable | Default | Purpose | +| ------------- | ------------- | ------------- | +| DB_USERNAME | no default. this value is required | The username of the user to connect to the database with | +| DB_PASSWORD | no default. this value is required | The password of the user to connect to the database with | +| DB_HOST | `localhost` | the hostname where the database is located | +| DB_NAME | `classclock` | the name of the database to use if it is different | +| DB_CONNECTION_URL | constructed based on the above values | Allows the SQLAlchemy connection string to be manually set | +| AUTH0_DOMAIN | no default | The Auth0 api domain i.e. `yourapp.auth0.com` | +| API_IDENTIFIER | no default | Your Auth0 api identifier. This may be your API domain name. i.e. `https://api.yourdomain.com` | +| AUTH0_CLIENT_ID | no default | Your Auth0 Client ID | +| AUTH0_CLIENT_SECRET | no default | Your Auth0 Client Secret | +| SENTRY_DSN | no default | The dsn URL from the sentry.io setup in case you wish to set up error monitoring | +| TRUSTED_PROXY_COUNT | no default | The number of proxies that are in between users and the app itself. Setting this too high can create security problems. Setting too low can cause rate limiting to not work. see [here](https://flask-limiter.readthedocs.io/en/stable/recipes.html#deploying-an-application-behind-a-proxy) for what this is used for | + + +## First time Setup + +1. Prepare an empty database and have its configuration information handy (login, hostname/port, db name .etc) +2. Install all dependencies (including dev dependencies) using `pipenv install -d` +3. set up a `.env` file with your database configuration settings from earlier +4. create the db by running `pipenv run python3 createdb.py`. add the `--demo` flag to createdb if you want to include demo data. + +After this you should be ready to run the API. + +## Docker + +This API has been set up to run in a docker container. + +The simplest way to run it is to: +1. aquire the container either from a docker repository or by running `docker build . -t classclock-api` in a cloned version of this repo +2. set up the environment variables you want per the above table +3. use a command like `docker run -p 8000:8000 --rm -it --env-file dev.env classclock-api:latest` to run the container interactively using `dev.env` as the source of the environment variables. The app will start up on port 8000. + +## Contributing + +If you are interested in making changes to the ClassClock API, see the [CONTRIBUTING](./CONTRIBUTING.md) file for details on how to do so. \ No newline at end of file diff --git a/api.py b/api.py index 54b3aab..d325136 100644 --- a/api.py +++ b/api.py @@ -1,73 +1,61 @@ -from flask import Flask +from flask import Flask, render_template import logging -from versions import v0 +from blueprints import v0, main +from docs import create_docs from flask_limiter import Limiter -from flasgger import Swagger -from common.helpers import get_request_origin_identifier +from flask_limiter.util import get_remote_address +from common.helpers import make_error_object, respond from common.db_schema import db +from common.schemas import * +from auth import db_connection_string +from flask_migrate import Migrate +from werkzeug.middleware.proxy_fix import ProxyFix + from os import environ as env -app = Flask(__name__) -limiter = Limiter(app, default_limits=[ - "25/hour", "5/minute"], key_func=get_request_origin_identifier, headers_enabled=True) +if env.get("SENTRY_DSN"): + # app.logger.info("Detected Sentry DSN, setting up sentry...") + + import sentry_sdk + sentry_sdk.init( + dsn=env.get("SENTRY_DSN"), + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + # We recommend adjusting this value in production. + traces_sample_rate=1.0 + ) + +def create_app(config_filename=None): + app = Flask(__name__) + if env.get("TRUSTED_PROXY_COUNT"): + app.logger.info("Detected value for TRUSTED_PROXY_COUNT, setting up ProxyFix..." + env.get("TRUSTED_PROXY_COUNT")) + + # for example if the request goes through one proxy + # before hitting your application server + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=int(env.get("TRUSTED_PROXY_COUNT") or 0)) + + if config_filename: + app.config.from_pyfile(config_filename) + + limiter = Limiter(get_remote_address, app=app, default_limits=[ + "500/hour", "100/minute"], headers_enabled=True) -app.register_blueprint(v0.blueprint, url_prefix='/v0') + app.register_blueprint(v0.blueprint, url_prefix='/v0') + app.register_blueprint(main.main_pages) + app.config.update(SQLALCHEMY_DATABASE_URI=db_connection_string,DEBUG=True, SQLALCHEMY_TRACK_MODIFICATIONS=False) + db.init_app(app) + migrate = Migrate(app, db) -swagger = Swagger(app, config={ - "headers": [ - ], - "specs": [ - { - "endpoint": 'apispec_v1', - "route": '/apispec_v1.json', - "rule_filter": lambda rule: True, # all in - "model_filter": lambda tag: True, # all in - } - ], - "static_url_path": "/flasgger_static", - # "static_folder": "static", # must be set by user - "swagger_ui": True, - "specs_route": "/docs/" -}, - template={ + create_docs(app) - "info": { - "title": "ClassClock API", - "version": "0.1", - "description": "The first beta development version of the ClassClock API", - "contact": { - # "responsibleOrganization": "ME", - # "responsibleDeveloper": "Me", - # "email": "me@me.com", - # "url": "www.me.com", - }, - "termsOfService": "http://me.com/terms", - }, - "servers": [ - { - "url": "https://api.classclock.app/", - "description": "ClassClock API Server" - }, - { - "url": "https://localhost:5000/", - "description": "Dev server" - } - ], - # "host": "api.classclock.app", # overrides localhost:500 - # "basePath": "/v0", # base bash for blueprint registration - "schemes": [ - "https" - ] -}) + return app -app.config.update( - SQLALCHEMY_DATABASE_URI='mysql+mysqlconnector://{user}:{pw}@{url}/{db}'.format(user=env.get("DB_USERNAME"), pw=env.get("DB_PASSWORD"), url=env.get("DB_HOST"), db="classclock"), DEBUG=True, SQLALCHEMY_TRACK_MODIFICATIONS=False) -db.init_app(app) if __name__ == "__main__": - app.run() + create_app().run() else: + app=create_app() gunicorn_logger = logging.getLogger('gunicorn.error') app.logger.handlers = gunicorn_logger.handlers app.logger.setLevel(gunicorn_logger.level) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..38436ed --- /dev/null +++ b/auth.py @@ -0,0 +1,79 @@ + + +import os, sys, getpass +from os import environ as env + +#default values are only needed for some of these +db_username = env.get("DB_USERNAME") +db_password = env.get("DB_PASSWORD") +db_host = os.getenv("DB_HOST", "localhost") +db_name = os.getenv("DB_NAME", "classclock") + +connection_url = f'mysql+mysqlconnector://{db_username}:{db_password}@{db_host}/{db_name}?charset=utf8mb4&collation=utf8mb4_general_ci' +db_connection_string = os.getenv("DB_CONNECTION_URL", connection_url) + +db_connection_string = os.getenv("DATABASE_URL", connection_url) + +# elif sys.argv[1] == "demo": +# # https://stackoverflow.com/a/46541219 +# with app.app_context(): +# from common.db_schema import * +# import datetime +# import time + + +# def today_plus(sch, num_days): +# return BellScheduleDate(school_id = sch, date=datetime.date.today() + datetime.timedelta(days=num_days)) + +# s = School(owner_id="1234567890", full_name="Demonstration High School", acronym="DHS") + + +# sc1 = BellSchedule( +# school_id = s.id, +# name="Even Day", +# display_name= "Even", +# dates=[ +# today_plus(s.id, 0), +# today_plus(s.id, 2), +# today_plus(s.id, 4) +# ] +# ) + +# sc2 = BellSchedule( +# school_id = s.id, +# name="Odd Day", +# display_name="Odd", +# dates=[ +# today_plus(s.id, 1), +# today_plus(s.id, 3) +# ] +# ) + + +# s.schedules = [sc1,sc2] + + +# sc1.meeting_times = [ +# BellScheduleMeetingTime( +# bell_schedule_id =sc1.id, +# name="First Period", +# start_time=datetime.time(hour=8, minute=25, second=0, microsecond=0), +# end_time=datetime.time(9,25) +# ) +# ] + +# sc2.meeting_times = [ +# BellScheduleMeetingTime( +# bell_schedule_id =sc2.id, +# name="First Period", +# start_time=datetime.time(8,45), +# end_time=datetime.time(9,45) +# ) + +# ] + +# db.session.add(s) +# db.session.commit() +# print("Done.") +# exit(0) + \ No newline at end of file diff --git a/versions/__init__.py b/blueprints/__init__.py similarity index 100% rename from versions/__init__.py rename to blueprints/__init__.py diff --git a/blueprints/main.py b/blueprints/main.py new file mode 100644 index 0000000..ea23271 --- /dev/null +++ b/blueprints/main.py @@ -0,0 +1,20 @@ +from flask import Blueprint, render_template, abort +from jinja2 import TemplateNotFound +from werkzeug.exceptions import HTTPException + + +main_pages = Blueprint('main_pages', __name__, + template_folder='templates') + +@main_pages.route("/", methods=['GET']) +def home(): + return render_template('home.html') + + +@main_pages.errorhandler(HTTPException) +def handle_HTTP_error(e): + return respond( + make_error_object( + e.code, title=e.name, message=e.description), + code=e.code + ) \ No newline at end of file diff --git a/blueprints/v0.py b/blueprints/v0.py new file mode 100644 index 0000000..6fe37fc --- /dev/null +++ b/blueprints/v0.py @@ -0,0 +1,611 @@ +import uuid +import datetime +from flask_limiter import util +from flask import current_app, json +from os import environ as env + +from flask_limiter.util import get_remote_address +from common.helpers import respond +from flask import Blueprint, abort, jsonify, request +from werkzeug.exceptions import HTTPException +from flask_cors import CORS +from marshmallow.exceptions import ValidationError + +# from bson import json_util +# from bson.objectid import ObjectId +import http.client +from common.db_schema import School as SchoolDB, db, BellSchedule as BellScheduleDB +from sqlalchemy import create_engine + +from common.helpers import * +from common.constants import APIScopes, HTTP_DATE_FORMAT +from common.schemas import SchoolSchema, BellScheduleSchema +from common.services import auth0management +import common.exceptions + +# +# App Setup +# + + +DB_HOST = env.get("DB_HOST") +DB_USERNAME = env.get("DB_USERNAME") +DB_PASSWORD = env.get("DB_PASSWORD") + + +blueprint = Blueprint('v0', __name__) + +flex_url = "http://localhost:3000" if env.get("FLASK_ENV") == 'development' else "classclock-*-moralcode.vercel.app" + + +CORS(blueprint, origins=["https://web.classclock.app", "https://beta.web.classclock.app", flex_url], allow_headers=[ + "Accept", "Authorization", "Content-Type"]) + # supports_credentials=True) + + +# @blueprint.route("/schools", methods=['GET']) +# @cross_origin(headers=["Content-Type", "Authorization"]) +# @cross_origin(headers=["Access-Control-Allow-Origin", "http://localhost:5000"]) +# @requires_auth +# def get_schools(): +# """Get a list of every publicly accessible ClassClock school +# --- +# responses: +# '200': +# description: A list of every publicly accessible ClassClock school +# content: +# application/json: +# ... +# '400': +# description: Unauthorized for some reason such as an invalid access token or incorrect scopes +# """ + + +# @blueprint.route("/school/", methods=['GET']) +# @cross_origin(headers=["Content-Type", "Authorization"]) +# @cross_origin(headers=["Access-Control-Allow-Origin", "http://localhost:5000"]) +# @requires_auth +# def get_school_by_id(identifier): +# """ +# Get information about a single school +# --- +# parameters: +# - name: identifier +# in: path +# type: string +# required: true +# description: the hexadecimal identifier of the school you are requesting +# responses: +# 200: +# description: data for a single school +# '400': +# description: Unauthorized for some reason such as an invalid access token or incorrect scopes +# default: +# description: error payload + +# """ + +@blueprint.route("/ping", strict_slashes=False, methods=['GET']) +def ping(): + """ Returns the text "pong" as a connectivity check + --- + responses: + 200: + description: the text "pong" + """ + return "pong" + +# TODO: add a search parameter +@blueprint.route("/schools", strict_slashes=False, methods=['GET']) +@check_headers +def list_schools(): + """ Returns a list of schools + --- + responses: + 200: + description: A list of schools + content: + application/json: + schema: + $ref: '#/definitions/School' + """ + + school_list = [] + schools = SchoolDB.query.filter_by(soft_deleted=False).all() + + return respond(SchoolSchema(exclude=('soft_deleted',)).dump(schools, many=True)) + + +@blueprint.route("/school/", strict_slashes=False, methods=['GET']) +@check_headers +def get_school(school_id): + """ Returns a single school + --- + responses: + 200: + description: A single school object + schema: + $ref: '#/definitions/School' + parameters: + - in: path + name: school_id + schema: + type: string + length: 32 + required: true + - in: header + name: If-Modified-Since + schema: + type: string + format: date + required: false + """ + + school = SchoolDB.query.filter_by(id=school_id, soft_deleted=False).first() + #double check this + if school is None: + raise Oops("No school was found with the specified id.", + 404, title="Resource Not Found") + + if 'If-Modified-Since' in request.headers: + since = datetime.strptime(request.headers.get('If-Modified-Since'), HTTP_DATE_FORMAT) + # TODO: make this a more robust check + if school.last_modified == since: + return respond(code=304) #Not Modified + + return respond(SchoolSchema(exclude=('soft_deleted',)).dump(school)) + + +@blueprint.route("/school", strict_slashes=False, methods=['POST']) +@check_headers +@requires_auth(permissions=[APIScopes.CREATE_SCHOOL]) +@requires_admin +def create_school(): + """ Creates a new school + --- + parameters: + - name: school + in: body + type: object + schema: + $ref: '#/definitions/School' + security: + - ApiKeyAuth: [] + responses: + 200: + description: A list of schools + schema: + $ref: '#/definitions/School' + """ + # if len(list_owned_school_ids()) > 0: + # raise Oops( + # "Authorizing user is already the owner of another school", 401) + + data = get_request_body(request) + + if data is None: + raise Oops("Invalid or non-JSON request body provided.", 400) + + new_object = None + try: + #Numbers, booleans, strings, and ``None`` are considered invalid input to `Schema.load + new_object = SchoolSchema().load(data, session=db.session) + except ValidationError as err: + # print(err.messages) # => {"email": ['"foo" is not a valid email address.']} + # print(err.valid_data) + return respond(err.messages, code=400) + + # if new_object.errors != {}: + # return handle_marshmallow_errors(new_object.errors) + + db.session.add(new_object) + db.session.commit() + + #TODO: need to verify that the insert worked? + + return respond(SchoolSchema(exclude=('soft_deleted',)).dump(new_object)) + + +@blueprint.route("/school/", strict_slashes=False, methods=['PATCH']) +@check_headers +@requires_auth(permissions=[APIScopes.EDIT_SCHOOL]) +@requires_admin +def update_school(school_id): + """ + updates a school + --- + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: school_id + schema: + type: string + length: 32 + required: true + - in: body + name: school + schema: + $ref: '#/definitions/School' + required: true + """ + + data = get_request_body(request) + + # if new_object.errors != {}: + # return handle_marshmallow_errors(new_object.errors) + + school = SchoolDB.query.filter_by(id=school_id, soft_deleted=False).first() + + if school is None: + raise Oops("No records could be updated because none were found", + 404, title="No Records Found") + else: + check_ownership(school) + + + # check modification times + # this needs to happen after the school is retreived from the DB for comparison + if 'If-Unmodified-Since' in request.headers: + since = datetime.datetime.strptime(request.headers.get('If-Unmodified-Since'), HTTP_DATE_FORMAT) + trap_object_modified_since(school.last_modified, since) + + + try: + updated_school = SchoolSchema().load(data, session=db.session, instance=school) + except ValidationError as err: + # print(err.messages) # => {"email": ['"foo" is not a valid email address.']} + # print(err.valid_data) + return respond(err.messages, code=400) + + db.session.commit() + #TODO: need to verify that the update worked? + + return respond(SchoolSchema(exclude=('soft_deleted',)).dump(school)) + + +@blueprint.route("/school/", strict_slashes=False, methods=['DELETE']) +@check_headers +@requires_auth(permissions=[APIScopes.DELETE_SCHOOL, APIScopes.DELETE_BELL_SCHEDULE]) +@requires_admin +def delete_school(school_id): + """ + deletes a school + --- + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: school_id + schema: + type: string + length: 32 + required: true + - in: header + name: If-Unmodified-Since + schema: + type: string + format: date + required: false + + """ + + school = SchoolDB.query.filter_by(id=school_id, soft_deleted=False).first() + if school is None: + raise Oops("No records could be deleted because none were found", + 404, title="No Records Found") + else: + check_ownership(school) + + # check modification times + # this needs to happen after the school is retreived from the DB for comparison + if 'If-Unmodified-Since' in request.headers: + since = datetime.datetime.strptime(request.headers.get('If-Unmodified-Since'), HTTP_DATE_FORMAT) + trap_object_modified_since(school.last_modified, since) + + db.session.delete(school) + db.session.commit() + # should this just archive the school? or delete it and all related records? + # sqlalchemy can be set to cascade deletes (i think). + return None, 204 + +@blueprint.route("/bellschedules", strict_slashes=False, methods=['GET']) +@check_headers +@requires_auth#(permissions=[APIScopes.DELETE_SCHOOL, APIScopes.DELETE_BELL_SCHEDULE]) +@requires_admin +def list_owned_bellschedules(): + """ + gets a list of bell schedules that are part of schools that the current user owns + --- + security: + - ApiKeyAuth: [] + responses: + 200: + description: A list of bell schedules + schema: + $ref: '#/definitions/BellSchedule' + """ + #if get_api_user_id() not in school.owner_id + + schedules = BellScheduleDB.query.join(BellScheduleDB.school).filter(SchoolDB.owner_id==get_api_user_id(), SchoolDB.soft_deleted==False, BellScheduleDB.soft_deleted==False) + + return respond(BellScheduleSchema(exclude=('school_id','soft_deleted')).dump(schedules, many=True)) + +#TODO: add filtering for return values to reduce size of response. i.e. filter dates by after today, exclude meeting times if they havent changed +@blueprint.route("/bellschedules/", strict_slashes=False, methods=['GET']) +@check_headers +def list_bellschedules(school_id): + """ + gets a list of bell schedules + --- + parameters: + - in: path + name: school_id + schema: + type: string + length: 32 + required: true + responses: + 200: + description: A list of bell schedules + schema: + $ref: '#/definitions/BellSchedule' + + """ + + schedules = BellScheduleDB.query.filter_by(school_id=school_id, soft_deleted=False) + + excluded_fields = exclude_unless_logged_in(['internal_description']) + excluded_fields.extend(('school_id',)) + + return respond(BellScheduleSchema(exclude=excluded_fields).dump(schedules, many=True)) + +@blueprint.route("/bellschedule/", strict_slashes=False, methods=['GET']) +@check_headers +def get_bellschedule(bell_schedule_id): + """ + gets a single bell schedule + --- + parameters: + - in: path + name: bell_schedule_id + schema: + type: string + length: 32 + required: true + - in: header + name: If-Modified-Since + schema: + type: string + format: date + required: false + responses: + 200: + description: A single of bell schedule + schema: + $ref: '#/definitions/BellSchedule' + """ + + schedule = BellScheduleDB.query.filter_by( + id=bell_schedule_id, soft_deleted=False).first() + + #double check this + if schedule is None: + raise Oops("No bell schedule was found with the specified id.", + 404, title="Resource Not Found") + + if 'If-Modified-Since' in request.headers: + since = datetime.strptime(request.headers.get('If-Modified-Since'), HTTP_DATE_FORMAT) + # TODO: make this a more robust check + if schedule.last_modified == since: + return respond(code=304) #Not Modified + + excluded_fields = exclude_unless_logged_in(['internal_description']) + excluded_fields.extend(('soft_deleted',)) + + return respond(BellScheduleSchema(exclude=excluded_fields).dump(schedule)) + + +@blueprint.route("/bellschedule", strict_slashes=False, methods=['POST']) +@check_headers +@requires_auth(permissions=[APIScopes.CREATE_BELL_SCHEDULE]) +@requires_admin +def create_bellschedule(): + """ + Create a new bell schedule + --- + security: + - ApiKeyAuth: [] + parameters: + - in: body + name: schedule + schema: + $ref: '#/definitions/BellSchedule' + required: true + """ + request_data = get_request_body(request) + school_id= request_data["school_id"] + # get school_id from a data parameter + school = SchoolDB.query.filter_by(id=school_id).first() + check_ownership(school) + + new_schedule = BellScheduleSchema().load(request_data, session=db.session) + + school.schedules.append(new_schedule) + + db.session.commit() + + return respond(BellScheduleSchema(exclude=('school_id','soft_deleted')).dump(new_schedule)) + + +@blueprint.route("/bellschedule/", strict_slashes=False, methods=['PATCH']) +@check_headers +@requires_auth(permissions=[APIScopes.EDIT_BELL_SCHEDULE]) +@requires_admin +def update_bellschedule(bell_schedule_id): + """ + Updates a bell schedule + --- + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: bell_schedule_id + schema: + type: string + length: 32 + required: true + - in: body + name: schedule + schema: + $ref: '#/definitions/BellSchedule' + required: true + - in: header + name: If-Unmodified-Since + schema: + type: string + format: date + required: false + """ + + schedule = BellScheduleDB.query.filter_by(id=bell_schedule_id).first() + school = SchoolDB.query.filter_by(id=schedule.school_id).first() + if school is not None: + check_ownership(school) + + if 'If-Unmodified-Since' in request.headers: + since = datetime.datetime.strptime(request.headers.get('If-Unmodified-Since'), HTTP_DATE_FORMAT) + trap_object_modified_since(school.last_modified, since) + + data = get_request_body(request) + # remove ID from request body if provided because for some reason, the exclude parameter isnt working or may not be correctly getting passed down to the nested/plucked fields + if data['id']: + del data['id'] + + try: + updated_schedule = BellScheduleSchema(exclude=('id', 'creation_date')).load( + data, session=db.session, instance=schedule) + except ValidationError as err: + # print(err.messages) # => {"email": ['"foo" is not a valid email address.']} + # print(err.valid_data) + return respond(err.messages, code=400) + + db.session.commit() + + return respond(BellScheduleSchema(exclude=('school_id','soft_deleted')).dump(schedule)) + + +@blueprint.route("/bellschedule/", methods=['DELETE']) +@check_headers +@requires_auth(permissions=[APIScopes.DELETE_BELL_SCHEDULE]) +@requires_admin +def delete_bellschedule(bell_schedule_id): + """ + deletes a bell schedule + --- + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: bell_schedule_id + schema: + type: string + length: 32 + required: true + - in: header + name: If-Unmodified-Since + schema: + type: string + format: date + required: false + """ + + schedule = BellScheduleDB.query.filter_by(id=bell_schedule_id, soft_deleted=False).first() + school = SchoolDB.query.filter_by(id=schedule.school_id).first() + if school is not None: + check_ownership(school) + + if 'If-Unmodified-Since' in request.headers: + since = datetime.datetime.strptime(request.headers.get('If-Unmodified-Since'), HTTP_DATE_FORMAT) + trap_object_modified_since(school.last_modified, since) + + schedule.soft_deleted = True + # db.session.delete(schedule) + db.session.commit() + + return respond("success", code=204) + +# +# Routes +# + + +# register_api(api, School, "v0", name_of_optional_param="school_id") + +# register_api(api, BellSchedule, "v0", url_prefix="/school/", +# name_of_optional_param="bell_schedule_id") + + + +@blueprint.before_request +def before(): + current_app.logger.info(request.method + " " + request.path) + + +@blueprint.after_request +def after_request(response): + response.headers['Content-Type'] = 'application/json' + if response.status_code != 200: + current_app.logger.info( "Handled request with HTTP status: " + str(response.status_code)) + + if response.status_code > 399: + current_app.logger.info(str(response.get_data())) + return response + +# +# +# Error Handler Section +# +# + +# override default rate limit exceeded error and return a JSON response instead +# https://flask-limiter.readthedocs.io/en/stable/#custom-rate-limit-exceeded-responses +@blueprint.errorhandler(429) +def ratelimit_handler(e): + current_app.logger.warning(e) + current_app.logger.warning("User at address " + get_remote_address() + "exceeded rate limit of " + e.description) + return respond( + make_error_object(429, title="Ratelimit Exceeded", + message="ratelimit of " + e.description + " exceeded"), + code=429 + ) + + +@blueprint.errorhandler(AuthError) +def handle_auth_error(e): + return respond( + make_error_object(e.status_code, message=e.error), code=e.status_code + ) + + +@blueprint.errorhandler(Oops) +def handle_error(e): + if e.title is not None: + return respond( + make_error_object(e.status_code, message=e.message, title=e.title), code=e.status_code + ) + else: + return respond( + make_error_object(e.status_code, message=e.message), code=e.status_code + ) + + + +# @blueprint.errorhandler(Exception) +# def generic_exception_handler(e): +# # "We're sorry, but the electrons that were tasked with handling your request became terribly misguided and forgot what it is that they were supposed to be doing. Our team of scientists in the Electron Amnesia Recovery Ward is currently nursing them back to health; if you have any information about what it is these electrons were supposed to be doing at the time of this incident, please contact the maintainer of this service." +# print("an exception occurred") +# print(e) +# return respond( +# make_error_object(500), code=500, headers={'Content-Type': 'application/json'} +# ) diff --git a/common/constants.py b/common/constants.py index 392453c..6d7ca89 100644 --- a/common/constants.py +++ b/common/constants.py @@ -1,5 +1,10 @@ from enum import Enum +API_DATATYPE ='application/json' + +API_DATATYPE_HEADER = {'Content-Type': API_DATATYPE} + +HTTP_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' class AuthType(Enum): TOKEN = "Bearer" diff --git a/common/convert.py b/common/convert.py deleted file mode 100644 index 764a31f..0000000 --- a/common/convert.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Customized Marshmallow-SQLAlchemy ModelConverter to combine Related and Relationship fields. -""" - - -import marshmallow_sqlalchemy - -from common.helpers import camel_to_delimiter_separated -from common.fields import Relationship - - -class ModelConverter(marshmallow_sqlalchemy.ModelConverter): - """ Customize SQLAlchemy Model Converter to use JSON API Relationships. """ - - # property2field will convert the Relationship to a List field if the prop.direction.name in - # this mapping returns True. Hack to work around that since this mapping is only used there. - # https://github.com/marshmallow-code/marshmallow-sqlalchemy/blob/af8304a33bfc11468d9ddb6c96e183964806d637/marshmallow_sqlalchemy/convert.py#L131 - DIRECTION_MAPPING = { - 'MANYTOONE': False, - 'MANYTOMANY': False, - 'ONETOMANY': False, - } - - def _get_field_class_for_property(self, prop): - """ Use our Relationship field type for SQLAlchemy relations instead. """ - if hasattr(prop, 'direction'): - return Relationship - return super()._get_field_class_for_property(prop) - - def _add_relationship_kwargs(self, kwargs, prop): - """ Customize the kwargs for Relationship field based on prop. """ - # super()._add_relationship_kwargs(kwargs, prop) - - # All Schema names should be based on Model name. - kwargs['schema'] = prop.mapper.class_.__name__ + 'Schema' - # If the relation uses a list then the Relationship is many - kwargs['many'] = prop.uselist - # JSONAPI type is calculated from Model name to kebab-case. - kwargs['type_'] = camel_to_delimiter_separated(prop.mapper.class_.__name__, glue='-') - # Attribute of the model for this relationship. - kwargs['attribute'] = prop.key - # self.schema_cls is not an instance of the class so we need to use the options directly. - # Name of the relationship on parent model to use for constructing relationship URLs. - kwargs['relationship_name'] = self.schema_cls.opts.inflect(prop.key) - # The relationship's URLs will be calculated from the parent schema's self URL. - kwargs['parent_self_url'] = self.schema_cls.opts.self_url - # Relationship URLs will use the same kwargs as the parent schema. - kwargs['self_url_kwargs'] = self.schema_cls.opts.self_url_kwargs - # Store the model of the schema_cls so the relationship knows everything necessary for the - # endpoint. - kwargs['parent_model'] = self.schema_cls.opts.model diff --git a/common/db_schema.py b/common/db_schema.py index 8c67d65..c0bf213 100644 --- a/common/db_schema.py +++ b/common/db_schema.py @@ -18,12 +18,11 @@ class School(db.Model): description: A School """ __tablename__ = "schools" - type="school" - id = db.Column('school_id', HashColumn(length=16), + id = db.Column('school_id', HashColumn(length=32), primary_key=True, default=get_uuid) owner_id = db.Column('owner_id', db.VARCHAR(length=35)) full_name = db.Column('school_name', db.VARCHAR(length=75)) - schedules = db.relationship("BellSchedule") + schedules = db.relationship("BellSchedule",backref=db.backref("school")) acronym = db.Column( 'school_acronym', db.VARCHAR(length=75), nullable=True) alternate_freeperiod_name = db.Column( @@ -32,24 +31,26 @@ class School(db.Model): default=datetime.utcnow()) last_modified = db.Column('last_modified', db.DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow) + soft_deleted = db.Column('soft_deleted', db.Boolean, nullable=False, default=False) class BellSchedule(db.Model): """ description: A BellSchedule """ __tablename__ = "bellschedules" - type = "bellschedule" - id = db.Column('bell_schedule_id', HashColumn(length=16), + id = db.Column('bell_schedule_id', HashColumn(length=32), primary_key=True, default=get_uuid) - school_id = db.Column(HashColumn(length=16), ForeignKey(School.id)) - name = db.Column('bell_schedule_name', db.VARCHAR(length=75)) - dates = db.relationship("BellScheduleDate") - #meetingtimes = db.relationship("BellScheduleMeetingTime") + school_id = db.Column(HashColumn(length=32), ForeignKey(School.id)) + full_name = db.Column('bell_schedule_name', db.VARCHAR(length=75)) + meeting_times = db.relationship("BellScheduleMeetingTime", cascade="save-update, merge,delete, delete-orphan") display_name = db.Column('bell_schedule_display_name', db.VARCHAR(length=75)) + audience = db.Column('audience', db.VARCHAR(length=75), nullable=False, server_default="everyone") + internal_description = db.Column('internal_description', db.VARCHAR(length=250), nullable=False, server_default="") creation_date = db.Column('creation_date', db.DateTime, default=datetime.utcnow()) last_modified = db.Column('last_modified', db.DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow) + soft_deleted = db.Column('soft_deleted', db.Boolean, nullable=False, default=False) def get_uri(self, blueprint_name): # here the second time blueprint_name is called, it is acting like the api version number @@ -62,12 +63,14 @@ class BellScheduleDate(db.Model): description: A date during which a particular bell schedule is in effect """ __tablename__ = "bellscheduledates" - type = "bellscheduledate" - id = db.Column('bell_schedule_id', HashColumn(length=16), ForeignKey(BellSchedule.id), primary_key=True) - school_id = db.Column(HashColumn(length=16), ForeignKey(School.id)) + bell_schedule_id = db.Column('bell_schedule_id', HashColumn(length=32), ForeignKey(BellSchedule.id), primary_key=True) + # school_id = db.Column(HashColumn(length=32), ForeignKey(School.id)) date = db.Column('date', db.Date, primary_key=True) creation_date = db.Column('creation_date', db.DateTime, default=datetime.today().isoformat()) + # This needs to be here because of he way that dates are updated. Since date entries are deleted and recreated instead of being modified, we need to also mark them for deletion when they are de-associated from the bell schedule. + # See: https://stackoverflow.com/a/23734727 + bellSchedule = db.relationship("BellSchedule", backref=db.backref("dates",cascade="save-update, merge,delete, delete-orphan")) # def get_uri(self, blueprint_name): @@ -81,9 +84,8 @@ class BellScheduleMeetingTime(db.Model): description: A meeting time for a particular bell schedule (aka a class period) """ __tablename__ = "bellschedulemeetingtimes" - type = "bellschedulemeetingtime" - schedule_id = db.Column(HashColumn(length=16), ForeignKey(BellSchedule.id), primary_key=True) - school_id = db.Column(HashColumn(length=16), ForeignKey(School.id)) + bell_schedule_id = db.Column(HashColumn(length=32), ForeignKey(BellSchedule.id), primary_key=True) + # school_id = db.Column(HashColumn(length=32), ForeignKey(School.id)) name = db.Column('classperiod_name', db.VARCHAR(length=75), primary_key=True) start_time = db.Column('start_time', db.Time, diff --git a/common/exceptions.py b/common/exceptions.py index 077b25e..ff5cbcf 100644 --- a/common/exceptions.py +++ b/common/exceptions.py @@ -1,31 +1,3 @@ -""" -Custom exceptions for our marshmallow -""" -from marshmallow_jsonapi.exceptions import IncorrectTypeError - -class ForbiddenIdError(IncorrectTypeError): - """ - Raised when a client attempts to provide an id when creating a resource. Special case so we can - return the correct response code. - """ - pointer = '/data/id' - default_message = '`data` object must not include `id` key.' - - -class MismatchIdError(IncorrectTypeError): - """ - Raised when a client provides an id that doesn't match the request. Special case so we can - return the correct response code. - """ - pointer = '/data/id' - default_message = 'Mismatched id. Expected "{expected}".' - - -class NullPrimaryData(Exception): - """ Raised by Schema.unwrap_request when the primary data object is null/None. """ - pass - - # Format error response and append status code. class AuthError(Exception): diff --git a/common/fields.py b/common/fields.py deleted file mode 100644 index c19d800..0000000 --- a/common/fields.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Customized Fields for Our Marshmallow. -""" - -from marshmallow.base import FieldABC -# Make marshmallow core and jsonapi fields importable from ourmarshmallow -from marshmallow.fields import * # pylint: disable=wildcard-import,unused-wildcard-import -import marshmallow_jsonapi.fields -import marshmallow_sqlalchemy.fields - - -class MetaData(marshmallow_jsonapi.fields.ResourceMeta): #update - """ Per-field metadata wrapper. Turns another field into MetaData. - Based on marshmallow.fields.List """ - def __init__(self, cls_or_instance, **kwargs): - # Force dump_only=True. - kwargs['dump_only'] = True - # Since we don't always know what the attribute is for this field, and Meta sets the - # load_from to '_meta' it is difficult to load this value so don't. - - super().__init__(**kwargs) - - if isinstance(cls_or_instance, type): - if not issubclass(cls_or_instance, FieldABC): - raise ValueError('The type of the metadata elements must be a subclass of ' - 'marshmallow.base.FieldABC') - self.container = cls_or_instance() - else: - if not isinstance(cls_or_instance, FieldABC): - raise ValueError('The instances of the metadata elements must be of type ' - 'marshmallow.base.FieldABC') - self.container = cls_or_instance - - def _serialize(self, value, attr, obj): # pylint: disable=arguments-differ - return {self.dump_to or attr: self.container._serialize(value, attr, obj)} # pylint: disable=protected-access - - -class Relationship(marshmallow_jsonapi.fields.Relationship, marshmallow_sqlalchemy.fields.Related): - """ - Combine the marshmallow-jsonapi.fields.Relationship with marshmallow-sqlalchemy.fields.Related. - """ - def __init__(self, parent_self_url='', relationship_name='', parent_model=None, **kwargs): - """ - :param str parent_self_url: Used to calculate self_url and related_url from a parent schema. - :param str relationship_name: Name of this relationship for self_url and related_url. - :param models.bases.BaseModel parent_model: Model Class of Schema containing this field. - """ - # Calculate our relationship URLs beased on the parent schema's self_url - if parent_self_url and relationship_name and kwargs['self_url_kwargs']: - kwargs['self_url'] = '{0}/relationships/{1}'.format(parent_self_url, relationship_name) - kwargs['related_url'] = '{0}/{1}'.format(parent_self_url, relationship_name) - kwargs['related_url_kwargs'] = kwargs['self_url_kwargs'] - - # Set the class of the model for the schema containing this field. - self.parent_model = parent_model - - super().__init__(**kwargs) diff --git a/common/guid.py b/common/guid.py index fa25853..65736c2 100644 --- a/common/guid.py +++ b/common/guid.py @@ -1,13 +1,37 @@ from __future__ import absolute_import -# import uuid +import uuid +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy import types, func -class HashColumn(types.VARCHAR): +#https://docs.sqlalchemy.org/en/13/core/custom_types.html#backend-agnostic-guid-type +class HashColumn(types.TypeDecorator): + impl=types.CHAR - def bind_expression(self, bindvalue): - # convert the bind's type from Hex string to binary - return func.UNHEX(bindvalue) + def load_dialect_impl(self, dialect): + if dialect.name == 'postgresql': + return dialect.type_descriptor(UUID()) + else: + return dialect.type_descriptor(types.BINARY(16)) - def column_expression(self, col): - # convert select value from binary to hex String - return func.HEX(col) + + def process_bind_param(self, value, dialect): + if value is None: + return value + elif dialect.name == 'postgresql': + return uuid.UUID(value).bytes + else: + if not isinstance(value, uuid.UUID): + return uuid.UUID(value).bytes + else: + # bytes + return value.bytes + + def process_result_value(self, value, dialect): + if value is None: + return value + else: + return uuid.UUID(bytes=value).hex + + # This is a shallow copy and is provided to fulfill part of the TypeEngine contract. It usually does not need to be overridden unless the user-defined TypeDecorator has local state that should be deep-copied. + # def copy(self, **kw): + # return HashColumn(32) \ No newline at end of file diff --git a/common/helpers.py b/common/helpers.py index 822a5d8..b5d8aeb 100644 --- a/common/helpers.py +++ b/common/helpers.py @@ -1,4 +1,4 @@ -from flask import _request_ctx_stack, request, url_for, make_response, jsonify +from flask import _request_ctx_stack, request, url_for, make_response, jsonify, current_app from werkzeug.wrappers import Response from functools import wraps from jose import jwt @@ -7,17 +7,15 @@ from os import environ as env import json from uuid import UUID, uuid4 -from datetime import datetime +from datetime import datetime, time from common.services import auth0management import flask_limiter import re import marshmallow -import marshmallow_jsonapi -import marshmallow_jsonapi.flask import marshmallow_sqlalchemy -from common.constants import AuthType +from common.constants import AuthType, API_DATATYPE_HEADER, API_DATATYPE from common.db_schema import db from common.exceptions import Oops, AuthError @@ -26,12 +24,20 @@ API_IDENTIFIER = env.get("API_IDENTIFIER") ALGORITHMS = ["RS256"] -management_API = auth0management.Auth0ManagementService() +try: + #TODO: remove dependency on setting up auth0 to test the API + management_API = auth0management.Auth0ManagementService() +except Exception as e: + current_app.logger.error(e) + #TODO: need to implement better logging. + current_app.logger.warning('Auth0 is not configured correctly. Access control for requests will not be enforced.') + management_API = None + class JSONEncoder(json.JSONEncoder): # this was copied from https://github.com/miLibris/flask-rest-jsonapi/blob/ad3f90f81955fa41aaf0fb8c49a75a5fbe334f5f/flask_rest_jsonapi/utils.py under the terms of the MIT license. def default(self, obj): - if isinstance(obj, datetime): + if isinstance(obj, (datetime, time)): return obj.isoformat() elif isinstance(obj, UUID): return obj.hex @@ -60,26 +66,6 @@ def is_client_error(code): def is_server_error(code): return code >= 500 and code <= 599 -def new_patch_val(body_val, db_val): - if body_val is not None and body_val != db_val: - return body_val - return db_val - -#https: // github.com/rgant/saas-api-boilerplate/blob/d1599716eb77b4994781b465fec27c91f8721cb5/common/utilities.py # L16 -def camel_to_delimiter_separated(name, glue='_'): - """ - Convert CamelCase to a delimiter-separated naming convention. Snake_case by default. - :param str name: CamelCase name to convert - :param str glue: Delimiter to use, default is an underscore for snake_case. - :return str: delimiter-separated version of name - """ - # From https://stackoverflow.com/a/1176023 - first_cap_re = re.compile('(.)([A-Z][a-z]+)') - all_cap_re = re.compile('([a-z0-9])([A-Z])') - replacement = fr'\1{glue}\2' - ex = first_cap_re.sub(replacement, name) - return all_cap_re.sub(replacement, ex).lower() - def register_api(api, resource, api_version, name_of_optional_param='id', type_of_optional_param='string', url_prefix=""): name = resource.__name__.lower() url = "/" + name + "/" @@ -109,8 +95,8 @@ def register_api(api, resource, api_version, name_of_optional_param='id', type_o ) -def make_jsonapi_error_object(code, error_id=None, title=None, message=None): - """ Generates a JSON:API error response +def make_error_object(code, error_id=None, title=None, message=None): + """ Generates an error response object Arguments: code {number} -- The HTTP status code to return for the error; both through HTTP and in the JSON response. @@ -120,7 +106,7 @@ def make_jsonapi_error_object(code, error_id=None, title=None, message=None): message {string} -- An optional message giving more details about the error (default: {None}) Returns: - A flask Response object for the web server + an error JSON object """ error_data = {'status': str(code)} @@ -137,11 +123,11 @@ def make_jsonapi_error_object(code, error_id=None, title=None, message=None): return error_data -def make_jsonapi_response(response_data=None, code=None, headers={}): - """ Forms a Flask-and-JSON:API-compatible JSON response +def respond(response_data=None, code=200, headers=API_DATATYPE_HEADER): + """ Forms the data into a JSON response Arguments: - response_data {dict} -- The jsonapi object dict to return in the JSON response + response_data {dict} -- The object dict to return in the JSON response Keyword Arguments: code {number} -- The optional HTTP status code to return with the response (used for errors) (default: {None}) @@ -152,7 +138,6 @@ def make_jsonapi_response(response_data=None, code=None, headers={}): """ content = {} - content["jsonapi"] = {"version": "1.0"} if code is not None and (is_client_error(code) or is_server_error(code)): # error @@ -160,80 +145,30 @@ def make_jsonapi_response(response_data=None, code=None, headers={}): else: content["data"] = response_data + #TODO: handle if response_data is none (i.e. in case of 304 not modified) if code is None: return make_response(json.dumps(content, cls=JSONEncoder), headers) else: return make_response(json.dumps(content, cls=JSONEncoder), code, headers) -def J(*args, **kwargs): - """Wrapper around jsonify that sets the Content-Type of the response to - application/vnd.api+json. - """ - response = jsonify(*args, **kwargs) - response.mimetype = "application/vnd.api+json" - return response - -def filter_dict(dict, filter, is_whitelist=True): - return {key: val for key, val in dict.items() if ((key in filter) if is_whitelist else (key not in filter))} - -def make_jsonapi_links_object(**kwargs): - """Creates a JSON:API "links object" from a dict of data - Returns: - dict -- A links object dict with contents formatted per the JSON:API spec - """ - links_object = {} - for link_name in kwargs: - links_object[link_name] = kwargs[link_name] - - return links_object - - -def make_jsonapi_resource_object(resource, attributes_schema, blueprint_name): - """Creates a JSON:API "resource object" from a dict of data - - Arguments: - data_dict {dict} -- The data to create the resource object from - data_domain {string} -- A string describing what the data in data_dict represents (i.e. "school", "schedule", etc.) - uri_function_name_mappings {dict} -- A mapping of the keys of identifiers in data_dict to the name of the function whose route should be used to generate URI's for responses - TODO: maybe make uri_function_name_mappings an enum or something - Raises: - e: A Key Error if the data_dict somehow does not contain an "id" field. should never happen - - Returns: - dict -- A resource object dict with contents formatted per the JSON:API spec - """ - resource_object = {} - resource_object["type"] = resource.type - resource_object["id"] = resource.id - - resource_object["links"] = make_jsonapi_links_object( - self=resource.get_uri(blueprint_name)) - - resource_object["attributes"] = attributes_schema.dump(resource) - - if resource.type == "bellschedule": - resource_object["relationships"] = {} - resource_object["relationships"]["schools"] = {} - resource_object["relationships"]["schools"]["links"] = make_jsonapi_links_object(self=url_for( - blueprint_name + "." + blueprint_name + "_single_school", school_id=resource.school_id.hex, _external=True)) - return resource_object +def trap_object_modified_since(obj_last_modification, since): + """ checks the If-Modified-Since header checks it to ensure that there is no data loss + :type obj_last_modification: datetime + :param obj_last_modification: the last modification time of the object being checked + :type since: datetime + :param since: the value of the header -def deconstruct_resource_object(resource_object): - """extracts a more processable dict from a JSON:API "resource object" + :raises: Oops - Arguments: - resource_object {dict} -- a dict in JSON:API format - - Returns: - dict -- a flatter dict for easier processing + :rtype: None """ - resource = {} - resource["type"] = resource_object.get("type", None) - resource["id"] = UUID(resource_object.get("id", uuid4().hex)) + if since > datetime.now(): + raise Oops("The date provided to the If-Modified-Since header cannot be in the future", 412, title="No Future Modification Dates") - return {**resource, **resource_object["attributes"]} + if since < obj_last_modification: + raise Oops("The resource you are trying to change has been modified elsewhere", 412, title="Resource has been Modified") def handle_marshmallow_errors(errors): @@ -247,16 +182,18 @@ def handle_marshmallow_errors(errors): message = input_error[:-1] + " provided to " + \ property_name + " of type " + value_type - error = make_jsonapi_error_object( + error = make_error_object( 400, title="Validation failure", message=message) error_list.append(error) return error_list, 400 -def get_request_origin_identifier(): - user_id_parts = get_api_user_id().split("|") - return flask_limiter.util.get_remote_address() + get_api_client_id() + user_id_parts[1] if len(user_id_parts) > 2 else "" +def get_request_body(request): + """ + provides a central place to modify the data in the request body + """ + return request.get_json() def get_api_client_id(): @@ -273,6 +210,7 @@ def get_api_client_id(): def get_api_user_id(): """Returns the id of the user for whom data is being accessed on behalf of + This is only set for requests to endpoints using the @requires_auth decorator """ if hasattr(_request_ctx_stack.top, 'current_user'): raw_id = _request_ctx_stack.top.current_user["sub"] @@ -284,6 +222,13 @@ def get_api_user_id(): return "" +def exclude_unless_logged_in(fields: list): + is_admin = check_for_roles(["admin", "school admin"]) + if is_admin: + return [] + else: + return fields + def get_token_auth_header(): return get_valid_auth_header_of_type(AuthType.TOKEN) @@ -308,48 +253,65 @@ def get_valid_auth_header_of_type(auth_header_type): return parts[1] # return the value provided with the token - -def has_permission(permission): - """Raises an AuthError if the specified scope is not present +def check_permissions(user, permissions_to_check): + """Raises an AuthError if the specified scopes are not present Arguments: - scope {string} -- The scope to check + scope {string[]} -- A list of scopes to check Raises: AuthError: An authentication error """ - return - if not permission in _request_ctx_stack.top.current_user.permissions: +# _request_ctx_stack.top.current_user + perms_not_present = [] + for perm in permissions_to_check: + if perm.value not in user['permissions']: + perms_not_present.append(perm) + if perms_not_present != []: + perms_needed = " ".join(perm.value for perm in perms_not_present) raise AuthError( - "You do not have the necessary permission (" + permission.value + ") to access to this resource", 403) + "You have not been granted the necessary permissions to access to this resource. You are missing the following permissions: " + perms_needed, 403) +def check_for_roles(roles:list, accept_any=True): + """Performs a simple, stupid, name-based check against the roles that a user has. -def check_permissions(permissions_to_check): - """Raises an AuthError if the specified scopes are not present + This must be used after the @requires_auth decorator is applied - Arguments: - scope {string[]} -- A list of scopes to check + Args: + roles (list): a list of names of roles to check + accept_any (boolean): True to accept ANY of the provided roles. False to accept only ALL provided roles. Defaults to True. - Raises: - AuthError: An authentication error + Returns: + bool: true if the user has any of the roles provided, false if the user has none of them, and None if there is no currently authenticated user """ - for perm in permissions_to_check: - has_permission(perm) - - -def check_for_role(role): user_id = get_api_user_id() + #TODO: make management API optional and check if it is present + + if management_API is None: + #TODO: need to implement better logging. + current_app.logger.warning("Because Auth0 is not configured correctly, access control is not being enforced. All requests to check a users role will automatically pass.") + return True + if user_id != "": - return role in management_API.get_roles_for_user(user_id) + roles_json = management_API.get_roles_for_user(user_id) + # current_app.logger.info(roles_json) + user_role_names = [r["name"].lower() for r in roles_json] + user_role_names = set(user_role_names) + requested_roles = set([r.lower() for r in roles]) + + required_threshold = 1 if accept_any else len(requested_roles) + + return len(requested_roles & user_role_names) >= required_threshold else: return None def check_ownership(school): if get_api_user_id() not in school.owner_id: - raise Oops("Authorizing user is not the owner of this school", 401) + raise Oops("Authorizing user does not have permission to access the requested school", 401) def list_owned_school_ids(cursor, school_id): + #TODO: remove manual SQL, also this is like horrifically broken cursor.execute( "SELECT UNHEX(school_id) as id FROM schools WHERE owner_id=%s", (school_id,)) # dict_keys_map defines the keys for the dictionary that is generated from the tuples returned from the database (so order matters) @@ -358,105 +320,6 @@ def list_owned_school_ids(cursor, school_id): return [sch_id[0] for sch_id in cursor] -# from https://stackoverflow.com/a/3675423 - - -def replace_last(source_string, replace_what, replace_with): - """Replaces only the last occurrence of a substring in a source string with a different string - - Arguments: - source_string {string} -- The string perform the search on - replace_what {string} -- The string to search for in the source string - replace_with {string} -- The string to replace the search string for - - Returns: - string -- The source string with the last occurrence of the replacement string replaces with the search string - """ - head, _sep, tail = source_string.rpartition(replace_what) - return head + replace_with + tail - - -def make_dict(the_tuple, keys): - """Creates a dict from a pair of tuples of equal length - - Arguments: - the_tuple {tuple} -- A tuple containing the data/values for the resulting dict - keys {tuple (or maybe list)} -- A tuple containing the keys for the resulting dict - - Returns: - A dict containing the data from both inputs - """ - the_dict = {} - for index, value in enumerate(the_tuple): - key = keys[index] - the_dict[key] = value - - return the_dict - - -def time_from_delta(delta): - # print(type(delta)) - # print(type((datetime.min + delta))) - return (datetime.min + delta).time().isoformat('minutes') - - -def extract_valid_credentials(encoded_credentials): - """Extracts a username and password from a base64 encoded HTTP Authorization header - - Arguments: - encoded_credentials {string} -- The raw/encoded HTTP Authorization header value - - Raises: - Oops: A general Exception - Oops: A general Exception - - Returns: - list -- A list containing the decoded credentials in the form of [username, password] - """ - try: - decoded = base64.b64decode( - encoded_credentials).decode("utf-8").split(":") - except: - raise Oops("An error occured while decoding the credentials", 401) - - if len(decoded) != 2: - raise Oops("credentials must not contain the ':' character", 401) - - return decoded - - -def get_comma_separated_string(the_list): - """creates a string of comma-sepatated values from an input list - - Arguments: - dictionary {[type]} -- [description] - """ - output = "" - - for index, value in enumerate(the_list): - output += value - - if index < len(the_list)-1: - output += ", " - return output - - -def build_sql_column_update_list(input_object, updateable_fields_schema, colname_mappings): - - sql_set = [] - values = () - - json = updateable_fields_schema.dump(input_object).data - # print(json) - for field in json: - if json[field] is not None: - sql_set.append( - (colname_mappings[field] - if field in colname_mappings else field) + "=%s" - ) - values += (json[field],) - - return get_comma_separated_string(sql_set), values # @@ -464,53 +327,74 @@ def build_sql_column_update_list(input_object, updateable_fields_schema, colname # -def requires_auth(f): +def requires_auth(_func=None, *, permissions=None): """Determines if the access token is valid """ - @wraps(f) - def decorated(*args, **kwargs): - token = get_token_auth_header() - jsonurl = urlopen("https://"+AUTH0_DOMAIN+"/.well-known/jwks.json") - jwks = json.loads(jsonurl.read()) - try: - unverified_header = jwt.get_unverified_header(token) - except jwt.JWTError: - raise AuthError( - "Invalid token. Use an RS256 signed JWT Access Token", 401) - if unverified_header["alg"] == "HS256": - raise AuthError( - "Invalid token algorithm. Use an RS256 signed JWT Access Token", 401) - rsa_key = {} - for key in jwks["keys"]: - if key["kid"] == unverified_header["kid"]: - rsa_key = { - "kty": key["kty"], - "kid": key["kid"], - "use": key["use"], - "n": key["n"], - "e": key["e"] - } - if rsa_key: + # https://realpython.com/primer-on-python-decorators/#decorators-with-arguments + def args_or_no(func): + @wraps(func) + def decorated(*args, **kwargs): + if not management_API: + #TODO: need to implement better logging. + current_app.logger.warning("Because Auth0 is not configured correctly, access control is not being enforced. All requests to protected endpoints will automatically succeed.") + return func(*args, **kwargs) + + token = get_token_auth_header() + jsonurl = urlopen("https://"+AUTH0_DOMAIN+"/.well-known/jwks.json") + jwks = json.loads(jsonurl.read()) try: - payload = jwt.decode( - token, - rsa_key, - algorithms=ALGORITHMS, - audience=API_IDENTIFIER, - issuer="https://"+AUTH0_DOMAIN+"/" - ) - except jwt.ExpiredSignatureError: - raise AuthError("Token has expired", 401) - except jwt.JWTClaimsError: + unverified_header = jwt.get_unverified_header(token) + except jwt.JWTError: + raise AuthError( + "Invalid token. Use an RS256 signed JWT Access Token", 401) + if unverified_header["alg"] == "HS256": raise AuthError( - "Incorrect JWT claims. Please check the audience and issuer", 401) - except Exception: - raise AuthError("Unable to parse authentication token.", 401) + "Invalid token algorithm. Use an RS256 signed JWT Access Token", 401) + rsa_key = {} + for key in jwks["keys"]: + if key["kid"] == unverified_header["kid"]: + rsa_key = { + "kty": key["kty"], + "kid": key["kid"], + "use": key["use"], + "n": key["n"], + "e": key["e"] + } + if rsa_key: + try: + payload = jwt.decode( + token, + rsa_key, + algorithms=ALGORITHMS, + audience=API_IDENTIFIER, + issuer="https://"+AUTH0_DOMAIN+"/" + ) + except jwt.ExpiredSignatureError: + raise AuthError("Token has expired", 401) + except jwt.JWTClaimsError: + raise AuthError( + "Incorrect JWT claims. Please check the audience and issuer", 401) + except Exception: + raise AuthError("Unable to parse authentication token.", 401) + + _request_ctx_stack.top.current_user = payload + + current_app.logger.info( "Successfully authenticated user '" + get_api_user_id() + "'" ) + + #this permissions check was added separately from the auth0 validation code + if permissions is not None: + check_permissions(payload, permissions) + + return func(*args, **kwargs) + + raise AuthError("Unable to find appropriate key", 401) + return decorated + + if _func is None: + return args_or_no + else: + return args_or_no(_func) - _request_ctx_stack.top.current_user = payload - return f(*args, **kwargs) - raise AuthError("Unable to find appropriate key", 401) - return decorated def requires_admin(f): @@ -519,7 +403,7 @@ def requires_admin(f): @wraps(f) def decorated(*args, **kwargs): - is_admin = check_for_role("admin") + is_admin = check_for_roles(["admin", "school admin"]) if is_admin is None: raise Oops("There must be a user signed in to perform this action", 400, title="No User Authorization") @@ -532,26 +416,26 @@ def decorated(*args, **kwargs): # decorator modified from https://github.com/miLibris/flask-rest-jsonapi/blob/ad3f90f81955fa41aaf0fb8c49a75a5fbe334f5f/flask_rest_jsonapi/decorators.py def check_headers(func): - """Check headers according to jsonapi reference + """decorator that provides a place to check headers :param callable func: the function to decorate :return callable: the wrapped function """ @wraps(func) def wrapper(*args, **kwargs): if request.method in ('POST', 'PATCH', 'PUT'): - if 'Content-Type' in request.headers and request.headers['Content-Type'] != 'application/vnd.api+json': - - error = make_jsonapi_error_object( - message='Content-Type header must be application/vnd.api+json', title='Invalid request header', code=415) - return make_jsonapi_response(response_data=error, code=415, headers={'Content-Type': 'application/vnd.api+json'}) + if 'Content-Type' in request.headers and request.headers['Content-Type'] != API_DATATYPE: + error = make_error_object( + message='Content-Type header must be ' + API_DATATYPE, title='Invalid request header', code=415) + return respond(response_data=error, code=415) if 'Accept' in request.headers: for accept in request.headers['Accept'].split(','): - if accept.strip() != 'application/vnd.api+json': - - error = make_jsonapi_error_object( - message='Accept header must be application/vnd.api+json without media type parameters', title='Invalid request header', code=406) - return make_jsonapi_response(response_data=error, code=406, headers={'Content-Type': 'application/vnd.api+json'}) + if accept.strip() != API_DATATYPE: + #this will error if any of the accept headers is not correct... + #TODO + error = make_error_object( + message='Accept header must be ' + API_DATATYPE, title='Invalid request header', code=406) + return respond(response_data=error, code=406) return func(*args, **kwargs) diff --git a/common/schemas.py b/common/schemas.py index bfb324f..46e5d23 100644 --- a/common/schemas.py +++ b/common/schemas.py @@ -2,222 +2,71 @@ Customized Marshmallow-SQLAlchemy and Marshmallow-JSONAPI Schemas to combine Schema Meta data. """ import marshmallow as ma -import marshmallow_jsonapi -import marshmallow_sqlalchemy +from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field +from marshmallow_sqlalchemy.fields import Nested +from marshmallow.fields import Pluck -from common.helpers import camel_to_delimiter_separated from common.db_schema import db -from .convert import ModelConverter -from .exceptions import ForbiddenIdError, MismatchIdError, NullPrimaryData -from .fields import MetaData from common.db_schema import BellSchedule, BellScheduleMeetingTime, School, BellScheduleDate -class SchemaOpts(marshmallow_jsonapi.SchemaOpts, marshmallow_sqlalchemy.ModelSchemaOpts): # pylint: disable=too-few-public-methods - """ Combine JSON API Schema Opts with SQLAlchemy Schema Opts. - This fixes the error: AttributeError: 'SchemaOpts' object has no attribute 'model_converter """ - def __init__(self, meta, *args, **kwargs): - """ - Forces strict=True. - Forces type_ to kebab-case of self.opts.model.__name__ for JSONAPI recommendation. - Sets up default self_url and kwargs if model option is set. - Sets up default self_url_many if model and listable are truthy. - """ - # Does Resource support a read list endpoint? Default False. Needed for Swagger. - self.listable = getattr(meta, 'listable', False) +# Modified From https://github.com/marshmallow-code/marshmallow-sqlalchemy/commit/cf996b1f448d9b115b083489c8eb96be3bf1dd40#diff-7e28a06588f9d4acda3f3dd4224899afR136 +class SessionPluck(Pluck): + """Pluck field that inherits the session from its parent like Nested does.""" - # TODO: ROB 20170726 Check status of github.com/marshmallow-code/marshmallow/issues/377 - # Force strict by default until ticket is resolved. - meta.strict = True + def _deserialize(self, *args, **kwargs): + if hasattr(self.schema, "session"): + try: + self.schema.session = self.root.session + except AttributeError: + # Marshmallow 2.0.0 has no root property. + self.schema.session = self.parent.session + return super(SessionPluck, self)._deserialize(*args, **kwargs) - # When the base Schema below is first initialized there won't be a model element. - model = getattr(meta, 'model', None) - if model: - # Automatically set the JSONAPI type (marshmallow-jsonapi) based on model name. - # JSONAPI recommends kebab-case for naming: http://jsonapi.org/recommendations/#naming - type_ = camel_to_delimiter_separated( - model.__name__, glue='-') - meta.type_ = type_ - - # Self URLs are always based on the JSONAPI type and the model id - meta.self_url = f'/{type_}/{{id}}' - meta.self_url_kwargs = {'id': ''} - # Always include the many url for resource creation at least - meta.self_url_many = f'/{type_}s' - - # Use our custom ModelConverter to turn SQLAlchemy relations into JSONAPI Relationships. - meta.model_converter = ModelConverter - - def dasherize(text): - """ - Convert snake_case field names to jsonapi kebab-case. - :param str text: field name - :return str: kebab-case name - """ - return text.replace('_', '-') - meta.inflect = dasherize - - super().__init__(meta, *args, **kwargs) - - -class Schema(marshmallow_jsonapi.Schema, marshmallow_sqlalchemy.ModelSchema): - """ Set custom options class to combine JSONAPI and SQLAlchemy. Set strict option by default. - Configure DB connection on init. """ - OPTIONS_CLASS = SchemaOpts - - # id must be a string: http://jsonapi.org/format/#document-resource-object-identification - id = marshmallow_jsonapi.fields.String(length=16) # pylint: disable=invalid-name - # This field is read only, place in meta data: http://jsonapi.org/format/#document-meta - modified_at = MetaData(marshmallow_jsonapi.fields.DateTime()) - - def __init__(self, *args, **kwargs): - """ - Combine the inits for marshmallow_jsonapi.Schema, marshmallow_sqlalchemy.ModelSchema. - Forces session=db.connect(). - """ - # control if unwrap_item checks for the id field in the object. - self.load_existing = True - - # Each instance of the schema should have the current session with the DB - # (marshmallow-sqlschema). This must be done on instance init, not on class creation! - - # kwargs['session'] = db.connect() - - super().__init__(*args, **kwargs) - - @classmethod - def field_for(cls, field_name): - """ - Get a marshmallow field object for an attribute of this schema. - :param str field_name: Name of attribute to return the marshmallow.fields for. - :return ourmarshmallow.fields.Field: Subclass of Field for attribute of schema class. - """ - # _declared_fields is set by the metaclass marshmallow.schema.SchemaMeta - ret = cls._declared_fields[field_name] # pylint: disable=no-member - # ret._add_to_schema(field_name, cls) - return ret - - @ma.post_load(pass_many=True) - def make_instance(self, data, many): # pylint: disable=arguments-differ - """ - Deserialize data to instances of the model. Update an existing if specified in - `self.instance` or loaded by primary key(s) in the data; else create a new model. - :param data: Data to deserialize. - :param many: Does data represent many models. - :return models.BaseModel or list(models.BaseModel): Instance(s) of model(s) updated with - data. - """ - if many: - return [super().make_instance(each) for each in data] - return super().make_instance(data) - - def unwrap_item(self, item): - """ - If the schema has an existing instance the id field must be set. - :raises ValidationError: id field isn't present when required. - """ - id_in_item = 'id' in item - if self.load_existing and not id_in_item: - # Updating Resources must include type and id keys - # http://jsonapi.org/format/1.1/#crud-updating - raise ma.ValidationError([{'detail': '`data` object must include `id` key.', - 'source': {'pointer': '/data'}}]) - - if not self.load_existing and id_in_item: - # Don't support client side identifier generation at this time. - raise ForbiddenIdError() - - return super().unwrap_item(item) - - @ma.pre_load(pass_many=True) - def unwrap_request(self, data, many): - if 'data' not in data: - raise ma.ValidationError('Object must include `data` key.') - - data = data['data'] - data_is_collection = ma.utils.is_collection(data) - if many: - if not data_is_collection: - raise ma.ValidationError([{'detail': '`data` object must be a collection.', - 'source': {'pointer': '/data'}}]) - - return [self.unwrap_item(each) for each in data] - - if data_is_collection: - raise ma.ValidationError([ - {'detail': '`data` object must be an object, not a collection.', - 'source': {'pointer': '/data'}}]) - - if data is None: - # When updating relationships we need to specially handle the primary data being null. - # http://jsonapi.org/format/1.1/#crud-updating-to-one-relationships - raise NullPrimaryData() - - return self.unwrap_item(data) - - @ma.validates('id') - def validate_id(self, value): - """ - If the schema has an existing instance the id value must match id. Use custom errors so we - can generate to the correct source.pointer and response code. - :param int value: identifier from payload. - :raises MismatchIdError: id field doesn't match self.instance. - """ - if self.instance and self.instance.id != value: - raise MismatchIdError(actual=value, expected=self.instance.id) - - -class SchoolSchema(Schema): +class SchoolSchema(SQLAlchemyAutoSchema): class Meta: model = School - include_relationships = True + include_relationships = False load_instance = True - include_fk = True + include_fk = False + + id = auto_field(dump_only=True) + creation_date = auto_field(dump_only=True) + last_modified = auto_field(dump_only=True) - # Marshmallow-JSONAPI - type_ = model.__name__.lower() - self_view = type_ + '_detail' - self_view_kwargs = {'id': ''} - self_view_many = type_ + '_list' - -class BellScheduleDateSchema(Schema): +class BellScheduleDateSchema(SQLAlchemyAutoSchema): class Meta: model = BellScheduleDate - include_relationships = True + include_relationships = False load_instance = True - include_fk = True + include_fk = False + + creation_date = auto_field(dump_only=True) - # Marshmallow-JSONAPI - type_ = model.__name__.lower() - self_view = type_ + '_detail' - self_view_kwargs = {'id': ''} - self_view_many = type_ + '_list' -class BellScheduleMeetingTimeSchema(Schema): +class BellScheduleMeetingTimeSchema(SQLAlchemyAutoSchema): class Meta: model = BellScheduleMeetingTime - include_relationships = True + include_relationships = False load_instance = True include_fk = True + + creation_date = auto_field(dump_only=True) - # Marshmallow-JSONAPI - type_ = model.__name__.lower() - self_view = type_ + '_detail' - self_view_kwargs = {'id': ''} - self_view_many = type_ + '_list' - -class BellScheduleSchema(Schema): +class BellScheduleSchema(SQLAlchemyAutoSchema): class Meta: model = BellSchedule include_relationships = True load_instance = True include_fk = True - - # Marshmallow-JSONAPI - type_ = model.__name__.lower() - self_view = type_ + '_detail' - self_view_kwargs = {'id': ''} - self_view_many = type_ + '_list' + + id = auto_field(dump_only=True) + full_name = auto_field(data_key="name") + creation_date = auto_field(dump_only=True) + last_modified = auto_field(dump_only=True) + + classes = Nested(BellScheduleMeetingTimeSchema, exclude=("bell_schedule_id", "creation_date"), many=True) + dates = SessionPluck(BellScheduleDateSchema, "date", many=True) diff --git a/common/services/auth0management.py b/common/services/auth0management.py index 9b1ed46..22adce6 100644 --- a/common/services/auth0management.py +++ b/common/services/auth0management.py @@ -1,49 +1,62 @@ -import http.client +import requests import json from os import environ as env - +import logging class Auth0ManagementService: base_path = "/api/v2" + base_url = "https://" + env.get("AUTH0_DOMAIN") + base_path headers = {"Content-Type": "application/json"} def __init__(self): - self.conn = http.client.HTTPSConnection("classclock.auth0.com") self.access_token = self.get_token() def get_user(self, user_id): - url = Auth0ManagementService.base_path + "/users/" + user_id + url = Auth0ManagementService.base_url + "/users/" + user_id heads = {**Auth0ManagementService.headers, ** {"Authorization": "Bearer " + self.access_token}} - self.conn.request("GET", url, "", heads) - - res = self.conn.getresponse() - data = res.read() + resp = requests.get(url, headers=heads) - return data.decode("utf-8") + data = resp.json() + return data # print(access_token) def get_roles_for_user(self, user_id): - url = Auth0ManagementService.base_path + "/users/" + user_id + "/roles" + url = Auth0ManagementService.base_url + "/users/" + user_id + "/roles" heads = {**Auth0ManagementService.headers, ** {"Authorization": "Bearer " + self.access_token}} - self.conn.request("GET", url, "", heads) + resp = requests.get(url, headers=heads) + data = resp.json() + if isinstance(data, list): + return data + elif (data.get("statusCode") == 401 and "xpired token" in data.get("message")): + self.access_token = self.get_token() + return self.get_roles_for_user(user_id) + else: + logging.error("encountered unexpected auth0 management API response") + logging.error(data) + return [] - res = self.conn.getresponse() - data = res.read() - - return data.decode("utf-8") def get_token(self): - payload = "{\"client_id\": \"" + env.get("AUTH0_CLIENT_ID") + "\" ,\"client_secret\": \"" + env.get( - "AUTH0_CLIENT_SECRET") + "\" ,\"audience\": \"https://classclock.auth0.com/api/v2/\",\"grant_type\": \"client_credentials\"}" - - self.conn.request("POST", "/oauth/token", payload, - Auth0ManagementService.headers) - res = self.conn.getresponse() - data = res.read() - return json.loads(data.decode("utf-8"))["access_token"] + # TODO: use the auth0 python SDK/lib for this https://github.com/auth0/auth0-python#management-sdk + # missing the trailing slash on the audience can cause problems: https://community.auth0.com/t/getting-service-not-enabled-within-domain-when-requesting-an-api-token/12634 + payload = { + "client_id": env.get("AUTH0_CLIENT_ID"), + "client_secret": env.get("AUTH0_CLIENT_SECRET"), + "audience": self.base_url + "/", + "grant_type": "client_credentials" + } + resp = requests.post("https://" + env.get("AUTH0_DOMAIN") + "/oauth/token", data=json.dumps(payload), headers=Auth0ManagementService.headers) + + data = resp.json() + if data.get("error"): + logging.error("failed to get auth0 management API access token") + logging.error(data) + return "" + else: + return data["access_token"] diff --git a/createdb.py b/createdb.py new file mode 100755 index 0000000..3c3e99e --- /dev/null +++ b/createdb.py @@ -0,0 +1,86 @@ +from common.db_schema import db +import argparse +from api import create_app +from flask_migrate import stamp + +parser = argparse.ArgumentParser(description='Set up a fresh ClassClock database.') +parser.add_argument('--demo', action='store_true', + help='Add some demo data to the database after creation') + +args = parser.parse_args() + + + +print("Beginning database creation...") +with create_app().app_context(): + print("Creating tables...") + db.create_all() + print("Committing...") + db.session.commit() + + + # then, load the migration configuration and generate the + # version table, "stamping" it with the most recent rev: + print("Stamping db version for future upgrades...") + stamp() + print("Done creating DB.") + + if args.demo: + print("Loading Demo Data...") + from common.db_schema import * + import datetime + import time + + + def today_plus(sch, num_days): + return BellScheduleDate(date=datetime.date.today() + datetime.timedelta(days=num_days)) + + s = School(owner_id="1234567890", full_name="Demonstration High School", acronym="DHS") + + + sc1 = BellSchedule( + full_name="Even Day", + display_name= "Even", + dates=[ + today_plus(s.id, 0), + today_plus(s.id, 2), + today_plus(s.id, 4) + ] + ) + + sc2 = BellSchedule( + full_name="Odd Day", + display_name="Odd", + dates=[ + today_plus(s.id, 1), + today_plus(s.id, 3) + ] + ) + + + s.schedules = [sc1,sc2] + + + sc1.meeting_times = [ + BellScheduleMeetingTime( + bell_schedule_id =sc1.id, + name="First Period", + start_time=datetime.time(hour=8, minute=25, second=0, microsecond=0), + end_time=datetime.time(9,25) + ) + ] + + sc2.meeting_times = [ + BellScheduleMeetingTime( + bell_schedule_id =sc2.id, + name="First Period", + start_time=datetime.time(8,45), + end_time=datetime.time(9,45) + ) + + ] + + db.session.add(s) + db.session.commit() + print("Done loading demo data.") + print("Done") \ No newline at end of file diff --git a/docs.py b/docs.py new file mode 100644 index 0000000..39314be --- /dev/null +++ b/docs.py @@ -0,0 +1,91 @@ +from flask import Blueprint, render_template, abort +from jinja2 import TemplateNotFound +from flasgger import APISpec, Schema, Swagger, fields +from apispec.ext.marshmallow import MarshmallowPlugin +from apispec_webframeworks.flask import FlaskPlugin +from common.schemas import * +from blueprints.v0 import * + + + +# docs = Blueprint('docs_pages', __name__, ) + +def create_docs(app): + + # Create an APISpec + # info: { + spec = APISpec( + title="ClassClock API", + version="0.3.3", + openapi_version='2.0', + + plugins=[ + FlaskPlugin(), + MarshmallowPlugin(), + ], + # options= { + # "description": "The development version of the ClassClock API", + # "servers": [ + # { + # "url": "https://api.classclock.app/v0", + # "description": "ClassClock API Server" + # }, + # { + # "url": "https://localhost:8000/v0", + # "description": "Dev server" + # } + # ], + # "externalDocs": { + # "description": "This API might loosely follow the JSON:API specofocation", + # "url": "https://jsonapi.org" + # }#, + # "contact": { + # # "responsibleOrganization": "ME", + # # "responsibleDeveloper": "Me", + # # "email": "me@me.com", + # # "url": "www.me.com", + # }, + # "termsOfService": "http://me.com/terms", + # } + ) + + + template = spec.to_flasgger( + app, + definitions=[SchoolSchema, BellScheduleSchema], + paths=[get_school, get_bellschedule ] + ) + + + # config={ + # "headers": [ + # ], + # "specs": [ + # { + # "endpoint": 'apispec_v1', + # "route": '/apispec_v1.json', + # "rule_filter": lambda rule: True, # all in + # "model_filter": lambda tag: True, # all in + # } + # ], + # "static_url_path": "/flasgger_static", + # # "static_folder": "static", # must be set by user + # "swagger_ui": True, + # # "specs_route": "/v0/docs/", + # "basePath": "/docs", + # "host": "api.classclock.app", # overrides localhost:500 + # "schemes": [ + # "https" + # ], + # "securityDefinitions": { + # "ApiKeyAuth": { + # "type": "apiKey", + # "in": "header", + # "name": "Authentication" + # } + # } + # }, + + # template["basePath"] = "/docs" + swagger = Swagger(app, template=template) + return swagger diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..b313750 --- /dev/null +++ b/fly.toml @@ -0,0 +1,22 @@ +# fly.toml app configuration file generated for classclockapi on 2023-04-25T11:44:51-04:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = "classclockapi" +primary_region = "den" + +swap_size_mb = 512 + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + +[checks] + [checks.alive] + type = "tcp" + interval = "15s" + timeout = "2s" + grace_period = "5s" 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/env.py b/migrations/env.py new file mode 100644 index 0000000..68feded --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,91 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.get_engine().url).replace( + '%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() 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/2399c50496f7_add_soft_deletion.py b/migrations/versions/2399c50496f7_add_soft_deletion.py new file mode 100644 index 0000000..afd745b --- /dev/null +++ b/migrations/versions/2399c50496f7_add_soft_deletion.py @@ -0,0 +1,30 @@ +"""Add Soft Deletion + +Revision ID: 2399c50496f7 +Revises: 93d6649210e1 +Create Date: 2022-06-05 20:40:27.591102 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '2399c50496f7' +down_revision = '93d6649210e1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('bellschedules', sa.Column('soft_deleted', sa.Boolean(), nullable=False, default=False)) + op.add_column('schools', sa.Column('soft_deleted', sa.Boolean(), nullable=False, default=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('schools', 'soft_deleted') + op.drop_column('bellschedules', 'soft_deleted') + # ### end Alembic commands ### diff --git a/migrations/versions/93d6649210e1_initial_migration.py b/migrations/versions/93d6649210e1_initial_migration.py new file mode 100644 index 0000000..87e0be1 --- /dev/null +++ b/migrations/versions/93d6649210e1_initial_migration.py @@ -0,0 +1,88 @@ +"""Initial migration. + +Revision ID: 93d6649210e1 +Revises: +Create Date: 2022-06-05 20:34:41.432858 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '93d6649210e1' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('bellscheduledates', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=True) + op.drop_constraint('bellscheduledates_FK', 'bellscheduledates', type_='foreignkey') + op.drop_column('bellscheduledates', 'school_id') + op.alter_column('bellschedulemeetingtimes', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=True) + op.drop_constraint('bellschedulemeetingtimes_FK', 'bellschedulemeetingtimes', type_='foreignkey') + op.drop_column('bellschedulemeetingtimes', 'school_id') + op.alter_column('bellschedules', 'bell_schedule_name', + existing_type=mysql.VARCHAR(charset='latin1', length=60), + nullable=True) + op.alter_column('bellschedules', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=True) + op.alter_column('bellschedules', 'last_modified', + existing_type=mysql.DATETIME(), + nullable=True) + op.alter_column('schools', 'owner_id', + existing_type=mysql.VARCHAR(length=35), + nullable=True) + op.alter_column('schools', 'school_name', + existing_type=mysql.VARCHAR(charset='latin1', length=75), + nullable=True) + op.alter_column('schools', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=True) + op.alter_column('schools', 'last_modified', + existing_type=mysql.DATETIME(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('schools', 'last_modified', + existing_type=mysql.DATETIME(), + nullable=False) + op.alter_column('schools', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=False) + op.alter_column('schools', 'school_name', + existing_type=mysql.VARCHAR(charset='latin1', length=75), + nullable=False) + op.alter_column('schools', 'owner_id', + existing_type=mysql.VARCHAR(length=35), + nullable=False) + op.alter_column('bellschedules', 'last_modified', + existing_type=mysql.DATETIME(), + nullable=False) + op.alter_column('bellschedules', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=False) + op.alter_column('bellschedules', 'bell_schedule_name', + existing_type=mysql.VARCHAR(charset='latin1', length=60), + nullable=False) + op.add_column('bellschedulemeetingtimes', sa.Column('school_id', sa.BINARY(length=16), nullable=False)) + op.create_foreign_key('bellschedulemeetingtimes_FK', 'bellschedulemeetingtimes', 'schools', ['school_id'], ['school_id']) + op.alter_column('bellschedulemeetingtimes', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=False) + op.add_column('bellscheduledates', sa.Column('school_id', sa.BINARY(length=16), nullable=False)) + op.create_foreign_key('bellscheduledates_FK', 'bellscheduledates', 'schools', ['school_id'], ['school_id']) + op.alter_column('bellscheduledates', 'creation_date', + existing_type=mysql.DATETIME(), + nullable=False) + # ### end Alembic commands ### diff --git a/migrations/versions/9c3dd7f95cdc_add_audience_and_internal_description_.py b/migrations/versions/9c3dd7f95cdc_add_audience_and_internal_description_.py new file mode 100644 index 0000000..03fd596 --- /dev/null +++ b/migrations/versions/9c3dd7f95cdc_add_audience_and_internal_description_.py @@ -0,0 +1,30 @@ +"""add audience and internal description to bell schedule + +Revision ID: 9c3dd7f95cdc +Revises: 2399c50496f7 +Create Date: 2025-09-03 18:05:04.456307 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9c3dd7f95cdc' +down_revision = '2399c50496f7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('bellschedules', sa.Column('audience', sa.VARCHAR(length=75), server_default='everyone', nullable=False)) + op.add_column('bellschedules', sa.Column('internal_description', sa.VARCHAR(length=250), server_default='', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('bellschedules', 'internal_description') + op.drop_column('bellschedules', 'audience') + # ### end Alembic commands ### diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..f6cb578 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,24 @@ + + + + ClassClock API + + +

ClassClock API

+

This is the API for ClassClock.

+

If you came here looking for API docs, you can find them at the links below:

+ + +

Source Code

+

+ The ClassClock API is licensed under the terms of the GNU AGPL and welcomes contributions. The source code is available at + https://github.com/MoralCode/ClassClockAPI + + +

+ + + \ No newline at end of file diff --git a/versions/v0.py b/versions/v0.py deleted file mode 100644 index bd7b7d3..0000000 --- a/versions/v0.py +++ /dev/null @@ -1,386 +0,0 @@ -import json -import uuid -import datetime -from flask_limiter import util -from flask import current_app -from os import environ as env - -from flask import Blueprint, abort, jsonify, request -from werkzeug.exceptions import HTTPException -from flask_cors import CORS - -# from bson import json_util -# from bson.objectid import ObjectId -import http.client -from common.db_schema import School as SchoolDB, db, BellSchedule as BellScheduleDB -from sqlalchemy import create_engine - -from common.helpers import * -from common.constants import APIScopes -from common.schemas import SchoolSchema, BellScheduleSchema -from common.services import auth0management -import common.exceptions - -# -# App Setup -# - - -DB_HOST = env.get("DB_HOST") -DB_USERNAME = env.get("DB_USERNAME") -DB_PASSWORD = env.get("DB_PASSWORD") - - -blueprint = Blueprint('v0', __name__) - -CORS(blueprint, origins="https://web.classclock.app", allow_headers=[ - "Accept", "Authorization"], - supports_credentials=True) - - -# @blueprint.route("/schools", methods=['GET']) -# @cross_origin(headers=["Content-Type", "Authorization"]) -# @cross_origin(headers=["Access-Control-Allow-Origin", "http://localhost:5000"]) -# @requires_auth -# def get_schools(): -# """Get a list of every publicly accessible ClassClock school -# --- -# responses: -# '200': -# description: A list of every publicly accessible ClassClock school -# '400': -# description: Unauthorized for some reason such as an invalid access token or incorrect scopes -# """ - - -# @blueprint.route("/school/", methods=['GET']) -# @cross_origin(headers=["Content-Type", "Authorization"]) -# @cross_origin(headers=["Access-Control-Allow-Origin", "http://localhost:5000"]) -# @requires_auth -# def get_school_by_id(identifier): -# """ -# Get information about a single school -# --- -# parameters: -# - name: identifier -# in: path -# type: string -# required: true -# description: the hexadecimal identifier of the school you are requesting -# responses: -# 200: -# description: data for a single school -# '400': -# description: Unauthorized for some reason such as an invalid access token or incorrect scopes -# default: -# description: error payload - -# """ - - -@blueprint.route("/schools/", methods=['GET']) -@check_headers -def list_schools(): - check_permissions([APIScopes.LIST_SCHOOLS]) - - school_list = [] - schools = SchoolDB.query.all() - - for school in schools: - school_list.append(school) - return SchoolSchema().dump(school_list, many=True) - - -@blueprint.route("/school//", methods=['GET']) -@check_headers -def get_school(school_id): - check_permissions([APIScopes.LIST_SCHOOLS]) - - school = SchoolDB.query.filter_by(id=school_id).first() - #double check this - if school is None: - raise Oops("No school was found with the specified id.", - 404, title="Resource Not Found") - - return SchoolSchema().dump(school) - - -@blueprint.route("/school/", methods=['POST']) -@check_headers -@requires_auth -@requires_admin -def create_school(self): - - check_permissions([APIScopes.CREATE_SCHOOL]) - # if len(list_owned_school_ids()) > 0: - # raise Oops( - # "Authorizing user is already the owner of another school", 401) - - data = deconstruct_resource_object(request.get_json()["data"]) - - new_object = SchoolDB( - full_name=data['full_name'], - alternate_freeperiod_name=data['alternate_freeperiod_name'], - acronym=data['acronym'], - owner_id=data['owner_id'] - ) - - # if new_object.errors != {}: - # return handle_marshmallow_errors(new_object.errors) - - db.session.add(new_object) - db.session.commit() - - #TODO: need to verify that the insert worked? - - return SchoolSchema().dump(new_object) - - -@blueprint.route("/school//", methods=['PATCH']) -@check_headers -@requires_auth -@requires_admin -def update_school(self, school_id): - """ input: - { - "data": { - "type": "school", - "id": "2C49E3159EE011E986F2181DEA92AD79", - "links": { - "self": "http://localhost:5000/v0/school/2C49E3159EE011E986F2181DEA92AD79" - }, - "attributes": { - "acronym": "LMHS", - "creation_date": "2019-07-04T21:48:46+00:00", - "alternate_freeperiod_name": null, - "full_name": "Lake Mosswego High School" - } - } - } - """ - - check_permissions([APIScopes.EDIT_SCHOOL]) - - data = deconstruct_resource_object(request.get_json()["data"]) - - # if new_object.errors != {}: - # return handle_marshmallow_errors(new_object.errors) - - school = SchoolDB.query.filter_by( - id=school_id, owner_id=get_api_user_id()).first() - - if school == None: - raise Oops("No records were found. Please make sure you are the owner for the school you are trying to modify", - 404, title="No Records Updated") - - if data.id.hex.lower() != school.id.lower(): - raise Oops("The id provided in the request body must match the id specified in the URL", - 400, title="Identifier Mismatch") - - school.full_name = new_patch_val(data.full_name, school.full_name) - school.acronym = new_patch_val(data.acronym, school.acronym) - school.alternate_freeperiod_name = new_patch_val( - data.alternate_freeperiod_name, school.alternate_freeperiod_name) - # last_modified is automatically set in db_schema - - - db.session.commit() - #TODO: need to verify that the update worked? - - return SchoolSchema().dump(school) - - -@blueprint.route("/school//", methods=['DELETE']) -@check_headers -@requires_auth -@requires_admin -def delete_school(self, school_id): - - check_permissions( - [APIScopes.DELETE_SCHOOL, APIScopes.DELETE_BELL_SCHEDULE]) - - school = SchoolDB.query.filter_by( - id=school_id, owner_id=get_api_user_id()).first() - - if school == None: - raise Oops("No records were found. Please make sure you are the owner for the school you are trying to delete", - 404, title="No Records Updated") - - db.session.delete(school) - db.session.commit() - # should this just archive the school? or delete it and all related records? - # sqlalchemy can be set to cascade deletes (i think). - return None, 204 - - -@blueprint.route("/school//bellschedules/", methods=['GET']) -@check_headers -def list_bellschedules(self, school_id): - - check_permissions([APIScopes.LIST_BELL_SCHEDULES]) - - schedule_list = [] - schedules = BellScheduleDB.query.filter_by(school_id=school_id) - - for schedule in schedules: - schedule_list.append(schedule) - return BellScheduleSchema(exclude=('school_id',)).dump(schedule_list, many=True) - - -@blueprint.route("/school//bellschedule/", methods=['GET']) -@check_headers -def get_bellschedule(self, school_id, bell_schedule_id): - - check_permissions([APIScopes.READ_BELL_SCHEDULE]) - - schedule = BellScheduleDB.query.filter_by( - id=bell_schedule_id, school_id=school_id).first() - - #double check this - if schedule is None: - raise Oops("No school was found with the specified id.", - 404, title="Resource Not Found") - - return BellScheduleSchema(exclude=('school_id',)).dump(schedule) - - -@blueprint.route("/school//bellschedule/", methods=['POST']) -@check_headers -@requires_auth -@requires_admin -def create_bellschedule(self, school_id): - - check_permissions([APIScopes.CREATE_BELL_SCHEDULE]) - - school = SchoolDB.query.filter_by(id=school_id).first() - - check_ownership(school) - - new_schedule = BellScheduleSchema().load(request.get_json()["data"]).data - - school.schedules.append(new_schedule) - - db.session.commit() - - return BellScheduleSchema(exclude=('school_id',)).dump(new_schedule) - - -@blueprint.route("/school//bellschedule/", methods=['PATCH']) -@check_headers -@requires_auth -@requires_admin -def update_bellschedule(self, school_id, bell_schedule_id): - - check_permissions([APIScopes.EDIT_BELL_SCHEDULE]) - - school = SchoolDB.query.filter_by(id=school_id).first() - - check_ownership(school) - - schedule = school.schedules.filter_by(id=bell_schedule_id).first() - - updated_schedule = BellScheduleSchema().load( - request.get_json()["data"]).data - - if not updated_schedule.id or updated_schedule.id.lower() != bell_schedule_id.lower(): - raise Oops("The identifier provided in the request body must match the identifier specified in the URL", - 400, title="Identifier Mismatch") - - schedule.name = new_patch_val(updated_schedule.name, schedule.name) - schedule.display_name = new_patch_val( - updated_schedule.display_name, schedule.display_name) - - db.session.commit() - - return BellScheduleSchema(exclude=('school_id',)).dump(schedule) - - -@blueprint.route("/school//bellschedule/", methods=['DELETE']) -@check_headers -@requires_auth -@requires_admin -def delete_bellschedule(self, school_id, bell_schedule_id): - - check_permissions([APIScopes.DELETE_BELL_SCHEDULE]) - - school = SchoolDB.query.filter_by(id=school_id).first() - - check_ownership(school) - - schedule = school.schedules.filter_by(id=bell_schedule_id).first() - - db.session.delete(schedule) - - return None, 204 - -# -# Routes -# - - -# register_api(api, School, "v0", name_of_optional_param="school_id") - -# register_api(api, BellSchedule, "v0", url_prefix="/school/", -# name_of_optional_param="bell_schedule_id") - - - -@blueprint.before_request -def before(): - current_app.logger.info( "Handling " + request.method + " Request for endpoint " + request.path + " from API user '" + get_api_user_id() + "' from address " + util.get_remote_address() ) - pass - -# -# -# Error Handler Section -# -# - -# override default rate limit exceeded error and return a JSON response instead -# https://flask-limiter.readthedocs.io/en/stable/#custom-rate-limit-exceeded-responses -@blueprint.errorhandler(429) -def ratelimit_handler(e): - print(e) - return make_jsonapi_response( - make_jsonapi_error_object(429, title="Ratelimit Exceeded", - message="ratelimit of " + e.description + " exceeded"), - code=429, headers={'Content-Type': 'application/vnd.api+json'} - ) - - -@blueprint.errorhandler(AuthError) -def handle_auth_error(e): - return make_jsonapi_response( - make_jsonapi_error_object(e.status_code, message=e.error), code=e.status_code, headers={'Content-Type': 'application/vnd.api+json'} - ) - - -@blueprint.errorhandler(Oops) -def handle_error(e): - if e.title is not None: - return make_jsonapi_response( - make_jsonapi_error_object(e.status_code, message=e.message, title=e.title), code=e.status_code, headers={'Content-Type': 'application/vnd.api+json'} - ) - else: - return make_jsonapi_response( - make_jsonapi_error_object(e.status_code, message=e.message), code=e.status_code, headers={'Content-Type': 'application/vnd.api+json'} - ) - - -@blueprint.errorhandler(HTTPException) -def handle_HTTP_error(e): - return make_jsonapi_response( - make_jsonapi_error_object( - e.code, title=e.name(), message=e.description), - code=e.code, headers={'Content-Type': 'application/vnd.api+json'} - ) - - -# @blueprint.errorhandler(Exception) -# def generic_exception_handler(e): -# # "We're sorry, but the electrons that were tasked with handling your request became terribly misguided and forgot what it is that they were supposed to be doing. Our team of scientists in the Electron Amnesia Recovery Ward is currently nursing them back to health; if you have any information about what it is these electrons were supposed to be doing at the time of this incident, please contact the maintainer of this service." -# print("an exception occurred") -# print(e) -# return make_jsonapi_response( -# make_jsonapi_error_object(500), code=500, headers={'Content-Type': 'application/vnd.api+json'} -# )