├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── issue_template.md ├── pull_request_template.md └── workflows │ ├── build-and-deploy.yml │ ├── greeting.yml │ ├── lint-and-test.yml │ └── status-webhook.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENCE ├── Pipfile ├── Pipfile.lock ├── README.md ├── api ├── __init__.py ├── app.py ├── dependencies.py ├── services │ ├── http.py │ ├── piston.py │ └── redis.py └── versions │ ├── __init__.py │ └── v1 │ ├── __init__.py │ └── routers │ ├── auth │ ├── __init__.py │ ├── helpers.py │ ├── models.py │ └── routes.py │ ├── challenges │ ├── __init__.py │ ├── languages │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── models.py │ │ └── routes.py │ ├── models.py │ └── routes.py │ ├── roles │ ├── __init__.py │ ├── models.py │ └── routes.py │ ├── router.py │ └── users │ ├── __init__.py │ ├── models.py │ └── routes.py ├── config.py ├── docker-compose.yml ├── docs └── cli.md ├── launch.py ├── prod.Dockerfile ├── snowflake.sql ├── tests ├── __init__.py ├── conftest.py ├── test_auth.py ├── test_challenges.py ├── test_roles.py └── test_utils │ └── test_snowflake.py ├── tox.ini └── utils ├── __init__.py ├── permissions.py ├── response.py └── time.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | local.env 106 | .env 107 | .venv 108 | .env.save 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | .env.local 115 | 116 | # VSCode project settings 117 | .vscode/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Pycharm project settings 124 | .idea 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | postgres/ 141 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discord Server 4 | url: https://discord.gg/twt 5 | about: Need help setting up the repository? 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | 11 | 12 | **Describe the solution you'd like** 13 | 14 | 15 | **Describe alternatives you've considered** 16 | 17 | 18 | **Additional context** 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | 12 | ## To Reproduce 13 | 14 | 15 | ## Expected behavior 16 | 17 | 18 | ## Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | ## Checklist 6 | 7 | 8 | 9 | - [ ] If endpoints were changed then they have been documented and tested. 10 | - [ ] I have updated the docmentation to reflect the changes. 11 | - [ ] I have updated the tests to support the changes. 12 | - [ ] This PR fixes an issue. 13 | - [ ] This PR adds something new (e.g. new endpoint or parameter). 14 | - [ ] This PR is a breaking change (e.g. endpoint or parameters removed/renamed) 15 | - [ ] This PR is **not** a code change (e.g. documentation, README, ...) 16 | -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | workflow_run: 8 | workflows: ["Lint & Test"] 9 | branches: ["staging"] 10 | types: ["completed"] 11 | 12 | jobs: 13 | build: 14 | if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' 15 | name: Build & Deploy 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Get branch 20 | id: branch 21 | run: | 22 | if ${{ github.event_name == 'workflow_run' }}; then 23 | echo "::set-output name=branch::staging" 24 | else 25 | echo "::set-output name=branch::main" 26 | fi 27 | 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v1 30 | 31 | - name: Checkout repository 32 | uses: actions/checkout@v2 33 | with: 34 | ref: ${{ steps.branch.outputs.branch }} 35 | submodules: recursive 36 | 37 | - name: Create SHA Container Tag 38 | id: sha_tag 39 | run: | 40 | tag=$(cut -c 1-7 <<< $GITHUB_SHA) 41 | echo "::set-output name=tag::$tag" 42 | 43 | - name: Login to Github Container Registry 44 | uses: docker/login-action@v1 45 | with: 46 | registry: ghcr.io 47 | username: ${{ github.actor }} 48 | password: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | - name: Build and push 51 | uses: docker/build-push-action@v2 52 | with: 53 | context: . 54 | push: true 55 | file: Dockerfile 56 | cache-from: type=registry,ref=ghcr.io/tech-with-tim/api:latest 57 | cache-to: type=inline 58 | tags: | 59 | ghcr.io/tech-with-tim/api:${{ steps.branch.outputs.branch }}-${{ steps.sha_tag.outputs.tag }} 60 | ghcr.io/tech-with-tim/api:${{ steps.branch.outputs.branch }}-latest 61 | ghcr.io/tech-with-tim/api:latest 62 | -------------------------------------------------------------------------------- /.github/workflows/greeting.yml: -------------------------------------------------------------------------------- 1 | name: Greeting 2 | 3 | on: [issues, pull_request_target] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/first-interaction@v1 11 | with: 12 | repo-token: ${{ secrets.GITHUB_TOKEN }} 13 | issue-message: | 14 | Hi **${{ github.actor }}**, Thanks for reporting an issue in our Repository. 15 | If you haven't already, please include relevant information asked for in our templates. 16 | pr-message: | 17 | 18 | Hey **${{ github.actor }}**, welcome to the repo for the Tech With Tim API. 19 | Please follow the following guidelines while opening a PR: 20 | 21 | - Any new or changed endpoints should be thoroughly documented. 22 | - Write and or update tests for your new / updated endpoints. 23 | - All code should be easly readable or commented. 24 | 25 | If your code does not meet these requirements your PR will not be accepted. 26 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | pull_request: 8 | branches: 9 | - "**" 10 | 11 | jobs: 12 | lint-and-test: 13 | runs-on: ubuntu-latest 14 | env: 15 | # Hide the graphical elements from pipenv's output 16 | PIPENV_HIDE_EMOJIS: 1 17 | PIPENV_NOSPIN: 1 18 | 19 | services: 20 | postgres: 21 | image: postgres:13 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DB: api 26 | ports: 27 | - 5432:5432 28 | # needed because the postgres container does not provide a healthcheck 29 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | submodules: recursive 36 | 37 | - name: Set up Python3.8 38 | uses: actions/setup-python@v2 39 | with: 40 | python-version: 3.8 41 | 42 | - name: Install dependencies 43 | run: | 44 | pip install pipenv 45 | pipenv install --dev --system --deploy 46 | 47 | - name: Run pre-commit hooks. 48 | run: SKIP=flake8; pre-commit run --all-files 49 | 50 | # Thanks pydis! 51 | # Run flake8 and have it format the linting errors in the format of 52 | # the GitHub Workflow command to register error annotations. This 53 | # means that our flake8 output is automatically added as an error 54 | # annotation to both the run result and in the "Files" tab of a 55 | # pull request. 56 | # 57 | # Format used: 58 | # ::error file={filename},line={line},col={col}::{message} 59 | - name: Run flake8 60 | run: "flake8 \ 61 | --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ 62 | [flake8] %(code)s: %(text)s'" 63 | 64 | - name: Run pytest 65 | run: | 66 | pytest 67 | env: 68 | TEST_POSTGRES_URI: postgresql://postgres:postgres@localhost:5432/api 69 | # This isn't the real SECRET_KEY but the one used for testing 70 | SECRET_KEY: nqk8umrpc4f968_2%jz_%r-r2o@v4!21#%)h&-s_7qm150=o@6 71 | -------------------------------------------------------------------------------- /.github/workflows/status-webhook.yml: -------------------------------------------------------------------------------- 1 | name: Status Webhook 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Lint & Test 7 | - Build & Deploy 8 | types: 9 | - completed 10 | 11 | 12 | jobs: 13 | send-embed: 14 | runs-on: ubuntu-latest 15 | name: Send an embed to discord 16 | 17 | steps: 18 | - name: Run the Github Actions Status Embed Action 19 | uses: SebastiaanZ/github-status-embed-for-discord@main 20 | with: 21 | webhook_id: '796006792995143710' 22 | webhook_token: ${{ secrets.WEBHOOK_TOKEN }} 23 | status: ${{ github.event.workflow_run.conclusion }} 24 | 25 | ref: ${{ github.ref }} 26 | actor: ${{ github.actor }} 27 | repository: ${{ github.repository }} 28 | run_id: ${{ github.event.workflow_run.id }} 29 | sha: ${{ github.event.workflow_run.head_sha }} 30 | workflow_name: ${{ github.event.workflow_run.name }} 31 | run_number: ${{ github.event.workflow_run.run_number }} 32 | 33 | pr_title: ${{ github.event.pull_request.title }} 34 | pr_number: ${{ github.event.pull_request.number }} 35 | pr_source: ${{ github.event.pull_request.head.label }} 36 | pr_author_login: ${{ github.event.pull_request.user.login }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | local.env 106 | .env 107 | .venv 108 | .env.save 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | .env.local 115 | 116 | # VSCode project settings 117 | .vscode/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Pycharm project settings 124 | .idea 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | postgres/ 141 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "api/models"] 2 | path = api/models 3 | url = https://github.com/Tech-With-Tim/models 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | entry: black --check . 7 | language: system 8 | types: [python] 9 | 10 | - id: flake8 11 | name: Flake8 12 | entry: flake8 13 | language: system 14 | types: [python] 15 | require_serial: true 16 | 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v2.5.0 19 | hooks: 20 | - id: check-json 21 | - id: check-yaml 22 | - id: end-of-file-fixer 23 | - id: check-merge-conflict 24 | - id: trailing-whitespace 25 | args: [--markdown-linebreak-ext=md] 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONFAULTHANDLER 1 5 | 6 | # Let service stop gracefully 7 | STOPSIGNAL SIGQUIT 8 | 9 | # Copy project files into working directory 10 | WORKDIR /app 11 | 12 | RUN apt-get update && apt-get install gcc -y 13 | 14 | RUN pip install pipenv 15 | COPY Pipfile Pipfile.lock ./ 16 | RUN pipenv install --deploy --system 17 | 18 | ADD . /app 19 | 20 | # Run the API. 21 | CMD python launch.py runserver --initdb --verbose --debug 22 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Tech With Tim Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | black = "*" 8 | httpx = "~=0.19" 9 | pytest = "*" 10 | flake8 = "*" 11 | pre-commit = "*" 12 | pytest-mock = "*" 13 | pytest-asyncio = "*" 14 | 15 | [packages] 16 | pyjwt = "*" 17 | postdb = "*" 18 | aiohttp = "~=3.7" 19 | fastapi = "*" 20 | aioredis = "*" 21 | fakeredis = "*" 22 | typing_extensions = "*" 23 | uvicorn = {extras = ["standard"], version = "*"} 24 | uvloop = {markers = "platform_system == 'linux'", version = "*"} 25 | 26 | [requires] 27 | python_version = "3.8" 28 | 29 | [pipenv] 30 | allow_prereleases = true 31 | 32 | [scripts] 33 | test = "python -m pytest" 34 | lint = "pre-commit run --all-files" 35 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "9ea8ebc14f98a86a358f60a9dac721d5612b7fcf7018bc460eba59c7147a068e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiohttp": { 20 | "hashes": [ 21 | "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", 22 | "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe", 23 | "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5", 24 | "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8", 25 | "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd", 26 | "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb", 27 | "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c", 28 | "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87", 29 | "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0", 30 | "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290", 31 | "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5", 32 | "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287", 33 | "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde", 34 | "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf", 35 | "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8", 36 | "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16", 37 | "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf", 38 | "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809", 39 | "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213", 40 | "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f", 41 | "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013", 42 | "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b", 43 | "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9", 44 | "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5", 45 | "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb", 46 | "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df", 47 | "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4", 48 | "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439", 49 | "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f", 50 | "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22", 51 | "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f", 52 | "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5", 53 | "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970", 54 | "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009", 55 | "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc", 56 | "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", 57 | "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" 58 | ], 59 | "index": "pypi", 60 | "version": "==3.7.4.post0" 61 | }, 62 | "aioredis": { 63 | "hashes": [ 64 | "sha256:3a2de4b614e6a5f8e104238924294dc4e811aefbe17ddf52c04a93cbf06e67db", 65 | "sha256:9921d68a3df5c5cdb0d5b49ad4fc88a4cfdd60c108325df4f0066e8410c55ffb" 66 | ], 67 | "index": "pypi", 68 | "version": "==2.0.0" 69 | }, 70 | "anyio": { 71 | "hashes": [ 72 | "sha256:0b993a2ef6c1dc456815c2b5ca2819f382f20af98087cc2090a4afed3a501436", 73 | "sha256:c32da314c510b34a862f5afeaf8a446ffed2c2fde21583e654bd71ecfb5b744b" 74 | ], 75 | "markers": "python_full_version >= '3.6.2'", 76 | "version": "==3.3.2" 77 | }, 78 | "asgiref": { 79 | "hashes": [ 80 | "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", 81 | "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" 82 | ], 83 | "markers": "python_version >= '3.6'", 84 | "version": "==3.4.1" 85 | }, 86 | "async-timeout": { 87 | "hashes": [ 88 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 89 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 90 | ], 91 | "markers": "python_full_version >= '3.5.3'", 92 | "version": "==3.0.1" 93 | }, 94 | "asyncpg": { 95 | "hashes": [ 96 | "sha256:129d501f3d30616afd51eb8d3142ef51ba05374256bd5834cec3ef4956a9b317", 97 | "sha256:29ef6ae0a617fc13cc2ac5dc8e9b367bb83cba220614b437af9b67766f4b6b20", 98 | "sha256:41704c561d354bef01353835a7846e5606faabbeb846214dfcf666cf53319f18", 99 | "sha256:556b0e92e2b75dc028b3c4bc9bd5162ddf0053b856437cf1f04c97f9c6837d03", 100 | "sha256:8ff5073d4b654e34bd5eaadc01dc4d68b8a9609084d835acd364cd934190a08d", 101 | "sha256:a458fc69051fbb67d995fdda46d75a012b5d6200f91e17d23d4751482640ed4c", 102 | "sha256:a7095890c96ba36f9f668eb552bb020dddb44f8e73e932f8573efc613ee83843", 103 | "sha256:a738f4807c853623d3f93f0fea11f61be6b0e5ca16ea8aeb42c2c7ee742aa853", 104 | "sha256:c4fc0205fe4ddd5aeb3dfdc0f7bafd43411181e1f5650189608e5971cceacff1", 105 | "sha256:dd2fa063c3344823487d9ddccb40802f02622ddf8bf8a6cc53885ee7a2c1c0c6", 106 | "sha256:ddffcb85227bf39cd1bedd4603e0082b243cf3b14ced64dce506a15b05232b83", 107 | "sha256:e36c6806883786b19551bb70a4882561f31135dc8105a59662e0376cf5b2cbc5", 108 | "sha256:eed43abc6ccf1dc02e0d0efc06ce46a411362f3358847c6b0ec9a43426f91ece" 109 | ], 110 | "markers": "python_version >= '3.6'", 111 | "version": "==0.24.0" 112 | }, 113 | "attrs": { 114 | "hashes": [ 115 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 116 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 117 | ], 118 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 119 | "version": "==21.2.0" 120 | }, 121 | "chardet": { 122 | "hashes": [ 123 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 124 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 125 | ], 126 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 127 | "version": "==4.0.0" 128 | }, 129 | "click": { 130 | "hashes": [ 131 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 132 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 133 | ], 134 | "markers": "python_version >= '3.6'", 135 | "version": "==8.0.1" 136 | }, 137 | "colorama": { 138 | "hashes": [ 139 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 140 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 141 | ], 142 | "version": "==0.4.4" 143 | }, 144 | "fakeredis": { 145 | "hashes": [ 146 | "sha256:0d06a9384fb79da9f2164ce96e34eb9d4e2ea46215070805ea6fd3c174590b47", 147 | "sha256:5eb1516f1fe1813e9da8f6c482178fc067af09f53de587ae03887ef5d9d13024" 148 | ], 149 | "index": "pypi", 150 | "version": "==1.6.1" 151 | }, 152 | "fastapi": { 153 | "hashes": [ 154 | "sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced", 155 | "sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c" 156 | ], 157 | "index": "pypi", 158 | "version": "==0.70.0" 159 | }, 160 | "h11": { 161 | "hashes": [ 162 | "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", 163 | "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" 164 | ], 165 | "markers": "python_version >= '3.6'", 166 | "version": "==0.12.0" 167 | }, 168 | "httptools": { 169 | "hashes": [ 170 | "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb", 171 | "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f", 172 | "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77", 173 | "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149", 174 | "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5", 175 | "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e", 176 | "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15", 177 | "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0", 178 | "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7", 179 | "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943", 180 | "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658", 181 | "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557", 182 | "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380", 183 | "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb", 184 | "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065" 185 | ], 186 | "version": "==0.2.0" 187 | }, 188 | "idna": { 189 | "hashes": [ 190 | "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", 191 | "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" 192 | ], 193 | "markers": "python_version >= '3.5'", 194 | "version": "==3.2" 195 | }, 196 | "multidict": { 197 | "hashes": [ 198 | "sha256:06560fbdcf22c9387100979e65b26fba0816c162b888cb65b845d3def7a54c9b", 199 | "sha256:067150fad08e6f2dd91a650c7a49ba65085303fcc3decbd64a57dc13a2733031", 200 | "sha256:0a2cbcfbea6dc776782a444db819c8b78afe4db597211298dd8b2222f73e9cd0", 201 | "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce", 202 | "sha256:0fed465af2e0eb6357ba95795d003ac0bdb546305cc2366b1fc8f0ad67cc3fda", 203 | "sha256:116347c63ba049c1ea56e157fa8aa6edaf5e92925c9b64f3da7769bdfa012858", 204 | "sha256:1b4ac3ba7a97b35a5ccf34f41b5a8642a01d1e55454b699e5e8e7a99b5a3acf5", 205 | "sha256:1c7976cd1c157fa7ba5456ae5d31ccdf1479680dc9b8d8aa28afabc370df42b8", 206 | "sha256:246145bff76cc4b19310f0ad28bd0769b940c2a49fc601b86bfd150cbd72bb22", 207 | "sha256:25cbd39a9029b409167aa0a20d8a17f502d43f2efebfe9e3ac019fe6796c59ac", 208 | "sha256:28e6d883acd8674887d7edc896b91751dc2d8e87fbdca8359591a13872799e4e", 209 | "sha256:2d1d55cdf706ddc62822d394d1df53573d32a7a07d4f099470d3cb9323b721b6", 210 | "sha256:2e77282fd1d677c313ffcaddfec236bf23f273c4fba7cdf198108f5940ae10f5", 211 | "sha256:32fdba7333eb2351fee2596b756d730d62b5827d5e1ab2f84e6cbb287cc67fe0", 212 | "sha256:35591729668a303a02b06e8dba0eb8140c4a1bfd4c4b3209a436a02a5ac1de11", 213 | "sha256:380b868f55f63d048a25931a1632818f90e4be71d2081c2338fcf656d299949a", 214 | "sha256:3822c5894c72e3b35aae9909bef66ec83e44522faf767c0ad39e0e2de11d3b55", 215 | "sha256:38ba256ee9b310da6a1a0f013ef4e422fca30a685bcbec86a969bd520504e341", 216 | "sha256:3bc3b1621b979621cee9f7b09f024ec76ec03cc365e638126a056317470bde1b", 217 | "sha256:3d2d7d1fff8e09d99354c04c3fd5b560fb04639fd45926b34e27cfdec678a704", 218 | "sha256:517d75522b7b18a3385726b54a081afd425d4f41144a5399e5abd97ccafdf36b", 219 | "sha256:5f79c19c6420962eb17c7e48878a03053b7ccd7b69f389d5831c0a4a7f1ac0a1", 220 | "sha256:5f841c4f14331fd1e36cbf3336ed7be2cb2a8f110ce40ea253e5573387db7621", 221 | "sha256:637c1896497ff19e1ee27c1c2c2ddaa9f2d134bbb5e0c52254361ea20486418d", 222 | "sha256:6ee908c070020d682e9b42c8f621e8bb10c767d04416e2ebe44e37d0f44d9ad5", 223 | "sha256:77f0fb7200cc7dedda7a60912f2059086e29ff67cefbc58d2506638c1a9132d7", 224 | "sha256:7878b61c867fb2df7a95e44b316f88d5a3742390c99dfba6c557a21b30180cac", 225 | "sha256:78c106b2b506b4d895ddc801ff509f941119394b89c9115580014127414e6c2d", 226 | "sha256:8b911d74acdc1fe2941e59b4f1a278a330e9c34c6c8ca1ee21264c51ec9b67ef", 227 | "sha256:93de39267c4c676c9ebb2057e98a8138bade0d806aad4d864322eee0803140a0", 228 | "sha256:9416cf11bcd73c861267e88aea71e9fcc35302b3943e45e1dbb4317f91a4b34f", 229 | "sha256:94b117e27efd8e08b4046c57461d5a114d26b40824995a2eb58372b94f9fca02", 230 | "sha256:9815765f9dcda04921ba467957be543423e5ec6a1136135d84f2ae092c50d87b", 231 | "sha256:98ec9aea6223adf46999f22e2c0ab6cf33f5914be604a404f658386a8f1fba37", 232 | "sha256:a37e9a68349f6abe24130846e2f1d2e38f7ddab30b81b754e5a1fde32f782b23", 233 | "sha256:a43616aec0f0d53c411582c451f5d3e1123a68cc7b3475d6f7d97a626f8ff90d", 234 | "sha256:a4771d0d0ac9d9fe9e24e33bed482a13dfc1256d008d101485fe460359476065", 235 | "sha256:a5635bcf1b75f0f6ef3c8a1ad07b500104a971e38d3683167b9454cb6465ac86", 236 | "sha256:a9acb76d5f3dd9421874923da2ed1e76041cb51b9337fd7f507edde1d86535d6", 237 | "sha256:ac42181292099d91217a82e3fa3ce0e0ddf3a74fd891b7c2b347a7f5aa0edded", 238 | "sha256:b227345e4186809d31f22087d0265655114af7cda442ecaf72246275865bebe4", 239 | "sha256:b61f85101ef08cbbc37846ac0e43f027f7844f3fade9b7f6dd087178caedeee7", 240 | "sha256:b70913cbf2e14275013be98a06ef4b412329fe7b4f83d64eb70dce8269ed1e1a", 241 | "sha256:b9aad49466b8d828b96b9e3630006234879c8d3e2b0a9d99219b3121bc5cdb17", 242 | "sha256:baf1856fab8212bf35230c019cde7c641887e3fc08cadd39d32a421a30151ea3", 243 | "sha256:bd6c9c50bf2ad3f0448edaa1a3b55b2e6866ef8feca5d8dbec10ec7c94371d21", 244 | "sha256:c1ff762e2ee126e6f1258650ac641e2b8e1f3d927a925aafcfde943b77a36d24", 245 | "sha256:c30ac9f562106cd9e8071c23949a067b10211917fdcb75b4718cf5775356a940", 246 | "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac", 247 | "sha256:cdd68778f96216596218b4e8882944d24a634d984ee1a5a049b300377878fa7c", 248 | "sha256:ce8cacda0b679ebc25624d5de66c705bc53dcc7c6f02a7fb0f3ca5e227d80422", 249 | "sha256:cfde464ca4af42a629648c0b0d79b8f295cf5b695412451716531d6916461628", 250 | "sha256:d3def943bfd5f1c47d51fd324df1e806d8da1f8e105cc7f1c76a1daf0f7e17b0", 251 | "sha256:d9b668c065968c5979fe6b6fa6760bb6ab9aeb94b75b73c0a9c1acf6393ac3bf", 252 | "sha256:da7d57ea65744d249427793c042094c4016789eb2562576fb831870f9c878d9e", 253 | "sha256:dc3a866cf6c13d59a01878cd806f219340f3e82eed514485e094321f24900677", 254 | "sha256:df23c83398715b26ab09574217ca21e14694917a0c857e356fd39e1c64f8283f", 255 | "sha256:dfc924a7e946dd3c6360e50e8f750d51e3ef5395c95dc054bc9eab0f70df4f9c", 256 | "sha256:e4a67f1080123de76e4e97a18d10350df6a7182e243312426d508712e99988d4", 257 | "sha256:e5283c0a00f48e8cafcecadebfa0ed1dac8b39e295c7248c44c665c16dc1138b", 258 | "sha256:e58a9b5cc96e014ddf93c2227cbdeca94b56a7eb77300205d6e4001805391747", 259 | "sha256:e6453f3cbeb78440747096f239d282cc57a2997a16b5197c9bc839099e1633d0", 260 | "sha256:e6c4fa1ec16e01e292315ba76eb1d012c025b99d22896bd14a66628b245e3e01", 261 | "sha256:e7d81ce5744757d2f05fc41896e3b2ae0458464b14b5a2c1e87a6a9d69aefaa8", 262 | "sha256:ea21d4d5104b4f840b91d9dc8cbc832aba9612121eaba503e54eaab1ad140eb9", 263 | "sha256:ecc99bce8ee42dcad15848c7885197d26841cb24fa2ee6e89d23b8993c871c64", 264 | "sha256:f0bb0973f42ffcb5e3537548e0767079420aefd94ba990b61cf7bb8d47f4916d", 265 | "sha256:f19001e790013ed580abfde2a4465388950728861b52f0da73e8e8a9418533c0", 266 | "sha256:f76440e480c3b2ca7f843ff8a48dc82446b86ed4930552d736c0bac507498a52", 267 | "sha256:f9bef5cff994ca3026fcc90680e326d1a19df9841c5e3d224076407cc21471a1", 268 | "sha256:fc66d4016f6e50ed36fb39cd287a3878ffcebfa90008535c62e0e90a7ab713ae", 269 | "sha256:fd77c8f3cba815aa69cb97ee2b2ef385c7c12ada9c734b0f3b32e26bb88bbf1d" 270 | ], 271 | "markers": "python_version >= '3.6'", 272 | "version": "==5.2.0" 273 | }, 274 | "packaging": { 275 | "hashes": [ 276 | "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", 277 | "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" 278 | ], 279 | "markers": "python_version >= '3.6'", 280 | "version": "==21.0" 281 | }, 282 | "postdb": { 283 | "hashes": [ 284 | "sha256:782584ffc75267d5987b813342459a71f6e498615637ed35052775833ee8a424", 285 | "sha256:fe1fa0b694ad8e55483139cbc0420c9cf288a7574c7e6c2626f1de0a6d459cc9" 286 | ], 287 | "index": "pypi", 288 | "version": "==0.2.3" 289 | }, 290 | "pydantic": { 291 | "hashes": [ 292 | "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd", 293 | "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739", 294 | "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f", 295 | "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840", 296 | "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23", 297 | "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287", 298 | "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62", 299 | "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b", 300 | "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb", 301 | "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820", 302 | "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3", 303 | "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b", 304 | "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e", 305 | "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3", 306 | "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316", 307 | "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b", 308 | "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4", 309 | "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20", 310 | "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e", 311 | "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505", 312 | "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1", 313 | "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833" 314 | ], 315 | "markers": "python_full_version >= '3.6.1'", 316 | "version": "==1.8.2" 317 | }, 318 | "pyjwt": { 319 | "hashes": [ 320 | "sha256:a0b9a3b4e5ca5517cac9f1a6e9cd30bf1aa80be74fcdf4e28eded582ecfcfbae", 321 | "sha256:b0ed5824c8ecc5362e540c65dc6247567db130c4226670bf7699aec92fb4dae1" 322 | ], 323 | "index": "pypi", 324 | "version": "==2.2.0" 325 | }, 326 | "pyparsing": { 327 | "hashes": [ 328 | "sha256:10fb0827f908440eda768ec659627c3ac5dc20a25b4adaf50e7e10b248c17a4f", 329 | "sha256:f72f2294ef53f917d984093e8ac8ed5818837516132e68c67b7fdd5350c8dabf" 330 | ], 331 | "markers": "python_version >= '3.6'", 332 | "version": "==3.0.0rc2" 333 | }, 334 | "python-dotenv": { 335 | "hashes": [ 336 | "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1", 337 | "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172" 338 | ], 339 | "version": "==0.19.0" 340 | }, 341 | "pyyaml": { 342 | "hashes": [ 343 | "sha256:0044ec607d28033fc79d9900748eafdd62e9a79d3882858e8c0c001f30e9c79a", 344 | "sha256:0495b956bd45025c5d13ae8af0ea238923e88f2d98efd7484ec99dac46291d22", 345 | "sha256:09317957e01b6b4da6f9ea96f9e661225662212acee57fcb201775a82196523e", 346 | "sha256:0b2167570ea8e96a2710efaae860d71cc3f80e35fe07a915093c596170f024bf", 347 | "sha256:1114a62d443cb157ac36beb79fc323d830f4a0586ffd1da8319d72fbf4514d6c", 348 | "sha256:11dae7bfe84016061d528c355bbe0faa0775f077f21831b1fbaa7997b266ca99", 349 | "sha256:171533a79a71520ccdf08136012ea0c8fe7f152cda69168dc38ea34da67c9f80", 350 | "sha256:1fffde5b126e4433829530b621a209c07a4c2a808b03e5b808419745b3509c79", 351 | "sha256:267bf63c911ab6302341e37329f049ddfcab4aea26c8895da1e6a5bcdf1bcc37", 352 | "sha256:26fdf07f0d7442fe4cc637aadeb08e9e6dc853f9a4d99a3d813bd23dc6e1cc52", 353 | "sha256:2ea9326fba939d5e87a1890c27a7aaf5a75e49cfe2c1696bc4fbb25305ca2767", 354 | "sha256:33d6ec1e993c063d4fb4009f439a3d81773ca81cc4109f701eb7a4782d171495", 355 | "sha256:40f637ea3333c0969c3f4127393a5f908f40f7e85605f9e6f568cff2c66849ef", 356 | "sha256:437e3796f5dd7f2ad8f70084f7fe82472b11483aa1c53c073ab430974bdf1b6d", 357 | "sha256:4983bee1be18c3faed20c8eead1c543c1356a2b468984c1385b523e23d3ab0ed", 358 | "sha256:499b0b45b68671df4b47b185a5a664d6e3871c93956c6f70bf04de5344c6ef02", 359 | "sha256:56e213cbb463f43f354a9d0a455b68128599df9a8aed0f7ac8e7b7fbbc219bee", 360 | "sha256:639787884913ee65a617fd826abb6d8b0c4896d533d0caae05b031d7b4089faf", 361 | "sha256:68877239810a357a100157233314784ab332ee33bc978de030d0135553db69e2", 362 | "sha256:69b8e0559a01ef14b5d76882c2b49f643ab6d5b953ec5e5590411d2bdb707f4a", 363 | "sha256:77627ca1909b1c9efdd42d7c7f680b22e620ffda5f64b01ba282bd5a59a5d108", 364 | "sha256:839f695fedfb65dbca690ce57907e076d355ee7c34b857c13926e251d1381ce7", 365 | "sha256:9175c938a11a371421e2221faf1ae9d32e86462953917854d70afd2048bb7495", 366 | "sha256:a978f8308deff49581479be11e7455af9b3d368dd3c3cb5c22b32c691b12b172", 367 | "sha256:acd3d75d7ce71850f9d3972f68baca1133a8cd27e6153d64d00be8504a491c8c", 368 | "sha256:b811ca77229f55eab2d9b3c6abc6d44c9be3f04461f04ab87f523d778c55c782", 369 | "sha256:da51ac8b4d42f9197cad065d6c3e2815eb768eac1d7fc0f9c343db280b1b9edc", 370 | "sha256:dfeba8c78b9db00667b94cddb1db6559e068cb7f469e681a4e9aa74dc3e86b24", 371 | "sha256:e35aff01f870f898f4e4b5360a3f18c98f9c47edab1c2d6bdfa5c876bf74ab73", 372 | "sha256:e47100ec1e464ff8a43e4c96758fd9cd66d202986d93de0f2962c1a7cb851391", 373 | "sha256:e628d48defb4a7eacb2087d42f9a7576324279c9b769625941ba39d78c441e29", 374 | "sha256:ebf35bb455f33c1ff3a61031a68f930bd69e88ee3a0882d3d5e677b823785d1e", 375 | "sha256:fb03b63e01fd848abb4219b2c2103a1962358c8f4db5150f7a5c1136d3e24d2e" 376 | ], 377 | "version": "==6.0b1" 378 | }, 379 | "redis": { 380 | "hashes": [ 381 | "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", 382 | "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" 383 | ], 384 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 385 | "version": "==3.5.3" 386 | }, 387 | "six": { 388 | "hashes": [ 389 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 390 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 391 | ], 392 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 393 | "version": "==1.16.0" 394 | }, 395 | "sniffio": { 396 | "hashes": [ 397 | "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", 398 | "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" 399 | ], 400 | "markers": "python_version >= '3.5'", 401 | "version": "==1.2.0" 402 | }, 403 | "sortedcontainers": { 404 | "hashes": [ 405 | "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", 406 | "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" 407 | ], 408 | "version": "==2.4.0" 409 | }, 410 | "starlette": { 411 | "hashes": [ 412 | "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f", 413 | "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870" 414 | ], 415 | "markers": "python_version >= '3.6'", 416 | "version": "==0.16.0" 417 | }, 418 | "typing-extensions": { 419 | "hashes": [ 420 | "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", 421 | "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", 422 | "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" 423 | ], 424 | "index": "pypi", 425 | "version": "==3.10.0.2" 426 | }, 427 | "uvicorn": { 428 | "extras": [ 429 | "standard" 430 | ], 431 | "hashes": [ 432 | "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1", 433 | "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff" 434 | ], 435 | "index": "pypi", 436 | "version": "==0.15.0" 437 | }, 438 | "uvloop": { 439 | "hashes": [ 440 | "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450", 441 | "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897", 442 | "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861", 443 | "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c", 444 | "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805", 445 | "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d", 446 | "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464", 447 | "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f", 448 | "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9", 449 | "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab", 450 | "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f", 451 | "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638", 452 | "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64", 453 | "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee", 454 | "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382", 455 | "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228" 456 | ], 457 | "markers": "platform_system == 'linux'", 458 | "version": "==0.16.0" 459 | }, 460 | "watchgod": { 461 | "hashes": [ 462 | "sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29", 463 | "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7" 464 | ], 465 | "version": "==0.7" 466 | }, 467 | "websockets": { 468 | "hashes": [ 469 | "sha256:01db0ecd1a0ca6702d02a5ed40413e18b7d22f94afb3bbe0d323bac86c42c1c8", 470 | "sha256:085bb8a6e780d30eaa1ba48ac7f3a6707f925edea787cfb761ce5a39e77ac09b", 471 | "sha256:1ac35426fe3e7d3d0fac3d63c8965c76ed67a8fd713937be072bf0ce22808539", 472 | "sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939", 473 | "sha256:2a43072e434c041a99f2e1eb9b692df0232a38c37c61d00e9f24db79474329e4", 474 | "sha256:5b2600e01c7ca6f840c42c747ffbe0254f319594ed108db847eb3d75f4aacb80", 475 | "sha256:62160772314920397f9d219147f958b33fa27a12c662d4455c9ccbba9a07e474", 476 | "sha256:706e200fc7f03bed99ad0574cd1ea8b0951477dd18cc978ccb190683c69dba76", 477 | "sha256:71358c7816e2762f3e4af3adf0040f268e219f5a38cb3487a9d0fc2e554fef6a", 478 | "sha256:7d2e12e4f901f1bc062dfdf91831712c4106ed18a9a4cdb65e2e5f502124ca37", 479 | "sha256:7f79f02c7f9a8320aff7d3321cd1c7e3a7dbc15d922ac996cca827301ee75238", 480 | "sha256:82b17524b1ce6ae7f7dd93e4d18e9b9474071e28b65dbf1dfe9b5767778db379", 481 | "sha256:82bd921885231f4a30d9bc550552495b3fc36b1235add6d374e7c65c3babd805", 482 | "sha256:8bbf8660c3f833ddc8b1afab90213f2e672a9ddac6eecb3cde968e6b2807c1c7", 483 | "sha256:9a4d889162bd48588e80950e07fa5e039eee9deb76a58092e8c3ece96d7ef537", 484 | "sha256:b4ade7569b6fd17912452f9c3757d96f8e4044016b6d22b3b8391e641ca50456", 485 | "sha256:b8176deb6be540a46695960a765a77c28ac8b2e3ef2ec95d50a4f5df901edb1c", 486 | "sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002", 487 | "sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567", 488 | "sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da", 489 | "sha256:d67646ddd17a86117ae21c27005d83c1895c0cef5d7be548b7549646372f868a", 490 | "sha256:e42a1f1e03437b017af341e9bbfdc09252cd48ef32a8c3c3ead769eab3b17368", 491 | "sha256:eb282127e9c136f860c6068a4fba5756eb25e755baffb5940b6f1eae071928b2", 492 | "sha256:fe83b3ec9ef34063d86dfe1029160a85f24a5a94271036e5714a57acfdd089a1", 493 | "sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465" 494 | ], 495 | "version": "==10.0" 496 | }, 497 | "yarl": { 498 | "hashes": [ 499 | "sha256:053e09817eafb892e94e172d05406c1b3a22a93bc68f6eff5198363a3d764459", 500 | "sha256:08c2044a956f4ef30405f2f433ce77f1f57c2c773bf81ae43201917831044d5a", 501 | "sha256:15ec41a5a5fdb7bace6d7b16701f9440007a82734f69127c0fbf6d87e10f4a1e", 502 | "sha256:1beef4734ca1ad40a9d8c6b20a76ab46e3a2ed09f38561f01e4aa2ea82cafcef", 503 | "sha256:1d3b8449dfedfe94eaff2b77954258b09b24949f6818dfa444b05dbb05ae1b7e", 504 | "sha256:22b2430c49713bfb2f0a0dd4a8d7aab218b28476ba86fd1c78ad8899462cbcf2", 505 | "sha256:263c81b94e6431942b27f6f671fa62f430a0a5c14bb255f2ab69eeb9b2b66ff7", 506 | "sha256:2e48f27936aa838939c798f466c851ba4ae79e347e8dfce43b009c64b930df12", 507 | "sha256:2e7ad9db939082f5d0b9269cfd92c025cb8f2fbbb1f1b9dc5a393c639db5bd92", 508 | "sha256:36ec44f15193f6d5288d42ebb8e751b967ebdfb72d6830983838d45ab18edb4f", 509 | "sha256:376e41775aab79c5575534924a386c8e0f1a5d91db69fc6133fd27a489bcaf10", 510 | "sha256:38173b8c3a29945e7ecade9a3f6ff39581eee8201338ee6a2c8882db5df3e806", 511 | "sha256:3a31e4a8dcb1beaf167b7e7af61b88cb961b220db8d3ba1c839723630e57eef7", 512 | "sha256:3ad51e17cd65ea3debb0e10f0120cf8dd987c741fe423ed2285087368090b33d", 513 | "sha256:3d461b7a8e139b9e4b41f62eb417ffa0b98d1c46d4caf14c845e6a3b349c0bb1", 514 | "sha256:3def6e681cc02397e5d8141ee97b41d02932b2bcf0fb34532ad62855eab7c60e", 515 | "sha256:46a742ed9e363bd01be64160ce7520e92e11989bd4cb224403cfd31c101cc83d", 516 | "sha256:484d61c047c45670ef5967653a1d0783e232c54bf9dd786a7737036828fa8d54", 517 | "sha256:50127634f519b2956005891507e3aa4ac345f66a7ea7bbc2d7dcba7401f41898", 518 | "sha256:59c0f13f9592820c51280d1cf811294d753e4a18baf90f0139d1dc93d4b6fc5f", 519 | "sha256:622a36fa779efb4ff9eff5fe52730ff17521431379851a31e040958fc251670c", 520 | "sha256:64773840952de17851a1c7346ad7f71688c77e74248d1f0bc230e96680f84028", 521 | "sha256:69945d13e1bbf81784a9bc48824feb9cd66491e6a503d4e83f6cd7c7cc861361", 522 | "sha256:7c8d0bb76eabc5299db203e952ec55f8f4c53f08e0df4285aac8c92bd9e12675", 523 | "sha256:7e37786ea89a5d3ffbbf318ea9790926f8dfda83858544f128553c347ad143c6", 524 | "sha256:7f7655ad83d1a8afa48435a449bf2f3009293da1604f5dd95b5ddcf5f673bd69", 525 | "sha256:81cfacdd1e40bc931b5519499342efa388d24d262c30a3d31187bfa04f4a7001", 526 | "sha256:821b978f2152be7695d4331ef0621d207aedf9bbd591ba23a63412a3efc29a01", 527 | "sha256:82ff6f85f67500a4f74885d81659cd270eb24dfe692fe44e622b8a2fd57e7279", 528 | "sha256:87721b549505a546eb003252185103b5ec8147de6d3ad3714d148a5a67b6fe53", 529 | "sha256:8a8b10d0e7bac154f959b709fcea593cda527b234119311eb950096653816a86", 530 | "sha256:8b8c409aa3a7966647e7c1c524846b362a6bcbbe120bf8a176431f940d2b9a2e", 531 | "sha256:8ba402f32184f0b405fb281b93bd0d8ab7e3257735b57b62a6ed2e94cdf4fe50", 532 | "sha256:8e3ffab21db0542ffd1887f3b9575ddd58961f2cf61429cb6458afc00c4581e0", 533 | "sha256:8e7ebaf62e19c2feb097ffb7c94deb0f0c9fab52590784c8cd679d30ab009162", 534 | "sha256:8ee78c9a5f3c642219d4607680a4693b59239c27a3aa608b64ef79ddc9698039", 535 | "sha256:91cbe24300c11835ef186436363352b3257db7af165e0a767f4f17aa25761388", 536 | "sha256:9624154ec9c02a776802da1086eed7f5034bd1971977f5146233869c2ac80297", 537 | "sha256:98c51f02d542945d306c8e934aa2c1e66ba5e9c1c86b5bf37f3a51c8a747067e", 538 | "sha256:98c9ddb92b60a83c21be42c776d3d9d5ec632a762a094c41bda37b7dfbd2cd83", 539 | "sha256:a06d9d0b9a97fa99b84fee71d9dd11e69e21ac8a27229089f07b5e5e50e8d63c", 540 | "sha256:a1fa866fa24d9f4108f9e58ea8a2135655419885cdb443e36b39a346e1181532", 541 | "sha256:a3455c2456d6307bcfa80bc1157b8603f7d93573291f5bdc7144489ca0df4628", 542 | "sha256:a532d75ca74431c053a88a802e161fb3d651b8bf5821a3440bc3616e38754583", 543 | "sha256:a7dfc46add4cfe5578013dbc4127893edc69fe19132d2836ff2f6e49edc5ecd6", 544 | "sha256:a7f08819dba1e1255d6991ed37448a1bf4b1352c004bcd899b9da0c47958513d", 545 | "sha256:aa9f0d9b62d15182341b3e9816582f46182cab91c1a57b2d308b9a3c4e2c4f78", 546 | "sha256:acbf1756d9dc7cd0ae943d883be72e84e04396f6c2ff93a6ddeca929d562039f", 547 | "sha256:b22ea41c7e98170474a01e3eded1377d46b2dfaef45888a0005c683eaaa49285", 548 | "sha256:b28cfb46140efe1a6092b8c5c4994a1fe70dc83c38fbcea4992401e0c6fb9cce", 549 | "sha256:b36f5a63c891f813c6f04ef19675b382efc190fd5ce7e10ab19386d2548bca06", 550 | "sha256:b64bd24c8c9a487f4a12260dc26732bf41028816dbf0c458f17864fbebdb3131", 551 | "sha256:b7de92a4af85cfcaf4081f8aa6165b1d63ee5de150af3ee85f954145f93105a7", 552 | "sha256:bb3e478175e15e00d659fb0354a6a8db71a7811a2a5052aed98048bc972e5d2b", 553 | "sha256:be52bc5208d767cdd8308a9e93059b3b36d1e048fecbea0e0346d0d24a76adc0", 554 | "sha256:c18a4b286e8d780c3a40c31d7b79836aa93b720f71d5743f20c08b7e049ca073", 555 | "sha256:c63c1e208f800daad71715786bfeb1cecdc595d87e2e9b1cd234fd6e597fd71d", 556 | "sha256:c7015dcedb91d90a138eebdc7e432aec8966e0147ab2a55f2df27b1904fa7291", 557 | "sha256:cb4ff1ac7cb4500f43581b3f4cbd627d702143aa6be1fdc1fa3ebffaf4dc1be5", 558 | "sha256:d30d67e3486aea61bb2cbf7cf81385364c2e4f7ce7469a76ed72af76a5cdfe6b", 559 | "sha256:d54c925396e7891666cabc0199366ca55b27d003393465acef63fd29b8b7aa92", 560 | "sha256:d579957439933d752358c6a300c93110f84aae67b63dd0c19dde6ecbf4056f6b", 561 | "sha256:d750503682605088a14d29a4701548c15c510da4f13c8b17409c4097d5b04c52", 562 | "sha256:db2372e350794ce8b9f810feb094c606b7e0e4aa6807141ac4fadfe5ddd75bb0", 563 | "sha256:e35d8230e4b08d86ea65c32450533b906a8267a87b873f2954adeaecede85169", 564 | "sha256:e510dbec7c59d32eaa61ffa48173d5e3d7170a67f4a03e8f5e2e9e3971aca622", 565 | "sha256:e78c91faefe88d601ddd16e3882918dbde20577a2438e2320f8239c8b7507b8f", 566 | "sha256:eb4b3f277880c314e47720b4b6bb2c85114ab3c04c5442c9bc7006b3787904d8", 567 | "sha256:ec1b5a25a25c880c976d0bb3d107def085bb08dbb3db7f4442e0a2b980359d24", 568 | "sha256:f3cd2158b2ed0fb25c6811adfdcc47224efe075f2d68a750071dacc03a7a66e4", 569 | "sha256:f46cd4c43e6175030e2a56def8f1d83b64e6706eeb2bb9ab0ef4756f65eab23f", 570 | "sha256:fdd1b90c225a653b1bd1c0cae8edf1957892b9a09c8bf7ee6321eeb8208eac0f" 571 | ], 572 | "markers": "python_version >= '3.6'", 573 | "version": "==1.7.0" 574 | } 575 | }, 576 | "develop": { 577 | "anyio": { 578 | "hashes": [ 579 | "sha256:0b993a2ef6c1dc456815c2b5ca2819f382f20af98087cc2090a4afed3a501436", 580 | "sha256:c32da314c510b34a862f5afeaf8a446ffed2c2fde21583e654bd71ecfb5b744b" 581 | ], 582 | "markers": "python_full_version >= '3.6.2'", 583 | "version": "==3.3.2" 584 | }, 585 | "atomicwrites": { 586 | "hashes": [ 587 | "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", 588 | "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" 589 | ], 590 | "markers": "sys_platform == 'win32'", 591 | "version": "==1.4.0" 592 | }, 593 | "attrs": { 594 | "hashes": [ 595 | "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", 596 | "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" 597 | ], 598 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 599 | "version": "==21.2.0" 600 | }, 601 | "backports.entry-points-selectable": { 602 | "hashes": [ 603 | "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a", 604 | "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc" 605 | ], 606 | "markers": "python_version >= '2.7'", 607 | "version": "==1.1.0" 608 | }, 609 | "black": { 610 | "hashes": [ 611 | "sha256:380f1b5da05e5a1429225676655dddb96f5ae8c75bdf91e53d798871b902a115", 612 | "sha256:7de4cfc7eb6b710de325712d40125689101d21d25283eed7e9998722cf10eb91" 613 | ], 614 | "index": "pypi", 615 | "version": "==21.9b0" 616 | }, 617 | "certifi": { 618 | "hashes": [ 619 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 620 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 621 | ], 622 | "version": "==2021.5.30" 623 | }, 624 | "cfgv": { 625 | "hashes": [ 626 | "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426", 627 | "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736" 628 | ], 629 | "markers": "python_full_version >= '3.6.1'", 630 | "version": "==3.3.1" 631 | }, 632 | "charset-normalizer": { 633 | "hashes": [ 634 | "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6", 635 | "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f" 636 | ], 637 | "markers": "python_version >= '3.5'", 638 | "version": "==2.0.6" 639 | }, 640 | "click": { 641 | "hashes": [ 642 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 643 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 644 | ], 645 | "markers": "python_version >= '3.6'", 646 | "version": "==8.0.1" 647 | }, 648 | "colorama": { 649 | "hashes": [ 650 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 651 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 652 | ], 653 | "version": "==0.4.4" 654 | }, 655 | "distlib": { 656 | "hashes": [ 657 | "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31", 658 | "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05" 659 | ], 660 | "version": "==0.3.3" 661 | }, 662 | "filelock": { 663 | "hashes": [ 664 | "sha256:8c7eab13dc442dc249e95158bcc12dec724465919bdc9831fdbf0660f03d1785", 665 | "sha256:bbc6a0382fe8ec4744ecdf6683a2e07f65eb10ff1aff53fc02a202565446cde0" 666 | ], 667 | "markers": "python_version >= '3.6'", 668 | "version": "==3.3.0" 669 | }, 670 | "flake8": { 671 | "hashes": [ 672 | "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", 673 | "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" 674 | ], 675 | "index": "pypi", 676 | "version": "==3.9.2" 677 | }, 678 | "h11": { 679 | "hashes": [ 680 | "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", 681 | "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" 682 | ], 683 | "markers": "python_version >= '3.6'", 684 | "version": "==0.12.0" 685 | }, 686 | "httpcore": { 687 | "hashes": [ 688 | "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3", 689 | "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0" 690 | ], 691 | "markers": "python_version >= '3.6'", 692 | "version": "==0.13.7" 693 | }, 694 | "httpx": { 695 | "hashes": [ 696 | "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0", 697 | "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435" 698 | ], 699 | "index": "pypi", 700 | "version": "==0.19.0" 701 | }, 702 | "identify": { 703 | "hashes": [ 704 | "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05", 705 | "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5" 706 | ], 707 | "markers": "python_full_version >= '3.6.1'", 708 | "version": "==2.3.0" 709 | }, 710 | "idna": { 711 | "hashes": [ 712 | "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", 713 | "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" 714 | ], 715 | "markers": "python_version >= '3.5'", 716 | "version": "==3.2" 717 | }, 718 | "iniconfig": { 719 | "hashes": [ 720 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 721 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 722 | ], 723 | "version": "==1.1.1" 724 | }, 725 | "mccabe": { 726 | "hashes": [ 727 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 728 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 729 | ], 730 | "version": "==0.6.1" 731 | }, 732 | "mypy-extensions": { 733 | "hashes": [ 734 | "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", 735 | "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" 736 | ], 737 | "version": "==0.4.3" 738 | }, 739 | "nodeenv": { 740 | "hashes": [ 741 | "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b", 742 | "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7" 743 | ], 744 | "version": "==1.6.0" 745 | }, 746 | "packaging": { 747 | "hashes": [ 748 | "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", 749 | "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" 750 | ], 751 | "markers": "python_version >= '3.6'", 752 | "version": "==21.0" 753 | }, 754 | "pathspec": { 755 | "hashes": [ 756 | "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", 757 | "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" 758 | ], 759 | "version": "==0.9.0" 760 | }, 761 | "platformdirs": { 762 | "hashes": [ 763 | "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2", 764 | "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d" 765 | ], 766 | "markers": "python_version >= '3.6'", 767 | "version": "==2.4.0" 768 | }, 769 | "pluggy": { 770 | "hashes": [ 771 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 772 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 773 | ], 774 | "markers": "python_version >= '3.6'", 775 | "version": "==1.0.0" 776 | }, 777 | "pre-commit": { 778 | "hashes": [ 779 | "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7", 780 | "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6" 781 | ], 782 | "index": "pypi", 783 | "version": "==2.15.0" 784 | }, 785 | "py": { 786 | "hashes": [ 787 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 788 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 789 | ], 790 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 791 | "version": "==1.10.0" 792 | }, 793 | "pycodestyle": { 794 | "hashes": [ 795 | "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", 796 | "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" 797 | ], 798 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 799 | "version": "==2.7.0" 800 | }, 801 | "pyflakes": { 802 | "hashes": [ 803 | "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", 804 | "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" 805 | ], 806 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 807 | "version": "==2.3.1" 808 | }, 809 | "pyparsing": { 810 | "hashes": [ 811 | "sha256:10fb0827f908440eda768ec659627c3ac5dc20a25b4adaf50e7e10b248c17a4f", 812 | "sha256:f72f2294ef53f917d984093e8ac8ed5818837516132e68c67b7fdd5350c8dabf" 813 | ], 814 | "markers": "python_version >= '3.6'", 815 | "version": "==3.0.0rc2" 816 | }, 817 | "pytest": { 818 | "hashes": [ 819 | "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89", 820 | "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134" 821 | ], 822 | "index": "pypi", 823 | "version": "==6.2.5" 824 | }, 825 | "pytest-asyncio": { 826 | "hashes": [ 827 | "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f", 828 | "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea" 829 | ], 830 | "index": "pypi", 831 | "version": "==0.15.1" 832 | }, 833 | "pytest-mock": { 834 | "hashes": [ 835 | "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3", 836 | "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62" 837 | ], 838 | "index": "pypi", 839 | "version": "==3.6.1" 840 | }, 841 | "pyyaml": { 842 | "hashes": [ 843 | "sha256:0044ec607d28033fc79d9900748eafdd62e9a79d3882858e8c0c001f30e9c79a", 844 | "sha256:0495b956bd45025c5d13ae8af0ea238923e88f2d98efd7484ec99dac46291d22", 845 | "sha256:09317957e01b6b4da6f9ea96f9e661225662212acee57fcb201775a82196523e", 846 | "sha256:0b2167570ea8e96a2710efaae860d71cc3f80e35fe07a915093c596170f024bf", 847 | "sha256:1114a62d443cb157ac36beb79fc323d830f4a0586ffd1da8319d72fbf4514d6c", 848 | "sha256:11dae7bfe84016061d528c355bbe0faa0775f077f21831b1fbaa7997b266ca99", 849 | "sha256:171533a79a71520ccdf08136012ea0c8fe7f152cda69168dc38ea34da67c9f80", 850 | "sha256:1fffde5b126e4433829530b621a209c07a4c2a808b03e5b808419745b3509c79", 851 | "sha256:267bf63c911ab6302341e37329f049ddfcab4aea26c8895da1e6a5bcdf1bcc37", 852 | "sha256:26fdf07f0d7442fe4cc637aadeb08e9e6dc853f9a4d99a3d813bd23dc6e1cc52", 853 | "sha256:2ea9326fba939d5e87a1890c27a7aaf5a75e49cfe2c1696bc4fbb25305ca2767", 854 | "sha256:33d6ec1e993c063d4fb4009f439a3d81773ca81cc4109f701eb7a4782d171495", 855 | "sha256:40f637ea3333c0969c3f4127393a5f908f40f7e85605f9e6f568cff2c66849ef", 856 | "sha256:437e3796f5dd7f2ad8f70084f7fe82472b11483aa1c53c073ab430974bdf1b6d", 857 | "sha256:4983bee1be18c3faed20c8eead1c543c1356a2b468984c1385b523e23d3ab0ed", 858 | "sha256:499b0b45b68671df4b47b185a5a664d6e3871c93956c6f70bf04de5344c6ef02", 859 | "sha256:56e213cbb463f43f354a9d0a455b68128599df9a8aed0f7ac8e7b7fbbc219bee", 860 | "sha256:639787884913ee65a617fd826abb6d8b0c4896d533d0caae05b031d7b4089faf", 861 | "sha256:68877239810a357a100157233314784ab332ee33bc978de030d0135553db69e2", 862 | "sha256:69b8e0559a01ef14b5d76882c2b49f643ab6d5b953ec5e5590411d2bdb707f4a", 863 | "sha256:77627ca1909b1c9efdd42d7c7f680b22e620ffda5f64b01ba282bd5a59a5d108", 864 | "sha256:839f695fedfb65dbca690ce57907e076d355ee7c34b857c13926e251d1381ce7", 865 | "sha256:9175c938a11a371421e2221faf1ae9d32e86462953917854d70afd2048bb7495", 866 | "sha256:a978f8308deff49581479be11e7455af9b3d368dd3c3cb5c22b32c691b12b172", 867 | "sha256:acd3d75d7ce71850f9d3972f68baca1133a8cd27e6153d64d00be8504a491c8c", 868 | "sha256:b811ca77229f55eab2d9b3c6abc6d44c9be3f04461f04ab87f523d778c55c782", 869 | "sha256:da51ac8b4d42f9197cad065d6c3e2815eb768eac1d7fc0f9c343db280b1b9edc", 870 | "sha256:dfeba8c78b9db00667b94cddb1db6559e068cb7f469e681a4e9aa74dc3e86b24", 871 | "sha256:e35aff01f870f898f4e4b5360a3f18c98f9c47edab1c2d6bdfa5c876bf74ab73", 872 | "sha256:e47100ec1e464ff8a43e4c96758fd9cd66d202986d93de0f2962c1a7cb851391", 873 | "sha256:e628d48defb4a7eacb2087d42f9a7576324279c9b769625941ba39d78c441e29", 874 | "sha256:ebf35bb455f33c1ff3a61031a68f930bd69e88ee3a0882d3d5e677b823785d1e", 875 | "sha256:fb03b63e01fd848abb4219b2c2103a1962358c8f4db5150f7a5c1136d3e24d2e" 876 | ], 877 | "version": "==6.0b1" 878 | }, 879 | "regex": { 880 | "hashes": [ 881 | "sha256:0de8ad66b08c3e673b61981b9e3626f8784d5564f8c3928e2ad408c0eb5ac38c", 882 | "sha256:1f1125bc5172ab3a049bc6f4b9c0aae95a2a2001a77e6d6e4239fa3653e202b5", 883 | "sha256:255791523f80ea8e48e79af7120b4697ef3b74f6886995dcdb08c41f8e516be0", 884 | "sha256:28040e89a04b60d579c69095c509a4f6a1a5379cd865258e3a186b7105de72c6", 885 | "sha256:37868075eda024470bd0feab872c692ac4ee29db1e14baec103257bf6cc64346", 886 | "sha256:3b71213ec3bad9a5a02e049f2ec86b3d7c3e350129ae0f4e2f99c12b5da919ed", 887 | "sha256:3be40f720af170a6b20ddd2ad7904c58b13d2b56f6734ee5d09bbdeed2fa4816", 888 | "sha256:42952d325439ef223e4e9db7ee6d9087b5c68c5c15b1f9de68e990837682fc7b", 889 | "sha256:470f2c882f2672d8eeda8ab27992aec277c067d280b52541357e1acd7e606dae", 890 | "sha256:4907fb0f9b9309a5bded72343e675a252c2589a41871874feace9a05a540241e", 891 | "sha256:4d87459ad3ab40cd8493774f8a454b2e490d8e729e7e402a0625867a983e4e02", 892 | "sha256:4fa7ba9ab2eba7284e0d7d94f61df7af86015b0398e123331362270d71fab0b9", 893 | "sha256:5b34d2335d6aedec7dcadd3f8283b9682fadad8b9b008da8788d2fce76125ebe", 894 | "sha256:6348a7ab2a502cbdd0b7fd0496d614007489adb7361956b38044d1d588e66e04", 895 | "sha256:638e98d069b14113e8afba6a54d1ca123f712c0d105e67c1f9211b2a825ef926", 896 | "sha256:66696c8336a1b5d1182464f3af3427cc760118f26d0b09a2ddc16a976a4d2637", 897 | "sha256:78cf6a1e023caf5e9a982f5377414e1aeac55198831b852835732cfd0a0ca5ff", 898 | "sha256:81e125d9ba54c34579e4539a967e976a3c56150796674aec318b1b2f49251be7", 899 | "sha256:81fdc90f999b2147fc62e303440c424c47e5573a9b615ed5d43a5b832efcca9e", 900 | "sha256:87e9c489aa98f50f367fb26cc9c8908d668e9228d327644d7aa568d47e456f47", 901 | "sha256:8c1ad61fa024195136a6b7b89538030bd00df15f90ac177ca278df9b2386c96f", 902 | "sha256:9910869c472e5a6728680ca357b5846546cbbd2ab3ad5bef986ef0bc438d0aa6", 903 | "sha256:9925985be05d54b3d25fd6c1ea8e50ff1f7c2744c75bdc4d3b45c790afa2bcb3", 904 | "sha256:9a0b0db6b49da7fa37ca8eddf9f40a8dbc599bad43e64f452284f37b6c34d91c", 905 | "sha256:9c065d95a514a06b92a5026766d72ac91bfabf581adb5b29bc5c91d4b3ee9b83", 906 | "sha256:a6f08187136f11e430638c2c66e1db091105d7c2e9902489f0dbc69b44c222b4", 907 | "sha256:ad0517df22a97f1da20d8f1c8cb71a5d1997fa383326b81f9cf22c9dadfbdf34", 908 | "sha256:b345ecde37c86dd7084c62954468a4a655fd2d24fd9b237949dd07a4d0dd6f4c", 909 | "sha256:b55442650f541d195a535ccec33078c78a9521973fb960923da7515e9ed78fa6", 910 | "sha256:c2b180ed30856dfa70cfe927b0fd38e6b68198a03039abdbeb1f2029758d87e7", 911 | "sha256:c9e30838df7bfd20db6466fd309d9b580d32855f8e2c2e6d74cf9da27dcd9b63", 912 | "sha256:cae4099031d80703954c39680323dabd87a69b21262303160776aa0e55970ca0", 913 | "sha256:ce7b1cca6c23f19bee8dc40228d9c314d86d1e51996b86f924aca302fc8f8bf9", 914 | "sha256:d0861e7f6325e821d5c40514c551fd538b292f8cc3960086e73491b9c5d8291d", 915 | "sha256:d331f238a7accfbbe1c4cd1ba610d4c087b206353539331e32a8f05345c74aec", 916 | "sha256:e07049cece3462c626d650e8bf42ddbca3abf4aa08155002c28cb6d9a5a281e2", 917 | "sha256:e2cb7d4909ed16ed35729d38af585673f1f0833e73dfdf0c18e5be0061107b99", 918 | "sha256:e3770781353a4886b68ef10cec31c1f61e8e3a0be5f213c2bb15a86efd999bc4", 919 | "sha256:e502f8d4e5ef714bcc2c94d499684890c94239526d61fdf1096547db91ca6aa6", 920 | "sha256:e6f2d2f93001801296fe3ca86515eb04915472b5380d4d8752f09f25f0b9b0ed", 921 | "sha256:f588209d3e4797882cd238195c175290dbc501973b10a581086b5c6bcd095ffb" 922 | ], 923 | "version": "==2021.9.30" 924 | }, 925 | "rfc3986": { 926 | "extras": [ 927 | "idna2008" 928 | ], 929 | "hashes": [ 930 | "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", 931 | "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" 932 | ], 933 | "version": "==1.5.0" 934 | }, 935 | "six": { 936 | "hashes": [ 937 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 938 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 939 | ], 940 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 941 | "version": "==1.16.0" 942 | }, 943 | "sniffio": { 944 | "hashes": [ 945 | "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", 946 | "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" 947 | ], 948 | "markers": "python_version >= '3.5'", 949 | "version": "==1.2.0" 950 | }, 951 | "toml": { 952 | "hashes": [ 953 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 954 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 955 | ], 956 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 957 | "version": "==0.10.2" 958 | }, 959 | "tomli": { 960 | "hashes": [ 961 | "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f", 962 | "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442" 963 | ], 964 | "markers": "python_version >= '3.6'", 965 | "version": "==1.2.1" 966 | }, 967 | "typing-extensions": { 968 | "hashes": [ 969 | "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e", 970 | "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7", 971 | "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34" 972 | ], 973 | "index": "pypi", 974 | "version": "==3.10.0.2" 975 | }, 976 | "virtualenv": { 977 | "hashes": [ 978 | "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300", 979 | "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8" 980 | ], 981 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 982 | "version": "==20.8.1" 983 | } 984 | } 985 | } 986 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Project logo 2 | 3 |

Tech With Tim - API

4 | 5 |
6 | 7 | [![Status](https://img.shields.io/website?url=https%3A%2F%2Fapi.dev.twtcodejam.net)](https://api.dev.twtcodejam.net) 8 | [![GitHub Issues](https://img.shields.io/github/issues/Tech-With-Tim/API.svg)](https://github.com/Tech-With-Tim/API/issues) 9 | [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/Tech-With-Tim/API.svg)](https://github.com/Tech-With-Tim/API/pulls) 10 | [![Licence](https://img.shields.io/badge/licence-MIT-blue.svg)](/LICENCE) 11 | [![Discord](https://discord.com/api/guilds/501090983539245061/widget.png?style=shield)](https://discord.gg/twt) 12 | [![Test and deploy](https://github.com/Tech-With-Tim/API/workflows/Release%20-%20Test%2C%20Build%20%26%20Redeploy/badge.svg)](https://github.com/Tech-With-Tim/API/actions?query=workflow%3A%22Release+-+Test%2C+Build+%26+Redeploy%22) 13 | 14 | 15 | 16 |
17 | 18 | API for the Tech With Tim website using [FastAPI](https://fastapi.tiangolo.com/). 19 | 20 | ## 📝 Table of Contents 21 | 22 | 23 | 24 | - [🏁 Getting Started](#-getting-started) 25 | - [Discord application](#discord-application) 26 | - [Prerequisites](#prerequisites) 27 | - [Environment variables](#environment-variables) 28 | - [Running](#running) 29 | - [🐳 Running with Docker](#-running-with-docker) 30 | - [✅ Linting](#-linting) 31 | - [🚨 Tests](#-tests) 32 | - [📚 Docs](/docs/README.md) 33 | - [📜 Licence](/LICENCE) 34 | - [⛏️ Built Using](#️-built-using) 35 | - [✍️ Authors](#️-authors) 36 | 37 | 40 | 41 | ## 🏁 Getting Started 42 | 43 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See [Running with Docker](#-running-with-docker) if you want to setup the API faster with Docker. 44 | 45 | ### Discord application 46 | 47 | Create a new Discord application [here](https://discord.com/developers/applications) by clicking the `New application` button and name it whatever you want. 48 | 49 | ![New application](https://cdn.discordapp.com/attachments/721750194797936823/794646477505822730/unknown.png) 50 | 51 | Now that you have an application, go to the OAuth2 tab. 52 | 53 | ![OAuth2 tab](https://cdn.discordapp.com/attachments/721750194797936823/794648158272487435/unknown.png) 54 | 55 | And add `http://127.0.0.1:5000/auth/discord/callback` to the redirects. 56 | 57 | ![Redirects](https://cdn.discordapp.com/attachments/721750194797936823/798276213776318494/unknown.png) 58 | 59 | ### Prerequisites 60 | 61 | Install Pipenv: 62 | 63 | ```sh 64 | pip install pipenv 65 | ``` 66 | 67 | Install the required packages and the packages for development with Pipenv: 68 | 69 | ```sh 70 | pipenv install --dev 71 | ``` 72 | 73 | ### Environment variables 74 | 75 | #### Required 76 | 77 | Start by writing this in a file named `.env`: 78 | 79 | ```prolog 80 | REDIS_URI= 81 | SECRET_KEY= 82 | POSTGRES_URI= 83 | DISCORD_CLIENT_ID= 84 | DISCORD_CLIENT_SECRET= 85 | ``` 86 | 87 | And fill in the variables with the values below: 88 | 89 | - `REDIS_URI` is the Redis server URI. 90 | - `SECRET_KEY` is the key used for JWT token encoding. 91 | - `POSTGRES_URI` is the PostgreSQL database URI. 92 | - `DISCORD_CLIENT_ID` is the Discord application ID. Copy it from your Discord application page (see below). 93 | - `DISCORD_CLIENT_SECRET` is the Discord application secret. Copy it from your Discord application page (see below). 94 | 95 | ![Client ID and secret](https://cdn.discordapp.com/attachments/721750194797936823/794646777840140298/unknown.png) 96 | 97 | #### Optional 98 | 99 | For testing you need to add these environment variables: 100 | 101 | - `TEST_REDIS_URI` is the Connection URI for Redis testing server. 102 | - `TEST_POSTGRES_URI` is the PostgreSQL database URI for tests. 103 | 104 | If you are self hosting the Piston API, you need to set the `PISTON_URL` environment variable. 105 | 106 | ### Running 107 | 108 | Run the API and initialise the database: 109 | 110 | #### Make sure submodules are up to date. 111 | > If you have not initialized submodules use this command:\ 112 | > `git submodule update --init` 113 | > 114 | > To update submodules:\ 115 | > `git submodule foreach git pull` 116 | 117 | ```sh 118 | pipenv run python launch.py runserver --initdb 119 | ``` 120 | 121 | The API should run at [http://127.0.0.1:5000](http://127.0.0.1:5000). For more information about the CLI, check the docs [here](/docs/cli.md). 122 | 123 | ## 🐳 Running with Docker 124 | 125 | Both the API and the [frontend](https://github.com/Tech-With-Tim/Frontend) can be started using Docker. Using Docker is generally recommended (but not strictly required) because it abstracts away some additional set up work. 126 | 127 | - Setup the discord app like done [here](#discord-application). 128 | 129 | - Make a file named `.env` like done [here](#environment-variables). You don't need the DB_URI environment variable though. 130 | 131 | - Then make sure you have `docker` and `docker-compose` installed, if not read [this for docker](https://docs.docker.com/engine/install/) and [this for docker compose](https://docs.docker.com/compose/install/). 132 | 133 | - Deploy the API: 134 | 135 | ```sh 136 | docker-compose up --build api 137 | ``` 138 | 139 | ## ✅ Linting 140 | 141 | We use a pre-commit hook for linting the code before each commit. Set up the pre-commit hook: 142 | 143 | ```sh 144 | pipenv run pre-commit install 145 | ``` 146 | 147 | If you want to run the pre-commit checks before trying to commit, you can do it with: 148 | 149 | ```sh 150 | pipenv run lint 151 | ``` 152 | 153 | ## 🚨 Tests 154 | 155 | To test the API, we use the [pytest](https://docs.pytest.org/en/stable/) framework to make sure that the code we write works. 156 | 157 | Run the tests: 158 | 159 | ```sh 160 | pipenv run test 161 | ``` 162 | 163 | **When you contribute, you need to add tests on the features you add.** 164 | 165 | ## ⛏️ Built Using 166 | 167 | - [Python](https://www.python.org/) - Language 168 | - [FastAPI](https://fastapi.tiangolo.com/) - Backend framework 169 | - [PostDB](https://github.com/SylteA/postDB) - Database module 170 | - [pytest](https://docs.pytest.org/en/stable/) - Testing framework 171 | - [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) - Testing plugin for [pytest](https://docs.pytest.org/en/stable/) 172 | 173 | ## ✍️ Authors 174 | 175 | - [@SylteA](https://github.com/SylteA) - Most of the backend 176 | - [@Shubhaankar-sharma](https://github.com/Shubhaankar-sharma) - Docker deployment 177 | - [@takos22](https://github.com/takos22) - Some endpoints and markdown files 178 | 179 | See also the list of [contributors](https://github.com/Tech-With-Tim/API/contributors) who participated in this project. 180 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import app 2 | 3 | __all__ = ("app",) 4 | -------------------------------------------------------------------------------- /api/app.py: -------------------------------------------------------------------------------- 1 | from fastapi.exceptions import RequestValidationError 2 | from fastapi import FastAPI, HTTPException, Request 3 | from fastapi.middleware.cors import CORSMiddleware 4 | from fakeredis.aioredis import FakeRedis 5 | from aiohttp import ClientSession 6 | from aioredis import Redis 7 | import logging 8 | 9 | from utils.response import JSONResponse 10 | from api import versions 11 | import config 12 | 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | app = FastAPI( 17 | title="Tech With Tim", 18 | docs_url="/api/docs", 19 | redoc_url="/api/redoc", 20 | openapi_url="/api/docs/openapi.json", 21 | openapi_tags=[ 22 | {"name": "roles", "description": "Manage roles"}, 23 | ], 24 | ) 25 | app.router.prefix = "/api" 26 | app.router.default_response_class = JSONResponse 27 | 28 | origins = ["*"] # TODO: change origins later 29 | app.add_middleware( 30 | CORSMiddleware, 31 | allow_methods=["*"], 32 | allow_headers=["*"], 33 | allow_origins=origins, 34 | expose_headers=["Location"], 35 | ) 36 | app.include_router(versions.v1.router) 37 | 38 | 39 | @app.on_event("startup") 40 | async def on_startup(): 41 | """Creates a ClientSession to be used app-wide.""" 42 | from api.services import redis, http 43 | 44 | if http.session is None or http.session.closed: 45 | http.session = ClientSession() 46 | log.info("Created HTTP ClientSession.") 47 | 48 | if redis.pool is None or redis.pool.connection is None: 49 | if (redis_uri := config.redis_uri()) is not None: 50 | redis.pool = Redis.from_url(redis_uri) 51 | log.info("Connected to redis server: " + str(redis.pool)) 52 | else: 53 | redis.pool = FakeRedis() 54 | log.warning( 55 | "\n" 56 | " > Created FakeRedis server, using a real redis server is suggested.\n" 57 | " > You can launch a local one using `docker compose up redis` and providing the url in env." 58 | ) 59 | 60 | 61 | @app.on_event("shutdown") 62 | async def on_shutdown(): 63 | """Closes the app-wide ClientSession""" 64 | from api.services import redis, http 65 | 66 | if http.session is not None and not http.session.closed: 67 | await http.session.close() 68 | 69 | if redis.pool is not None: 70 | await redis.pool.close() 71 | 72 | 73 | @app.exception_handler(RequestValidationError) 74 | async def validation_handler(_: Request, err: RequestValidationError): 75 | return JSONResponse( 76 | status_code=422, content={"error": "Invalid data", "data": err.errors()} 77 | ) 78 | 79 | 80 | @app.exception_handler(500) 81 | async def error_500(_: Request, error: HTTPException): 82 | """ 83 | TODO: Handle the error with our own error handling system. 84 | """ 85 | log.error( 86 | "500 - Internal Server Error", 87 | exc_info=(type(error), error, error.__traceback__), 88 | ) 89 | 90 | return JSONResponse( 91 | status_code=500, 92 | content={ 93 | "error": "Internal Server Error", 94 | "message": "Server got itself in trouble", 95 | }, 96 | ) 97 | -------------------------------------------------------------------------------- /api/dependencies.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import utils 3 | import config 4 | 5 | from api.models import User 6 | from typing import List, Union 7 | from fastapi import Depends, HTTPException, Request 8 | 9 | from api.models import Role 10 | from api.models.permissions import BasePermission 11 | 12 | 13 | def authorization(app_only: bool = False, user_only: bool = False): 14 | if app_only and user_only: 15 | raise ValueError("app_only and user_only are mutually exclusive") 16 | 17 | async def inner(request: Request): 18 | """Attempts to locate and decode JWT token.""" 19 | token = request.headers.get("authorization") 20 | 21 | if token is None: 22 | raise HTTPException(status_code=401) 23 | 24 | try: 25 | data = jwt.decode( 26 | jwt=token, 27 | algorithms=["HS256"], 28 | key=config.secret_key(), 29 | ) 30 | except jwt.PyJWTError: 31 | raise HTTPException(status_code=401, detail="Invalid token.") 32 | 33 | data["uid"] = int(data["uid"]) 34 | 35 | user = await User.fetch(data["uid"]) 36 | if not user: 37 | raise HTTPException(status_code=401, detail="Invalid token.") 38 | 39 | if app_only and not user.app: 40 | raise HTTPException(status_code=403, detail="Users can't use this endpoint") 41 | 42 | if user_only and user.app: 43 | raise HTTPException(status_code=403, detail="Bots can't use this endpoint") 44 | 45 | return user 46 | 47 | return Depends(inner) 48 | 49 | 50 | def has_permissions(permissions: List[Union[int, BasePermission]]): 51 | async def inner(user=authorization()): 52 | query = """ 53 | SELECT * 54 | FROM roles r 55 | WHERE r.id IN ( 56 | SELECT ur.role_id 57 | FROM userroles ur 58 | WHERE ur.user_id = $1 59 | ) 60 | """ 61 | records = await Role.pool.fetch(query, user.id) 62 | if not records: 63 | raise HTTPException(403, "Missing Permissions") 64 | 65 | user_permissions = 0 66 | for record in records: 67 | user_permissions |= record["permissions"] 68 | 69 | if not utils.has_permissions(user_permissions, permissions): 70 | raise HTTPException(403, "Missing Permissions") 71 | 72 | return [Role(**record) for record in records] 73 | 74 | return Depends(inner) 75 | -------------------------------------------------------------------------------- /api/services/http.py: -------------------------------------------------------------------------------- 1 | from aiohttp import ClientSession 2 | from typing import Optional 3 | 4 | 5 | __all__ = ("session",) 6 | 7 | session: Optional[ClientSession] = None 8 | -------------------------------------------------------------------------------- /api/services/piston.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, List 3 | 4 | import config 5 | from api.services import http 6 | 7 | 8 | __all__ = ("get_runtimes", "get_runtimes_dict", "get_runtime", "Runtime") 9 | 10 | log = logging.getLogger() 11 | 12 | _base_url: str = ( 13 | config.piston_url().rstrip("/") + "/" 14 | ) # make sure there's a / at the end 15 | 16 | 17 | async def _make_request(method: str, endpoint: str, data: Any = None) -> Any: 18 | async with http.session.request( 19 | method, 20 | _base_url + endpoint, 21 | json=data, 22 | raise_for_status=True, 23 | ) as response: 24 | return await response.json() 25 | 26 | 27 | async def get_runtimes() -> List["Runtime"]: 28 | """Get a list of all available runtimes.""" 29 | runtimes = await _make_request("GET", "runtimes") 30 | return [Runtime(runtime) for runtime in runtimes] 31 | 32 | 33 | async def get_runtimes_dict() -> Dict[str, List["Runtime"]]: 34 | """Get a dictionary of language names and aliases mapped to a list of 35 | all the runtimes with that name or alias. 36 | """ 37 | 38 | runtimes = await get_runtimes() 39 | runtimes_dict = {} 40 | 41 | for runtime in runtimes: 42 | if runtime.language in runtimes_dict: 43 | runtimes_dict[runtime.language].append(runtime) 44 | else: 45 | runtimes_dict[runtime.language] = [runtime] 46 | 47 | for alias in runtime.aliases: 48 | if alias in runtimes_dict: 49 | runtimes_dict[alias].append(runtime) 50 | else: 51 | runtimes_dict[alias] = [runtime] 52 | 53 | return runtimes_dict 54 | 55 | 56 | async def get_runtime(language: str) -> List["Runtime"]: 57 | """Get a runtime with a language or an alias.""" 58 | 59 | runtimes_dict = await get_runtimes_dict() 60 | return runtimes_dict.get(language, []) 61 | 62 | 63 | class Runtime: 64 | def __init__(self, data: dict): 65 | self.language = data["language"] 66 | self.version = data["version"] 67 | self.aliases = data["aliases"] 68 | self.runtime = data.get("runtime") 69 | -------------------------------------------------------------------------------- /api/services/redis.py: -------------------------------------------------------------------------------- 1 | from aioredis.client import EncodableT, ChannelT 2 | from fakeredis.aioredis import FakeRedis 3 | from typing import Optional, Union, Any 4 | from aioredis import Redis 5 | import logging 6 | import json 7 | 8 | 9 | __all__ = ("pool",) 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | async def dispatch(channel: ChannelT, message: Union[EncodableT, list, dict]) -> Any: 15 | """Dispatch a pubsub message to the specified channel with the provided message.""" 16 | if pool is None or type(pool) == FakeRedis: 17 | log.warning("Skipping dispatch call due to missing redis pubsub server.") 18 | return 19 | 20 | if isinstance(message, (list, dict)): 21 | message = json.dumps(message) 22 | 23 | return await pool.publish(channel=channel, message=message) 24 | 25 | 26 | pool: Optional[Union[FakeRedis, Redis]] = None 27 | -------------------------------------------------------------------------------- /api/versions/__init__.py: -------------------------------------------------------------------------------- 1 | from . import v1 2 | 3 | 4 | __all__ = (v1,) 5 | -------------------------------------------------------------------------------- /api/versions/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from .routers.router import router 2 | 3 | 4 | __all__ = (router,) 5 | -------------------------------------------------------------------------------- /api/versions/v1/routers/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | 3 | 4 | __all__ = (router,) 5 | -------------------------------------------------------------------------------- /api/versions/v1/routers/auth/helpers.py: -------------------------------------------------------------------------------- 1 | import config 2 | import typing 3 | 4 | from api.services import http 5 | from urllib.parse import quote_plus 6 | 7 | 8 | DISCORD_ENDPOINT = "https://discord.com/api" 9 | SCOPES = ["identify"] 10 | 11 | 12 | async def exchange_code( 13 | *, code: str, scope: str, redirect_uri: str, grant_type: str = "authorization_code" 14 | ) -> typing.Tuple[dict, int]: 15 | """Exchange discord oauth code for access and refresh tokens.""" 16 | async with http.session.post( 17 | "%s/v6/oauth2/token" % DISCORD_ENDPOINT, 18 | data=dict( 19 | code=code, 20 | scope=scope, 21 | grant_type=grant_type, 22 | redirect_uri=redirect_uri, 23 | client_id=config.discord_client_id(), 24 | client_secret=config.discord_client_secret(), 25 | ), 26 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 27 | ) as response: 28 | return await response.json(), response.status 29 | 30 | 31 | async def get_user(access_token: str) -> dict: 32 | """Coroutine to fetch User data from discord using the users `access_token`""" 33 | async with http.session.get( 34 | "%s/v6/users/@me" % DISCORD_ENDPOINT, 35 | headers={"Authorization": "Bearer %s" % access_token}, 36 | ) as response: 37 | return await response.json() 38 | 39 | 40 | def format_scopes(scopes: typing.List[str]) -> str: 41 | """Format a list of scopes.""" 42 | return " ".join(scopes) 43 | 44 | 45 | def get_redirect(callback: str, scopes: typing.List[str]) -> str: 46 | """Generates the correct oauth link depending on our provided arguments.""" 47 | return ( 48 | "{BASE}/oauth2/authorize?response_type=code" 49 | "&client_id={client_id}" 50 | "&scope={scopes}" 51 | "&redirect_uri={redirect_uri}" 52 | "&prompt=consent" 53 | ).format( 54 | BASE=DISCORD_ENDPOINT, 55 | scopes=format_scopes(scopes), 56 | redirect_uri=quote_plus(callback), 57 | client_id=config.discord_client_id(), 58 | ) 59 | -------------------------------------------------------------------------------- /api/versions/v1/routers/auth/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pydantic import BaseModel, HttpUrl 3 | 4 | 5 | class CallbackResponse(BaseModel): 6 | token: str 7 | exp: datetime 8 | 9 | 10 | class CallbackBody(BaseModel): 11 | code: str 12 | callback: HttpUrl 13 | -------------------------------------------------------------------------------- /api/versions/v1/routers/auth/routes.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import utils 3 | import config 4 | 5 | from pydantic import HttpUrl 6 | from fastapi import APIRouter, Request 7 | from datetime import datetime, timedelta 8 | from fastapi.responses import RedirectResponse 9 | 10 | from api.models import User, Token 11 | from .models import CallbackBody, CallbackResponse 12 | from .helpers import ( 13 | SCOPES, 14 | get_user, 15 | get_redirect, 16 | exchange_code, 17 | format_scopes, 18 | ) 19 | 20 | router = APIRouter(prefix="/auth") 21 | 22 | 23 | @router.get( 24 | "/discord/redirect", 25 | tags=["auth"], 26 | status_code=307, 27 | ) 28 | async def redirect_to_discord_oauth_portal(request: Request, callback: HttpUrl = None): 29 | """Redirect user to correct oauth link depending on specified domain and requested scopes.""" 30 | callback = callback or (str(request.base_url) + "api/v1/auth/discord/callback") 31 | 32 | return RedirectResponse( 33 | get_redirect(callback=callback, scopes=SCOPES), status_code=307 34 | ) 35 | 36 | 37 | if config.debug(): 38 | 39 | @router.get( 40 | "/discord/callback", 41 | tags=["auth"], 42 | response_model=CallbackResponse, 43 | response_description="GET Discord OAuth Callback", 44 | ) 45 | async def get_discord_oauth_callback( 46 | request: Request, code: str, callback: HttpUrl = None 47 | ): 48 | """ 49 | Callback endpoint for finished discord authorization flow. 50 | """ 51 | callback = callback or (str(request.base_url) + "api/v1/auth/discord/callback") 52 | return await post_discord_oauth_callback( 53 | CallbackBody(code=code, callback=callback) 54 | ) 55 | 56 | 57 | @router.post( 58 | "/discord/callback", 59 | tags=["auth"], 60 | response_model=CallbackResponse, 61 | response_description="POST Discord OAuth Callback", 62 | ) 63 | async def post_discord_oauth_callback(data: CallbackBody): 64 | """ 65 | Callback endpoint for finished discord authorization flow. 66 | """ 67 | access_data, status_code = await exchange_code( 68 | code=data.code, scope=format_scopes(SCOPES), redirect_uri=data.callback 69 | ) 70 | 71 | if access_data.get("error", False): 72 | if status_code == 400: 73 | return utils.JSONResponse( 74 | { 75 | "error": "Bad Request", 76 | "message": "Discord returned 400 status.", 77 | "data": access_data, 78 | }, 79 | 400, 80 | ) 81 | 82 | if status_code < 200 or status_code >= 300: 83 | return utils.JSONResponse( 84 | { 85 | "error": "Bad Gateway", 86 | "message": "Discord returned non 2xx status code", 87 | }, 88 | 502, 89 | ) 90 | 91 | expires_at = datetime.utcnow() + timedelta(seconds=access_data["expires_in"]) 92 | expires_at = expires_at.replace(microsecond=0) 93 | 94 | user_data = await get_user(access_token=access_data["access_token"]) 95 | user_data["id"] = uid = int(user_data["id"]) 96 | 97 | user = await User.fetch(id=uid) 98 | 99 | if user is None: 100 | user = await User.create( 101 | id=user_data["id"], 102 | username=user_data["username"], 103 | discriminator=user_data["discriminator"], 104 | avatar=user_data["avatar"], 105 | ) 106 | 107 | await Token( 108 | user_id=user.id, 109 | data=access_data, 110 | expires_at=expires_at, 111 | token=access_data["access_token"], 112 | ).update() 113 | 114 | token = jwt.encode( 115 | {"uid": user.id, "exp": expires_at, "iat": datetime.utcnow()}, 116 | key=config.secret_key(), 117 | ) 118 | 119 | return {"token": token, "exp": expires_at} 120 | -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/__init__.py: -------------------------------------------------------------------------------- 1 | from . import languages 2 | from .routes import router 3 | 4 | router.include_router(languages.router) 5 | 6 | __all__ = (router,) 7 | -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/languages/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | 3 | __all__ = (router,) 4 | -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/languages/helpers.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | 3 | from api.services import piston 4 | 5 | 6 | async def check_piston_language_version(language: str, version: str): 7 | """Checks if a language and its version are installed on the Piston service. 8 | 9 | Raises an :class:`fastapi.HTTPException` otherwise with a 404 status code. 10 | """ 11 | 12 | runtimes = await piston.get_runtime(language) 13 | if not runtimes: 14 | raise HTTPException(404, "Piston language not found") 15 | 16 | versions = [runtime.version for runtime in runtimes] 17 | if version not in versions: 18 | raise HTTPException(404, "Piston language version not found") 19 | -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/languages/models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, Field, HttpUrl 4 | 5 | 6 | class ChallengeLanguageResponse(BaseModel): 7 | id: str 8 | name: str 9 | download_url: Optional[HttpUrl] 10 | disabled: bool = False 11 | piston_lang: str 12 | piston_lang_ver: str 13 | 14 | 15 | class NewChallengeLanguageBody(BaseModel): 16 | name: str = Field(..., min_length=4, max_length=32) 17 | download_url: Optional[HttpUrl] = None 18 | disabled: bool = False 19 | piston_lang: str 20 | piston_lang_ver: str 21 | 22 | 23 | class UpdateChallengeLanguageBody(BaseModel): 24 | name: str = Field("", min_length=4, max_length=32) 25 | download_url: Optional[HttpUrl] = None 26 | disabled: bool = False 27 | piston_lang: str = "" 28 | piston_lang_ver: str = "" 29 | -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/languages/routes.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import asyncpg 4 | from fastapi import APIRouter, HTTPException, Response 5 | 6 | import utils 7 | from api.dependencies import has_permissions 8 | from api.models import ChallengeLanguage 9 | from api.models.permissions import ManageWeeklyChallengeLanguages 10 | 11 | from .helpers import check_piston_language_version 12 | from .models import ( 13 | ChallengeLanguageResponse, 14 | NewChallengeLanguageBody, 15 | UpdateChallengeLanguageBody, 16 | ) 17 | 18 | router = APIRouter(prefix="/languages") 19 | 20 | 21 | @router.get( 22 | "", 23 | tags=["challenge languages"], 24 | response_model=List[ChallengeLanguageResponse], 25 | ) 26 | async def fetch_all_languages(): 27 | """Fetch all the weekly challenge languages, ordered alphabetically.""" 28 | 29 | query = """ 30 | SELECT *, 31 | l.id::TEXT 32 | FROM challengelanguages l 33 | ORDER BY name 34 | """ 35 | records = await ChallengeLanguage.pool.fetch(query) 36 | 37 | return [dict(record) for record in records] 38 | 39 | 40 | @router.get( 41 | "/{id}", 42 | tags=["challenge languages"], 43 | response_model=ChallengeLanguageResponse, 44 | responses={ 45 | 404: {"description": "Language not found"}, 46 | }, 47 | ) 48 | async def fetch_language(id: int): 49 | """Fetch a weekly challenge language by its id.""" 50 | 51 | query = """ 52 | SELECT *, 53 | l.id::TEXT 54 | FROM challengelanguages l 55 | WHERE l.id = $1 56 | """ 57 | record = await ChallengeLanguage.pool.fetchrow(query, id) 58 | 59 | if not record: 60 | raise HTTPException(404, "Language not found") 61 | 62 | return dict(record) 63 | 64 | 65 | @router.post( 66 | "", 67 | tags=["challenge languages"], 68 | response_model=ChallengeLanguageResponse, 69 | responses={ 70 | 201: {"description": "Language Created Successfully"}, 71 | 401: {"description": "Unauthorized"}, 72 | 403: {"description": "Missing Permissions"}, 73 | 404: {"description": "Piston language or version not found"}, 74 | 409: {"description": "Language with that name already exists"}, 75 | }, 76 | status_code=201, 77 | response_class=utils.JSONResponse, 78 | dependencies=[has_permissions([ManageWeeklyChallengeLanguages()])], 79 | ) 80 | async def create_language(body: NewChallengeLanguageBody): 81 | """Create a weekly challenge language.""" 82 | 83 | await check_piston_language_version(body.piston_lang, body.piston_lang_ver) 84 | 85 | query = """ 86 | INSERT INTO challengelanguages (id, name, download_url, disabled, piston_lang, piston_lang_ver) 87 | VALUES (create_snowflake(), $1, $2, $3, $4, $5) 88 | RETURNING *; 89 | """ 90 | 91 | try: 92 | record = await ChallengeLanguage.pool.fetchrow( 93 | query, 94 | body.name, 95 | body.download_url, 96 | body.disabled, 97 | body.piston_lang, 98 | body.piston_lang_ver, 99 | ) 100 | except asyncpg.exceptions.UniqueViolationError: 101 | raise HTTPException(409, "Language with that name already exists") 102 | 103 | return dict(record) 104 | 105 | 106 | @router.patch( 107 | "/{id}", 108 | tags=["challenge languages"], 109 | responses={ 110 | 204: {"description": "Language Updated Successfully"}, 111 | 401: {"description": "Unauthorized"}, 112 | 403: {"description": "Missing Permissions"}, 113 | 404: {"description": "Piston language or version not found"}, 114 | 409: {"description": "Language with that name already exists"}, 115 | }, 116 | status_code=204, 117 | dependencies=[has_permissions([ManageWeeklyChallengeLanguages()])], 118 | ) 119 | async def update_language(id: int, body: UpdateChallengeLanguageBody): 120 | """Update a weekly challenge language.""" 121 | 122 | query = "SELECT * FROM challengelanguages WHERE id = $1" 123 | record = await ChallengeLanguage.pool.fetchrow(query, id) 124 | 125 | if not record: 126 | raise HTTPException(404, "Language not found") 127 | 128 | language = ChallengeLanguage(**record) 129 | data = body.dict(exclude_unset=True) 130 | 131 | if "piston_lang" in data or "piston_lang_ver" in data: 132 | await check_piston_language_version( 133 | data.get("piston_lang", language.piston_lang), 134 | data.get("piston_lang_ver", language.piston_lang_ver), 135 | ) 136 | 137 | if data: 138 | query = "UPDATE challengelanguages SET " 139 | query += ", ".join(f"{key} = ${i}" for i, key in enumerate(data, 2)) 140 | query += " WHERE id = $1" 141 | 142 | try: 143 | await ChallengeLanguage.pool.execute(query, id, *data.values()) 144 | except asyncpg.exceptions.UniqueViolationError: 145 | raise HTTPException(409, "Language with that name already exists") 146 | 147 | return Response(status_code=204, content="") 148 | 149 | 150 | @router.delete( 151 | "/{id}", 152 | tags=["challenge languages"], 153 | responses={ 154 | 204: {"description": "Language Deleted Successfully"}, 155 | 401: {"description": "Unauthorized"}, 156 | 403: {"description": "Missing Permissions or language used in a challenge"}, 157 | 404: {"description": "Language not found"}, 158 | }, 159 | status_code=204, 160 | dependencies=[has_permissions([ManageWeeklyChallengeLanguages()])], 161 | ) 162 | async def delete_language(id: int): 163 | """Delete a weekly challenge language, if it hasn't been used in any challenges.""" 164 | query = "SELECT * FROM challengelanguages WHERE id = $1" 165 | record = await ChallengeLanguage.pool.fetchrow(query, id) 166 | 167 | if not record: 168 | raise HTTPException(404, "Language not found") 169 | 170 | # language = ChallengeLanguage(**record) 171 | 172 | query = """ 173 | SELECT * FROM challenges WHERE $1 = ANY(language_ids) 174 | """ 175 | records = await ChallengeLanguage.pool.fetch(query, id) 176 | if records: 177 | raise HTTPException(403, "Language used in a challenge") 178 | 179 | await ChallengeLanguage.pool.execute( 180 | "DELETE FROM challengelanguages WHERE id = $1", id 181 | ) 182 | 183 | return Response(status_code=204, content="") 184 | -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tech-With-Tim/API/b37911d3afa4e843ce2ffeca3fc37baaa0f3facf/api/versions/v1/routers/challenges/models.py -------------------------------------------------------------------------------- /api/versions/v1/routers/challenges/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | router = APIRouter(prefix="/challenges") 4 | -------------------------------------------------------------------------------- /api/versions/v1/routers/roles/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | 3 | 4 | __all__ = (router,) 5 | -------------------------------------------------------------------------------- /api/versions/v1/routers/roles/models.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel, Field 3 | 4 | 5 | class RoleResponse(BaseModel): 6 | id: str 7 | name: str 8 | position: int 9 | permissions: int 10 | color: Optional[int] 11 | 12 | 13 | class DetailedRoleResponse(RoleResponse): 14 | members: List[str] 15 | 16 | 17 | class NewRoleBody(BaseModel): 18 | name: str = Field(..., min_length=4, max_length=32) 19 | color: Optional[int] = Field(None, le=0xFFFFFF, ge=0) 20 | permissions: Optional[int] = Field(0, ge=0) 21 | 22 | 23 | class UpdateRoleBody(BaseModel): 24 | name: str = Field("", min_length=4, max_length=64) 25 | color: Optional[int] = Field(None, le=0xFFFFFF, ge=0) 26 | permissions: int = Field(0, ge=0) 27 | position: int = Field(0, ge=0) 28 | -------------------------------------------------------------------------------- /api/versions/v1/routers/roles/routes.py: -------------------------------------------------------------------------------- 1 | import utils 2 | import asyncpg 3 | 4 | from typing import List, Union 5 | from fastapi import APIRouter, HTTPException, Response 6 | 7 | from api.models import Role, UserRole 8 | from api.dependencies import has_permissions 9 | from api.models.permissions import ManageRoles 10 | from api.versions.v1.routers.roles.models import ( 11 | NewRoleBody, 12 | RoleResponse, 13 | UpdateRoleBody, 14 | DetailedRoleResponse, 15 | ) 16 | 17 | 18 | router = APIRouter(prefix="/roles") 19 | 20 | 21 | @router.get("", tags=["roles"], response_model=List[RoleResponse]) 22 | async def fetch_all_roles(): 23 | """Fetch all roles""" 24 | 25 | query = """ 26 | SELECT *, 27 | r.id::TEXT 28 | FROM roles r 29 | """ 30 | records = await Role.pool.fetch(query) 31 | 32 | return [dict(record) for record in records] 33 | 34 | 35 | @router.get( 36 | "/{id}", 37 | tags=["roles"], 38 | response_model=DetailedRoleResponse, 39 | responses={ 40 | 404: {"description": "Role not found"}, 41 | }, 42 | ) 43 | async def fetch_role(id: int): 44 | """Fetch a role by its id""" 45 | 46 | query = """ 47 | SELECT *, 48 | id::TEXT, 49 | COALESCE( 50 | ( 51 | SELECT json_agg(ur.user_id::TEXT) 52 | FROM userroles ur 53 | WHERE ur.role_id = r.id 54 | ), '[]' 55 | ) members 56 | FROM roles r 57 | WHERE r.id = $1 58 | """ 59 | record = await Role.pool.fetchrow(query, id) 60 | 61 | if not record: 62 | raise HTTPException(404, "Role not found") 63 | 64 | return dict(record) 65 | 66 | 67 | @router.post( 68 | "", 69 | tags=["roles"], 70 | response_model=RoleResponse, 71 | responses={ 72 | 201: {"description": "Role Created Successfully"}, 73 | 401: {"description": "Unauthorized"}, 74 | 403: {"description": "Missing Permissions"}, 75 | 409: {"description": "Role with that name already exists"}, 76 | }, 77 | status_code=201, 78 | ) 79 | async def create_role(body: NewRoleBody, roles=has_permissions([ManageRoles()])): 80 | # Check if the user has administrator permission or all the permissions provided in the role 81 | user_permissions = 0 82 | for role in roles: 83 | user_permissions |= role.permissions 84 | 85 | if not utils.has_permission(user_permissions, body.permissions): 86 | raise HTTPException(403, "Missing Permissions") 87 | 88 | query = """ 89 | INSERT INTO roles (id, name, color, permissions, position) 90 | VALUES (create_snowflake(), $1, $2, $3, (SELECT COUNT(*) FROM roles) + 1) 91 | RETURNING *; 92 | """ 93 | 94 | try: 95 | record = await Role.pool.fetchrow( 96 | query, body.name, body.color, body.permissions 97 | ) 98 | except asyncpg.exceptions.UniqueViolationError: 99 | raise HTTPException(409, "Role with that name already exists") 100 | 101 | return utils.JSONResponse(status_code=201, content=dict(record)) 102 | 103 | 104 | @router.patch( 105 | "/{id}", 106 | tags=["roles"], 107 | responses={ 108 | 204: {"description": "Role Updated Successfully"}, 109 | 401: {"description": "Unauthorized"}, 110 | 403: {"description": "Missing Permissions"}, 111 | 404: {"description": "Role not found"}, 112 | 409: {"description": "Role with that name already exists"}, 113 | }, 114 | status_code=204, 115 | ) 116 | async def update_role( 117 | id: int, 118 | body: UpdateRoleBody, 119 | roles=has_permissions([ManageRoles()]), 120 | ): 121 | role = await Role.fetch(id) 122 | if not role: 123 | raise HTTPException(404, "Role Not Found") 124 | 125 | # Check if the user has administrator permission or all the permissions provided in the role 126 | user_permissions = 0 127 | for r in roles: 128 | user_permissions |= r.permissions 129 | 130 | top_role = min(roles, key=lambda role: role.position) 131 | if top_role.position >= role.position: 132 | raise HTTPException(403, "Missing Permissions") 133 | 134 | data = body.dict(exclude_unset=True) 135 | if not utils.has_permission(user_permissions, body.permissions): 136 | raise HTTPException(403, "Missing Permissions") 137 | 138 | if name := data.get("name", None): 139 | record = await Role.pool.fetchrow("SELECT * FROM roles WHERE name = $1", name) 140 | 141 | if record: 142 | raise HTTPException(409, "Role with that name already exists") 143 | 144 | if ( 145 | position := data.pop("position", None) 146 | ) is not None and position != role.position: 147 | if position <= top_role.position: 148 | raise HTTPException(403, "Missing Permissions") 149 | 150 | if position > role.position: 151 | new_pos = position + 0.5 152 | else: 153 | new_pos = position - 0.5 154 | 155 | query = """ 156 | UPDATE roles r SET position = $1 157 | WHERE r.id = $2; 158 | """ 159 | await Role.pool.execute(query, new_pos, id) 160 | 161 | query = """ 162 | WITH todo AS ( 163 | SELECT r.id, 164 | ROW_NUMBER() OVER (ORDER BY position) AS position 165 | FROM roles r 166 | ) 167 | UPDATE roles r SET 168 | position = td.position 169 | FROM todo td 170 | WHERE r.id = td.id; 171 | """ 172 | await Role.pool.execute(query) 173 | 174 | if data: 175 | query = "UPDATE ROLES SET " 176 | query += ", ".join("%s = $%d" % (key, i) for i, key in enumerate(data, 2)) 177 | query += " WHERE id = $1" 178 | 179 | await Role.pool.execute(query, id, *data.values()) 180 | 181 | return Response(status_code=204, content="") 182 | 183 | 184 | @router.delete( 185 | "/{id}", 186 | tags=["roles"], 187 | responses={ 188 | 204: {"description": "Role Updated Successfully"}, 189 | 401: {"description": "Unauthorized"}, 190 | 403: {"description": "Missing Permissions"}, 191 | 404: {"description": "Role not found"}, 192 | }, 193 | status_code=204, 194 | ) 195 | async def delete_role(id: int, roles=has_permissions([ManageRoles()])): 196 | role = await Role.fetch(id) 197 | if not role: 198 | raise HTTPException(404, "Role Not Found") 199 | 200 | top_role = min(roles, key=lambda role: role.position) 201 | if top_role.position >= role.position: 202 | raise HTTPException(403, "Missing Permissions") 203 | 204 | query = """ 205 | WITH deleted AS ( 206 | DELETE FROM roles r 207 | WHERE r.id = $1 208 | RETURNING r.id 209 | ), 210 | to_update AS ( 211 | SELECT r.id, 212 | ROW_NUMBER() OVER (ORDER BY r.position) AS position 213 | FROM roles r 214 | WHERE r.id != (SELECT id FROM deleted) 215 | ) 216 | UPDATE roles r SET 217 | position = tu.position 218 | FROM to_update tu 219 | WHERE r.id = tu.id 220 | """ 221 | await Role.pool.execute(query, id) 222 | 223 | return Response(status_code=204, content="") 224 | 225 | 226 | @router.put( 227 | "/{role_id}/members/{member_id}", 228 | tags=["roles"], 229 | responses={ 230 | 204: {"description": "Role assigned to member"}, 231 | 401: {"description": "Unauthorized"}, 232 | 403: {"description": "Missing Permissions"}, 233 | 404: {"description": "Role or member not found"}, 234 | 409: {"description": "User already has the role"}, 235 | }, 236 | status_code=204, 237 | ) 238 | async def add_member_to_role( 239 | role_id: int, member_id: int, roles=has_permissions([ManageRoles()]) 240 | ) -> Union[Response, utils.JSONResponse]: 241 | role = await Role.fetch(role_id) 242 | if not role: 243 | raise HTTPException(404, "Role Not Found") 244 | 245 | top_role = min(roles, key=lambda role: role.position) 246 | if top_role.position >= role.position: 247 | raise HTTPException(403, "Missing Permissions") 248 | 249 | try: 250 | await UserRole.create(member_id, role_id) 251 | except asyncpg.exceptions.UniqueViolationError: 252 | raise HTTPException(409, "User already has the role") 253 | except asyncpg.exceptions.ForeignKeyViolationError: 254 | raise HTTPException(404, "Member not found") 255 | 256 | return Response(status_code=204, content="") 257 | 258 | 259 | @router.delete( 260 | "/{role_id}/members/{member_id}", 261 | tags=["roles"], 262 | responses={ 263 | 204: {"description": "Role removed from member"}, 264 | 401: {"description": "Unauthorized"}, 265 | 403: {"description": "Missing Permissions"}, 266 | 404: {"description": "Role not found"}, 267 | }, 268 | status_code=204, 269 | ) 270 | async def remove_member_from_role( 271 | role_id: int, member_id: int, roles=has_permissions([ManageRoles()]) 272 | ) -> Union[Response, utils.JSONResponse]: 273 | role = await Role.fetch(role_id) 274 | if not role: 275 | raise HTTPException(404, "Role Not Found") 276 | 277 | top_role = min(roles, key=lambda role: role.position) 278 | if top_role.position >= role.position: 279 | raise HTTPException(403, "Missing Permissions") 280 | 281 | await UserRole.delete(member_id, role_id) 282 | 283 | return Response(status_code=204, content="") 284 | -------------------------------------------------------------------------------- /api/versions/v1/routers/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from . import auth, challenges, roles, users 4 | 5 | router = APIRouter(prefix="/v1") 6 | 7 | router.include_router(auth.router) 8 | router.include_router(challenges.router) 9 | router.include_router(roles.router) 10 | router.include_router(users.router) 11 | -------------------------------------------------------------------------------- /api/versions/v1/routers/users/__init__.py: -------------------------------------------------------------------------------- 1 | from .routes import router 2 | 3 | __all__ = (router,) 4 | -------------------------------------------------------------------------------- /api/versions/v1/routers/users/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel 3 | 4 | 5 | class UserResponse(BaseModel): 6 | id: str 7 | username: str 8 | discriminator: str 9 | avatar: str 10 | app: bool 11 | roles: List[str] 12 | -------------------------------------------------------------------------------- /api/versions/v1/routers/users/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .models import UserResponse 4 | 5 | from api.models import User, UserRole 6 | from api.dependencies import authorization 7 | 8 | 9 | router = APIRouter(prefix="/users") 10 | 11 | 12 | @router.get( 13 | "/@me", 14 | response_model=UserResponse, 15 | responses={401: {"description": "Unauthorized"}}, 16 | ) 17 | async def get_current_user(user: User = authorization()): 18 | query = """SELECT role_id FROM userroles WHERE user_id = $1;""" 19 | roles = [record["role_id"] for record in await UserRole.pool.fetch(query, user.id)] 20 | 21 | return {**user.as_dict(), "roles": roles} 22 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing 3 | import os 4 | 5 | 6 | log = logging.getLogger("Config") 7 | 8 | __debug = False 9 | 10 | 11 | def debug() -> bool: 12 | return __debug 13 | 14 | 15 | def set_debug(value: bool): 16 | global __debug 17 | __debug = value 18 | 19 | 20 | def postgres_uri() -> str: 21 | """Connection URI for PostgreSQL database.""" 22 | value = os.environ.get("POSTGRES_URI") 23 | 24 | if value: 25 | return value 26 | 27 | raise EnvironmentError('Required environment variable "POSTGRES_URI" is missing') 28 | 29 | 30 | def secret_key() -> typing.Optional[str]: 31 | """Key for validating and creating JWT tokens""" 32 | value = os.environ.get("SECRET_KEY", None) 33 | 34 | if not value: 35 | log.warning('Optional environment variable "SECRET_KEY" is missing') 36 | 37 | return value 38 | 39 | 40 | def discord_client_id() -> typing.Optional[str]: 41 | """The client id of the application used for authentication""" 42 | value = os.environ.get("DISCORD_CLIENT_ID", 0) 43 | 44 | if not value: 45 | log.warning('Optional environment variable "DISCORD_CLIENT_ID" is missing') 46 | 47 | return value 48 | 49 | 50 | def discord_client_secret() -> typing.Optional[str]: 51 | """The client secret of the application used for authentication""" 52 | value = os.environ.get("DISCORD_CLIENT_SECRET", "") 53 | 54 | if not value: 55 | log.warning('Optional environment variable "DISCORD_CLIENT_SECRET" is missing') 56 | 57 | return value 58 | 59 | 60 | def test_postgres_uri() -> typing.Optional[str]: 61 | """Connection URI for PostgreSQL database for testing.""" 62 | value = os.environ.get("TEST_POSTGRES_URI", "") 63 | 64 | if not value: 65 | log.warning('Optional environment variable "TEST_POSTGRES_URI" is missing') 66 | 67 | return value 68 | 69 | 70 | def redis_uri() -> typing.Optional[str]: 71 | """Connection URI for Redis server.""" 72 | value = os.environ.get("REDIS_URI") 73 | 74 | if not value: 75 | log.warning('Optional environment variable "REDIS_URI" is missing') 76 | 77 | return value 78 | 79 | 80 | def test_redis_uri() -> typing.Optional[str]: 81 | """Connection URI for Redis testing server.""" 82 | value = os.environ.get("TEST_REDIS_URI") 83 | 84 | if not value: 85 | log.warning('Optional environment variable "TEST_REDIS_URI" is missing') 86 | 87 | return value 88 | 89 | 90 | def piston_url() -> str: 91 | """URL of the Piston API.""" 92 | default = "https://emkc.org/api/v2/piston/" 93 | value = os.environ.get("PISTON_URL") 94 | 95 | if not value: 96 | log.info( 97 | f'Optional environment variable "PISTON_URL" is missing, defaults to {default}' 98 | ) 99 | 100 | return value or default 101 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | redis: 5 | image: redis:6.2 6 | ports: 7 | - "6379:6379" 8 | restart: unless-stopped 9 | volumes: 10 | - redis-data:/data 11 | networks: 12 | - default 13 | 14 | postgres: 15 | image: postgres:13 16 | ports: 17 | - "7777:5432" 18 | restart: unless-stopped 19 | environment: 20 | POSTGRES_USER: twt 21 | POSTGRES_PASSWORD: twt 22 | POSTGRES_DB: twt 23 | volumes: 24 | - postgres-data:/var/lib/postgresql/data 25 | networks: 26 | - default 27 | api: 28 | build: 29 | context: . 30 | dockerfile: Dockerfile 31 | ports: 32 | - "5000:5000" 33 | restart: unless-stopped 34 | depends_on: 35 | - postgres 36 | - redis 37 | environment: 38 | SECRET_KEY: "${SECRET_KEY}" 39 | DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID}" 40 | DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET}" 41 | POSTGRES_URI: postgres://twt:twt@postgres:5432/twt 42 | REDIS_URI: redis://redis 43 | networks: 44 | - default 45 | 46 | volumes: 47 | postgres-data: 48 | redis-data: 49 | 50 | networks: 51 | default: 52 | name: twt 53 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # Command line interface docs 2 | 3 | Documentation for the CLI commands of the [launch.py](../launch.py) file. 4 | 5 | Each command needs to be run from inside the pipenv environment: 6 | 7 | ```sh 8 | pipenv run python launch.py [args] 9 | ``` 10 | 11 | ## ``initdb`` 12 | 13 | Creates all tables defined in the app. 14 | 15 | ```sh 16 | pipenv run python launch.py initdb 17 | ``` 18 | 19 | ### Options 20 | 21 | - ``-v`` | ``--verbose`` : Print SQL statements when creating models. 22 | 23 | ## ``dropdb`` 24 | 25 | Drops all tables defined in the app. 26 | 27 | ```sh 28 | pipenv run python launch.py dropdb 29 | ``` 30 | 31 | ### Options 32 | 33 | - ``-v`` | ``--verbose`` : Print SQL statements when dropping models. 34 | 35 | ## ``runserver`` 36 | 37 | Run the API. 38 | 39 | ```sh 40 | pipenv run python launch.py runserver 41 | ``` 42 | 43 | ### Options 44 | 45 | - ``-h {host}`` | ``--host {host}`` : Host to run the API on. Default: `127.0.0.1`. 46 | - ``-p {port}`` | ``--port {port}`` : Port to run the API on. Default: `5000`. 47 | - ``-d`` | ``--debug`` : Run server in debug mode. 48 | - ``-i`` | ``--initdb`` : Create models before running the API. Equivalent of running the ``initdb`` command. 49 | - ``-v`` | ``--verbose`` : Set logging to DEBUG instead of INFO. 50 | -------------------------------------------------------------------------------- /launch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import asyncpg 4 | import config 5 | import click 6 | 7 | from uvicorn import Config, Server 8 | from typing import Any, Coroutine 9 | from postDB import Model 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | 13 | 14 | try: 15 | import uvloop # noqa f401 16 | except ModuleNotFoundError: 17 | loop = asyncio.new_event_loop() 18 | else: 19 | loop = uvloop.new_event_loop() 20 | 21 | asyncio.set_event_loop(loop) 22 | 23 | 24 | def run_async(coro: Coroutine) -> Any: 25 | """ 26 | Used to run coroutines outside any coroutine. 27 | 28 | :param coro: The coroutine to run. 29 | :returns: Whatever the coroutine returned. 30 | """ 31 | return asyncio.get_event_loop().run_until_complete(coro) 32 | 33 | 34 | async def prepare_postgres( 35 | retries: int = 5, 36 | interval: float = 10.0, 37 | db_uri: str = None, 38 | loop: asyncio.AbstractEventLoop = None, 39 | ) -> bool: 40 | """ 41 | Prepare the postgres database connection. 42 | 43 | :param int retries: Included to fix issue with docker starting API before DB is finished starting. 44 | :param float interval: Interval of which to wait for next retry. 45 | :param str db_uri: DB URI to connect to. 46 | :param AbstractEventLoop loop: Asyncio loop to run the pool with. 47 | """ 48 | 49 | log = logging.getLogger("DB") 50 | db_name = db_uri.split("/")[-1] 51 | log.info('[i] Attempting to connect to DB "%s"' % db_name) 52 | for i in range(1, retries + 1): 53 | try: 54 | await Model.create_pool( 55 | uri=db_uri, 56 | max_con=10, # We might want to increase this number in the future. 57 | loop=loop, 58 | ) 59 | 60 | except asyncpg.InvalidPasswordError as e: 61 | log.error("[!] %s" % str(e)) 62 | return False 63 | 64 | except (ConnectionRefusedError,): 65 | log.warning( 66 | "[!] Failed attempt #%s/%s, trying again in %ss" 67 | % (i, retries, interval) 68 | ) 69 | 70 | if i == retries: 71 | log.error("[!] Failed final connection attempt, exiting.") 72 | return False 73 | 74 | await asyncio.sleep(interval) 75 | 76 | log.info('[✔] Connected to database "%s"' % db_name) 77 | return True 78 | 79 | 80 | async def safe_create_tables(verbose: bool = False) -> None: 81 | """ 82 | Safely create all tables using the specified order in `~/api/models/__init__.py`. 83 | 84 | :param verbose: Whether or not to print the postgres statements being executed. 85 | """ 86 | log = logging.getLogger("DB") 87 | from api.models import models_ordered 88 | 89 | log.info("Attempting to create %s tables." % len(models_ordered)) 90 | 91 | with open("snowflake.sql") as f: 92 | query = f.read() 93 | 94 | if verbose: 95 | print(query) 96 | 97 | await Model.pool.execute(query) 98 | 99 | for model in models_ordered: 100 | await model.create_table(verbose=verbose) 101 | log.info("Created table %s" % model.__tablename__) 102 | 103 | 104 | async def delete_tables(verbose: bool = False): 105 | """ 106 | Delete all tables. 107 | 108 | :param verbose: Whether or not to print the postgres statements being executed. 109 | """ 110 | 111 | log = logging.getLogger("DB") 112 | 113 | for model in Model.all_models(): 114 | await model.drop_table(verbose=verbose) 115 | log.info("Dropped table %s" % type(model).__tablename__) 116 | 117 | await Model.pool.execute("DROP FUNCTION IF EXISTS create_snowflake") 118 | await Model.pool.execute("DROP SEQUENCE IF EXISTS global_snowflake_id_seq") 119 | 120 | 121 | @click.group() 122 | def cli(): 123 | pass 124 | 125 | 126 | @cli.command(name="initdb") 127 | @click.option("-v", "--verbose", default=False, is_flag=True) 128 | def _initdb(verbose: bool): 129 | """ 130 | Creates all tables defined in the app. 131 | 132 | :param verbose: Print SQL statements when creating models? 133 | """ 134 | if not run_async( 135 | prepare_postgres( 136 | retries=6, interval=10.0, db_uri=config.postgres_uri(), loop=loop 137 | ) 138 | ): 139 | exit(1) # Connecting to our postgres server failed. 140 | 141 | run_async(safe_create_tables(verbose=verbose)) 142 | 143 | 144 | @cli.command(name="dropdb") 145 | @click.option("-v", "--verbose", default=False, is_flag=True) 146 | def _dropdb(verbose: bool): 147 | """ 148 | Drops all tables defined in the app. 149 | 150 | :param verbose: Print SQL statements when dropping models? 151 | """ 152 | if not run_async( 153 | prepare_postgres( 154 | retries=6, interval=10.0, db_uri=config.postgres_uri(), loop=loop 155 | ) 156 | ): 157 | exit(1) # Connecting to our postgres server failed. 158 | 159 | run_async(delete_tables(verbose=verbose)) 160 | 161 | 162 | @cli.command() 163 | @click.option("-p", "--port", default=5000) 164 | @click.option("-h", "--host", default="0.0.0.0") 165 | @click.option("-d", "--debug", default=False, is_flag=True) 166 | @click.option("-i", "--initdb", default=False, is_flag=True) 167 | @click.option("-r", "--resetdb", default=False, is_flag=True) 168 | @click.option("-v", "--verbose", default=False, is_flag=True) 169 | def runserver( 170 | host: str, port: str, debug: bool, initdb: bool, resetdb: bool, verbose: bool 171 | ): 172 | """ 173 | Run the FastAPI Server. 174 | 175 | :param host: Host to run it on. 176 | :param port: Port to run it on. 177 | :param debug: Run server in debug mode? 178 | :param initdb: Create models before running API? 179 | :param verbose: Set logging to DEBUG instead of INFO 180 | """ 181 | config.set_debug(debug) 182 | 183 | if verbose: 184 | logging.basicConfig(level=logging.DEBUG) 185 | 186 | if not run_async( 187 | prepare_postgres( 188 | retries=6, interval=10.0, db_uri=config.postgres_uri(), loop=loop 189 | ) 190 | ): 191 | exit(1) # Connecting to our postgres server failed. 192 | 193 | server_config = Config("api.app:app", host=host, port=port, debug=debug) 194 | server = Server(config=server_config) 195 | 196 | async def worker(): 197 | if initdb: 198 | await safe_create_tables(verbose=verbose) 199 | elif resetdb: 200 | await delete_tables(verbose=verbose) 201 | await safe_create_tables(verbose=verbose) 202 | 203 | await server.serve() 204 | 205 | run_async(worker()) 206 | 207 | 208 | if __name__ == "__main__": 209 | cli() 210 | -------------------------------------------------------------------------------- /prod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONFAULTHANDLER 1 5 | 6 | # Let service stop gracefully 7 | STOPSIGNAL SIGQUIT 8 | 9 | # Copy project files into working directory 10 | WORKDIR /app 11 | 12 | RUN apt-get update && apt-get install gcc -y 13 | 14 | RUN pip install pipenv 15 | COPY Pipfile Pipfile.lock ./ 16 | RUN pipenv install --deploy --system 17 | 18 | ADD . /app 19 | 20 | # Run the API. 21 | CMD python launch.py runserver --initdb --verbose 22 | -------------------------------------------------------------------------------- /snowflake.sql: -------------------------------------------------------------------------------- 1 | CREATE SEQUENCE IF NOT EXISTS global_snowflake_id_seq; 2 | 3 | CREATE OR REPLACE FUNCTION create_snowflake(shard_id INT DEFAULT 1) 4 | RETURNS bigint 5 | LANGUAGE 'plpgsql' 6 | AS $$ 7 | DECLARE 8 | our_epoch bigint := 1609459200; 9 | seq_id bigint; 10 | now_millis bigint; 11 | result bigint:= 0; 12 | BEGIN 13 | SELECT nextval('global_snowflake_id_seq') % 1024 INTO seq_id; 14 | 15 | SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) INTO now_millis; 16 | result := (now_millis - our_epoch) << 22; 17 | result := result | (shard_id << 9); 18 | result := result | (seq_id); 19 | return result; 20 | END; 21 | $$; 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tech-With-Tim/API/b37911d3afa4e843ce2ffeca3fc37baaa0f3facf/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import config 3 | import pytest 4 | import asyncio 5 | 6 | from postDB import Model 7 | from httpx import AsyncClient 8 | 9 | from api.models import User 10 | from launch import prepare_postgres, safe_create_tables, delete_tables 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def event_loop(): 15 | """Create an instance of the default event loop for each test case.""" 16 | loop = asyncio.get_event_loop_policy().new_event_loop() 17 | asyncio.set_event_loop(loop) 18 | yield loop 19 | loop.close() 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | async def app(event_loop: asyncio.AbstractEventLoop) -> AsyncClient: 24 | from api import app 25 | 26 | async with AsyncClient(app=app, base_url="http://127.0.0.1:8000") as client: 27 | await app.router.startup() 28 | yield client 29 | await app.router.shutdown() 30 | 31 | 32 | @pytest.fixture(scope="session") 33 | async def db(event_loop) -> bool: 34 | assert await prepare_postgres(db_uri=config.test_postgres_uri(), loop=event_loop) 35 | await safe_create_tables() 36 | yield Model.pool 37 | await delete_tables() 38 | 39 | 40 | @pytest.fixture(scope="function") 41 | async def user(db): 42 | yield await User.create(0, "Test", "0001") 43 | await db.execute("""DELETE FROM users WHERE username = 'Test'""") 44 | 45 | 46 | @pytest.fixture(scope="function") 47 | async def token(user, db): 48 | yield jwt.encode( 49 | {"uid": user.id}, 50 | key=config.secret_key(), 51 | ) 52 | 53 | 54 | def pytest_addoption(parser): 55 | parser.addoption( 56 | "--no-db", 57 | action="store_true", 58 | default=False, 59 | help="don't run tests that require a database set up", 60 | ) 61 | 62 | 63 | def pytest_configure(config): 64 | config.addinivalue_line("markers", "db: mark test as needing an database to run") 65 | 66 | 67 | def pytest_collection_modifyitems(config, items): 68 | if not config.getoption("--no-db"): 69 | # --no-db not given in cli: do not skip tests that require database 70 | return 71 | 72 | skip_db = pytest.mark.skip(reason="need --no-db option removed to run") 73 | for item in items: 74 | if "db" in item.keywords: 75 | item.add_marker(skip_db) 76 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from httpx import AsyncClient 4 | from pytest_mock import MockerFixture 5 | from api.versions.v1.routers.auth.helpers import get_redirect, SCOPES 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_redirect_default_code(app: AsyncClient): 10 | res = await app.get("/api/v1/auth/discord/redirect", allow_redirects=False) 11 | assert res.status_code == 307 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_redirect_default_url(app: AsyncClient): 16 | res = await app.get("/api/v1/auth/discord/redirect", allow_redirects=False) 17 | assert res.headers.get("Location") == get_redirect( 18 | callback="http://127.0.0.1:8000/api/v1/auth/discord/callback", 19 | scopes=SCOPES, 20 | ) 21 | 22 | 23 | @pytest.mark.asyncio 24 | @pytest.mark.parametrize( 25 | "callback,status", 26 | [("okand", 422), ("", 422)], 27 | ) 28 | async def test_redirect_invalid_callback(app: AsyncClient, callback, status): 29 | res = await app.get(f"/api/v1/auth/discord/redirect?callback={callback}") 30 | assert res.status_code == status 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_redirect_valid_callback_url(app: AsyncClient): 35 | res = await app.get("/api/v1/auth/discord/redirect?callback=https://twtcodejam.net") 36 | assert str(res.url) == get_redirect( 37 | callback="https://twtcodejam.net", 38 | scopes=SCOPES, 39 | ) 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_callback_discord_error(app: AsyncClient, mocker: MockerFixture): 44 | async def exchange_code(**kwargs): 45 | return {"error": "internal server error"}, 500 46 | 47 | mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) 48 | 49 | res = await app.post( 50 | "/api/v1/auth/discord/callback", 51 | json={"code": "okand", "callback": "https://twtcodejam.net"}, 52 | ) 53 | 54 | assert res.status_code == 502 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_callback_invalid_code(app: AsyncClient, mocker: MockerFixture): 59 | async def exchange_code(**kwargs): 60 | return {"error": 'invalid "code" in request'}, 400 61 | 62 | mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) 63 | res = await app.post( 64 | "/api/v1/auth/discord/callback", 65 | json={"code": "okand", "callback": "https://twtcodejam.net"}, 66 | ) 67 | 68 | assert res.json() == { 69 | "error": "Bad Request", 70 | "data": (await exchange_code())[0], 71 | "message": "Discord returned 400 status.", 72 | } 73 | 74 | 75 | @pytest.mark.asyncio 76 | @pytest.mark.db 77 | async def test_callback_success(app: AsyncClient, db, mocker: MockerFixture): 78 | async def exchange_code(**kwargs): 79 | return { 80 | "expires_in": 69420, 81 | "access_token": "super_doper_secret_token", 82 | "refresh_token": "super_doper_doper_secret_token", 83 | }, 200 84 | 85 | async def get_user(**kwargs): 86 | return { 87 | "id": 1, 88 | "username": "Test2", 89 | "avatar": "avatar", 90 | "discriminator": "0001", 91 | } 92 | 93 | mocker.patch("api.versions.v1.routers.auth.routes.get_user", new=get_user) 94 | mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) 95 | 96 | res = await app.post( 97 | "/api/v1/auth/discord/callback", 98 | json={"code": "okand", "callback": "https://twtcodejam.net"}, 99 | ) 100 | 101 | assert res.status_code == 200 102 | 103 | await db.execute("DELETE FROM users WHERE id = 1") 104 | -------------------------------------------------------------------------------- /tests/test_challenges.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from httpx import AsyncClient 4 | 5 | from api.models import Challenge, ChallengeLanguage, Role, User, UserRole 6 | from api.models.permissions import ( 7 | CreateWeeklyChallenge, 8 | EditWeeklyChallenge, 9 | DeleteWeeklyChallenge, 10 | ManageWeeklyChallengeLanguages, 11 | ) 12 | from api.services import piston 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | async def manage_challenges_role(db): 17 | query = """ 18 | INSERT INTO roles (id, name, color, permissions, position) 19 | VALUES (create_snowflake(), $1, $2, $3, (SELECT COUNT(*) FROM roles) + 1) 20 | RETURNING *; 21 | """ 22 | record = await Role.pool.fetchrow( 23 | query, 24 | "Challenges Manager", 25 | 0x0, 26 | CreateWeeklyChallenge().value 27 | | EditWeeklyChallenge().value 28 | | DeleteWeeklyChallenge().value, 29 | ) 30 | yield Role(**record) 31 | await db.execute("DELETE FROM roles WHERE id = $1;", record["id"]) 32 | 33 | 34 | @pytest.fixture(scope="function") 35 | async def manage_challenges_user(manage_challenges_role: Role, user: User): 36 | await UserRole.create(user.id, manage_challenges_role.id) 37 | yield user 38 | await UserRole.delete(user.id, manage_challenges_role.id) 39 | 40 | 41 | @pytest.fixture(scope="module") 42 | async def manage_challenge_languages_role(db): 43 | query = """ 44 | INSERT INTO roles (id, name, color, permissions, position) 45 | VALUES (create_snowflake(), $1, $2, $3, (SELECT COUNT(*) FROM roles) + 1) 46 | RETURNING *; 47 | """ 48 | record = await Role.pool.fetchrow( 49 | query, 50 | "Challenge Languages Manager", 51 | 0x0, 52 | ManageWeeklyChallengeLanguages().value, 53 | ) 54 | yield Role(**record) 55 | await db.execute("DELETE FROM roles WHERE id = $1;", record["id"]) 56 | 57 | 58 | @pytest.fixture(scope="function") 59 | async def manage_challenge_languages_user( 60 | manage_challenge_languages_role: Role, user: User 61 | ): 62 | await UserRole.create(user.id, manage_challenge_languages_role.id) 63 | yield user 64 | await UserRole.delete(user.id, manage_challenge_languages_role.id) 65 | 66 | 67 | @pytest.fixture(scope="function") 68 | async def language(db, manage_challenge_languages_user: User): 69 | query = """ 70 | INSERT INTO challengelanguages (id, name, download_url, disabled, piston_lang, piston_lang_ver) 71 | VALUES (create_snowflake(), $1, $2, $3, $4, $5) 72 | RETURNING *; 73 | """ 74 | language = ChallengeLanguage( 75 | **await db.fetchrow( 76 | query, 77 | "test", 78 | "https://example.com/download", 79 | False, 80 | "testlang", 81 | "1.2.3", 82 | ) 83 | ) 84 | yield language 85 | await db.execute("DELETE FROM challengelanguages WHERE id = $1", language.id) 86 | 87 | 88 | async def complete_piston_data(data: dict): 89 | """Replace "TO COMPLETE" data with actual data from the Piston API""" 90 | if ( 91 | data.get("piston_lang") == "TO COMPLETE" 92 | or data.get("piston_lang_ver") == "TO COMPLETE" 93 | ): 94 | runtime = (await piston.get_runtimes())[0] 95 | 96 | if data.get("piston_lang") == "TO COMPLETE": 97 | data["piston_lang"] = runtime.language 98 | 99 | if data.get("piston_lang_ver") == "TO COMPLETE": 100 | data["piston_lang_ver"] = runtime.version 101 | 102 | 103 | @pytest.mark.db 104 | @pytest.mark.asyncio 105 | @pytest.mark.parametrize( 106 | ("data", "status"), 107 | [ 108 | ({}, 422), 109 | ( 110 | { 111 | "name": "", 112 | "piston_lang": "....", 113 | "piston_lang_ver": "....", 114 | }, 115 | 422, 116 | ), 117 | ( 118 | { 119 | "name": "....", 120 | "piston_lang": "....", 121 | "piston_lang_ver": "....", 122 | "download_url": "not an url", 123 | }, 124 | 422, 125 | ), 126 | ( 127 | { 128 | "name": "....", 129 | "piston_lang": "doesntexist", 130 | "piston_lang_ver": "0.0.0", 131 | }, 132 | 404, 133 | ), 134 | ( 135 | { 136 | "name": "test1", 137 | "piston_lang": "TO COMPLETE", # completed inside the test bcs the wrapper is async 138 | "piston_lang_ver": "TO COMPLETE", 139 | }, 140 | 201, 141 | ), 142 | ( 143 | { 144 | "name": "test1", 145 | "piston_lang": "TO COMPLETE", # completed inside the test bcs the wrapper is async 146 | "piston_lang_ver": "TO COMPLETE", 147 | }, 148 | 409, 149 | ), 150 | ], 151 | ) 152 | async def test_challenge_languages_create( 153 | app: AsyncClient, 154 | db, 155 | token: str, 156 | manage_challenge_languages_user: User, 157 | data: dict, 158 | status: int, 159 | ): 160 | await complete_piston_data(data) 161 | 162 | try: 163 | res = await app.post( 164 | "/api/v1/challenges/languages", 165 | json=data, 166 | headers={"Authorization": token}, 167 | ) 168 | assert res.status_code == status 169 | 170 | finally: 171 | if status == 409: 172 | await db.execute( 173 | "DELETE FROM challengelanguages WHERE name = $1", data["name"] 174 | ) 175 | 176 | 177 | @pytest.mark.db 178 | @pytest.mark.asyncio 179 | async def test_fetch_all_challenge_languages(app: AsyncClient): 180 | res = await app.get("/api/v1/challenges/languages") 181 | 182 | assert res.status_code == 200 183 | assert type(res.json()) == list 184 | 185 | 186 | @pytest.mark.db 187 | @pytest.mark.asyncio 188 | @pytest.mark.parametrize( 189 | ("request_data", "new_data", "status"), 190 | [ 191 | ( 192 | {}, 193 | { 194 | "name": "test", 195 | "download_url": "https://example.com/download", 196 | "disabled": False, 197 | "piston_lang": "testlang", 198 | "piston_lang_ver": "1.2.3", 199 | }, 200 | 204, 201 | ), 202 | ( 203 | {"name": ""}, 204 | { 205 | "name": "test", 206 | "download_url": "https://example.com/download", 207 | "disabled": False, 208 | "piston_lang": "testlang", 209 | "piston_lang_ver": "1.2.3", 210 | }, 211 | 422, 212 | ), 213 | ( 214 | {"download_url": "not an url"}, 215 | { 216 | "name": "test", 217 | "download_url": "https://example.com/download", 218 | "disabled": False, 219 | "piston_lang": "testlang", 220 | "piston_lang_ver": "1.2.3", 221 | }, 222 | 422, 223 | ), 224 | ( 225 | {"disabled": "not a boolean"}, 226 | { 227 | "name": "test", 228 | "download_url": "https://example.com/download", 229 | "disabled": False, 230 | "piston_lang": "testlang", 231 | "piston_lang_ver": "1.2.3", 232 | }, 233 | 422, 234 | ), 235 | ( 236 | { 237 | "piston_lang": "doesntexist", 238 | "piston_lang_ver": "0.0.0", 239 | }, 240 | { 241 | "name": "test", 242 | "download_url": "https://example.com/download", 243 | "disabled": False, 244 | "piston_lang": "testlang", 245 | "piston_lang_ver": "1.2.3", 246 | }, 247 | 404, 248 | ), 249 | ( 250 | { 251 | "name": "new name", 252 | "download_url": "https://test.com/download", 253 | "piston_lang": "TO COMPLETE", # completed inside the test bcs the wrapper is async 254 | "piston_lang_ver": "TO COMPLETE", 255 | }, 256 | { 257 | "name": "new name", 258 | "download_url": "https://test.com/download", 259 | "disabled": False, 260 | "piston_lang": "TO COMPLETE", # completed inside the test bcs the wrapper is async 261 | "piston_lang_ver": "TO COMPLETE", 262 | }, 263 | 204, 264 | ), 265 | ], 266 | ) 267 | async def test_challenge_language_update( 268 | app: AsyncClient, 269 | db, 270 | token, 271 | language: ChallengeLanguage, 272 | request_data: dict, 273 | new_data: dict, 274 | status: int, 275 | ): 276 | await complete_piston_data(request_data) 277 | await complete_piston_data(new_data) 278 | 279 | res = await app.patch( 280 | f"/api/v1/challenges/languages/{language.id}", 281 | json=request_data, 282 | headers={"Authorization": token}, 283 | ) 284 | 285 | assert res.status_code == status 286 | 287 | language = ChallengeLanguage( 288 | **await db.fetchrow( 289 | "SELECT * FROM challengelanguages WHERE id = $1", language.id 290 | ) 291 | ) 292 | 293 | data = language.as_dict() 294 | data.pop("id") 295 | 296 | assert data == new_data 297 | 298 | 299 | @pytest.mark.db 300 | @pytest.mark.asyncio 301 | async def test_challenge_language_delete_success( 302 | app: AsyncClient, 303 | db, 304 | token: str, 305 | language: ChallengeLanguage, 306 | ): 307 | res = await app.delete( 308 | f"/api/v1/challenges/languages/{language.id}", 309 | headers={"Authorization": token}, 310 | ) 311 | 312 | assert res.status_code == 204 313 | 314 | record = await db.fetchrow( 315 | "SELECT * FROM challengelanguages WHERE id = $1", language.id 316 | ) 317 | assert record is None 318 | 319 | 320 | @pytest.mark.db 321 | @pytest.mark.asyncio 322 | async def test_challenge_language_delete_fail_403( 323 | app: AsyncClient, 324 | db, 325 | token: str, 326 | manage_challenges_user: User, 327 | language: ChallengeLanguage, 328 | ): 329 | try: 330 | query = """ 331 | INSERT INTO challenges (id, title, slug, author_id, description, example_in, example_out, language_ids) 332 | VALUES (create_snowflake(), $1, $2, $3, $4, $5, $6, $7) 333 | RETURNING *; 334 | """ 335 | challenge = Challenge( 336 | **await db.fetchrow( 337 | query, 338 | "Test challenge", 339 | "test-challenge", 340 | manage_challenges_user.id, 341 | "For testing", 342 | ["in"], 343 | ["out"], 344 | [language.id], 345 | ) 346 | ) 347 | 348 | res = await app.delete( 349 | f"/api/v1/challenges/languages/{language.id}", 350 | headers={"Authorization": token}, 351 | ) 352 | 353 | assert res.status_code == 403 354 | finally: 355 | await db.execute("DELETE FROM challenges WHERE id = $1", challenge.id) 356 | 357 | 358 | @pytest.mark.db 359 | @pytest.mark.asyncio 360 | async def test_challenge_language_delete_fail_404( 361 | app: AsyncClient, 362 | token: str, 363 | manage_challenge_languages_user: User, 364 | ): 365 | res = await app.delete( 366 | "/api/v1/challenges/languages/0", 367 | headers={"Authorization": token}, 368 | ) 369 | 370 | assert res.status_code == 404 371 | -------------------------------------------------------------------------------- /tests/test_roles.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from httpx import AsyncClient 4 | 5 | from api.models import Role, UserRole 6 | from api.models.permissions import ManageRoles 7 | 8 | 9 | @pytest.fixture 10 | async def manage_roles_role(db): 11 | query = """ 12 | INSERT INTO roles (id, name, color, permissions, position) 13 | VALUES (create_snowflake(), $1, $2, $3, (SELECT COUNT(*) FROM roles) + 1) 14 | RETURNING *; 15 | """ 16 | record = await Role.pool.fetchrow(query, "Roles Manager", 0x0, ManageRoles().value) 17 | yield Role(**record) 18 | await db.execute("DELETE FROM roles WHERE id = $1;", record["id"]) 19 | 20 | 21 | @pytest.mark.db 22 | @pytest.mark.asyncio 23 | @pytest.mark.parametrize( 24 | ("data", "status"), 25 | [ 26 | ({}, 422), 27 | ({"name": ""}, 422), 28 | ({"permissions": -1}, 422), 29 | ({"name": "test1", "color": 0xFFFFFFF}, 422), 30 | ({"name": "test1", "color": -0x000001}, 422), 31 | ({"name": "test2", "color": 0x000000, "permissions": 8}, 403), 32 | ({"name": "test2", "color": 0x000000, "permissions": 0}, 201), 33 | ({"name": "test2", "color": 0x000000, "permissions": 0}, 409), 34 | ], 35 | ) 36 | async def test_role_create( 37 | app: AsyncClient, db, user, token, manage_roles_role, data, status 38 | ): 39 | try: 40 | await UserRole.create(user.id, manage_roles_role.id) 41 | res = await app.post( 42 | "/api/v1/roles", json=data, headers={"Authorization": token} 43 | ) 44 | assert res.status_code == status 45 | finally: 46 | await db.execute( 47 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 48 | manage_roles_role.id, 49 | user.id, 50 | ) 51 | if status == 409: 52 | await db.execute("DELETE FROM roles WHERE name = $1", data["name"]) 53 | 54 | 55 | @pytest.mark.db 56 | @pytest.mark.asyncio 57 | async def test_fetch_all_roles(app: AsyncClient): 58 | res = await app.get("/api/v1/roles") 59 | 60 | assert res.status_code == 200 61 | assert type(res.json()) == list 62 | 63 | 64 | @pytest.mark.db 65 | @pytest.mark.asyncio 66 | @pytest.mark.parametrize( 67 | ("request_data", "new_data", "status"), 68 | [ 69 | ({}, {"name": "test update", "permissions": 0, "color": 0}, 204), 70 | ({"name": ""}, {"name": "test update", "permissions": 0, "color": 0}, 422), 71 | ( 72 | {"permissions": -1}, 73 | {"name": "test update", "permissions": 0, "color": 0}, 74 | 422, 75 | ), 76 | ( 77 | {"color": 0xFFFFFFF}, 78 | {"name": "test update", "permissions": 0, "color": 0}, 79 | 422, 80 | ), 81 | ( 82 | {"color": -0x000001}, 83 | {"name": "test update", "permissions": 0, "color": 0}, 84 | 422, 85 | ), 86 | ( 87 | {"color": 0x5, "permissions": 8}, 88 | {"name": "test update", "permissions": 0, "color": 0x0}, 89 | 403, 90 | ), 91 | ( 92 | {"color": 0x5, "permissions": ManageRoles().value}, 93 | {"name": "test update", "permissions": ManageRoles().value, "color": 0x5}, 94 | 204, 95 | ), 96 | ], 97 | ) 98 | async def test_role_update( 99 | app: AsyncClient, db, user, token, manage_roles_role, request_data, new_data, status 100 | ): 101 | try: 102 | query = """ 103 | INSERT INTO roles (id, name, color, permissions, position) 104 | VALUES (create_snowflake(), 'test update', 0, 0, (SELECT COUNT(*) FROM roles) + 1) 105 | RETURNING *; 106 | """ 107 | role = Role(**await Role.pool.fetchrow(query)) 108 | await UserRole.create(user.id, manage_roles_role.id) 109 | 110 | res = await app.patch( 111 | f"/api/v1/roles/{role.id}", 112 | json=request_data, 113 | headers={"Authorization": token}, 114 | ) 115 | 116 | assert res.status_code == status 117 | 118 | role = await Role.fetch(role.id) 119 | 120 | data = role.as_dict() 121 | data.pop("id") 122 | data.pop("position") 123 | 124 | assert data == new_data 125 | finally: 126 | await db.execute( 127 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 128 | manage_roles_role.id, 129 | user.id, 130 | ) 131 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 132 | 133 | 134 | @pytest.mark.db 135 | @pytest.mark.asyncio 136 | async def test_role_delete(app: AsyncClient, db, user, token, manage_roles_role): 137 | try: 138 | query = """ 139 | INSERT INTO roles (id, name, color, permissions, position) 140 | VALUES (create_snowflake(), 'test delete', 0, 0, (SELECT COUNT(*) FROM roles) + 1) 141 | RETURNING *; 142 | """ 143 | role = Role(**await Role.pool.fetchrow(query)) 144 | await UserRole.create(user.id, manage_roles_role.id) 145 | 146 | res = await app.delete( 147 | f"/api/v1/roles/{role.id}", 148 | headers={"Authorization": token}, 149 | ) 150 | 151 | assert res.status_code == 204 152 | finally: 153 | await db.execute( 154 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 155 | manage_roles_role.id, 156 | user.id, 157 | ) 158 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 159 | 160 | 161 | @pytest.mark.db 162 | @pytest.mark.asyncio 163 | async def test_role_delete_high_position( 164 | app: AsyncClient, db, user, token, manage_roles_role 165 | ): 166 | try: 167 | query = """ 168 | INSERT INTO roles (id, name, color, permissions, position) 169 | VALUES (create_snowflake(), 'test delete', 0, 0, 0) 170 | RETURNING *; 171 | """ 172 | role = Role(**await Role.pool.fetchrow(query)) 173 | await UserRole.create(user.id, manage_roles_role.id) 174 | 175 | res = await app.delete( 176 | f"/api/v1/roles/{role.id}", 177 | headers={"Authorization": token}, 178 | ) 179 | 180 | assert res.status_code == 403 181 | finally: 182 | await db.execute( 183 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 184 | manage_roles_role.id, 185 | user.id, 186 | ) 187 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 188 | 189 | 190 | @pytest.mark.db 191 | @pytest.mark.asyncio 192 | async def test_role_add(app: AsyncClient, db, user, token, manage_roles_role): 193 | try: 194 | query = """ 195 | INSERT INTO roles (id, name, color, permissions, position) 196 | VALUES (create_snowflake(), 'test add', 0, 0, (SELECT COUNT(*) FROM roles) + 1) 197 | RETURNING *; 198 | """ 199 | role = Role(**await Role.pool.fetchrow(query)) 200 | await UserRole.create(user.id, manage_roles_role.id) 201 | 202 | res = await app.put( 203 | f"/api/v1/roles/{role.id}/members/{user.id}", 204 | headers={"Authorization": token}, 205 | ) 206 | 207 | assert res.status_code == 204 208 | finally: 209 | await db.execute( 210 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 211 | manage_roles_role.id, 212 | user.id, 213 | ) 214 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 215 | 216 | 217 | @pytest.mark.db 218 | @pytest.mark.asyncio 219 | async def test_role_add_high_position( 220 | app: AsyncClient, db, user, token, manage_roles_role 221 | ): 222 | try: 223 | query = """ 224 | INSERT INTO roles (id, name, color, permissions, position) 225 | VALUES (create_snowflake(), 'test add', 0, 0, 0) 226 | RETURNING *; 227 | """ 228 | role = Role(**await Role.pool.fetchrow(query)) 229 | await UserRole.create(user.id, manage_roles_role.id) 230 | 231 | res = await app.put( 232 | f"/api/v1/roles/{role.id}/members/{user.id}", 233 | headers={"Authorization": token}, 234 | ) 235 | 236 | assert res.status_code == 403 237 | finally: 238 | await db.execute( 239 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 240 | manage_roles_role.id, 241 | user.id, 242 | ) 243 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 244 | 245 | 246 | @pytest.mark.db 247 | @pytest.mark.asyncio 248 | async def test_role_remove(app: AsyncClient, db, user, token, manage_roles_role): 249 | try: 250 | query = """ 251 | INSERT INTO roles (id, name, color, permissions, position) 252 | VALUES (create_snowflake(), 'test remove', 0, 0, (SELECT COUNT(*) FROM roles) + 1) 253 | RETURNING *; 254 | """ 255 | role = Role(**await Role.pool.fetchrow(query)) 256 | await UserRole.create(user.id, manage_roles_role.id) 257 | 258 | res = await app.delete( 259 | f"/api/v1/roles/{role.id}/members/{user.id}", 260 | headers={"Authorization": token}, 261 | ) 262 | 263 | assert res.status_code == 204 264 | finally: 265 | await db.execute( 266 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 267 | manage_roles_role.id, 268 | user.id, 269 | ) 270 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 271 | 272 | 273 | @pytest.mark.db 274 | @pytest.mark.asyncio 275 | async def test_role_remove_high_position( 276 | app: AsyncClient, db, user, token, manage_roles_role 277 | ): 278 | try: 279 | query = """ 280 | INSERT INTO roles (id, name, color, permissions, position) 281 | VALUES (create_snowflake(), 'test remove', 0, 0, 0) 282 | RETURNING *; 283 | """ 284 | role = Role(**await Role.pool.fetchrow(query)) 285 | await UserRole.create(user.id, manage_roles_role.id) 286 | 287 | res = await app.delete( 288 | f"/api/v1/roles/{role.id}/members/{user.id}", 289 | headers={"Authorization": token}, 290 | ) 291 | 292 | assert res.status_code == 403 293 | finally: 294 | await db.execute( 295 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 296 | manage_roles_role.id, 297 | user.id, 298 | ) 299 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 300 | 301 | 302 | @pytest.mark.db 303 | @pytest.mark.asyncio 304 | async def test_update_role_positions_up( 305 | app: AsyncClient, db, user, token, manage_roles_role 306 | ): 307 | try: 308 | roles = [] 309 | # manage roles -> 1 -> 3 -> 2 -> 4 310 | role_names = ["1", "3", "2", "4"] 311 | for role_name in role_names: 312 | query = """ 313 | INSERT INTO roles (id, name, color, permissions, position) 314 | VALUES (create_snowflake(), $1, 0, 0, (SELECT COUNT(*) FROM roles) + 1) 315 | RETURNING *; 316 | """ 317 | role = Role(**await Role.pool.fetchrow(query, role_name)) 318 | roles.append(role) 319 | 320 | await UserRole.create(user.id, manage_roles_role.id) 321 | 322 | res = await app.patch( 323 | f"/api/v1/roles/{roles[2].id}", 324 | json={"position": 3}, 325 | headers={"Authorization": token}, 326 | ) 327 | assert res.status_code == 204 328 | 329 | res = await app.get("/api/v1/roles") 330 | new_roles = sorted(res.json(), key=lambda x: x["position"]) 331 | 332 | for i, role in enumerate(new_roles, 1): 333 | assert ( 334 | role["position"] == i 335 | ) # make sure roles are ordered with no missing positions 336 | 337 | for i in range(1, 5): 338 | assert new_roles[i]["name"] == str(i) 339 | finally: 340 | await db.execute( 341 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 342 | manage_roles_role.id, 343 | user.id, 344 | ) 345 | for role in roles: 346 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 347 | 348 | 349 | @pytest.mark.db 350 | @pytest.mark.asyncio 351 | async def test_update_role_positions_down( 352 | app: AsyncClient, db, user, token, manage_roles_role 353 | ): 354 | try: 355 | roles = [] 356 | # manage roles -> 1 -> 3 -> 2 -> 4 357 | role_names = ["1", "3", "2", "4"] 358 | for role_name in role_names: 359 | query = """ 360 | INSERT INTO roles (id, name, color, permissions, position) 361 | VALUES (create_snowflake(), $1, 0, 0, (SELECT COUNT(*) FROM roles) + 1) 362 | RETURNING *; 363 | """ 364 | role = Role(**await Role.pool.fetchrow(query, role_name)) 365 | roles.append(role) 366 | 367 | await UserRole.create(user.id, manage_roles_role.id) 368 | 369 | res = await app.patch( 370 | f"/api/v1/roles/{roles[1].id}", 371 | json={"position": 4}, 372 | headers={"Authorization": token}, 373 | ) 374 | assert res.status_code == 204 375 | 376 | res = await app.get("/api/v1/roles") 377 | new_roles = sorted(res.json(), key=lambda x: x["position"]) 378 | 379 | for i, role in enumerate(new_roles, 1): 380 | assert ( 381 | role["position"] == i 382 | ) # make sure roles are ordered with no missing positions 383 | 384 | for i in range(1, 5): 385 | assert new_roles[i]["name"] == str(i) 386 | finally: 387 | await db.execute( 388 | "DELETE FROM userroles WHERE role_id = $1 AND user_id = $2;", 389 | manage_roles_role.id, 390 | user.id, 391 | ) 392 | for role in roles: 393 | await db.execute("DELETE FROM roles WHERE id = $1", role.id) 394 | -------------------------------------------------------------------------------- /tests/test_utils/test_snowflake.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import pytest 3 | 4 | from utils import snowflake_time 5 | 6 | 7 | EXAMPLE_INTERNAL_ID = 6802059911472611845 8 | EXAMPLE_DISCORD_ID = 144112966176997376 9 | INVALID_ID = 144112966175 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "test_id,is_internal,expected", 14 | [ 15 | (EXAMPLE_DISCORD_ID, False, datetime(2016, 2, 2, 16, 13, 28, 626000)), 16 | (EXAMPLE_DISCORD_ID, True, datetime(1971, 2, 21, 7, 17, 47, 826000)), 17 | (EXAMPLE_INTERNAL_ID, True, datetime(2021, 6, 10, 17, 41, 58, 257000)), 18 | (EXAMPLE_INTERNAL_ID, False, datetime(2066, 5, 23, 2, 37, 39, 57000)), 19 | (INVALID_ID, False, datetime(2015, 1, 1, 0, 0, 34, 359000)), 20 | (0, False, datetime(2015, 1, 1, 0, 0)), 21 | (-INVALID_ID, False, datetime(2014, 12, 31, 23, 59, 25, 640000)), 22 | ], 23 | ) 24 | def test_snowflake_time(test_id, is_internal, expected): 25 | """ 26 | :param test_id: Example Snowflake ID 27 | :param is_internal: Internal or Discord ID 28 | :param expected: Expected datetime 29 | """ 30 | actual = snowflake_time(id=test_id, internal=is_internal) 31 | assert expected == actual 32 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | import-order-style=pycharm 4 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .time import snowflake_time 2 | from .response import JSONResponse 3 | from .permissions import has_permission, has_permissions 4 | 5 | __all__ = ( 6 | JSONResponse, 7 | snowflake_time, 8 | has_permission, 9 | has_permissions, 10 | ) 11 | -------------------------------------------------------------------------------- /utils/permissions.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | from api.models.permissions import BasePermission, Administrator 3 | 4 | 5 | def has_permissions( 6 | permissions: int, required: List[Union[int, BasePermission]] 7 | ) -> bool: 8 | """Returns `True` if `permissions` has all required permissions""" 9 | if permissions & Administrator().value: 10 | return True 11 | 12 | all_perms = 0 13 | for perm in required: 14 | if isinstance(perm, int): 15 | all_perms |= perm 16 | else: 17 | all_perms |= perm.value 18 | 19 | return permissions & all_perms == all_perms 20 | 21 | 22 | def has_permission(permissions: int, permission: Union[BasePermission, int]) -> bool: 23 | """Returns `True` if `permissions` has required permission""" 24 | if permissions & Administrator().value: 25 | return True 26 | 27 | if isinstance(permission, int): 28 | return permissions & permission == permission 29 | 30 | return permissions & permission.value == permission.value 31 | -------------------------------------------------------------------------------- /utils/response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import typing 3 | 4 | from fastapi.responses import JSONResponse as BaseResponse 5 | 6 | 7 | class JSONResponse(BaseResponse): 8 | def render(self, content: typing.Any) -> bytes: 9 | if isinstance(content, datetime): 10 | return content.replace(microsecond=0).isoformat() 11 | 12 | return super().render(content) 13 | -------------------------------------------------------------------------------- /utils/time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | DISCORD_EPOCH = 1420070400000 5 | MAX_ASYNCIO_SECONDS = 3456000 6 | OUR_EPOCH = 1609459200 7 | 8 | 9 | def snowflake_time(id: int, *, internal: bool = True) -> datetime.datetime: 10 | """ 11 | :param id: The ID we want to convert. 12 | :param internal: Whether it's a internal ID or not. 13 | 14 | :return: :class:`datetime.datetime` instance. 15 | """ 16 | 17 | epoch = OUR_EPOCH 18 | 19 | if not internal: 20 | epoch = DISCORD_EPOCH 21 | 22 | return datetime.datetime.utcfromtimestamp(((id >> 22) + epoch) / 1000) 23 | --------------------------------------------------------------------------------