diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 4f3635ca0..16ebf7272 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -1,10 +1,10 @@ name: Backend CI on: + pull_request: push: - paths: - - ".github/workflows/backend.yml" - - "backend/**" - - "!**.md" + branches: + - master + - develop defaults: run: @@ -12,66 +12,67 @@ defaults: jobs: Dependencies: - runs-on: self-hosted - container: python:3.10.2-alpine + runs-on: ubuntu-latest + container: python:3.10.2-slim-bullseye steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - uses: actions/cache@v2 id: cache with: path: /usr/local/lib/python3.10/site-packages - key: ${{ runner.os }}-site-packages-${{ hashFiles('**/requirements.txt', '**/requirements-dev.txt') }} + key: venv-${{ runner.os }}-bullseye-3.10.2-v2-${{ hashFiles('**/poetry.lock') }} restore-keys: | - ${{ runner.os }}-site-packages- + venv-${{ runner.os }}-bullseye-3.10.2-v2 - if: steps.cache.outputs.cache-hit != 'true' - run: apk add gcc musl-dev build-base mariadb-dev libffi-dev + run: apt update - if: steps.cache.outputs.cache-hit != 'true' - run: pip install -r requirements.txt + run: apt install -y build-essential libmariadb-dev - if: steps.cache.outputs.cache-hit != 'true' - run: pip install -r requirements-dev.txt + run: pip install -U pip poetry + + - if: steps.cache.outputs.cache-hit != 'true' + run: | + python -m poetry export --dev --format requirements.txt --output requirements.txt --without-hashes + pip install -r requirements.txt Test: needs: [Dependencies] - runs-on: self-hosted - container: python:3.10.2-alpine + runs-on: ubuntu-latest + container: python:3.10.2-slim-bullseye steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - uses: actions/cache@v2 with: path: /usr/local/lib/python3.10/site-packages - key: ${{ runner.os }}-site-packages-${{ hashFiles('**/requirements.txt', '**/requirements-dev.txt') }} + key: venv-${{ runner.os }}-bullseye-3.10.2-v2-${{ hashFiles('**/poetry.lock') }} - run: python -m pytest Lint: needs: [Test] - runs-on: self-hosted - container: python:3.10.2-alpine + runs-on: ubuntu-latest + container: python:3.10.2-slim-bullseye steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - uses: actions/cache@v2 with: path: /usr/local/lib/python3.10/site-packages - key: ${{ runner.os }}-site-packages-${{ hashFiles('**/requirements.txt', '**/requirements-dev.txt') }} + key: venv-${{ runner.os }}-bullseye-3.10.2-v2-${{ hashFiles('**/poetry.lock') }} - - run: python -m pylint src tests + - run: python -m pylint src Type: needs: [Test] - runs-on: self-hosted - container: python:3.10.2-alpine + runs-on: ubuntu-latest + container: python:3.10.2-slim-bullseye steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - uses: actions/cache@v2 with: path: /usr/local/lib/python3.10/site-packages - key: ${{ runner.os }}-site-packages-${{ hashFiles('**/requirements.txt', '**/requirements-dev.txt') }} + key: venv-${{ runner.os }}-bullseye-3.10.2-v2-${{ hashFiles('**/poetry.lock') }} - - run: python -m mypy src tests + - run: python -m mypy src diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index d93d40559..1e9a778bb 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,10 +1,10 @@ name: Frontend CI on: + pull_request: push: - paths: - - ".github/workflows/frontend.yml" - - "frontend/**" - - "!**.md" + branches: + - master + - develop defaults: run: @@ -12,12 +12,10 @@ defaults: jobs: Dependencies: - runs-on: self-hosted - container: node:16.14.0-alpine + runs-on: ubuntu-latest + container: node:16.14.0-bullseye-slim steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - - name: Get yarn cache directory path id: yarn-cache-dir run: echo "::set-output name=dir::$(yarn cache dir)" @@ -35,12 +33,10 @@ jobs: Test: needs: [Dependencies] - runs-on: self-hosted - container: node:16.14.0-alpine + runs-on: ubuntu-latest + container: node:16.14.0-bullseye-slim steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - - name: Get yarn cache directory path id: yarn-cache-dir run: echo "::set-output name=dir::$(yarn cache dir)" @@ -55,12 +51,10 @@ jobs: Lint: needs: [Test] - runs-on: self-hosted - container: node:16.14.0-alpine + runs-on: ubuntu-latest + container: node:16.14.0-bullseye-slim steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - - name: Get yarn cache directory path id: yarn-cache-dir run: echo "::set-output name=dir::$(yarn cache dir)" @@ -75,12 +69,10 @@ jobs: Style: needs: [Test] - runs-on: self-hosted - container: node:16.14.0-alpine + runs-on: ubuntu-latest + container: node:16.14.0-bullseye-slim steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - - name: Get yarn cache directory path id: yarn-cache-dir run: echo "::set-output name=dir::$(yarn cache dir)" @@ -95,12 +87,10 @@ jobs: Build: needs: [Style, Lint] - runs-on: self-hosted - container: node:16.14.0-alpine + runs-on: ubuntu-latest + container: node:16.14.0-bullseye-slim steps: - - run: apk add --no-cache tar - uses: actions/checkout@v2 - - name: Get yarn cache directory path id: yarn-cache-dir run: echo "::set-output name=dir::$(yarn cache dir)" diff --git a/README.md b/README.md index 11aeafa3b..1b8649069 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ Below are the instructions on how to set up the frontend and backend. Instructio - Create a `Virtual Environment` (`python3 -m venv venv`) -- Install the dependencies (`pip3 install -r requirements.txt -r requirements-dev.txt`) +- Install `Poetry` (`pip3 install poetry`) + +- Install the dependencies (`poetry install`) - Required scripts: @@ -129,7 +131,7 @@ Below are the instructions on how to set up the frontend and backend. Instructio ```shell # Install the required Node version nvm install 16.14.1 - + # Make your shell use the newly-installed version nvm use 16 ``` @@ -218,10 +220,24 @@ yarn build source venv/bin/activate ``` -3. Install the regular dependencies and development dependencies +3. Install Poetry and configure it to use the `Virtual Environment` we created in the previous step + + ```shell + pip3 install poetry + + # Use the existing venv instead of creating a new one + poetry config virtualenvs.create false + poetry config virtualenvs.in-project true + ``` + +3. Install the dependencies ``` - pip3 install -r requirements.txt -r requirements-dev.txt + # Install all dependencies + poetry install + + # Only install production dependencies + poetry install --no-dev ``` For all commands below, make sure your `Virtual Environment` is activated at all times. Otherwise, your Python interpreter won't be able to find the correct package. diff --git a/backend/.coveragerc b/backend/.coveragerc deleted file mode 100644 index 03cd94de9..000000000 --- a/backend/.coveragerc +++ /dev/null @@ -1,8 +0,0 @@ -[run] -omit = - *tests* - -[report] -omit = - *tests* - *__init__.py* \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index e3c24e158..eb6a08b2d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,10 @@ DB_PORT=3306 # JWT key (needs to be changed for production) # Can be generated using "openssl rand -hex 32" SECRET_KEY=4d16a9cc83d74144322e893c879b5f639088c15dc1606b11226abbd7e97f5ee5 +# The ACCESS JWT token should be valid for ... +ACCESS_TOKEN_EXPIRE_M = 5 +# The REFRESH JWT token should be valid for ... +REFRESH_TOKEN_EXPIRE_M = 2880 # Frontend FRONTEND_URL="http://localhost:3000" \ No newline at end of file diff --git a/backend/.pylintrc b/backend/.pylintrc deleted file mode 100644 index 5c3034585..000000000 --- a/backend/.pylintrc +++ /dev/null @@ -1,11 +0,0 @@ -[MASTER] -load-plugins=pylint_pytest -argument-rgx=[a-z_][a-z0-9_]{1,31}$ -disable= - import-outside-toplevel, - missing-module-docstring, - too-few-public-methods, -extension-pkg-whitelist=pydantic - -[FORMAT] -max-line-length=120 \ No newline at end of file diff --git a/backend/README.md b/backend/README.md index 776b940e4..1b739db34 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,10 @@ # Backend -## Setting up a venv +## Setting up a venv and installing dependencies -```bash +Create a venv, install Poetry, and then install the dependencies: + +```shell # Navigate to this directory cd backend @@ -13,11 +15,15 @@ python3 -m venv venv # PyCharm does this automatically, so this is only required if you're using another IDE source venv/bin/activate -# Install requirements -pip3 install -r requirements.txt +# Install Poetry +pip3 install poetry + +# Install all dependencies and dev dependencies +poetry install -# Install dev requirements -pip3 install -r requirements-dev.txt +# Use the existing venv instead of creating a new one +poetry config virtualenvs.create false +poetry config virtualenvs.in-project true ``` Note that, in case your IDE does not do this automatically, you have to run `source venv/bin/activate` every time you want to run the backend, as otherwise your interpreter won't be able to find the packages. @@ -34,9 +40,15 @@ This directory contains a `.env.example` file which shows the general structure ## Keeping requirements up to date -Whenever you'd like to install a new package, make sure to **update the `requirements.txt` or `requirements-dev.txt` files** so that everyone has the same packages installed, and our tests can run easily. +Whenever you'd like to install a new package, install it using `Poetry` so that the [`pyproject.toml`](pyproject.toml)-file is always up-to-date. This ensures we always have the same versions of every package. -In case your package installs multiple other dependencies, it's not necessary to install those along with it. The main package you installed (along with the correct version) is satisfactory. +```shell +# Install a regular dependency +poetry add package_name + +# Install a dev dependency +poetry add --dev package_name +``` ## Type annotations diff --git a/backend/create_admin.py b/backend/create_admin.py index 294b0f5cd..b2aff3d49 100644 --- a/backend/create_admin.py +++ b/backend/create_admin.py @@ -45,24 +45,23 @@ def get_hashed_password() -> str: def create_admin(name: str, email: str, pw: str): """Create a new user in the database""" - session = DBSession() - transaction = session.begin_nested() - - try: - user = create_user(session, name, email) - user.admin = True - session.add(user) - session.commit() - - # Add an email auth entry - create_auth_email(session, user, pw) - except sqlalchemy.exc.SQLAlchemyError as e: - # Something went wrong: rollback the transaction & print the error - transaction.rollback() - - # Print the traceback of the exception - print(traceback.format_exc()) - exit(3) + with DBSession.begin() as session: + try: + transaction = session.begin_nested() + user = create_user(session, name, commit=False) + user.admin = True + + # Add an email auth entry + create_auth_email(session, user, pw, email, commit=False) + except sqlalchemy.exc.SQLAlchemyError: + # Something went wrong: rollback the transaction & print the error + transaction.rollback() + + # Print the traceback of the exception + print(traceback.format_exc()) + exit(3) + + session.close() if __name__ == "__main__": diff --git a/backend/migrations/versions/1862d7dea4cc_.py b/backend/migrations/versions/1862d7dea4cc_.py new file mode 100644 index 000000000..094e930eb --- /dev/null +++ b/backend/migrations/versions/1862d7dea4cc_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: 1862d7dea4cc +Revises: 8c97ecc58e5f, a4a047b881db +Create Date: 2022-04-08 16:05:01.649808 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1862d7dea4cc' +down_revision = ('8c97ecc58e5f', 'a4a047b881db') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/migrations/versions/43e6e98fe039_create_enum_for_email_statuses.py b/backend/migrations/versions/43e6e98fe039_create_enum_for_email_statuses.py new file mode 100644 index 000000000..f51d5c0aa --- /dev/null +++ b/backend/migrations/versions/43e6e98fe039_create_enum_for_email_statuses.py @@ -0,0 +1,33 @@ +"""Create enum for email statuses + +Revision ID: 43e6e98fe039 +Revises: a4a047b881db +Create Date: 2022-04-13 16:24:26.687617 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '43e6e98fe039' +down_revision = 'a4a047b881db' +branch_labels = None +depends_on = None + +new_type = sa.Enum('APPLIED', 'AWAITING_PROJECT', 'APPROVED', 'CONTRACT_CONFIRMED', 'CONTRACT_DECLINED', 'REJECTED', + name='emailstatusenum') +old_type = sa.Enum('UNDECIDED', 'YES', 'MAYBE', 'NO', name='decisionenum') + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("decision_emails", schema=None) as batch_op: + batch_op.alter_column("decision", type_=new_type, existing_type=old_type, nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("decision_emails", schema=None) as batch_op: + batch_op.alter_column("decision", type_=old_type, existing_type=new_type, nullable=False) + # ### end Alembic commands ### diff --git a/backend/migrations/versions/64c42bb48aee_.py b/backend/migrations/versions/64c42bb48aee_.py new file mode 100644 index 000000000..23597345c --- /dev/null +++ b/backend/migrations/versions/64c42bb48aee_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: 64c42bb48aee +Revises: 964637070800, d4eaf2b564a4 +Create Date: 2022-04-22 16:25:02.453857 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '64c42bb48aee' +down_revision = ('964637070800', 'd4eaf2b564a4') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/migrations/versions/8c97ecc58e5f_multiple_column_unique_constraint_coach_.py b/backend/migrations/versions/8c97ecc58e5f_multiple_column_unique_constraint_coach_.py new file mode 100644 index 000000000..91354c241 --- /dev/null +++ b/backend/migrations/versions/8c97ecc58e5f_multiple_column_unique_constraint_coach_.py @@ -0,0 +1,32 @@ +"""multiple column unique constraint coach & student suggestion + +Revision ID: 8c97ecc58e5f +Revises: f125e90b2cf3 +Create Date: 2022-03-16 21:07:44.193388 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8c97ecc58e5f' +down_revision = 'f125e90b2cf3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('suggestions', schema=None) as batch_op: + batch_op.create_unique_constraint('unique_coach_student_suggestion', ['coach_id', 'student_id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('suggestions', schema=None) as batch_op: + batch_op.drop_constraint('unique_coach_student_suggestion', type_='unique') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/964637070800_.py b/backend/migrations/versions/964637070800_.py new file mode 100644 index 000000000..9c4080542 --- /dev/null +++ b/backend/migrations/versions/964637070800_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: 964637070800 +Revises: a4a047b881db, c5bdaa5815ca +Create Date: 2022-04-08 20:25:41.099295 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '964637070800' +down_revision = ('a4a047b881db', 'c5bdaa5815ca') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/migrations/versions/a4a047b881db_add_email_to_google_github_auth.py b/backend/migrations/versions/a4a047b881db_add_email_to_google_github_auth.py new file mode 100644 index 000000000..f293ca85c --- /dev/null +++ b/backend/migrations/versions/a4a047b881db_add_email_to_google_github_auth.py @@ -0,0 +1,42 @@ +"""Add email to Google & GitHub auth + +Revision ID: a4a047b881db +Revises: a5f19eb19cca +Create Date: 2022-04-06 17:40:15.305860 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a4a047b881db' +down_revision = 'a5f19eb19cca' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('github_auths', schema=None) as batch_op: + batch_op.add_column(sa.Column('email', sa.Text(), nullable=False)) + batch_op.create_unique_constraint("uq_github_auths_email", ['email']) + + with op.batch_alter_table('google_auths', schema=None) as batch_op: + batch_op.add_column(sa.Column('email', sa.Text(), nullable=False)) + batch_op.create_unique_constraint("uq_google_auths_email", ['email']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('google_auths', schema=None) as batch_op: + batch_op.drop_constraint("uq_google_auths_email", type_='unique') + batch_op.drop_column('email') + + with op.batch_alter_table('github_auths', schema=None) as batch_op: + batch_op.drop_constraint("uq_github_auths_email", type_='unique') + batch_op.drop_column('email') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/a5f19eb19cca_add_name_to_edition.py b/backend/migrations/versions/a5f19eb19cca_add_name_to_edition.py new file mode 100644 index 000000000..3fe73026f --- /dev/null +++ b/backend/migrations/versions/a5f19eb19cca_add_name_to_edition.py @@ -0,0 +1,34 @@ +"""add name to edition + +Revision ID: a5f19eb19cca +Revises: ca4c4182b93a +Create Date: 2022-03-27 14:16:49.714952 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a5f19eb19cca' +down_revision = 'ca4c4182b93a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('editions', schema=None) as batch_op: + batch_op.add_column(sa.Column('name', sa.Text(), nullable=False)) + batch_op.create_unique_constraint("uq_edition_name", ['name']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('editions', schema=None) as batch_op: + batch_op.drop_constraint("uq_edition_name", type_='unique') + batch_op.drop_column('name') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/c5bdaa5815ca_.py b/backend/migrations/versions/c5bdaa5815ca_.py new file mode 100644 index 000000000..6de85ba03 --- /dev/null +++ b/backend/migrations/versions/c5bdaa5815ca_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: c5bdaa5815ca +Revises: a5f19eb19cca +Create Date: 2022-04-06 10:46:56.160993 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c5bdaa5815ca' +down_revision = 'a5f19eb19cca' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/migrations/versions/ca4c4182b93a_moved_email_to_authemail.py b/backend/migrations/versions/ca4c4182b93a_moved_email_to_authemail.py new file mode 100644 index 000000000..9973803f7 --- /dev/null +++ b/backend/migrations/versions/ca4c4182b93a_moved_email_to_authemail.py @@ -0,0 +1,40 @@ +"""moved email to AuthEmail + +Revision ID: ca4c4182b93a +Revises: f125e90b2cf3 +Create Date: 2022-03-27 10:47:50.051982 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'ca4c4182b93a' +down_revision = 'f125e90b2cf3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('email_auths', schema=None) as batch_op: + batch_op.add_column(sa.Column('email', sa.Text(), nullable=False)) + batch_op.create_unique_constraint("uq_email_auth_email", ['email']) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('email') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('email', mysql.TEXT(), nullable=True)) + + with op.batch_alter_table('email_auths', schema=None) as batch_op: + batch_op.drop_constraint("uq_email_auth_email", type_='unique') + batch_op.drop_column('email') + + # ### end Alembic commands ### diff --git a/backend/migrations/versions/d4eaf2b564a4_merge_heads.py b/backend/migrations/versions/d4eaf2b564a4_merge_heads.py new file mode 100644 index 000000000..410472ae3 --- /dev/null +++ b/backend/migrations/versions/d4eaf2b564a4_merge_heads.py @@ -0,0 +1,24 @@ +"""merge heads + +Revision ID: d4eaf2b564a4 +Revises: 43e6e98fe039, 1862d7dea4cc +Create Date: 2022-04-19 09:53:31.222511 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd4eaf2b564a4' +down_revision = ('43e6e98fe039', '1862d7dea4cc') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/backend/mypy.ini b/backend/mypy.ini deleted file mode 100644 index f5cdb3614..000000000 --- a/backend/mypy.ini +++ /dev/null @@ -1,8 +0,0 @@ -[mypy] -plugins = sqlalchemy.ext.mypy.plugin - -[mypy-sqlalchemy_utils] -ignore_errors = True - -[mypy-jose.*] -ignore_missing_imports = True \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock new file mode 100644 index 000000000..7ecdcb9c2 --- /dev/null +++ b/backend/poetry.lock @@ -0,0 +1,1668 @@ +[[package]] +name = "alembic" +version = "1.7.6" +description = "A database migration tool for SQLAlchemy." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" + +[package.extras] +tz = ["python-dateutil"] + +[[package]] +name = "anyio" +version = "3.5.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] + +[[package]] +name = "asgiref" +version = "3.5.0" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + +[[package]] +name = "astroid" +version = "2.9.3" +description = "An abstract syntax tree for Python with inference support." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +lazy-object-proxy = ">=1.4.0" +wrapt = ">=1.11,<1.14" + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "bcrypt" +version = "3.2.0" +description = "Modern password hashing for your software and your servers" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "cffi" +version = "1.15.0" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "2.0.12" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.0.4" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.3.1" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cryptography" +version = "36.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + +[[package]] +name = "ecdsa" +version = "0.17.0" +description = "ECDSA cryptographic signature library (pure python)" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "environs" +version = "9.5.0" +description = "simplified environment variable parsing" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +marshmallow = ">=3.0.0" +python-dotenv = "*" + +[package.extras] +dev = ["pytest", "dj-database-url", "dj-email-url", "django-cache-url", "flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)", "tox"] +django = ["dj-database-url", "dj-email-url", "django-cache-url"] +lint = ["flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "mypy (==0.910)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "dj-database-url", "dj-email-url", "django-cache-url"] + +[[package]] +name = "faker" +version = "13.3.1" +description = "Faker is a Python package that generates fake data for you." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +python-dateutil = ">=2.4" + +[[package]] +name = "fastapi" +version = "0.74.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.17.1" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] +test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] + +[[package]] +name = "greenlet" +version = "1.1.2" +description = "Lightweight in-process concurrent programming" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.extras] +docs = ["sphinx"] + +[[package]] +name = "h11" +version = "0.13.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "httptools" +version = "0.2.0" +description = "A collection of framework independent HTTP protocol utils." +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["Cython (==0.29.22)"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "lazy-object-proxy" +version = "1.7.1" +description = "A fast and thorough lazy object proxy." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mako" +version = "1.2.0" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "mariadb" +version = "1.0.10" +description = "Python MariaDB extension" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "marshmallow" +version = "3.15.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +packaging = "*" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (==0.940)", "flake8 (==4.0.1)", "flake8-bugbear (==22.1.11)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (==4.4.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.7)"] +lint = ["mypy (==0.940)", "flake8 (==4.0.1)", "flake8-bugbear (==22.1.11)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy" +version = "0.940" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = ">=1.1.0" +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +bcrypt = {version = ">=3.1.0", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"] +totp = ["cryptography"] + +[[package]] +name = "platformdirs" +version = "2.5.1" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydantic" +version = "1.9.0" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pyhumps" +version = "3.5.3" +description = "🐫 Convert strings (and dictionary keys) between snake case, camel case and pascal case in Python. Inspired by Humps for Node" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pylint" +version = "2.12.2" +description = "python code static checker" +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +astroid = ">=2.9.0,<2.10" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +isort = ">=4.2.5,<6" +mccabe = ">=0.6,<0.7" +platformdirs = ">=2.2.0" +toml = ">=0.9.2" + +[[package]] +name = "pylint-pytest" +version = "1.1.2" +description = "A Pylint plugin to suppress pytest-related false positives." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pylint = "*" +pytest = ">=4.6" + +[[package]] +name = "pyparsing" +version = "3.0.7" +description = "Python parsing module" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "7.0.1" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-env" +version = "0.6.2" +description = "py.test plugin that allows you to add environment variables." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=2.6.0" + +[[package]] +name = "pytest-mock" +version = "3.7.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "tox", "pytest-asyncio"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "0.19.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-jose" +version = "3.3.0" +description = "JOSE implementation in Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""} +ecdsa = "!=0.15" +pyasn1 = "*" +rsa = "*" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)", "pyasn1"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)", "pyasn1"] + +[[package]] +name = "python-multipart" +version = "0.0.5" +description = "A streaming multipart parser for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.4.0" + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.27.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "rsa" +version = "4.8" +description = "Pure-Python RSA implementation" +category = "main" +optional = false +python-versions = ">=3.6,<4" + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "sqlalchemy" +version = "1.4.31" +description = "Database Abstraction Library" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] +aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"] +mariadb_connector = ["mariadb (>=1.0.1)"] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"] +mysql_connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] +postgresql_pg8000 = ["pg8000 (>=1.16.6)"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql (<1)", "pymysql"] +sqlcipher = ["sqlcipher3-binary"] + +[[package]] +name = "sqlalchemy-utils" +version = "0.38.2" +description = "Various utility functions for SQLAlchemy." +category = "main" +optional = false +python-versions = "~=3.4" + +[package.dependencies] +six = "*" +SQLAlchemy = ">=1.0" + +[package.extras] +arrow = ["arrow (>=0.3.4)"] +babel = ["Babel (>=1.3)"] +color = ["colour (>=0.0.4)"] +encrypted = ["cryptography (>=0.6)"] +intervals = ["intervals (>=0.7.1)"] +password = ["passlib (>=1.6,<2.0)"] +pendulum = ["pendulum (>=2.0.5)"] +phone = ["phonenumbers (>=5.9.2)"] +test = ["pytest (>=2.7.1)", "Pygments (>=1.2)", "Jinja2 (>=2.3)", "docutils (>=0.10)", "flexmock (>=0.9.7)", "mock (==2.0.0)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pg8000 (>=1.12.4)", "pytz (>=2014.2)", "python-dateutil (>=2.6)", "pymysql", "flake8 (>=2.4.0)", "isort (>=4.2.2)", "pyodbc", "backports.zoneinfo"] +test_all = ["Babel (>=1.3)", "Jinja2 (>=2.3)", "Pygments (>=1.2)", "arrow (>=0.3.4)", "colour (>=0.0.4)", "cryptography (>=0.6)", "docutils (>=0.10)", "flake8 (>=2.4.0)", "flexmock (>=0.9.7)", "furl (>=0.4.1)", "intervals (>=0.7.1)", "isort (>=4.2.2)", "mock (==2.0.0)", "passlib (>=1.6,<2.0)", "pendulum (>=2.0.5)", "pg8000 (>=1.12.4)", "phonenumbers (>=5.9.2)", "psycopg2 (>=2.5.1)", "psycopg2cffi (>=2.8.1)", "pymysql", "pyodbc", "pytest (>=2.7.1)", "python-dateutil", "python-dateutil (>=2.6)", "pytz (>=2014.2)", "backports.zoneinfo"] +timezone = ["python-dateutil"] +url = ["furl (>=0.4.1)"] + +[[package]] +name = "sqlalchemy2-stubs" +version = "0.0.2a20" +description = "Typing Stubs for SQLAlchemy 1.4" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = ">=3.7.4" + +[[package]] +name = "starlette" +version = "0.17.1" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +anyio = ">=3.0.0,<4" + +[package.extras] +full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "types-passlib" +version = "1.7.0" +description = "Typing stubs for passlib" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "4.1.1" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "urllib3" +version = "1.26.9" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "uvicorn" +version = "0.15.0" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +asgiref = ">=3.4.0" +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.2.0,<0.3.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchgod = {version = ">=0.6", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=9.1", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] + +[[package]] +name = "uvloop" +version = "0.16.0" +description = "Fast implementation of asyncio event loop on top of libuv" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] + +[[package]] +name = "watchgod" +version = "0.8" +description = "Simple, modern file watching and code reload in python." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0.0,<4" + +[[package]] +name = "websockets" +version = "10.2" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "wrapt" +version = "1.13.3" +description = "Module for decorators, wrappers and monkey patching." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "5bf64e89fc7b51ce108190ab5525229fc27a35b889808d5507c3d2f22f5de71d" + +[metadata.files] +alembic = [ + {file = "alembic-1.7.6-py3-none-any.whl", hash = "sha256:ad842f2c3ab5c5d4861232730779c05e33db4ba880a08b85eb505e87c01095bc"}, + {file = "alembic-1.7.6.tar.gz", hash = "sha256:6c0c05e9768a896d804387e20b299880fe01bc56484246b0dffe8075d6d3d847"}, +] +anyio = [ + {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"}, + {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, +] +asgiref = [ + {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, + {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, +] +astroid = [ + {file = "astroid-2.9.3-py3-none-any.whl", hash = "sha256:506daabe5edffb7e696ad82483ad0228245a9742ed7d2d8c9cdb31537decf9f6"}, + {file = "astroid-2.9.3.tar.gz", hash = "sha256:1efdf4e867d4d8ba4a9f6cf9ce07cd182c4c41de77f23814feb27ca93ca9d877"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +bcrypt = [ + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b589229207630484aefe5899122fb938a5b017b0f4349f769b8c13e78d99a8fd"}, + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a0584a92329210fcd75eb8a3250c5a941633f8bfaf2a18f81009b097732839b7"}, + {file = "bcrypt-3.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:56e5da069a76470679f312a7d3d23deb3ac4519991a0361abc11da837087b61d"}, + {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"}, + {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"}, + {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +cffi = [ + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, +] +click = [ + {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, + {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +coverage = [ + {file = "coverage-6.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeffd96882d8c06d31b65dddcf51db7c612547babc1c4c5db6a011abe9798525"}, + {file = "coverage-6.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:621f6ea7260ea2ffdaec64fe5cb521669984f567b66f62f81445221d4754df4c"}, + {file = "coverage-6.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84f2436d6742c01136dd940ee158bfc7cf5ced3da7e4c949662b8703b5cd8145"}, + {file = "coverage-6.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de73fca6fb403dd72d4da517cfc49fcf791f74eee697d3219f6be29adf5af6ce"}, + {file = "coverage-6.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78fbb2be068a13a5d99dce9e1e7d168db880870f7bc73f876152130575bd6167"}, + {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f5a4551dfd09c3bd12fca8144d47fe7745275adf3229b7223c2f9e29a975ebda"}, + {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7bff3a98f63b47464480de1b5bdd80c8fade0ba2832c9381253c9b74c4153c27"}, + {file = "coverage-6.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a06c358f4aed05fa1099c39decc8022261bb07dfadc127c08cfbd1391b09689e"}, + {file = "coverage-6.3.1-cp310-cp310-win32.whl", hash = "sha256:9fff3ff052922cb99f9e52f63f985d4f7a54f6b94287463bc66b7cdf3eb41217"}, + {file = "coverage-6.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:276b13cc085474e482566c477c25ed66a097b44c6e77132f3304ac0b039f83eb"}, + {file = "coverage-6.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:56c4a409381ddd7bbff134e9756077860d4e8a583d310a6f38a2315b9ce301d0"}, + {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eb494070aa060ceba6e4bbf44c1bc5fa97bfb883a0d9b0c9049415f9e944793"}, + {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e15d424b8153756b7c903bde6d4610be0c3daca3986173c18dd5c1a1625e4cd"}, + {file = "coverage-6.3.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d47a897c1e91f33f177c21de897267b38fbb45f2cd8e22a710bcef1df09ac1"}, + {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:25e73d4c81efa8ea3785274a2f7f3bfbbeccb6fcba2a0bdd3be9223371c37554"}, + {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fac0bcc5b7e8169bffa87f0dcc24435446d329cbc2b5486d155c2e0f3b493ae1"}, + {file = "coverage-6.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:72128176fea72012063200b7b395ed8a57849282b207321124d7ff14e26988e8"}, + {file = "coverage-6.3.1-cp37-cp37m-win32.whl", hash = "sha256:1bc6d709939ff262fd1432f03f080c5042dc6508b6e0d3d20e61dd045456a1a0"}, + {file = "coverage-6.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:618eeba986cea7f621d8607ee378ecc8c2504b98b3fdc4952b30fe3578304687"}, + {file = "coverage-6.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ed164af5c9078596cfc40b078c3b337911190d3faeac830c3f1274f26b8320"}, + {file = "coverage-6.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:352c68e233409c31048a3725c446a9e48bbff36e39db92774d4f2380d630d8f8"}, + {file = "coverage-6.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:448d7bde7ceb6c69e08474c2ddbc5b4cd13c9e4aa4a717467f716b5fc938a734"}, + {file = "coverage-6.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9fde6b90889522c220dd56a670102ceef24955d994ff7af2cb786b4ba8fe11e4"}, + {file = "coverage-6.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e647a0be741edbb529a72644e999acb09f2ad60465f80757da183528941ff975"}, + {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a5cdc3adb4f8bb8d8f5e64c2e9e282bc12980ef055ec6da59db562ee9bdfefa"}, + {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2dd70a167843b4b4b2630c0c56f1b586fe965b4f8ac5da05b6690344fd065c6b"}, + {file = "coverage-6.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9ad0a117b8dc2061ce9461ea4c1b4799e55edceb236522c5b8f958ce9ed8fa9a"}, + {file = "coverage-6.3.1-cp38-cp38-win32.whl", hash = "sha256:e92c7a5f7d62edff50f60a045dc9542bf939758c95b2fcd686175dd10ce0ed10"}, + {file = "coverage-6.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:482fb42eea6164894ff82abbcf33d526362de5d1a7ed25af7ecbdddd28fc124f"}, + {file = "coverage-6.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c5b81fb37db76ebea79aa963b76d96ff854e7662921ce742293463635a87a78d"}, + {file = "coverage-6.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a4f923b9ab265136e57cc14794a15b9dcea07a9c578609cd5dbbfff28a0d15e6"}, + {file = "coverage-6.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56d296cbc8254a7dffdd7bcc2eb70be5a233aae7c01856d2d936f5ac4e8ac1f1"}, + {file = "coverage-6.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245ab82e8554fa88c4b2ab1e098ae051faac5af829efdcf2ce6b34dccd5567c"}, + {file = "coverage-6.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f2b05757c92ad96b33dbf8e8ec8d4ccb9af6ae3c9e9bd141c7cc44d20c6bcba"}, + {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9e3dd806f34de38d4c01416344e98eab2437ac450b3ae39c62a0ede2f8b5e4ed"}, + {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d651fde74a4d3122e5562705824507e2f5b2d3d57557f1916c4b27635f8fbe3f"}, + {file = "coverage-6.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:704f89b87c4f4737da2860695a18c852b78ec7279b24eedacab10b29067d3a38"}, + {file = "coverage-6.3.1-cp39-cp39-win32.whl", hash = "sha256:2aed4761809640f02e44e16b8b32c1a5dee5e80ea30a0ff0912158bde9c501f2"}, + {file = "coverage-6.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:9976fb0a5709988778ac9bc44f3d50fccd989987876dfd7716dee28beed0a9fa"}, + {file = "coverage-6.3.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:463e52616ea687fd323888e86bf25e864a3cc6335a043fad6bbb037dbf49bbe2"}, + {file = "coverage-6.3.1.tar.gz", hash = "sha256:6c3f6158b02ac403868eea390930ae64e9a9a2a5bbfafefbb920d29258d9f2f8"}, +] +cryptography = [ + {file = "cryptography-36.0.2-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:4e2dddd38a5ba733be6a025a1475a9f45e4e41139d1321f412c6b360b19070b6"}, + {file = "cryptography-36.0.2-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:4881d09298cd0b669bb15b9cfe6166f16fc1277b4ed0d04a22f3d6430cb30f1d"}, + {file = "cryptography-36.0.2-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea634401ca02367c1567f012317502ef3437522e2fc44a3ea1844de028fa4b84"}, + {file = "cryptography-36.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:7be666cc4599b415f320839e36367b273db8501127b38316f3b9f22f17a0b815"}, + {file = "cryptography-36.0.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8241cac0aae90b82d6b5c443b853723bcc66963970c67e56e71a2609dc4b5eaf"}, + {file = "cryptography-36.0.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2d54e787a884ffc6e187262823b6feb06c338084bbe80d45166a1cb1c6c5bf"}, + {file = "cryptography-36.0.2-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:c2c5250ff0d36fd58550252f54915776940e4e866f38f3a7866d92b32a654b86"}, + {file = "cryptography-36.0.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ec6597aa85ce03f3e507566b8bcdf9da2227ec86c4266bd5e6ab4d9e0cc8dab2"}, + {file = "cryptography-36.0.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ca9f686517ec2c4a4ce930207f75c00bf03d94e5063cbc00a1dc42531511b7eb"}, + {file = "cryptography-36.0.2-cp36-abi3-win32.whl", hash = "sha256:f64b232348ee82f13aac22856515ce0195837f6968aeaa94a3d0353ea2ec06a6"}, + {file = "cryptography-36.0.2-cp36-abi3-win_amd64.whl", hash = "sha256:53e0285b49fd0ab6e604f4c5d9c5ddd98de77018542e88366923f152dbeb3c29"}, + {file = "cryptography-36.0.2-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:32db5cc49c73f39aac27574522cecd0a4bb7384e71198bc65a0d23f901e89bb7"}, + {file = "cryptography-36.0.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b3d199647468d410994dbeb8cec5816fb74feb9368aedf300af709ef507e3e"}, + {file = "cryptography-36.0.2-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:da73d095f8590ad437cd5e9faf6628a218aa7c387e1fdf67b888b47ba56a17f0"}, + {file = "cryptography-36.0.2-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:0a3bf09bb0b7a2c93ce7b98cb107e9170a90c51a0162a20af1c61c765b90e60b"}, + {file = "cryptography-36.0.2-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8897b7b7ec077c819187a123174b645eb680c13df68354ed99f9b40a50898f77"}, + {file = "cryptography-36.0.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82740818f2f240a5da8dfb8943b360e4f24022b093207160c77cadade47d7c85"}, + {file = "cryptography-36.0.2-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:1f64a62b3b75e4005df19d3b5235abd43fa6358d5516cfc43d87aeba8d08dd51"}, + {file = "cryptography-36.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e167b6b710c7f7bc54e67ef593f8731e1f45aa35f8a8a7b72d6e42ec76afd4b3"}, + {file = "cryptography-36.0.2.tar.gz", hash = "sha256:70f8f4f7bb2ac9f340655cbac89d68c527af5bb4387522a8413e841e3e6628c9"}, +] +ecdsa = [ + {file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"}, + {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"}, +] +environs = [ + {file = "environs-9.5.0-py2.py3-none-any.whl", hash = "sha256:1e549569a3de49c05f856f40bce86979e7d5ffbbc4398e7f338574c220189124"}, + {file = "environs-9.5.0.tar.gz", hash = "sha256:a76307b36fbe856bdca7ee9161e6c466fd7fcffc297109a118c59b54e27e30c9"}, +] +faker = [ + {file = "Faker-13.3.1-py3-none-any.whl", hash = "sha256:c88c8b5ee9376a242deca8fe829f9a3215ffa43c31da6f66d9594531fb344453"}, + {file = "Faker-13.3.1.tar.gz", hash = "sha256:fa060e331ffffb57cfa4c07f95d54911e339984ed72596ba6a9e7b6fa569d799"}, +] +fastapi = [ + {file = "fastapi-0.74.1-py3-none-any.whl", hash = "sha256:b8ec8400623ef0b2ff558ebe06753b349f8e3a5dd38afea650800f2644ddba34"}, + {file = "fastapi-0.74.1.tar.gz", hash = "sha256:b58a2c46df14f62ebe6f24a9439927539ba1959b9be55ba0e2f516a683e5b9d4"}, +] +greenlet = [ + {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"}, + {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"}, + {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"}, + {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"}, + {file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"}, + {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"}, + {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"}, + {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"}, + {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"}, + {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"}, + {file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"}, + {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"}, + {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"}, + {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"}, + {file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"}, + {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"}, + {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"}, + {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"}, + {file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"}, + {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"}, + {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"}, + {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"}, + {file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"}, + {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"}, + {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, + {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, +] +h11 = [ + {file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"}, + {file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"}, +] +httptools = [ + {file = "httptools-0.2.0-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5"}, + {file = "httptools-0.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149"}, + {file = "httptools-0.2.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7"}, + {file = "httptools-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77"}, + {file = "httptools-0.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658"}, + {file = "httptools-0.2.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb"}, + {file = "httptools-0.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e"}, + {file = "httptools-0.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb"}, + {file = "httptools-0.2.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943"}, + {file = "httptools-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15"}, + {file = "httptools-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380"}, + {file = "httptools-0.2.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557"}, + {file = "httptools-0.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065"}, + {file = "httptools-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f"}, + {file = "httptools-0.2.0.tar.gz", hash = "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +lazy-object-proxy = [ + {file = "lazy-object-proxy-1.7.1.tar.gz", hash = "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-win32.whl", hash = "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win32.whl", hash = "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-win32.whl", hash = "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-win32.whl", hash = "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61"}, + {file = "lazy_object_proxy-1.7.1-pp37.pp38-none-any.whl", hash = "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84"}, +] +mako = [ + {file = "Mako-1.2.0-py3-none-any.whl", hash = "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba"}, + {file = "Mako-1.2.0.tar.gz", hash = "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"}, +] +mariadb = [ + {file = "mariadb-1.0.10-cp310-cp310-win32.whl", hash = "sha256:a27ada21397f4939bffc93f5266cc5bb188aa7d54872ec1237b1295781460f25"}, + {file = "mariadb-1.0.10-cp310-cp310-win_amd64.whl", hash = "sha256:9a9f5f72b32a11ea619243b32ccf34a99e3333466e666f061ad90e8b5e871ee3"}, + {file = "mariadb-1.0.10-cp37-cp37m-win32.whl", hash = "sha256:e2d5e3ec72e3195502deca357f75f842aee3648aaba9624c6676d3ecd34a370f"}, + {file = "mariadb-1.0.10-cp37-cp37m-win_amd64.whl", hash = "sha256:ec236f8ab200088ffd80a12d94cf4a589e1246a7982f3a6fcb9197a755c9abd2"}, + {file = "mariadb-1.0.10-cp38-cp38-win32.whl", hash = "sha256:bda35e5742e50894a225ab995d31984ef6e452723266d4e378d9addb9dc0c321"}, + {file = "mariadb-1.0.10-cp38-cp38-win_amd64.whl", hash = "sha256:6c04dc33894181ad127b8023f10455552d95732a700a8acad4751389f0b3a111"}, + {file = "mariadb-1.0.10-cp39-cp39-win32.whl", hash = "sha256:547e8a363bd5b211c98b084b92d72cc9b6f76da7d063ec0246f5bb85100690e3"}, + {file = "mariadb-1.0.10-cp39-cp39-win_amd64.whl", hash = "sha256:526938f6de1e3be87b87b9f9f46cdbcc502b35afb68dbd027108ad23a97c1d79"}, + {file = "mariadb-1.0.10.zip", hash = "sha256:79028ba6051173dad1ad0be7518389cab70239f92b4ff8b8813dae55c3f2c53d"}, +] +markupsafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] +marshmallow = [ + {file = "marshmallow-3.15.0-py3-none-any.whl", hash = "sha256:ff79885ed43b579782f48c251d262e062bce49c65c52412458769a4fb57ac30f"}, + {file = "marshmallow-3.15.0.tar.gz", hash = "sha256:2aaaab4f01ef4f5a011a21319af9fce17ab13bf28a026d1252adab0e035648d5"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy = [ + {file = "mypy-0.940-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0fdc9191a49c77ab5fa0439915d405e80a1118b163ab03cd2a530f346b12566a"}, + {file = "mypy-0.940-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1903c92ff8642d521b4627e51a67e49f5be5aedb1fb03465b3aae4c3338ec491"}, + {file = "mypy-0.940-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:471af97c35a32061883b0f8a3305ac17947fd42ce962ca9e2b0639eb9141492f"}, + {file = "mypy-0.940-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:13677cb8b050f03b5bb2e8bf7b2668cd918b001d56c2435082bbfc9d5f730f42"}, + {file = "mypy-0.940-cp310-cp310-win_amd64.whl", hash = "sha256:2efd76893fb8327eca7e942e21b373e6f3c5c083ff860fb1e82ddd0462d662bd"}, + {file = "mypy-0.940-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8fe1bfab792e4300f80013edaf9949b34e4c056a7b2531b5ef3a0fb9d598ae2"}, + {file = "mypy-0.940-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2dba92f58610d116f68ec1221fb2de2a346d081d17b24a784624389b17a4b3f9"}, + {file = "mypy-0.940-cp36-cp36m-win_amd64.whl", hash = "sha256:712affcc456de637e774448c73e21c84dfa5a70bcda34e9b0be4fb898a9e8e07"}, + {file = "mypy-0.940-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8aaf18d0f8bc3ffba56d32a85971dfbd371a5be5036da41ac16aefec440eff17"}, + {file = "mypy-0.940-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:51be997c1922e2b7be514a5215d1e1799a40832c0a0dee325ba8794f2c48818f"}, + {file = "mypy-0.940-cp37-cp37m-win_amd64.whl", hash = "sha256:628f5513268ebbc563750af672ccba5eef7f92d2d90154233edd498dfb98ca4e"}, + {file = "mypy-0.940-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:68038d514ae59d5b2f326be502a359160158d886bd153fc2489dbf7a03c44c96"}, + {file = "mypy-0.940-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b2fa5f2d597478ccfe1f274f8da2f50ea1e63da5a7ae2342c5b3b2f3e57ec340"}, + {file = "mypy-0.940-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b1a116c451b41e35afc09618f454b5c2704ba7a4e36f9ff65014fef26bb6075b"}, + {file = "mypy-0.940-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f66f2309cdbb07e95e60e83fb4a8272095bd4ea6ee58bf9a70d5fb304ec3e3f"}, + {file = "mypy-0.940-cp38-cp38-win_amd64.whl", hash = "sha256:3ac14949677ae9cb1adc498c423b194ad4d25b13322f6fe889fb72b664c79121"}, + {file = "mypy-0.940-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6eab2bcc2b9489b7df87d7c20743b66d13254ad4d6430e1dfe1a655d51f0933d"}, + {file = "mypy-0.940-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0b52778a018559a256c819ee31b2e21e10b31ddca8705624317253d6d08dbc35"}, + {file = "mypy-0.940-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d9d7647505bf427bc7931e8baf6cacf9be97e78a397724511f20ddec2a850752"}, + {file = "mypy-0.940-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a0e5657ccaedeb5fdfda59918cc98fc6d8a8e83041bc0cec347a2ab6915f9998"}, + {file = "mypy-0.940-cp39-cp39-win_amd64.whl", hash = "sha256:83f66190e3c32603217105913fbfe0a3ef154ab6bbc7ef2c989f5b2957b55840"}, + {file = "mypy-0.940-py3-none-any.whl", hash = "sha256:a168da06eccf51875fdff5f305a47f021f23f300e2b89768abdac24538b1f8ec"}, + {file = "mypy-0.940.tar.gz", hash = "sha256:71bec3d2782d0b1fecef7b1c436253544d81c1c0e9ca58190aed9befd8f081c5"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +passlib = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] +platformdirs = [ + {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, + {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyasn1 = [ + {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, + {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, + {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, + {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, + {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, + {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, + {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, + {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, + {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, + {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pydantic = [ + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, +] +pyhumps = [ + {file = "pyhumps-3.5.3-py3-none-any.whl", hash = "sha256:8d7e9865d6ddb6e64a2e97d951b78b5cc827d3d66cda1297310fc83b2ddf51dc"}, + {file = "pyhumps-3.5.3.tar.gz", hash = "sha256:0ecf7fee84503b45afdd3841ec769b529d32dfaed855e07046ff8babcc0ab831"}, +] +pylint = [ + {file = "pylint-2.12.2-py3-none-any.whl", hash = "sha256:daabda3f7ed9d1c60f52d563b1b854632fd90035bcf01443e234d3dc794e3b74"}, + {file = "pylint-2.12.2.tar.gz", hash = "sha256:9d945a73640e1fec07ee34b42f5669b770c759acd536ec7b16d7e4b87a9c9ff9"}, +] +pylint-pytest = [ + {file = "pylint_pytest-1.1.2-py2.py3-none-any.whl", hash = "sha256:fb20ef318081cee3d5febc631a7b9c40fa356b05e4f769d6e60a337e58c8879b"}, +] +pyparsing = [ + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, +] +pytest = [ + {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, + {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +pytest-env = [ + {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, +] +pytest-mock = [ + {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"}, + {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +python-dotenv = [ + {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"}, + {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"}, +] +python-jose = [ + {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, + {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, +] +python-multipart = [ + {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +requests = [ + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +] +rsa = [ + {file = "rsa-4.8-py3-none-any.whl", hash = "sha256:95c5d300c4e879ee69708c428ba566c59478fd653cc3a22243eeb8ed846950bb"}, + {file = "rsa-4.8.tar.gz", hash = "sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sniffio = [ + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, +] +sqlalchemy = [ + {file = "SQLAlchemy-1.4.31-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c3abc34fed19fdeaead0ced8cf56dd121f08198008c033596aa6aae7cc58f59f"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8d0949b11681380b4a50ac3cd075e4816afe9fa4a8c8ae006c1ca26f0fa40ad8"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27m-win32.whl", hash = "sha256:f3b7ec97e68b68cb1f9ddb82eda17b418f19a034fa8380a0ac04e8fe01532875"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27m-win_amd64.whl", hash = "sha256:81f2dd355b57770fdf292b54f3e0a9823ec27a543f947fa2eb4ec0df44f35f0d"}, + {file = "SQLAlchemy-1.4.31-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4ad31cec8b49fd718470328ad9711f4dc703507d434fd45461096da0a7135ee0"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:05fa14f279d43df68964ad066f653193187909950aa0163320b728edfc400167"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dccff41478050e823271642837b904d5f9bda3f5cf7d371ce163f00a694118d6"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57205844f246bab9b666a32f59b046add8995c665d9ecb2b7b837b087df90639"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8210090a816d48a4291a47462bac750e3bc5c2442e6d64f7b8137a7c3f9ac5"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-win32.whl", hash = "sha256:2e216c13ecc7fcdcbb86bb3225425b3ed338e43a8810c7089ddb472676124b9b"}, + {file = "SQLAlchemy-1.4.31-cp310-cp310-win_amd64.whl", hash = "sha256:e3a86b59b6227ef72ffc10d4b23f0fe994bef64d4667eab4fb8cd43de4223bec"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:2fd4d3ca64c41dae31228b80556ab55b6489275fb204827f6560b65f95692cf3"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f22c040d196f841168b1456e77c30a18a3dc16b336ddbc5a24ce01ab4e95ae0"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c0c7171aa5a57e522a04a31b84798b6c926234cb559c0939840c3235cf068813"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d046a9aeba9bc53e88a41e58beb72b6205abb9a20f6c136161adf9128e589db5"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-win32.whl", hash = "sha256:d86132922531f0dc5a4f424c7580a472a924dd737602638e704841c9cb24aea2"}, + {file = "SQLAlchemy-1.4.31-cp36-cp36m-win_amd64.whl", hash = "sha256:ca68c52e3cae491ace2bf39b35fef4ce26c192fd70b4cd90f040d419f70893b5"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:cf2cd387409b12d0a8b801610d6336ee7d24043b6dd965950eaec09b73e7262f"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb4b15fb1f0aafa65cbdc62d3c2078bea1ceecbfccc9a1f23a2113c9ac1191fa"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c317ddd7c586af350a6aef22b891e84b16bff1a27886ed5b30f15c1ed59caeaa"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c7ed6c69debaf6198fadb1c16ae1253a29a7670bbf0646f92582eb465a0b999"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-win32.whl", hash = "sha256:6a01ec49ca54ce03bc14e10de55dfc64187a2194b3b0e5ac0fdbe9b24767e79e"}, + {file = "SQLAlchemy-1.4.31-cp37-cp37m-win_amd64.whl", hash = "sha256:330eb45395874cc7787214fdd4489e2afb931bc49e0a7a8f9cd56d6e9c5b1639"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5e9c7b3567edbc2183607f7d9f3e7e89355b8f8984eec4d2cd1e1513c8f7b43f"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de85c26a5a1c72e695ab0454e92f60213b4459b8d7c502e0be7a6369690eeb1a"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:975f5c0793892c634c4920057da0de3a48bbbbd0a5c86f5fcf2f2fedf41b76da"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5c20c8415173b119762b6110af64448adccd4d11f273fb9f718a9865b88a99c"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-win32.whl", hash = "sha256:b35dca159c1c9fa8a5f9005e42133eed82705bf8e243da371a5e5826440e65ca"}, + {file = "SQLAlchemy-1.4.31-cp38-cp38-win_amd64.whl", hash = "sha256:b7b20c88873675903d6438d8b33fba027997193e274b9367421e610d9da76c08"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:85e4c244e1de056d48dae466e9baf9437980c19fcde493e0db1a0a986e6d75b4"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79e73d5ee24196d3057340e356e6254af4d10e1fc22d3207ea8342fc5ffb977"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:15a03261aa1e68f208e71ae3cd845b00063d242cbf8c87348a0c2c0fc6e1f2ac"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc5e5ccc0160e7ad190e5c61eb57560f38559e22586955f205e537cda26034"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-win32.whl", hash = "sha256:289465162b1fa1e7a982f8abe59d26a8331211cad4942e8031d2b7db1f75e649"}, + {file = "SQLAlchemy-1.4.31-cp39-cp39-win_amd64.whl", hash = "sha256:9e4fb2895b83993831ba2401b6404de953fdbfa9d7d4fa6a4756294a83bbc94f"}, + {file = "SQLAlchemy-1.4.31.tar.gz", hash = "sha256:582b59d1e5780a447aada22b461e50b404a9dc05768da1d87368ad8190468418"}, +] +sqlalchemy-utils = [ + {file = "SQLAlchemy-Utils-0.38.2.tar.gz", hash = "sha256:9e01d6d3fb52d3926fcd4ea4a13f3540701b751aced0316bff78264402c2ceb4"}, + {file = "SQLAlchemy_Utils-0.38.2-py3-none-any.whl", hash = "sha256:622235b1598f97300e4d08820ab024f5219c9a6309937a8b908093f487b4ba54"}, +] +sqlalchemy2-stubs = [ + {file = "sqlalchemy2-stubs-0.0.2a20.tar.gz", hash = "sha256:3e96a5bb7d46a368c780ba57dcf2afbe2d3efdd75f7724ae7a859df0b0625f38"}, + {file = "sqlalchemy2_stubs-0.0.2a20-py3-none-any.whl", hash = "sha256:da31d0e30a2af2e5ad83dbce5738543a9f488089774f506de5ec7d28d425a202"}, +] +starlette = [ + {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, + {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +types-passlib = [ + {file = "types-passlib-1.7.0.tar.gz", hash = "sha256:b069e428b601216e7220f5d3972c57706d85bdf2cd715be28c2a31ae4e5deaec"}, + {file = "types_passlib-1.7.0-py3-none-any.whl", hash = "sha256:e7d09757cd56343806cba44a1857809c0e594294badd83404c2138349b0ea8ec"}, +] +typing-extensions = [ + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, +] +urllib3 = [ + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, +] +uvicorn = [ + {file = "uvicorn-0.15.0-py3-none-any.whl", hash = "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1"}, + {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"}, +] +uvloop = [ + {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"}, + {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"}, + {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"}, + {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"}, + {file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"}, + {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"}, + {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"}, + {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"}, + {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"}, + {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"}, + {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"}, + {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"}, + {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"}, + {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"}, + {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"}, + {file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"}, +] +watchgod = [ + {file = "watchgod-0.8-py3-none-any.whl", hash = "sha256:339c2cfede1ccc1e277bbf5e82e42886f3c80801b01f45ab10d9461c4118b5eb"}, + {file = "watchgod-0.8.tar.gz", hash = "sha256:29a1d8f25e1721ddb73981652ca318c47387ffb12ec4171ddd7b9d01540033b1"}, +] +websockets = [ + {file = "websockets-10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5396710f86a306cf52f87fd8ea594a0e894ba0cc5a36059eaca3a477dc332aa"}, + {file = "websockets-10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b22bdc795e62e71118b63e14a08bacfa4f262fd2877de7e5b950f5ac16b0348f"}, + {file = "websockets-10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b04270b5613f245ec84bb2c6a482a9d009aefad37c0575f6cda8499125d5d5c"}, + {file = "websockets-10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5c335dc0e7dc271ef36df3f439868b3c790775f345338c2f61a562f1074187b"}, + {file = "websockets-10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a009eb551c46fd79737791c0c833fc0e5b56bcd1c3057498b262d660b92e9cd"}, + {file = "websockets-10.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a10c0c1ee02164246f90053273a42d72a3b2452a7e7486fdae781138cf7fbe2d"}, + {file = "websockets-10.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b38a5c9112e3dbbe45540f7b60c5204f49b3cb501b40950d6ab34cd202ab1d0"}, + {file = "websockets-10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2aa9b91347ecd0412683f28aabe27f6bad502d89bd363b76e0a3508b1596402e"}, + {file = "websockets-10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b7fe45ae43ac814beb8ca09d6995b56800676f2cfa8e23f42839dc69bba34a42"}, + {file = "websockets-10.2-cp310-cp310-win32.whl", hash = "sha256:cef40a1b183dcf39d23b392e9dd1d9b07ab9c46aadf294fff1350fb79146e72b"}, + {file = "websockets-10.2-cp310-cp310-win_amd64.whl", hash = "sha256:c21a67ab9a94bd53e10bba21912556027fea944648a09e6508415ad14e37c325"}, + {file = "websockets-10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb316b87cbe3c0791c2ad92a5a36bf6adc87c457654335810b25048c1daa6fd5"}, + {file = "websockets-10.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f14bd10e170abc01682a9f8b28b16e6f20acf6175945ef38db6ffe31b0c72c3f"}, + {file = "websockets-10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fa35c5d1830d0fb7b810324e9eeab9aa92e8f273f11fdbdc0741dcded6d72b9f"}, + {file = "websockets-10.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:71a4491cfe7a9f18ee57d41163cb6a8a3fa591e0f0564ca8b0ed86b2a30cced4"}, + {file = "websockets-10.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6193bbc1ee63aadeb9a4d81de0e19477401d150d506aee772d8380943f118186"}, + {file = "websockets-10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8beac786a388bb99a66c3be4ab0fb38273c0e3bc17f612a4e0a47c4fc8b9c045"}, + {file = "websockets-10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c67d9cacb3f6537ca21e9b224d4fd08481538e43bcac08b3d93181b0816def39"}, + {file = "websockets-10.2-cp37-cp37m-win32.whl", hash = "sha256:a03a25d95cc7400bd4d61a63460b5d85a7761c12075ee2f51de1ffe73aa593d3"}, + {file = "websockets-10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f8296b8408ec6853b26771599990721a26403e62b9de7e50ac0a056772ac0b5e"}, + {file = "websockets-10.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7bb9d8a6beca478c7e9bdde0159bd810cc1006ad6a7cb460533bae39da692ca2"}, + {file = "websockets-10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:05f6e9757017270e7a92a2975e2ae88a9a582ffc4629086fd6039aa80e99cd86"}, + {file = "websockets-10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1c9031e90ebfc486e9cdad532b94004ade3aa39a31d3c46c105bb0b579cd2490"}, + {file = "websockets-10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82bc33db6d8309dc27a3bee11f7da2288ad925fcbabc2a4bb78f7e9c56249baf"}, + {file = "websockets-10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:24b879ba7db12bb525d4e58089fcbe6a3df3ce4666523183654170e86d372cbe"}, + {file = "websockets-10.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cf931c33db9c87c53d009856045dd524e4a378445693382a920fa1e0eb77c36c"}, + {file = "websockets-10.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:669e54228a4d9457abafed27cbf0e2b9f401445c4dfefc12bf8e4db9751703b8"}, + {file = "websockets-10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bffc65442dd35c473ca9790a3fa3ba06396102a950794f536783f4b8060af8dd"}, + {file = "websockets-10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4d110a84b63c5cfdd22485acc97b8b919aefeecd6300c0c9d551e055b9a88ea"}, + {file = "websockets-10.2-cp38-cp38-win32.whl", hash = "sha256:117383d0a17a0dda349f7a8790763dde75c1508ff8e4d6e8328b898b7df48397"}, + {file = "websockets-10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b66421f9f13d4df60cd48ab977ed2c2b6c9147ae1a33caf5a9f46294422fda1"}, + {file = "websockets-10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac081aa0307f263d63c5ff0727935c736c8dad51ddf2dc9f5d0c4759842aefaa"}, + {file = "websockets-10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b4059e2ccbe6587b6dc9a01db5fc49ead9a884faa4076eea96c5ec62cb32f42a"}, + {file = "websockets-10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9ca2ca05a4c29179f06cf6727b45dba5d228da62623ec9df4184413d8aae6cb9"}, + {file = "websockets-10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97950c7c844ec6f8d292440953ae18b99e3a6a09885e09d20d5e7ecd9b914cf8"}, + {file = "websockets-10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:98f57b3120f8331cd7440dbe0e776474f5e3632fdaa474af1f6b754955a47d71"}, + {file = "websockets-10.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a72b92f96e5e540d5dda99ee3346e199ade8df63152fa3c737260da1730c411f"}, + {file = "websockets-10.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:038afef2a05893578d10dadbdbb5f112bd115c46347e1efe99f6a356ff062138"}, + {file = "websockets-10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f09f46b1ff6d09b01c7816c50bd1903cf7d02ebbdb63726132717c2fcda835d5"}, + {file = "websockets-10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2349fa81b6b959484bb2bda556ccb9eb70ba68987646a0f8a537a1a18319fb03"}, + {file = "websockets-10.2-cp39-cp39-win32.whl", hash = "sha256:bef03a51f9657fb03d8da6ccd233fe96e04101a852f0ffd35f5b725b28221ff3"}, + {file = "websockets-10.2-cp39-cp39-win_amd64.whl", hash = "sha256:1c1f3b18c8162e3b09761d0c6a0305fd642934202541cc511ef972cb9463261e"}, + {file = "websockets-10.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5a38a0175ae82e4a8c4bac29fc01b9ee26d7d5a614e5ee11e7813c68a7d938ce"}, + {file = "websockets-10.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e56606842bb24e16e36ae7eb308d866b4249cf0be8f63b212f287eeb76b124"}, + {file = "websockets-10.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0f73cb2526d6da268e86977b2c4b58f2195994e53070fe567d5487c6436047e6"}, + {file = "websockets-10.2-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cd02f36d37e503aca88ab23cc0a1a0e92a263d37acf6331521eb38040dcf77b"}, + {file = "websockets-10.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:56d48eebe9e39ce0d68701bce3b21df923aa05dcc00f9fd8300de1df31a7c07c"}, + {file = "websockets-10.2.tar.gz", hash = "sha256:8351c3c86b08156337b0e4ece0e3c5ec3e01fcd14e8950996832a23c99416098"}, +] +wrapt = [ + {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca"}, + {file = "wrapt-1.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44"}, + {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056"}, + {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785"}, + {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096"}, + {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33"}, + {file = "wrapt-1.13.3-cp310-cp310-win32.whl", hash = "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f"}, + {file = "wrapt-1.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755"}, + {file = "wrapt-1.13.3-cp35-cp35m-win32.whl", hash = "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851"}, + {file = "wrapt-1.13.3-cp35-cp35m-win_amd64.whl", hash = "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13"}, + {file = "wrapt-1.13.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918"}, + {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade"}, + {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc"}, + {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf"}, + {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125"}, + {file = "wrapt-1.13.3-cp36-cp36m-win32.whl", hash = "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36"}, + {file = "wrapt-1.13.3-cp36-cp36m-win_amd64.whl", hash = "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10"}, + {file = "wrapt-1.13.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068"}, + {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709"}, + {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df"}, + {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2"}, + {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b"}, + {file = "wrapt-1.13.3-cp37-cp37m-win32.whl", hash = "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829"}, + {file = "wrapt-1.13.3-cp37-cp37m-win_amd64.whl", hash = "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea"}, + {file = "wrapt-1.13.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9"}, + {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554"}, + {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c"}, + {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b"}, + {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce"}, + {file = "wrapt-1.13.3-cp38-cp38-win32.whl", hash = "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79"}, + {file = "wrapt-1.13.3-cp38-cp38-win_amd64.whl", hash = "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb"}, + {file = "wrapt-1.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb"}, + {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32"}, + {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7"}, + {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e"}, + {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640"}, + {file = "wrapt-1.13.3-cp39-cp39-win32.whl", hash = "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374"}, + {file = "wrapt-1.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb"}, + {file = "wrapt-1.13.3.tar.gz", hash = "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185"}, +] diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 000000000..3c714a732 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,116 @@ +[tool.poetry] +name = "backend" +version = "0.1.0" +description = "backend" +authors = ["Team 3"] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.10" + +# Alembic: Database migrations extension for SQLAlchemy +alembic = "1.7.6" + +# Environs: simplified environment variable parsing +environs = "9.5.0" + +# FastAPI: API framework +fastapi = "0.74.1" + +# MariaDB: Python MariaDB connector +mariadb = "1.0.10" + +# Hash passwords +passlib = { "version" = "1.7.4", extras = ["bcrypt"] } + +# Generate and verify JWT tokens +python-jose = { "version" = "3.3.0", extras = ["cryptography"] } + +# Humps: Convert strings (and dictionary keys) between snake case, camel case and pascal case in Python +pyhumps = "3.5.3" + +# OAuth2 form data +python-multipart = "0.0.5" + +# Requests: HTTP library +requests = "2.27.1" + +# SQLAlchemy: ORM and database toolkit +SQLAlchemy = "1.4.31" + +# SQLAlchemy-Utils: Various utility functions and datatypes for SQLAlchemy +sqlalchemy-utils = "0.38.2" + +# Uvicorn: ASGI web server implementation +uvicorn = { "version" = ">=0.12.0, < 0.16.0", extras = ["standard"] } + +[tool.poetry.dev-dependencies] +# Coverage: generate code coverage reports +coverage = { "version" = "6.3.1", extras = ["toml"] } + +# faker: Generate dummy data +faker = "13.3.1" + +# Mypy: check type usage in code +mypy = "0.940" + +# Pylint: Python linter +pylint = "2.12.2" + +# Pylint-Pytest: A Pylint plugin to suppress pytest-related false positives. +pylint-pytest = "1.1.2" + +# Pytest: Python testing framework +# (more advanced than the built-in unittest module) +pytest = "7.0.1" + +# Pytest-cov: coverage plugin for pytest +pytest-cov = "3.0.0" + +# Pytest-env: env plugin for pytest +pytest-env = "0.6.2" + +# Pytest-mock: mocking library for pytest +pytest-mock = "3.7.0" + +# Sqlalchemy-stubs: type hints for sqlalchemy +sqlalchemy2-stubs="0.0.2a20" + +# Types for the passlib library +types-passlib="1.7.0" + +[tool.mypy] +plugins = ["sqlalchemy.ext.mypy.plugin"] + +[[tool.mypy.overrides]] +module = "sqlalchemy_utils" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "jose.*" +ignore_missing_imports = true + +[tool.pylint.master] +load-plugins=["pylint_pytest"] +argument-rgx = "[a-z_][a-z0-9_]{1,31}$" +disable=[ + "import-outside-toplevel", + "missing-module-docstring", + "too-few-public-methods", +] +extension-pkg-whitelist = "pydantic" + +[tool.pylint.format] +max-line-length = 120 + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore:.*The distutils package is deprecated:DeprecationWarning", +] +env = [ + "DB_USE_SQLITE = 1", +] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/backend/pytest.ini b/backend/pytest.ini deleted file mode 100644 index 5fa05dfe5..000000000 --- a/backend/pytest.ini +++ /dev/null @@ -1,6 +0,0 @@ -[pytest] -filterwarnings= - ignore:.*The distutils package is deprecated:DeprecationWarning - -env= - DB_USE_SQLITE=1 \ No newline at end of file diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt deleted file mode 100644 index 2f7fa8763..000000000 --- a/backend/requirements-dev.txt +++ /dev/null @@ -1,36 +0,0 @@ -# Coverage: generate code coverage reports -coverage==6.3.1 - -# Pylint: Python linter -pylint==2.12.2 - -# Pytest: Python testing framework -# (more advanced than the built-in unittest module) -pytest==7.0.1 - -# Pytest-cov: coverage plugin for pytest -pytest-cov==3.0.0 - -# Pytest-env: env plugin for pytest -pytest-env==0.6.2 - -# Pylint: check your codestyle -pylint==2.12.2 - -# Mypy: check type usage in code -mypy==0.931 - -# Sqlalchemy-stubs: type hints for sqlalchemy -sqlalchemy2-stubs==0.0.2a20 - -# Pylint-Pytest: A Pylint plugin to suppress pytest-related false positives. -pylint-pytest==1.1.2 - -# faker: Generate dummy data -faker==13.3.1 - -# Types for the passlib library -types-passlib==1.7.0 - -# Pytest-mock: mocking for pytest -pytest-mock==3.7.0 diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 364343e9e..000000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,35 +0,0 @@ -# Alembic: Database migrations extension for SQLAlchemy -alembic==1.7.6 - -# Environs: simplified environment variable parsing -environs==9.5.0 - -# FastAPI: API framework -fastapi==0.74.1 - -# Uvicorn: ASGI web server implementation -uvicorn[standard] >=0.12.0,<0.16.0 - -# MariaDB: Python MariaDB connector -mariadb==1.0.10 - -# Requests: HTTP library -requests==2.27.1 - -# SQLAlchemy: ORM and database toolkit -SQLAlchemy==1.4.31 - -# SQLAlchemy-Utils: Various utility functions and datatypes for SQLAlchemy -sqlalchemy-utils==0.38.2 - -# Humps: Convert strings (and dictionary keys) between snake case, camel case and pascal case in Python -pyhumps==3.5.3 - -# OAuth2 form data -python-multipart==0.0.5 - -# generate and verify JWT tokens -python-jose[cryptography]==3.3.0 - -# hash passwords -passlib[bcrypt]==1.7.4 diff --git a/backend/settings.py b/backend/settings.py index 4cc403067..342f8bd35 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -3,7 +3,6 @@ from environs import Env import enum - env = Env() # Read the .env file @@ -26,13 +25,18 @@ DB_PORT: int = env.int("DB_PORT", 3306) # Option to change te database used. Default False is Mariadb. DB_USE_SQLITE: bool = env.bool("DB_USE_SQLITE", False) +# Option to change the pagination size for all endpoints that have pagination. +DB_PAGE_SIZE: int = env.int("DB_PAGE_SIZE", 25) """JWT token key""" SECRET_KEY: str = env.str("SECRET_KEY", "4d16a9cc83d74144322e893c879b5f639088c15dc1606b11226abbd7e97f5ee5") +ACCESS_TOKEN_EXPIRE_M: int = env.int("ACCESS_TOKEN_EXPIRE_M", 5) +REFRESH_TOKEN_EXPIRE_M: int = env.int("REFRESH_TOKEN_EXPIRE_M", 2880) """Frontend""" FRONTEND_URL: str = env.str("FRONTEND_URL", "http://localhost:3000") + @enum.unique class FormMapping(enum.Enum): FIRST_NAME = "question_3ExXkL" @@ -41,7 +45,7 @@ class FormMapping(enum.Enum): PREFERRED_NAME = "question_3jlya9" EMAIL = "question_nW8NOQ" PHONE_NUMBER = "question_mea6qo" - #CV = "question_wa26Qy" + # CV = "question_wa26Qy" STUDENT_COACH = "question_wz7qEE" UNKNOWN = None # Returned when no specific question can be matched diff --git a/backend/src/app/app.py b/backend/src/app/app.py index 08ec01c22..520777d06 100644 --- a/backend/src/app/app.py +++ b/backend/src/app/app.py @@ -7,11 +7,10 @@ import settings from src.database.engine import engine from src.database.exceptions import PendingMigrationsException -from .routers import editions_router, login_router, skills_router from .exceptions import install_handlers +from .routers import editions_router, login_router, skills_router from .routers.users.users import users_router - # Main application app = FastAPI( title="OSOC Team 3", diff --git a/backend/src/app/exceptions/authentication.py b/backend/src/app/exceptions/authentication.py index 4eb22b94c..1445eecf8 100644 --- a/backend/src/app/exceptions/authentication.py +++ b/backend/src/app/exceptions/authentication.py @@ -22,3 +22,11 @@ class MissingPermissionsException(ValueError): when their application is still pending, and they haven't been accepted yet """ + + +class WrongTokenTypeException(ValueError): + """ + Exception raised when a request to a private route is made with a + valid jwt token, but a wrong token type. eg: trying to authenticate + using a refresh token + """ diff --git a/backend/src/app/exceptions/editions.py b/backend/src/app/exceptions/editions.py index 8221e908e..c61f8d3fe 100644 --- a/backend/src/app/exceptions/editions.py +++ b/backend/src/app/exceptions/editions.py @@ -4,3 +4,5 @@ class DuplicateInsertException(Exception): Args: Exception (Exception): base Exception class """ +class ReadOnlyEditionException(Exception): + """Exception raised when a read-only edition is being changed""" diff --git a/backend/src/app/exceptions/handlers.py b/backend/src/app/exceptions/handlers.py index dfbda514b..a2c82293b 100644 --- a/backend/src/app/exceptions/handlers.py +++ b/backend/src/app/exceptions/handlers.py @@ -1,14 +1,18 @@ import sqlalchemy.exc -from .editions import DuplicateInsertException from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from pydantic import ValidationError from starlette import status -from .authentication import ExpiredCredentialsException, InvalidCredentialsException, MissingPermissionsException +from .authentication import ( + ExpiredCredentialsException, InvalidCredentialsException, + MissingPermissionsException, WrongTokenTypeException) +from .editions import DuplicateInsertException, ReadOnlyEditionException from .parsing import MalformedUUIDError -from .webhooks import WebhookProcessException +from .projects import StudentInConflictException, FailedToAddProjectRoleException from .register import FailedToAddNewUserException +from .students_email import FailedToAddNewEmailException +from .webhooks import WebhookProcessException def install_handlers(app: FastAPI): @@ -74,8 +78,44 @@ def webhook_process_exception(_request: Request, exception: WebhookProcessExcept ) @app.exception_handler(FailedToAddNewUserException) - def failed_to_add_new_user_exception(_request: Request, exception: FailedToAddNewUserException): + def failed_to_add_new_user_exception(_request: Request, _exception: FailedToAddNewUserException): return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={'message': 'Something went wrong while creating a new user'} - ) \ No newline at end of file + ) + + @app.exception_handler(StudentInConflictException) + def student_in_conflict_exception(_request: Request, _exception: StudentInConflictException): + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={ + 'message': 'Resolve the conflict this student is in before confirming their role'} + ) + + @app.exception_handler(FailedToAddProjectRoleException) + def failed_to_add_project_role_exception(_request: Request, _exception: FailedToAddProjectRoleException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + 'message': 'Something went wrong while adding this student to the project'} + ) + + @app.exception_handler(WrongTokenTypeException) + async def wrong_token_type_exception(_request: Request, _exception: WrongTokenTypeException): + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={'message': 'You used the wrong token to access this resource.'} + ) + @app.exception_handler(ReadOnlyEditionException) + def read_only_edition_exception(_request: Request, _exception: ReadOnlyEditionException): + return JSONResponse( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + content={'message': 'This edition is Read-Only'} + ) + + @app.exception_handler(FailedToAddNewEmailException) + def failed_to_add_new_email_exception(_request: Request, _exception: FailedToAddNewEmailException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={'message': 'Something went wrong while creating a new email'} + ) diff --git a/backend/src/app/exceptions/projects.py b/backend/src/app/exceptions/projects.py new file mode 100644 index 000000000..9377d1a4c --- /dev/null +++ b/backend/src/app/exceptions/projects.py @@ -0,0 +1,10 @@ +class StudentInConflictException(Exception): + """ + Exception raised when a project_role of a student can't be confirmed because they are part of a conflict + """ + + +class FailedToAddProjectRoleException(Exception): + """ + Exception raised when a project_role can't be added for some reason + """ diff --git a/backend/src/app/exceptions/students_email.py b/backend/src/app/exceptions/students_email.py new file mode 100644 index 000000000..ba34f1951 --- /dev/null +++ b/backend/src/app/exceptions/students_email.py @@ -0,0 +1,4 @@ +class FailedToAddNewEmailException(Exception): + """ + Exception raised when a new email can't be added + """ diff --git a/backend/src/app/logic/editions.py b/backend/src/app/logic/editions.py index 43969ef13..da3eb4e5a 100644 --- a/backend/src/app/logic/editions.py +++ b/backend/src/app/logic/editions.py @@ -1,22 +1,16 @@ from sqlalchemy.orm import Session -from src.app.schemas.editions import Edition, EditionBase, EditionList -import src.database.crud.editions as crud_editions -from src.database.models import Edition +import src.database.crud.editions as crud_editions +from src.app.schemas.editions import EditionBase, EditionList +from src.database.models import Edition as EditionModel -def get_editions(db: Session) -> EditionList: - """Get a list of all editions. - Args: - db (Session): connection with the database. - - Returns: - EditionList: an object with a list of all the editions. - """ - return EditionList(editions=crud_editions.get_editions(db)) +def get_editions_page(db: Session, page: int) -> EditionList: + """Get a paginated list of all editions.""" + return EditionList(editions=crud_editions.get_editions_page(db, page)) -def get_edition_by_id(db: Session, edition_id: int) -> Edition: +def get_edition_by_name(db: Session, edition_name: str) -> EditionModel: """Get a specific edition. Args: @@ -25,10 +19,10 @@ def get_edition_by_id(db: Session, edition_id: int) -> Edition: Returns: Edition: an edition. """ - return crud_editions.get_edition_by_id(db, edition_id) + return crud_editions.get_edition_by_name(db, edition_name) -def create_edition(db: Session, edition: EditionBase) -> Edition: +def create_edition(db: Session, edition: EditionBase) -> EditionModel: """ Create a new edition. Args: @@ -40,13 +34,13 @@ def create_edition(db: Session, edition: EditionBase) -> Edition: return crud_editions.create_edition(db, edition) -def delete_edition(db: Session, edition_id: int): +def delete_edition(db: Session, edition_name: str): """Delete an existing edition. Args: db (Session): connection with the database. - edition_id (int): the id of the edition that needs to be deleted, if found. + edition_name (str): the name of the edition that needs to be deleted, if found. Returns: nothing """ - crud_editions.delete_edition(db, edition_id) + crud_editions.delete_edition(db, edition_name) diff --git a/backend/src/app/logic/invites.py b/backend/src/app/logic/invites.py index 2dbaff05d..028a0b121 100644 --- a/backend/src/app/logic/invites.py +++ b/backend/src/app/logic/invites.py @@ -1,36 +1,39 @@ -from sqlalchemy.orm import Session +import base64 -from src.app.schemas.invites import InvitesListResponse, EmailAddress, MailtoLink -from src.app.utils.mailto import generate_mailto_string -from src.database.crud.invites import create_invite_link, delete_invite_link as delete_link_db, get_all_pending_invites -from src.database.models import Edition, InviteLink +from sqlalchemy.orm import Session import settings +import src.database.crud.invites as crud +from src.app.schemas.invites import InvitesLinkList, EmailAddress, NewInviteLink +from src.app.utils.mailto import generate_mailto_string +from src.database.models import Edition, InviteLink as InviteLinkDB -def delete_invite_link(db: Session, invite_link: InviteLink): +def delete_invite_link(db: Session, invite_link: InviteLinkDB): """Delete an invite link from the database""" - delete_link_db(db, invite_link) + crud.delete_invite_link(db, invite_link) -def get_pending_invites_list(db: Session, edition: Edition) -> InvitesListResponse: - """ - Query the database for a list of invite links - and wrap the result in a pydantic model - """ - invites_orm = get_all_pending_invites(db, edition) - return InvitesListResponse(invite_links=invites_orm) +def get_pending_invites_page(db: Session, edition: Edition, page: int) -> InvitesLinkList: + """Query the database for a list of invite links and wrap the result in a pydantic model""" + return InvitesLinkList(invite_links=crud.get_pending_invites_for_edition_page(db, edition, page)) -def create_mailto_link(db: Session, edition: Edition, email_address: EmailAddress) -> MailtoLink: +def create_mailto_link(db: Session, edition: Edition, email_address: EmailAddress) -> NewInviteLink: """Add a new invite link into the database & return a mailto link for it""" - # Create db entry - new_link_db = create_invite_link(db, edition, email_address.email) + # Create db entry, drop existing. + invite = crud.get_optional_invite_link_by_edition_and_email(db, edition, email_address.email) + if invite is None: + invite = crud.create_invite_link(db, edition, email_address.email) + + # Add edition name & encode with base64 + encoded_uuid = f"{invite.edition.name}/{invite.uuid}".encode("utf-8") + encoded_link = base64.b64encode(encoded_uuid).decode("utf-8") # Create endpoint for the user to click on - link = f"{settings.FRONTEND_URL}/register/{new_link_db.uuid}" + link = f"{settings.FRONTEND_URL}/register/{encoded_link}" - return MailtoLink(mail_to=generate_mailto_string( + return NewInviteLink(mail_to=generate_mailto_string( recipient=email_address.email, subject=f"Open Summer Of Code {edition.year} invitation", body=link - )) + ), invite_link=link) diff --git a/backend/src/app/logic/projects.py b/backend/src/app/logic/projects.py new file mode 100644 index 000000000..a733f143f --- /dev/null +++ b/backend/src/app/logic/projects.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Session + +import src.database.crud.projects as crud +from src.app.schemas.projects import ( + ProjectList, ConflictStudentList, InputProject, ConflictStudent, QueryParamsProjects +) +from src.database.models import Edition, Project, User + + +def get_project_list(db: Session, edition: Edition, search_params: QueryParamsProjects, user: User) -> ProjectList: + """Returns a list of all projects from a certain edition""" + return ProjectList(projects=crud.get_projects_for_edition_page(db, edition, search_params, user)) + + +def create_project(db: Session, edition: Edition, input_project: InputProject) -> Project: + """Create a new project""" + return crud.add_project(db, edition, input_project) + + +def delete_project(db: Session, project_id: int): + """Delete a project""" + crud.delete_project(db, project_id) + + +def patch_project(db: Session, project_id: int, input_project: InputProject): + """Make changes to a project""" + crud.patch_project(db, project_id, input_project) + + +def get_conflicts(db: Session, edition: Edition) -> ConflictStudentList: + """Returns a list of all students together with the projects they are causing a conflict for""" + conflicts = crud.get_conflict_students(db, edition) + conflicts_model = [] + for student, projects in conflicts: + conflicts_model.append(ConflictStudent(student=student, projects=projects)) + + return ConflictStudentList(conflict_students=conflicts_model) diff --git a/backend/src/app/logic/projects_students.py b/backend/src/app/logic/projects_students.py new file mode 100644 index 000000000..12abaf612 --- /dev/null +++ b/backend/src/app/logic/projects_students.py @@ -0,0 +1,68 @@ +from sqlalchemy.orm import Session + +import src.app.logic.projects as logic_projects +import src.database.crud.projects_students as crud +from src.app.exceptions.projects import StudentInConflictException, FailedToAddProjectRoleException +from src.app.schemas.projects import ConflictStudentList +from src.database.models import Project, ProjectRole, Student, Skill + + +def remove_student_project(db: Session, project: Project, student_id: int): + """Remove a student from a project""" + crud.remove_student_project(db, project, student_id) + + +def add_student_project(db: Session, project: Project, student_id: int, skill_id: int, drafter_id: int): + """Add a student to a project""" + # check this project-skill combination does not exist yet + if db.query(ProjectRole).where(ProjectRole.skill_id == skill_id).where(ProjectRole.project == project) \ + .count() > 0: + raise FailedToAddProjectRoleException + # check that the student has the skill + student = db.query(Student).where(Student.student_id == student_id).one() + skill = db.query(Skill).where(Skill.skill_id == skill_id).one() + if skill not in student.skills: + raise FailedToAddProjectRoleException + # check that the student has not been confirmed in another project yet + if db.query(ProjectRole).where(ProjectRole.student == student).where(ProjectRole.definitive.is_(True)).count() > 0: + raise FailedToAddProjectRoleException + # check that the project requires the skill + project = db.query(Project).where(Project.project_id == project.project_id).one() + if skill not in project.skills: + raise FailedToAddProjectRoleException + + crud.add_student_project(db, project, student_id, skill_id, drafter_id) + + +def change_project_role(db: Session, project: Project, student_id: int, skill_id: int, drafter_id: int): + """Change the role of the student in the project""" + # check this project-skill combination does not exist yet + if db.query(ProjectRole).where(ProjectRole.skill_id == skill_id).where(ProjectRole.project == project) \ + .count() > 0: + raise FailedToAddProjectRoleException + # check that the student has the skill + student = db.query(Student).where(Student.student_id == student_id).one() + skill = db.query(Skill).where(Skill.skill_id == skill_id).one() + if skill not in student.skills: + raise FailedToAddProjectRoleException + # check that the student has not been confirmed in another project yet + if db.query(ProjectRole).where(ProjectRole.student == student).where( + ProjectRole.definitive.is_(True)).count() > 0: + raise FailedToAddProjectRoleException + # check that the project requires the skill + project = db.query(Project).where(Project.project_id == project.project_id).one() + if skill not in project.skills: + raise FailedToAddProjectRoleException + + crud.change_project_role(db, project, student_id, skill_id, drafter_id) + + +def confirm_project_role(db: Session, project: Project, student_id: int): + """Definitively bind this student to the project""" + # check if there are any conflicts concerning this student + conflict_list: ConflictStudentList = logic_projects.get_conflicts(db, project.edition) + for conflict in conflict_list.conflict_students: + if conflict.student.student_id == student_id: + raise StudentInConflictException + + crud.confirm_project_role(db, project, student_id) diff --git a/backend/src/app/logic/register.py b/backend/src/app/logic/register.py index c3202a338..9a48ffa95 100644 --- a/backend/src/app/logic/register.py +++ b/backend/src/app/logic/register.py @@ -1,22 +1,27 @@ +import sqlalchemy.exc from sqlalchemy.orm import Session -from src.app.schemas.register import NewUser -from src.database.models import Edition, InviteLink -from src.database.crud.register import create_user, create_auth_email, create_coach_request -from src.database.crud.invites import get_invite_link_by_uuid, delete_invite_link from src.app.exceptions.register import FailedToAddNewUserException from src.app.logic.security import get_password_hash +from src.app.schemas.register import NewUser +from src.database.crud.invites import get_invite_link_by_uuid, delete_invite_link +from src.database.crud.register import create_coach_request, create_user, create_auth_email +from src.database.models import Edition, InviteLink def create_request(db: Session, new_user: NewUser, edition: Edition) -> None: """Create a coach request. If something fails, the changes aren't committed""" - transaction = db.begin_nested() invite_link: InviteLink = get_invite_link_by_uuid(db, new_user.uuid) + try: - user = create_user(db, new_user.name, new_user.email) - create_auth_email(db, user, get_password_hash(new_user.pw)) - create_coach_request(db, user, edition) - delete_invite_link(db, invite_link) - except Exception as exception: - transaction.rollback() + # Make all functions in here not commit anymore, + # so we can roll back at the end if we have to + user = create_user(db, new_user.name, commit=False) + create_auth_email(db, user, get_password_hash(new_user.pw), new_user.email, commit=False) + create_coach_request(db, user, edition, commit=False) + delete_invite_link(db, invite_link, commit=False) + + db.commit() + except sqlalchemy.exc.SQLAlchemyError as exception: + db.rollback() raise FailedToAddNewUserException from exception diff --git a/backend/src/app/logic/security.py b/backend/src/app/logic/security.py index 4c1e52484..22fd39d60 100644 --- a/backend/src/app/logic/security.py +++ b/backend/src/app/logic/security.py @@ -1,36 +1,47 @@ +import enum from datetime import timedelta, datetime -from fastapi import Depends from jose import jwt from passlib.context import CryptContext from sqlalchemy.orm import Session +import settings from src.app.exceptions.authentication import InvalidCredentialsException from src.database import models -import settings - -from src.database.database import get_session +from src.database.crud.users import get_user_by_email +from src.database.models import User # Configuration + ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_HOURS = 24 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: - """Encode the user data with an expire timestamp to create the token""" - to_encode = data.copy() +@enum.unique +class TokenType(enum.Enum): + """Type of the token, used to check no access token is used to refresh and the reverse.""" + ACCESS = "access" + REFRESH = "refresh" + - if expires_delta is not None: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(hours=24) +def create_tokens(user: User) -> tuple[str, str]: + """ + Create an access token and refresh token. - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + Returns: (access_token, refresh_token) + """ + return ( + _create_token({"type": TokenType.ACCESS.value, "sub": str(user.user_id)}, settings.ACCESS_TOKEN_EXPIRE_M), + _create_token({"type": TokenType.REFRESH.value, "sub": str(user.user_id)}, settings.REFRESH_TOKEN_EXPIRE_M) + ) - return encoded_jwt + +def _create_token(data: dict, expires_delta: int) -> str: + """Encode the user data with an expiry timestamp to create the token""" + # The 'exp' key here is extremely important. if this key changes expiry will not be checked. + data["exp"] = datetime.utcnow() + timedelta(minutes=expires_delta) + return jwt.encode(data, settings.SECRET_KEY, algorithm=ALGORITHM) def verify_password(plain_password: str, hashed_password: str) -> bool: @@ -43,23 +54,11 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) -# TODO remove this when the users crud has been implemented -def get_user_by_email(db: Session, email: str) -> models.User: - """Find a user by their email address""" - return db.query(models.User).where(models.User.email == email).one() - - -# TODO remove this when the users crud has been implemented -def get_user_by_id(db: Session, user_id: int) -> models.User: - """Find a user by their id""" - return db.query(models.User).where(models.User.user_id == user_id).one() - - def authenticate_user(db: Session, email: str, password: str) -> models.User: """Match an email/password combination to a User model""" user = get_user_by_email(db, email) - if not verify_password(password, user.email_auth.pw_hash): + if user.email_auth.pw_hash is None or not verify_password(password, user.email_auth.pw_hash): raise InvalidCredentialsException() return user diff --git a/backend/src/app/logic/skills.py b/backend/src/app/logic/skills.py index 562916c3f..19a86d308 100644 --- a/backend/src/app/logic/skills.py +++ b/backend/src/app/logic/skills.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session -from src.app.schemas.skills import Skill, SkillBase, SkillList import src.database.crud.skills as crud_skills +from src.app.schemas.skills import SkillBase, SkillList from src.database.models import Skill diff --git a/backend/src/app/logic/students.py b/backend/src/app/logic/students.py new file mode 100644 index 000000000..d506fbe8d --- /dev/null +++ b/backend/src/app/logic/students.py @@ -0,0 +1,103 @@ +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound + +from src.app.schemas.students import NewDecision +from src.database.crud.skills import get_skills_by_ids +from src.database.crud.students import (get_last_emails_of_students, get_student_by_id, + set_definitive_decision_on_student, + delete_student, get_students, get_emails, + create_email) +from src.database.crud.suggestions import get_suggestions_of_student_by_type +from src.database.enums import DecisionEnum +from src.database.models import Edition, Student, Skill, DecisionEmail +from src.app.schemas.students import ( + ReturnStudentList, ReturnStudent, CommonQueryParams, ReturnStudentMailList, + Student as StudentModel, Suggestions as SuggestionsModel, + NewEmail, EmailsSearchQueryParams, + ListReturnStudentMailList) + + +def definitive_decision_on_student(db: Session, student: Student, decision: NewDecision) -> None: + """Set a definitive decion on a student""" + set_definitive_decision_on_student(db, student, decision.decision) + + +def remove_student(db: Session, student: Student) -> None: + """delete a student""" + delete_student(db, student) + + +def get_students_search(db: Session, edition: Edition, commons: CommonQueryParams) -> ReturnStudentList: + """return all students""" + if commons.skill_ids: + skills: list[Skill] = get_skills_by_ids(db, commons.skill_ids) + if len(skills) != len(commons.skill_ids): + return ReturnStudentList(students=[]) + else: + skills = [] + students_orm = get_students(db, edition, commons, skills) + + students: list[StudentModel] = [] + for student in students_orm: + students.append(StudentModel( + student_id=student.student_id, + first_name=student.first_name, + last_name=student.last_name, + preferred_name=student.preferred_name, + email_address=student.email_address, + phone_number=student.phone_number, + alumni=student.alumni, + finalDecision=student.decision, + wants_to_be_student_coach=student.wants_to_be_student_coach, + edition_id=student.edition_id, + skills=student.skills)) + nr_of_yes_suggestions = len(get_suggestions_of_student_by_type( + db, student.student_id, DecisionEnum.YES)) + nr_of_no_suggestions = len(get_suggestions_of_student_by_type( + db, student.student_id, DecisionEnum.NO)) + nr_of_maybe_suggestions = len(get_suggestions_of_student_by_type( + db, student.student_id, DecisionEnum.MAYBE)) + students[-1].nr_of_suggestions = SuggestionsModel( + yes=nr_of_yes_suggestions, no=nr_of_no_suggestions, maybe=nr_of_maybe_suggestions) + return ReturnStudentList(students=students) + + +def get_student_return(student: Student, edition: Edition) -> ReturnStudent: + """return a student""" + if student.edition == edition: + return ReturnStudent(student=student) + + raise NoResultFound + + +def get_emails_of_student(db: Session, edition: Edition, student: Student) -> ReturnStudentMailList: + """returns all mails of a student""" + if student.edition != edition: + raise NoResultFound + emails: list[DecisionEmail] = get_emails(db, student) + return ReturnStudentMailList(emails=emails, student=student) + + +def make_new_email(db: Session, edition: Edition, new_email: NewEmail) -> ListReturnStudentMailList: + """make a new email""" + student_emails: list[ReturnStudentMailList] = [] + for student_id in new_email.students_id: + student: Student = get_student_by_id(db, student_id) + if student.edition == edition: + email: DecisionEmail = create_email(db, student, new_email.email_status) + student_emails.append( + ReturnStudentMailList(student=student, emails=[email]) + ) + return ListReturnStudentMailList(student_emails=student_emails) + + +def last_emails_of_students(db: Session, edition: Edition, + commons: EmailsSearchQueryParams) -> ListReturnStudentMailList: + """get last emails of students with search params""" + emails: list[DecisionEmail] = get_last_emails_of_students( + db, edition, commons) + student_emails: list[ReturnStudentMailList] = [] + for email in emails: + student_emails.append(ReturnStudentMailList(student=email.student, + emails=[email])) + return ListReturnStudentMailList(student_emails=student_emails) diff --git a/backend/src/app/logic/suggestions.py b/backend/src/app/logic/suggestions.py new file mode 100644 index 000000000..d2b0a6771 --- /dev/null +++ b/backend/src/app/logic/suggestions.py @@ -0,0 +1,56 @@ +from sqlalchemy.orm import Session + +from src.app.schemas.suggestion import NewSuggestion +from src.database.crud.suggestions import ( + create_suggestion, get_suggestions_of_student, get_own_suggestion, delete_suggestion, update_suggestion) +from src.database.models import Suggestion, User +from src.app.schemas.suggestion import SuggestionListResponse, SuggestionResponse, suggestion_model_to_schema +from src.app.exceptions.authentication import MissingPermissionsException + + +def make_new_suggestion(db: Session, new_suggestion: NewSuggestion, + user: User, student_id: int | None) -> SuggestionResponse: + """"Make a new suggestion""" + own_suggestion = get_own_suggestion(db, student_id, user.user_id) + + if own_suggestion is None: + suggestion_orm = create_suggestion( + db, user.user_id, student_id, new_suggestion.suggestion, new_suggestion.argumentation) + else: + update_suggestion(db, own_suggestion, new_suggestion.suggestion, new_suggestion.argumentation) + suggestion_orm = own_suggestion + + suggestion = suggestion_model_to_schema(suggestion_orm) + return SuggestionResponse(suggestion=suggestion) + + +def all_suggestions_of_student(db: Session, student_id: int | None) -> SuggestionListResponse: + """Get all suggestions of a student""" + suggestions_orm = get_suggestions_of_student(db, student_id) + all_suggestions = [] + for suggestion in suggestions_orm: + all_suggestions.append(suggestion_model_to_schema(suggestion)) + return SuggestionListResponse(suggestions=all_suggestions) + + +def remove_suggestion(db: Session, suggestion: Suggestion, user: User) -> None: + """ + Delete a suggestion + Admins can delete all suggestions, coaches only their own suggestions + """ + if user.admin or suggestion.coach == user: + delete_suggestion(db, suggestion) + else: + raise MissingPermissionsException + + +def change_suggestion(db: Session, new_suggestion: NewSuggestion, suggestion: Suggestion, user: User) -> None: + """ + Update a suggestion + Admins can update all suggestions, coaches only their own suggestions + """ + if user.admin or suggestion.coach == user: + update_suggestion( + db, suggestion, new_suggestion.suggestion, new_suggestion.argumentation) + else: + raise MissingPermissionsException diff --git a/backend/src/app/logic/users.py b/backend/src/app/logic/users.py index 26cb22f2c..52ded0361 100644 --- a/backend/src/app/logic/users.py +++ b/backend/src/app/logic/users.py @@ -1,72 +1,85 @@ from sqlalchemy.orm import Session -from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse import src.database.crud.users as users_crud +from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse, user_model_to_schema, \ + FilterParameters, UserRequest +from src.database.models import User -def get_users_list(db: Session, admin: bool, edition_id: int | None) -> UsersListResponse: +def get_users_list( + db: Session, + params: FilterParameters +) -> UsersListResponse: """ Query the database for a list of users and wrap the result in a pydantic model """ - if admin: - if edition_id is None: - users_orm = users_crud.get_all_admins(db) - else: - users_orm = users_crud.get_admins_from_edition(db, edition_id) - else: - if edition_id is None: - users_orm = users_crud.get_all_users(db) - else: - users_orm = users_crud.get_users_from_edition(db, edition_id) + users_orm = users_crud.get_users_filtered_page(db, params) + + return UsersListResponse(users=[user_model_to_schema(user) for user in users_orm]) + - return UsersListResponse(users=users_orm) +def get_user_editions(db: Session, user: User) -> list[str]: + """Get all names of the editions this user is coach in""" + return users_crud.get_user_edition_names(db, user) def edit_admin_status(db: Session, user_id: int, admin: AdminPatch): """ Edit the admin-status of a user """ - users_crud.edit_admin_status(db, user_id, admin.admin) -def add_coach(db: Session, user_id: int, edition_id: int): +def add_coach(db: Session, user_id: int, edition_name: str): """ Add user as coach for the given edition """ - - users_crud.add_coach(db, user_id, edition_id) + users_crud.add_coach(db, user_id, edition_name) + users_crud.remove_request_if_exists(db, user_id, edition_name) -def remove_coach(db: Session, user_id: int, edition_id: int): +def remove_coach(db: Session, user_id: int, edition_name: str): """ Remove user as coach for the given edition """ + users_crud.remove_coach(db, user_id, edition_name) - users_crud.remove_coach(db, user_id, edition_id) + +def remove_coach_all_editions(db: Session, user_id: int): + """ + Remove user as coach from all editions + """ + users_crud.remove_coach_all_editions(db, user_id) -def get_request_list(db: Session, edition_id: int | None): +def get_request_list(db: Session, edition_name: str | None, user_name: str | None, page: int) -> UserRequestsResponse: """ Query the database for a list of all user requests and wrap the result in a pydantic model """ - if edition_id is None: - requests = users_crud.get_all_requests(db) + if user_name is None: + user_name = "" + + if edition_name is None: + requests = users_crud.get_requests_page(db, page, user_name) else: - requests = users_crud.get_all_requests_from_edition(db, edition_id) + requests = users_crud.get_requests_for_edition_page(db, edition_name, page, user_name) - return UserRequestsResponse(requests=requests) + requests_model = [] + for request in requests: + user_req = UserRequest(request_id=request.request_id, edition=request.edition, + user=user_model_to_schema(request.user)) + requests_model.append(user_req) + return UserRequestsResponse(requests=requests_model) def accept_request(db: Session, request_id: int): """ Accept user request """ - users_crud.accept_request(db, request_id) @@ -74,5 +87,4 @@ def reject_request(db: Session, request_id: int): """ Reject user request """ - users_crud.reject_request(db, request_id) diff --git a/backend/src/app/logic/webhooks.py b/backend/src/app/logic/webhooks.py index 4e0c3184d..15f89db6f 100644 --- a/backend/src/app/logic/webhooks.py +++ b/backend/src/app/logic/webhooks.py @@ -1,9 +1,11 @@ +from typing import cast + import sqlalchemy.exc from sqlalchemy.orm import Session from settings import FormMapping from src.app.exceptions.webhooks import WebhookProcessException -from src.app.schemas.webhooks import WebhookEvent, Question, Form +from src.app.schemas.webhooks import WebhookEvent, Question, Form, QuestionUpload, QuestionOption from src.database.enums import QuestionEnum as QE from src.database.models import Question as QuestionModel, QuestionAnswer, QuestionFileAnswer, Student, Edition @@ -18,10 +20,9 @@ def process_webhook(edition: Edition, data: WebhookEvent, database: Session): questions: list[Question] = form.fields extra_questions: list[Question] = [] - attributes: dict[str, str | int] = {'edition': edition} + attributes: dict = {'edition': edition} for question in questions: - question: Question match FormMapping(question.key): case FormMapping.FIRST_NAME: attributes['first_name'] = question.value @@ -34,10 +35,11 @@ def process_webhook(edition: Edition, data: WebhookEvent, database: Session): case FormMapping.PHONE_NUMBER: attributes['phone_number'] = question.value case FormMapping.STUDENT_COACH: - for option in question.options: - if option.id == question.value: - attributes['wants_to_be_student_coach'] = "yes" in option.text.lower() - break # Only 2 options, Yes and No. + if question.options is not None: + for option in question.options: + if option.id == question.value: + attributes['wants_to_be_student_coach'] = "yes" in option.text.lower() + break # Only 2 options, Yes and No. case _: extra_questions.append(question) @@ -76,7 +78,7 @@ def process_remaining_questions(student: Student, questions: list[Question], dat continue model = QuestionModel( - type=question.type, + type=QE(question.type), question=question.label, student=student ) @@ -85,8 +87,9 @@ def process_remaining_questions(student: Student, questions: list[Question], dat match QE(question.type): case QE.MULTIPLE_CHOICE: - value: str = question.value - for option in question.options: + value: str = cast(str, question.value) + options = cast(list[QuestionOption], question.options) + for option in options: if option.id == value: database.add(QuestionAnswer( answer=option.text, @@ -96,12 +99,13 @@ def process_remaining_questions(student: Student, questions: list[Question], dat case QE.INPUT_EMAIL | QE.INPUT_LINK | QE.INPUT_TEXT | QE.TEXTAREA | QE.INPUT_PHONE_NUMBER | QE.INPUT_NUMBER: if question.value: database.add(QuestionAnswer( - answer=question.value, + answer=cast(str, question.value), question=model )) case QE.FILE_UPLOAD: if question.value: - for upload in question.value: + uploads = cast(list[QuestionUpload], question.value) + for upload in uploads: database.add(QuestionFileAnswer( file_name=upload.name, url=upload.url, @@ -110,8 +114,10 @@ def process_remaining_questions(student: Student, questions: list[Question], dat question=model )) case QE.CHECKBOXES: - for value in question.value: - for option in question.options: + answers = cast(list[str], question.value) + for value in answers: + options = cast(list[QuestionOption], question.options) + for option in options: if option.id == value: database.add(QuestionAnswer( answer=option.text, diff --git a/backend/src/app/routers/editions/editions.py b/backend/src/app/routers/editions/editions.py index e000cf13d..0c5e4acc3 100644 --- a/backend/src/app/routers/editions/editions.py +++ b/backend/src/app/routers/editions/editions.py @@ -1,23 +1,21 @@ from fastapi import APIRouter, Depends -from starlette import status from sqlalchemy.orm import Session +from starlette import status +from src.app.logic import editions as logic_editions from src.app.routers.tags import Tags from src.app.schemas.editions import EditionBase, Edition, EditionList - from src.database.database import get_session -from src.app.logic import editions as logic_editions - +from src.database.models import User from .invites import invites_router from .projects import projects_router from .register import registration_router from .students import students_router from .webhooks import webhooks_router +from ...utils.dependencies import require_admin, require_auth, require_coach # Don't add the "Editions" tag here, because then it gets applied # to all child routes as well -from ...utils.dependencies import require_admin, require_auth, require_coach - editions_router = APIRouter(prefix="/editions") # Register all child routers @@ -27,40 +25,47 @@ ] for router in child_routers: - # All other routes have /editions/{id} as the prefix, so they are + # All other routes have /editions/{name} as the prefix, so they are # child routes of this router - # This also means all routes have access to the "edition_id" path parameter - editions_router.include_router(router, prefix="/{edition_id}") + # This also means all routes have access to the "edition_name" path parameter + editions_router.include_router(router, prefix="/{edition_name}") -@editions_router.get("/", response_model=EditionList, tags=[Tags.EDITIONS], dependencies=[Depends(require_auth)]) -async def get_editions(db: Session = Depends(get_session)): - """Get a list of all editions. +@editions_router.get("/", response_model=EditionList, tags=[Tags.EDITIONS]) +async def get_editions(db: Session = Depends(get_session), user: User = Depends(require_auth), page: int = 0): + """Get a paginated list of all editions. Args: db (Session, optional): connection with the database. Defaults to Depends(get_session). + user (User, optional): the current logged in user. Defaults to Depends(require_auth). + page (int): the page to return. Returns: EditionList: an object with a list of all the editions. """ - # TODO only return editions the user can see - return logic_editions.get_editions(db) + if user.admin: + return logic_editions.get_editions_page(db, page) + + return EditionList(editions=user.editions) -@editions_router.get("/{edition_id}", response_model=Edition, tags=[Tags.EDITIONS], dependencies=[Depends(require_coach)]) -async def get_edition_by_id(edition_id: int, db: Session = Depends(get_session)): +@editions_router.get("/{edition_name}", response_model=Edition, tags=[Tags.EDITIONS], + dependencies=[Depends(require_coach)]) +async def get_edition_by_name(edition_name: str, db: Session = Depends(get_session)): """Get a specific edition. Args: - edition_id (int): the id of the edition that you want to get. + edition_name (str): the name of the edition that you want to get. db (Session, optional): connection with the database. Defaults to Depends(get_session). + user (User, optional): the current logged in user. Defaults to Depends(get_current_active_user). Returns: Edition: an edition. """ - return logic_editions.get_edition_by_id(db, edition_id) + return logic_editions.get_edition_by_name(db, edition_name) -@editions_router.post("/", status_code=status.HTTP_201_CREATED, response_model=Edition, tags=[Tags.EDITIONS], dependencies=[Depends(require_admin)]) +@editions_router.post("/", status_code=status.HTTP_201_CREATED, response_model=Edition, tags=[Tags.EDITIONS], + dependencies=[Depends(require_admin)]) async def post_edition(edition: EditionBase, db: Session = Depends(get_session)): """ Create a new edition. @@ -73,13 +78,14 @@ async def post_edition(edition: EditionBase, db: Session = Depends(get_session)) return logic_editions.create_edition(db, edition) -@editions_router.delete("/{edition_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.EDITIONS], dependencies=[Depends(require_admin)]) -async def delete_edition(edition_id: int, db: Session = Depends(get_session)): +@editions_router.delete("/{edition_name}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.EDITIONS], + dependencies=[Depends(require_admin)]) +async def delete_edition(edition_name: str, db: Session = Depends(get_session)): """Delete an existing edition. Args: - edition_id (int): the id of the edition that needs to be deleted, if found. + edition_name (str): the name of the edition that needs to be deleted, if found. db (Session, optional): connection with the database. Defaults to Depends(get_session). """ - logic_editions.delete_edition(db, edition_id) + logic_editions.delete_edition(db, edition_name) diff --git a/backend/src/app/routers/editions/invites/invites.py b/backend/src/app/routers/editions/invites/invites.py index dd2480542..4f2bc798d 100644 --- a/backend/src/app/routers/editions/invites/invites.py +++ b/backend/src/app/routers/editions/invites/invites.py @@ -3,28 +3,28 @@ from starlette import status from starlette.responses import Response -from src.app.logic.invites import create_mailto_link, delete_invite_link, get_pending_invites_list +from src.app.logic.invites import create_mailto_link, delete_invite_link, get_pending_invites_page from src.app.routers.tags import Tags -from src.app.schemas.invites import InvitesListResponse, EmailAddress, MailtoLink, InviteLink as InviteLinkModel -from src.app.utils.dependencies import get_edition, get_invite_link, require_admin +from src.app.utils.dependencies import get_edition, get_invite_link, require_admin, get_latest_edition +from src.app.schemas.invites import InvitesLinkList, EmailAddress, NewInviteLink, InviteLink as InviteLinkModel from src.database.database import get_session from src.database.models import Edition, InviteLink as InviteLinkDB invites_router = APIRouter(prefix="/invites", tags=[Tags.INVITES]) -@invites_router.get("/", response_model=InvitesListResponse, dependencies=[Depends(require_admin)]) -async def get_invites(db: Session = Depends(get_session), edition: Edition = Depends(get_edition)): +@invites_router.get("/", response_model=InvitesLinkList, dependencies=[Depends(require_admin)]) +async def get_invites(db: Session = Depends(get_session), edition: Edition = Depends(get_edition), page: int = 0): """ Get a list of all pending invitation links. """ - return get_pending_invites_list(db, edition) + return get_pending_invites_page(db, edition, page) -@invites_router.post("/", status_code=status.HTTP_201_CREATED, response_model=MailtoLink, +@invites_router.post("/", status_code=status.HTTP_201_CREATED, response_model=NewInviteLink, dependencies=[Depends(require_admin)]) async def create_invite(email: EmailAddress, db: Session = Depends(get_session), - edition: Edition = Depends(get_edition)): + edition: Edition = Depends(get_latest_edition)): """ Create a new invitation link for the current edition. """ @@ -32,7 +32,7 @@ async def create_invite(email: EmailAddress, db: Session = Depends(get_session), @invites_router.delete("/{invite_uuid}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, - dependencies=[Depends(require_admin)]) + dependencies=[Depends(require_admin), Depends(get_edition)]) async def delete_invite(invite_link: InviteLinkDB = Depends(get_invite_link), db: Session = Depends(get_session)): """ Delete an existing invitation link manually so that it can't be used anymore. @@ -40,7 +40,7 @@ async def delete_invite(invite_link: InviteLinkDB = Depends(get_invite_link), db delete_invite_link(db, invite_link) -@invites_router.get("/{invite_uuid}", response_model=InviteLinkModel) +@invites_router.get("/{invite_uuid}", response_model=InviteLinkModel, dependencies=[Depends(get_edition)]) async def get_invite(invite_link: InviteLinkDB = Depends(get_invite_link)): """ Get a specific invitation link to see if it exists or not. Can be used to verify the validity diff --git a/backend/src/app/routers/editions/projects/projects.py b/backend/src/app/routers/editions/projects/projects.py index 5b873757a..0c55a326c 100644 --- a/backend/src/app/routers/editions/projects/projects.py +++ b/backend/src/app/routers/editions/projects/projects.py @@ -1,50 +1,73 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from starlette import status +from starlette.responses import Response +import src.app.logic.projects as logic from src.app.routers.tags import Tags +from src.app.utils.dependencies import get_edition, get_project, require_admin, require_coach, get_latest_edition +from src.app.schemas.projects import (ProjectList, Project, InputProject, + ConflictStudentList, QueryParamsProjects) +from src.database.database import get_session +from src.database.models import Edition, Project as ProjectModel, User from .students import project_students_router projects_router = APIRouter(prefix="/projects", tags=[Tags.PROJECTS]) projects_router.include_router(project_students_router, prefix="/{project_id}") -@projects_router.get("/") -async def get_projects(edition_id: int): +@projects_router.get("/", response_model=ProjectList) +async def get_projects(db: Session = Depends(get_session), edition: Edition = Depends(get_edition), + search_params: QueryParamsProjects = Depends(QueryParamsProjects), + user: User = Depends(require_coach)): """ Get a list of all projects. """ + return logic.get_project_list(db, edition, search_params, user) -@projects_router.post("/") -async def create_project(edition_id: int): +@projects_router.post("/", status_code=status.HTTP_201_CREATED, response_model=Project, + dependencies=[Depends(require_admin)]) +async def create_project(input_project: InputProject, + db: Session = Depends(get_session), edition: Edition = Depends(get_latest_edition)): """ - Create a new project. + Create a new project """ + return logic.create_project(db, edition, + input_project) -@projects_router.get("/conflicts") -async def get_conflicts(edition_id: int): +@projects_router.get("/conflicts", response_model=ConflictStudentList, dependencies=[Depends(require_coach)]) +async def get_conflicts(db: Session = Depends(get_session), edition: Edition = Depends(get_edition)): """ Get a list of all projects with conflicts, and the users that are causing those conflicts. """ + return logic.get_conflicts(db, edition) -@projects_router.delete("/{project_id}") -async def delete_project(edition_id: int, project_id: int): +@projects_router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, + dependencies=[Depends(require_admin)]) +async def delete_project(project_id: int, db: Session = Depends(get_session)): """ Delete a specific project. """ + return logic.delete_project(db, project_id) -@projects_router.get("/{project_id}") -async def get_project(edition_id: int, project_id: int): +@projects_router.get("/{project_id}", status_code=status.HTTP_200_OK, response_model=Project, + dependencies=[Depends(require_coach)]) +async def get_project_route(project: ProjectModel = Depends(get_project)): """ Get information about a specific project. """ + return project -@projects_router.patch("/{project_id}") -async def patch_project(edition_id: int, project_id: int): +@projects_router.patch("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, + dependencies=[Depends(require_admin), Depends(get_latest_edition)]) +async def patch_project(project_id: int, input_project: InputProject, db: Session = Depends(get_session)): """ Update a project, changing some fields. """ + logic.patch_project(db, project_id, input_project) diff --git a/backend/src/app/routers/editions/projects/students/projects_students.py b/backend/src/app/routers/editions/projects/students/projects_students.py index ff455ced5..9839ed979 100644 --- a/backend/src/app/routers/editions/projects/students/projects_students.py +++ b/backend/src/app/routers/editions/projects/students/projects_students.py @@ -1,28 +1,56 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from starlette import status +from starlette.responses import Response +import src.app.logic.projects_students as logic from src.app.routers.tags import Tags +from src.app.schemas.projects import InputStudentRole +from src.app.utils.dependencies import get_project, require_admin, require_coach, get_latest_edition +from src.database.database import get_session +from src.database.models import Project, User project_students_router = APIRouter(prefix="/students", tags=[Tags.PROJECTS, Tags.STUDENTS]) -@project_students_router.delete("/{student_id}") -async def remove_student_from_project(edition_id: int, project_id: int, student_id: int): +@project_students_router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, + dependencies=[Depends(require_coach)]) +async def remove_student_from_project(student_id: int, db: Session = Depends(get_session), + project: Project = Depends(get_project)): """ Remove a student from a project. """ + logic.remove_student_project(db, project, student_id) -@project_students_router.patch("/{student_id}") -async def change_project_role(edition_id: int, project_id: int, student_id: int): +@project_students_router.patch("/{student_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, + dependencies=[Depends(get_latest_edition)]) +async def change_project_role(student_id: int, input_sr: InputStudentRole, db: Session = Depends(get_session), + project: Project = Depends(get_project), user: User = Depends(require_coach)): """ Change the role a student is drafted for in a project. """ + logic.change_project_role(db, project, student_id, input_sr.skill_id, user.user_id) -@project_students_router.post("/{student_id}") -async def add_student_to_project(edition_id: int, project_id: int, student_id: int): +@project_students_router.post("/{student_id}", status_code=status.HTTP_201_CREATED, response_class=Response, + dependencies=[Depends(get_latest_edition)]) +async def add_student_to_project(student_id: int, input_sr: InputStudentRole, db: Session = Depends(get_session), + project: Project = Depends(get_project), user: User = Depends(require_coach)): """ Add a student to a project. This is not a definitive decision, but represents a coach drafting the student. """ + logic.add_student_project(db, project, student_id, input_sr.skill_id, user.user_id) + + +@project_students_router.post("/{student_id}/confirm", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, + dependencies=[Depends(require_admin), Depends(get_latest_edition)]) +async def confirm_project_role(student_id: int, db: Session = Depends(get_session), + project: Project = Depends(get_project)): + """ + Definitively add a student to a project (confirm its role). + This can only be performed by an admin. + """ + logic.confirm_project_role(db, project, student_id) diff --git a/backend/src/app/routers/editions/register/register.py b/backend/src/app/routers/editions/register/register.py index 3e333ff8e..e008c13b6 100644 --- a/backend/src/app/routers/editions/register/register.py +++ b/backend/src/app/routers/editions/register/register.py @@ -5,7 +5,7 @@ from src.app.logic.register import create_request from src.app.routers.tags import Tags from src.app.schemas.register import NewUser -from src.app.utils.dependencies import get_edition +from src.app.utils.dependencies import get_latest_edition from src.database.database import get_session from src.database.models import Edition @@ -13,7 +13,8 @@ @registration_router.post("/email", status_code=status.HTTP_201_CREATED) -async def register_email(user: NewUser, db: Session = Depends(get_session), edition: Edition = Depends(get_edition)): +async def register_email(user: NewUser, db: Session = Depends(get_session), + edition: Edition = Depends(get_latest_edition)): """ Register a new account using the email/password format. """ diff --git a/backend/src/app/routers/editions/students/students.py b/backend/src/app/routers/editions/students/students.py index 4e15564e4..076b43222 100644 --- a/backend/src/app/routers/editions/students/students.py +++ b/backend/src/app/routers/editions/students/students.py @@ -1,57 +1,88 @@ -from fastapi import APIRouter - -from fastapi import Depends -from src.database.database import get_session +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session +from starlette import status from src.app.routers.tags import Tags +from src.app.utils.dependencies import get_student, get_edition, require_admin, require_auth +from src.app.logic.students import ( + definitive_decision_on_student, remove_student, get_student_return, + get_students_search, get_emails_of_student, make_new_email, + last_emails_of_students) +from src.app.schemas.students import (NewDecision, CommonQueryParams, ReturnStudent, ReturnStudentList, + ReturnStudentMailList, NewEmail, EmailsSearchQueryParams, + ListReturnStudentMailList) +from src.database.database import get_session +from src.database.models import Student, Edition from .suggestions import students_suggestions_router students_router = APIRouter(prefix="/students", tags=[Tags.STUDENTS]) -students_router.include_router(students_suggestions_router, prefix="/{student_id}") +students_router.include_router( + students_suggestions_router, prefix="/{student_id}") -@students_router.get("/") -async def get_students(edition_id: int): +@students_router.get("/", dependencies=[Depends(require_auth)], response_model=ReturnStudentList) +async def get_students(db: Session = Depends(get_session), + commons: CommonQueryParams = Depends(CommonQueryParams), + edition: Edition = Depends(get_edition)): """ Get a list of all students. """ + return get_students_search(db, edition, commons) +@students_router.post("/emails", dependencies=[Depends(require_admin)], + status_code=status.HTTP_201_CREATED, response_model=ListReturnStudentMailList) +async def send_emails(new_email: NewEmail, db: Session = Depends(get_session), edition: Edition = Depends(get_edition)): + """ + Send a email to a list of students. + """ + return make_new_email(db, edition, new_email) + -@students_router.post("/emails") -async def send_emails(edition_id: int): +@students_router.get("/emails", dependencies=[Depends(require_admin)], + response_model=ListReturnStudentMailList) +async def get_emails(db: Session = Depends(get_session), edition: Edition = Depends(get_edition), + commons: EmailsSearchQueryParams = Depends(EmailsSearchQueryParams)): """ - Send a Yes/Maybe/No email to a list of students. + Get last emails of students """ + return last_emails_of_students(db, edition, commons) -@students_router.delete("/{student_id}") -async def delete_student(edition_id: int, student_id: int): +@students_router.delete("/{student_id}", dependencies=[Depends(require_admin)], status_code=status.HTTP_204_NO_CONTENT) +async def delete_student(student: Student = Depends(get_student), db: Session = Depends(get_session)): """ Delete all information stored about a specific student. """ + remove_student(db, student) -@students_router.get("/{student_id}") -async def get_student(edition_id: int, student_id: int): +@students_router.get("/{student_id}", dependencies=[Depends(require_auth)], response_model=ReturnStudent) +async def get_student_by_id(edition: Edition = Depends(get_edition), student: Student = Depends(get_student)): """ Get information about a specific student. """ + return get_student_return(student, edition) -@students_router.post("/{student_id}/decision") -async def make_decision(edition_id: int, student_id: int): +@students_router.put("/{student_id}/decision", dependencies=[Depends(require_admin)], + status_code=status.HTTP_204_NO_CONTENT) +async def make_decision(decision: NewDecision, student: Student = Depends(get_student), + db: Session = Depends(get_session)): """ Make a finalized Yes/Maybe/No decision about a student. This action can only be performed by an admin. """ + definitive_decision_on_student(db, student, decision) -@students_router.get("/{student_id}/emails") -async def get_student_email_history(edition_id: int, student_id: int): +@students_router.get("/{student_id}/emails", dependencies=[Depends(require_admin)], + response_model=ReturnStudentMailList) +async def get_student_email_history(edition: Edition = Depends(get_edition), student: Student = Depends(get_student), + db: Session = Depends(get_session)): """ Get the history of all Yes/Maybe/No emails that have been sent to a specific student so far. """ + return get_emails_of_student(db, edition, student) diff --git a/backend/src/app/routers/editions/students/suggestions/__init__.py b/backend/src/app/routers/editions/students/suggestions/__init__.py index b2a4c460b..34c20941b 100644 --- a/backend/src/app/routers/editions/students/suggestions/__init__.py +++ b/backend/src/app/routers/editions/students/suggestions/__init__.py @@ -1 +1 @@ -from .students_suggestions import students_suggestions_router +from .suggestions import students_suggestions_router diff --git a/backend/src/app/routers/editions/students/suggestions/students_suggestions.py b/backend/src/app/routers/editions/students/suggestions/students_suggestions.py deleted file mode 100644 index aac361f44..000000000 --- a/backend/src/app/routers/editions/students/suggestions/students_suggestions.py +++ /dev/null @@ -1,26 +0,0 @@ -from fastapi import APIRouter - -from src.app.routers.tags import Tags - -students_suggestions_router = APIRouter(prefix="/suggestions", tags=[Tags.STUDENTS]) - - -@students_suggestions_router.post("/") -async def create_suggestion(edition_id: int, student_id: int): - """ - Make a suggestion about a student. - """ - - -@students_suggestions_router.get("/{suggestion_id}") -async def delete_suggestion(edition_id: int, student_id: int, suggestion_id: int): - """ - Delete a suggestion you made about a student. - """ - - -@students_suggestions_router.put("/{suggestion_id}") -async def edit_suggestion(edition_id: int, student_id: int, suggestion_id: int): - """ - Edit a suggestion you made about a student. - """ diff --git a/backend/src/app/routers/editions/students/suggestions/suggestions.py b/backend/src/app/routers/editions/students/suggestions/suggestions.py new file mode 100644 index 000000000..7fa06def7 --- /dev/null +++ b/backend/src/app/routers/editions/students/suggestions/suggestions.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from starlette import status +from src.app.routers.tags import Tags +from src.app.utils.dependencies import require_auth, get_student, get_suggestion +from src.database.database import get_session +from src.database.models import Student, User, Suggestion +from src.app.logic.suggestions import (make_new_suggestion, all_suggestions_of_student, + remove_suggestion, change_suggestion) +from src.app.schemas.suggestion import NewSuggestion, SuggestionListResponse, SuggestionResponse + + +students_suggestions_router = APIRouter( + prefix="/suggestions", tags=[Tags.STUDENTS]) + + +@students_suggestions_router.post("/", status_code=status.HTTP_201_CREATED, response_model=SuggestionResponse) +async def create_suggestion(new_suggestion: NewSuggestion, student: Student = Depends(get_student), + db: Session = Depends(get_session), user: User = Depends(require_auth)): + """ + Make a suggestion about a student. + + In case you've already made a suggestion previously, this replaces the existing suggestion. + This simplifies the process in frontend, so we can just send a new request without making an edit interface. + """ + return make_new_suggestion(db, new_suggestion, user, student.student_id) + + +@students_suggestions_router.delete("/{suggestion_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_suggestion(db: Session = Depends(get_session), user: User = Depends(require_auth), + suggestion: Suggestion = Depends(get_suggestion)): + """ + Delete a suggestion you made about a student. + """ + remove_suggestion(db, suggestion, user) + + +@students_suggestions_router.put("/{suggestion_id}", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(get_student)]) +async def edit_suggestion(new_suggestion: NewSuggestion, db: Session = Depends(get_session), + user: User = Depends(require_auth), suggestion: Suggestion = Depends(get_suggestion)): + """ + Edit a suggestion you made about a student. + """ + change_suggestion(db, new_suggestion, suggestion, user) + + +@students_suggestions_router.get("/", dependencies=[Depends(require_auth)], + status_code=status.HTTP_200_OK, response_model=SuggestionListResponse) +async def get_suggestions(student: Student = Depends(get_student), db: Session = Depends(get_session)): + """ + Get all suggestions of a student. + """ + return all_suggestions_of_student(db, student.student_id) diff --git a/backend/src/app/routers/editions/webhooks/webhooks.py b/backend/src/app/routers/editions/webhooks/webhooks.py index 1026780fa..9a687aa7f 100644 --- a/backend/src/app/routers/editions/webhooks/webhooks.py +++ b/backend/src/app/routers/editions/webhooks/webhooks.py @@ -1,14 +1,14 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session +from starlette import status -from src.database.database import get_session -from src.database.crud.webhooks import get_webhook, create_webhook +from src.app.logic.webhooks import process_webhook +from src.app.routers.tags import Tags from src.app.schemas.webhooks import WebhookEvent, WebhookUrlResponse +from src.app.utils.dependencies import get_edition, require_admin, get_latest_edition +from src.database.crud.webhooks import get_webhook, create_webhook +from src.database.database import get_session from src.database.models import Edition -from src.app.utils.dependencies import get_edition, require_admin -from src.app.routers.tags import Tags -from src.app.logic.webhooks import process_webhook -from starlette import status webhooks_router = APIRouter(prefix="/webhooks", tags=[Tags.WEBHOOKS]) @@ -20,7 +20,7 @@ def valid_uuid(uuid: str, database: Session = Depends(get_session)): @webhooks_router.post("/", response_model=WebhookUrlResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)]) -def new(edition: Edition = Depends(get_edition), database: Session = Depends(get_session)): +def new(edition: Edition = Depends(get_latest_edition), database: Session = Depends(get_session)): """Create a new webhook for an edition""" return create_webhook(database, edition) diff --git a/backend/src/app/routers/login/login.py b/backend/src/app/routers/login/login.py index d2f37dcce..093f5cd13 100644 --- a/backend/src/app/routers/login/login.py +++ b/backend/src/app/routers/login/login.py @@ -1,5 +1,3 @@ -from datetime import timedelta - import sqlalchemy.exc from fastapi import APIRouter from fastapi import Depends @@ -7,17 +5,20 @@ from sqlalchemy.orm import Session from src.app.exceptions.authentication import InvalidCredentialsException -from src.app.logic.security import authenticate_user, ACCESS_TOKEN_EXPIRE_HOURS, create_access_token +from src.app.logic.security import authenticate_user, create_tokens +from src.app.logic.users import get_user_editions from src.app.routers.tags import Tags from src.app.schemas.login import Token +from src.app.schemas.users import user_model_to_schema +from src.app.utils.dependencies import get_user_from_refresh_token from src.database.database import get_session +from src.database.models import User login_router = APIRouter(prefix="/login", tags=[Tags.LOGIN]) @login_router.post("/token", response_model=Token) -async def login_for_access_token(db: Session = Depends(get_session), - form_data: OAuth2PasswordRequestForm = Depends()) -> dict[str, str]: +async def login_for_access_token(db: Session = Depends(get_session), form_data: OAuth2PasswordRequestForm = Depends()): """Called when logging in, generates an access token to use in other functions""" try: user = authenticate_user(db, form_data.username, form_data.password) @@ -25,10 +26,32 @@ async def login_for_access_token(db: Session = Depends(get_session), # Don't use our own error handler here because this should # be a 401 instead of a 404 raise InvalidCredentialsException() from not_found - - access_token_expires = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) - access_token = create_access_token( - data={"sub": str(user.user_id)}, expires_delta=access_token_expires - ) - return {"access_token": access_token, "token_type": "bearer"} + return await generate_token_response_for_user(db, user) + + +@login_router.post("/refresh", response_model=Token) +async def refresh_access_token(db: Session = Depends(get_session), user: User = Depends(get_user_from_refresh_token)): + """ + Return a new access & refresh token using on the old refresh token + + Swagger note: This endpoint will not work on swagger because it uses the access token to try & refresh + """ + return await generate_token_response_for_user(db, user) + + +async def generate_token_response_for_user(db: Session, user: User) -> Token: + """ + Generate new tokens for a user and put them in the Token response schema. + """ + access_token, refresh_token = create_tokens(user) + + user_data: dict = user_model_to_schema(user).__dict__ + user_data["editions"] = get_user_editions(db, user) + + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + user=user_data + ) diff --git a/backend/src/app/routers/skills/skills.py b/backend/src/app/routers/skills/skills.py index 42b60d72d..2da8892b5 100644 --- a/backend/src/app/routers/skills/skills.py +++ b/backend/src/app/routers/skills/skills.py @@ -24,7 +24,8 @@ async def get_skills(db: Session = Depends(get_session)): return logic_skills.get_skills(db) -@skills_router.post("/",status_code=status.HTTP_201_CREATED, response_model=Skill, tags=[Tags.SKILLS], dependencies=[Depends(require_auth)]) +@skills_router.post("/", status_code=status.HTTP_201_CREATED, response_model=Skill, tags=[Tags.SKILLS], + dependencies=[Depends(require_auth)]) async def create_skill(skill: SkillBase, db: Session = Depends(get_session)): """Add a new skill into the database. @@ -38,7 +39,8 @@ async def create_skill(skill: SkillBase, db: Session = Depends(get_session)): return logic_skills.create_skill(db, skill) -@skills_router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.SKILLS], dependencies=[Depends(require_auth)]) +@skills_router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT, tags=[Tags.SKILLS], + dependencies=[Depends(require_auth)]) async def delete_skill(skill_id: int, db: Session = Depends(get_session)): """Delete an existing skill. diff --git a/backend/src/app/routers/users/users.py b/backend/src/app/routers/users/users.py index 652d6e725..04452dc5d 100644 --- a/backend/src/app/routers/users/users.py +++ b/backend/src/app/routers/users/users.py @@ -1,73 +1,101 @@ from fastapi import APIRouter, Query, Depends -from requests import Session +from sqlalchemy.orm import Session +from starlette import status -from src.app.routers.tags import Tags import src.app.logic.users as logic -from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse -from src.app.utils.dependencies import require_admin +from src.app.routers.tags import Tags +from src.app.schemas.login import UserData +from src.app.schemas.users import UsersListResponse, AdminPatch, UserRequestsResponse, user_model_to_schema, \ + FilterParameters +from src.app.utils.dependencies import require_admin, get_user_from_access_token from src.database.database import get_session +from src.database.models import User as UserDB users_router = APIRouter(prefix="/users", tags=[Tags.USERS]) @users_router.get("/", response_model=UsersListResponse, dependencies=[Depends(require_admin)]) -async def get_users(admin: bool = Query(False), edition: int | None = Query(None), db: Session = Depends(get_session)): +async def get_users( + params: FilterParameters = Depends(), + db: Session = Depends(get_session)): """ Get users + + When the admin parameter is True, the edition and exclude_edition parameter will have no effect. + Since admins have access to all editions. """ + return logic.get_users_list(db, params) + - return logic.get_users_list(db, admin, edition) +@users_router.get("/current", response_model=UserData) +async def get_current_user(db: Session = Depends(get_session), user: UserDB = Depends(get_user_from_access_token)): + """Get a user based on their authorization credentials""" + user_data = user_model_to_schema(user).__dict__ + user_data["editions"] = logic.get_user_editions(db, user) + return user_data -@users_router.patch("/{user_id}", status_code=204, dependencies=[Depends(require_admin)]) + +@users_router.patch("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_admin)]) async def patch_admin_status(user_id: int, admin: AdminPatch, db: Session = Depends(get_session)): """ Set admin-status of user """ - logic.edit_admin_status(db, user_id, admin) -@users_router.post("/{user_id}/editions/{edition_id}", status_code=204, dependencies=[Depends(require_admin)]) -async def add_to_edition(user_id: int, edition_id: int, db: Session = Depends(get_session)): +@users_router.post("/{user_id}/editions/{edition_name}", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)]) +async def add_to_edition(user_id: int, edition_name: str, db: Session = Depends(get_session)): """ Add user as coach of the given edition """ - - logic.add_coach(db, user_id, edition_id) + logic.add_coach(db, user_id, edition_name) -@users_router.delete("/{user_id}/editions/{edition_id}", status_code=204, dependencies=[Depends(require_admin)]) -async def remove_from_edition(user_id: int, edition_id: int, db: Session = Depends(get_session)): +@users_router.delete("/{user_id}/editions/{edition_name}", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)]) +async def remove_from_edition(user_id: int, edition_name: str, db: Session = Depends(get_session)): """ Remove user as coach of the given edition """ + logic.remove_coach(db, user_id, edition_name) - logic.remove_coach(db, user_id, edition_id) + +@users_router.delete("/{user_id}/editions", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)]) +async def remove_from_all_editions(user_id: int, db: Session = Depends(get_session)): + """ + Remove user as coach from all editions + """ + logic.remove_coach_all_editions(db, user_id) @users_router.get("/requests", response_model=UserRequestsResponse, dependencies=[Depends(require_admin)]) -async def get_requests(edition: int | None = Query(None), db: Session = Depends(get_session)): +async def get_requests( + edition: str | None = Query(None), + user: str | None = Query(None), + page: int = 0, + db: Session = Depends(get_session)): """ Get pending userrequests """ + return logic.get_request_list(db, edition, user, page) - return logic.get_request_list(db, edition) - -@users_router.post("/requests/{request_id}/accept", status_code=204, dependencies=[Depends(require_admin)]) +@users_router.post("/requests/{request_id}/accept", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)]) async def accept_request(request_id: int, db: Session = Depends(get_session)): """ Accept a coach request """ - logic.accept_request(db, request_id) -@users_router.post("/requests/{request_id}/reject", status_code=204, dependencies=[Depends(require_admin)]) +@users_router.post("/requests/{request_id}/reject", status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_admin)]) async def reject_request(request_id: int, db: Session = Depends(get_session)): """ Reject a coach request """ - logic.reject_request(db, request_id) diff --git a/backend/src/app/schemas/editions.py b/backend/src/app/schemas/editions.py index e5a5dae19..3b3618d0e 100644 --- a/backend/src/app/schemas/editions.py +++ b/backend/src/app/schemas/editions.py @@ -1,19 +1,31 @@ -from src.app.schemas.webhooks import CamelCaseModel +from pydantic import validator + +from src.app.schemas.utils import CamelCaseModel +from src.app.schemas.validators import validate_edition class EditionBase(CamelCaseModel): """Schema of an edition""" + name: str year: int + @validator("name") + @classmethod + def valid_format(cls, value): + """Check that the edition name is of a valid format""" + validate_edition(value) + return value + class Edition(CamelCaseModel): """Schema of a created edition""" edition_id: int + name: str year: int class Config: + """Set to ORM mode""" orm_mode = True - allow_population_by_field_name = True class EditionList(CamelCaseModel): @@ -21,5 +33,5 @@ class EditionList(CamelCaseModel): editions: list[Edition] class Config: + """Set to ORM mode""" orm_mode = True - allow_population_by_field_name = True diff --git a/backend/src/app/schemas/invites.py b/backend/src/app/schemas/invites.py index 9f616bfe4..e0ff58f58 100644 --- a/backend/src/app/schemas/invites.py +++ b/backend/src/app/schemas/invites.py @@ -2,8 +2,8 @@ from pydantic import Field, validator +from src.app.schemas.utils import CamelCaseModel from src.app.schemas.validators import validate_email_format -from src.app.schemas.webhooks import CamelCaseModel class EmailAddress(CamelCaseModel): @@ -13,10 +13,11 @@ class EmailAddress(CamelCaseModel): email: str @validator("email") - def valid_format(cls, v): + @classmethod + def valid_format(cls, validate): """Check that the email is of a valid format""" - validate_email_format(v) - return v + validate_email_format(validate) + return validate class InviteLink(CamelCaseModel): @@ -26,13 +27,13 @@ class InviteLink(CamelCaseModel): invite_link_id: int = Field(alias="id") uuid: UUID target_email: str = Field(alias="email") - edition_id: int class Config: + """Set to ORM mode""" orm_mode = True -class InvitesListResponse(CamelCaseModel): +class InvitesLinkList(CamelCaseModel): """A list of invite link models Sending a pure list as JSON is bad practice, lists should be wrapped in a dict with 1 key that leads to them instead. This class handles that. @@ -40,6 +41,9 @@ class InvitesListResponse(CamelCaseModel): invite_links: list[InviteLink] -class MailtoLink(CamelCaseModel): - """A response containing a mailto link to invite a user with""" +class NewInviteLink(CamelCaseModel): + """A response containing a mailto link to invite a user with + Also contains the regular link in case the user wants to invite manually + """ mail_to: str + invite_link: str diff --git a/backend/src/app/schemas/login.py b/backend/src/app/schemas/login.py index 1c056a30d..b7e735e73 100644 --- a/backend/src/app/schemas/login.py +++ b/backend/src/app/schemas/login.py @@ -1,18 +1,17 @@ -from src.app.schemas.webhooks import CamelCaseModel +from src.app.schemas.users import User +from src.app.schemas.utils import BaseModel -class Token(CamelCaseModel): - """Token generated after login""" - access_token: str - token_type: str - - class Config: - allow_population_by_field_name = True +class UserData(User): + """User information that can be passed to frontend""" + editions: list[str] = [] -class User(CamelCaseModel): - """The fields used to find a user in the DB""" - user_id: int - - class Config: - allow_population_by_field_name = True +class Token(BaseModel): + """Token generated after login + Also contains data about the User to set permissions in frontend + """ + access_token: str + refresh_token: str + token_type: str + user: UserData diff --git a/backend/src/app/schemas/projects.py b/backend/src/app/schemas/projects.py new file mode 100644 index 000000000..93664116d --- /dev/null +++ b/backend/src/app/schemas/projects.py @@ -0,0 +1,128 @@ +from dataclasses import dataclass +from pydantic import BaseModel + +from src.app.schemas.utils import CamelCaseModel + + +class User(CamelCaseModel): + """Represents a User from the database""" + user_id: int + name: str + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class Skill(CamelCaseModel): + """Represents a Skill from the database""" + skill_id: int + name: str + description: str + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class Partner(CamelCaseModel): + """Represents a Partner from the database""" + partner_id: int + name: str + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class ProjectRole(CamelCaseModel): + """Represents a ProjectRole from the database""" + student_id: int + project_id: int + skill_id: int + definitive: bool + argumentation: str | None + drafter_id: int + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class Project(CamelCaseModel): + """Represents a Project from the database to return when a GET request happens""" + project_id: int + name: str + number_of_students: int + + coaches: list[User] + skills: list[Skill] + partners: list[Partner] + project_roles: list[ProjectRole] + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class Student(CamelCaseModel): + """Represents a Student to use in ConflictStudent""" + student_id: int + first_name: str + last_name: str + + class Config: + """Config Class""" + orm_mode = True + + +class ConflictProject(CamelCaseModel): + """A project to be used in ConflictStudent""" + project_id: int + name: str + + class Config: + """Config Class""" + orm_mode = True + + +class ProjectList(CamelCaseModel): + """A list of projects""" + projects: list[Project] + + +class ConflictStudent(CamelCaseModel): + """A student together with the projects they are causing a conflict for""" + student: Student + projects: list[ConflictProject] + + class Config: + """Config Class""" + orm_mode = True + + +class ConflictStudentList(CamelCaseModel): + """A list of ConflictStudents""" + conflict_students: list[ConflictStudent] + + +class InputProject(BaseModel): + """Used for passing the details of a project when creating/patching a project""" + name: str + number_of_students: int + skills: list[int] + partners: list[str] + coaches: list[int] + + +class InputStudentRole(BaseModel): + """Used for creating/patching a student role""" + skill_id: int + + +@dataclass +class QueryParamsProjects: + """search query parameters for projects""" + name: str = "" + coach: bool = False + page: int = 0 diff --git a/backend/src/app/schemas/register.py b/backend/src/app/schemas/register.py index a262d478a..e41e3d111 100644 --- a/backend/src/app/schemas/register.py +++ b/backend/src/app/schemas/register.py @@ -1,9 +1,13 @@ from uuid import UUID + from src.app.schemas.invites import EmailAddress class NewUser(EmailAddress): - """The scheme of a new user""" + """ + The scheme of a new user + The email address will be stored in AuthEmail, but is included here to easily create a user + """ name: str pw: str uuid: UUID diff --git a/backend/src/app/schemas/skills.py b/backend/src/app/schemas/skills.py index 69dd0d207..9b56bfdce 100644 --- a/backend/src/app/schemas/skills.py +++ b/backend/src/app/schemas/skills.py @@ -1,4 +1,4 @@ -from src.app.schemas.webhooks import CamelCaseModel +from src.app.schemas.utils import CamelCaseModel class SkillBase(CamelCaseModel): @@ -14,8 +14,8 @@ class Skill(CamelCaseModel): description: str | None = None class Config: + """Set to ORM mode""" orm_mode = True - allow_population_by_field_name = True class SkillList(CamelCaseModel): @@ -23,5 +23,5 @@ class SkillList(CamelCaseModel): skills: list[Skill] class Config: + """Set to ORM mode""" orm_mode = True - allow_population_by_field_name = True diff --git a/backend/src/app/schemas/students.py b/backend/src/app/schemas/students.py new file mode 100644 index 000000000..0e7467c56 --- /dev/null +++ b/backend/src/app/schemas/students.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from datetime import datetime +from fastapi import Query +from pydantic import Field + +from src.app.schemas.webhooks import CamelCaseModel +from src.database.enums import DecisionEnum, EmailStatusEnum +from src.app.schemas.skills import Skill + + +class NewDecision(CamelCaseModel): + """the fields of a decision""" + decision: DecisionEnum + + +class Suggestions(CamelCaseModel): + """ + Model to represent to number of suggestions organised by type + """ + yes: int + maybe: int + no: int + + +class Student(CamelCaseModel): + """ + Model to represent a Student + Sent as a response to API /GET requests + """ + student_id: int + first_name: str + last_name: str + preferred_name: str + email_address: str + phone_number: str + alumni: bool + decision: DecisionEnum = Field( + DecisionEnum.UNDECIDED, alias="finalDecision") + wants_to_be_student_coach: bool + edition_id: int + + skills: list[Skill] + nr_of_suggestions: Suggestions | None = None + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class ReturnStudent(CamelCaseModel): + """ + Model to return a student + """ + student: Student + + +class ReturnStudentList(CamelCaseModel): + """ + Model to return a list of students + """ + students: list[Student] + + +@dataclass +class CommonQueryParams: + """search query paramaters""" + name: str = "" + alumni: bool = False + student_coach: bool = False + skill_ids: list[int] = Query([]) + page: int = 0 + + +@dataclass +class EmailsSearchQueryParams: + """search query paramaters for email""" + name: str = "" + email_status: list[EmailStatusEnum] = Query([]) + page: int = 0 + + +class DecisionEmail(CamelCaseModel): + """ + Model to represent DecisionEmail + """ + email_id: int + decision: EmailStatusEnum + date: datetime + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class ReturnStudentMailList(CamelCaseModel): + """ + Model to return a list of mails of a student + """ + student: Student + emails: list[DecisionEmail] + + +class ListReturnStudentMailList(CamelCaseModel): + """Model to give a list of ReturnStudentMailList""" + student_emails: list[ReturnStudentMailList] + + +class NewEmail(CamelCaseModel): + """The fields of a DecisionEmail""" + students_id: list[int] + email_status: EmailStatusEnum diff --git a/backend/src/app/schemas/suggestion.py b/backend/src/app/schemas/suggestion.py new file mode 100644 index 000000000..db5b30eec --- /dev/null +++ b/backend/src/app/schemas/suggestion.py @@ -0,0 +1,49 @@ +from src.app.schemas.webhooks import CamelCaseModel +from src.database.enums import DecisionEnum +from src.app.schemas.users import User, user_model_to_schema +from src.database.models import Suggestion as Suggestion_model + + +class NewSuggestion(CamelCaseModel): + """The fields of a suggestion""" + suggestion: DecisionEnum + argumentation: str + + +class Suggestion(CamelCaseModel): + """ + Model to represent a Suggestion + Sent as a response to API /GET requests + """ + + suggestion_id: int + coach: User + suggestion: DecisionEnum + argumentation: str + + class Config: + """Set to ORM mode""" + orm_mode = True + + +class SuggestionListResponse(CamelCaseModel): + """ + A list of suggestions models + """ + suggestions: list[Suggestion] + + +class SuggestionResponse(CamelCaseModel): + """ + the suggestion that is created + """ + suggestion: Suggestion + + +def suggestion_model_to_schema(suggestion_model: Suggestion_model) -> Suggestion: + """Create Suggestion Schema from Suggestion Model""" + coach: User = user_model_to_schema(suggestion_model.coach) + return Suggestion(suggestion_id=suggestion_model.suggestion_id, + coach=coach, + suggestion=suggestion_model.suggestion, + argumentation=suggestion_model.argumentation) diff --git a/backend/src/app/schemas/users.py b/backend/src/app/schemas/users.py index 07b1aef21..27f8cabb1 100644 --- a/backend/src/app/schemas/users.py +++ b/backend/src/app/schemas/users.py @@ -1,18 +1,42 @@ +from src.app.schemas.editions import Edition +from src.app.schemas.utils import CamelCaseModel, BaseModel +from src.database.models import User as ModelUser -from src.app.schemas.webhooks import CamelCaseModel + +class Authentication(CamelCaseModel): + """Model for an authentication method""" + auth_type: str + email: str class User(CamelCaseModel): """Model for a user""" - user_id: int name: str - email: str admin: bool + auth: Authentication | None class Config: + """Set to ORM mode""" orm_mode = True - allow_population_by_field_name = True + + +def user_model_to_schema(model_user: ModelUser) -> User: + """Create User Schema from User Model""" + auth: Authentication | None = None + if model_user.email_auth is not None: + auth = Authentication(auth_type="email", email=model_user.email_auth.email) + elif model_user.github_auth is not None: + auth = Authentication(auth_type="github", email=model_user.github_auth.email) + elif model_user.google_auth is not None: + auth = Authentication(auth_type="google", email=model_user.google_auth.email) + + return User( + user_id=model_user.user_id, + name=model_user.name, + admin=model_user.admin, + auth=auth + ) class UsersListResponse(CamelCaseModel): @@ -31,15 +55,24 @@ class UserRequest(CamelCaseModel): """Model for a userrequest""" request_id: int - edition_id: int + edition: Edition user: User class Config: + """Set to ORM mode""" orm_mode = True - allow_population_by_field_name = True class UserRequestsResponse(CamelCaseModel): """Model for a list of userrequests""" requests: list[UserRequest] + + +class FilterParameters(BaseModel): + """Schema for query parameters""" + edition: str | None + exclude_edition: str | None + name: str | None + admin: bool | None + page: int = 0 diff --git a/backend/src/app/schemas/utils.py b/backend/src/app/schemas/utils.py new file mode 100644 index 000000000..9769656b7 --- /dev/null +++ b/backend/src/app/schemas/utils.py @@ -0,0 +1,16 @@ +from humps import camelize +from pydantic import BaseModel + + +def to_camel(string: str) -> str: + """Return the camel case version of a given string""" + return camelize(string) + + +class CamelCaseModel(BaseModel): + """Base model that converts snake to camel case when serialized""" + + class Config: + """Config""" + alias_generator = to_camel + allow_population_by_field_name = True diff --git a/backend/src/app/schemas/validators.py b/backend/src/app/schemas/validators.py index 30b394a44..0884418cb 100644 --- a/backend/src/app/schemas/validators.py +++ b/backend/src/app/schemas/validators.py @@ -12,3 +12,12 @@ def validate_email_format(email_address: str): """ if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email_address): raise ValidationException("Malformed email address.") + + +def validate_edition(edition: str): + """ + An edition should not contain any spaces in order for us to use it in + the path of various resources, this function checks that. + """ + if not re.fullmatch(r"\w+", edition): + raise ValidationException("Spaces detected in the edition name") diff --git a/backend/src/app/schemas/webhooks.py b/backend/src/app/schemas/webhooks.py index 2763da41d..471782635 100644 --- a/backend/src/app/schemas/webhooks.py +++ b/backend/src/app/schemas/webhooks.py @@ -1,21 +1,9 @@ from typing import Optional from uuid import UUID -from humps import camelize from pydantic import BaseModel - -def to_camel(string: str) -> str: - """Return the camel case version of a given string""" - return camelize(string) - - -class CamelCaseModel(BaseModel): - """Base model that converts snake to camel case when serialized""" - class Config: - """Config""" - alias_generator = to_camel - allow_population_by_field_name = True +from src.app.schemas.utils import CamelCaseModel class QuestionOption(CamelCaseModel): @@ -45,7 +33,6 @@ class Form(CamelCaseModel): """The form data containing all the questions""" response_id: str submission_id: str - response_id: str form_id: str form_name: str created_at: str diff --git a/backend/src/app/utils/dependencies.py b/backend/src/app/utils/dependencies.py index 8d7800f2b..1e6043417 100644 --- a/backend/src/app/utils/dependencies.py +++ b/backend/src/app/utils/dependencies.py @@ -5,34 +5,60 @@ from sqlalchemy.orm import Session import settings -from src.app.exceptions.authentication import ExpiredCredentialsException, InvalidCredentialsException, MissingPermissionsException -from src.app.logic.security import ALGORITHM, get_user_by_id -from src.database.crud.editions import get_edition_by_id +import src.database.crud.projects as crud_projects +from src.app.exceptions.authentication import ( + ExpiredCredentialsException, InvalidCredentialsException, + MissingPermissionsException, WrongTokenTypeException) +from src.app.exceptions.editions import ReadOnlyEditionException +from src.app.logic.security import ALGORITHM, TokenType +from src.database.crud.editions import get_edition_by_name, latest_edition from src.database.crud.invites import get_invite_link_by_uuid +from src.database.crud.students import get_student_by_id +from src.database.crud.suggestions import get_suggestion_by_id +from src.database.crud.users import get_user_by_id from src.database.database import get_session -from src.database.models import Edition, InviteLink, User +from src.database.models import Edition, InviteLink, Student, Suggestion, User, Project -# TODO: Might be nice to use a more descriptive year number here than primary id. -def get_edition(edition_id: int, database: Session = Depends(get_session)) -> Edition: - """Get an edition from the database, given the id in the path""" - return get_edition_by_id(database, edition_id) +def get_edition(edition_name: str, database: Session = Depends(get_session)) -> Edition: + """Get an edition from the database, given the name in the path""" + return get_edition_by_name(database, edition_name) -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login/token") +def get_student(student_id: int, database: Session = Depends(get_session)) -> Student: + """Get the student from the database, given the id in the path""" + return get_student_by_id(database, student_id) -async def get_current_active_user(db: Session = Depends(get_session), token: str = Depends(oauth2_scheme)) -> User: - """Check which user is making a request by decoding its token - This function is used as a dependency for other functions - """ +def get_suggestion(suggestion_id: int, database: Session = Depends(get_session)) -> Suggestion: + """Get the suggestion from the database, given the id in the path""" + return get_suggestion_by_id(database, suggestion_id) + + +def get_latest_edition(edition: Edition = Depends(get_edition), database: Session = Depends(get_session)) -> Edition: + """Checks if the given edition is the latest one (others are read-only) and returns it if it is""" + latest = latest_edition(database) + if edition != latest: + raise ReadOnlyEditionException + return latest + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/login/token") + + +async def _get_user_from_token(token_type: TokenType, db: Session, token: str) -> User: + """Check which user is making a request by decoding its token, and verifying the token type""" try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) user_id: int | None = payload.get("sub") + type_in_token: int | None = payload.get("type") - if user_id is None: + if user_id is None or type_in_token is None: raise InvalidCredentialsException() + if type_in_token != token_type.value: + raise WrongTokenTypeException() + try: user = get_user_by_id(db, int(user_id)) except sqlalchemy.exc.NoResultFound as not_found: @@ -45,7 +71,21 @@ async def get_current_active_user(db: Session = Depends(get_session), token: str raise InvalidCredentialsException() from jwt_err -async def require_auth(user: User = Depends(get_current_active_user)) -> User: +async def get_user_from_access_token(db: Session = Depends(get_session), token: str = Depends(oauth2_scheme)) -> User: + """Check which user is making a request by decoding its access token + This function is used as a dependency for other functions + """ + return await _get_user_from_token(TokenType.ACCESS, db, token) + + +async def get_user_from_refresh_token(db: Session = Depends(get_session), token: str = Depends(oauth2_scheme)) -> User: + """Check which user is making a request by decoding its refresh token + This function is used as a dependency for other functions + """ + return await _get_user_from_token(TokenType.REFRESH, db, token) + + +async def require_auth(user: User = Depends(get_user_from_access_token)) -> User: """Dependency to check if a user is at least a coach This dependency should be used to check for resources that aren't linked to editions @@ -64,7 +104,7 @@ async def require_auth(user: User = Depends(get_current_active_user)) -> User: return user -async def require_admin(user: User = Depends(get_current_active_user)) -> User: +async def require_admin(user: User = Depends(get_user_from_access_token)) -> User: """Dependency to create an admin-only route""" if not user.admin: raise MissingPermissionsException() @@ -72,7 +112,8 @@ async def require_admin(user: User = Depends(get_current_active_user)) -> User: return user -async def require_coach(edition: Edition = Depends(get_edition), user: User = Depends(get_current_active_user)) -> User: +async def require_coach(edition: Edition = Depends(get_edition), + user: User = Depends(get_user_from_access_token)) -> User: """Dependency to check if a user can see a given resource This comes down to checking if a coach is linked to an edition or not """ @@ -90,3 +131,8 @@ async def require_coach(edition: Edition = Depends(get_edition), user: User = De def get_invite_link(invite_uuid: str, db: Session = Depends(get_session)) -> InviteLink: """Get an invite link from the database, given the id in the path""" return get_invite_link_by_uuid(db, invite_uuid) + + +def get_project(project_id: int, db: Session = Depends(get_session)) -> Project: + """Get a project from het database, given the id in the path""" + return crud_projects.get_project(db, project_id) diff --git a/backend/src/database/crud/editions.py b/backend/src/database/crud/editions.py index 92f01aecf..1a29dcbec 100644 --- a/backend/src/database/crud/editions.py +++ b/backend/src/database/crud/editions.py @@ -1,35 +1,36 @@ -from sqlalchemy.orm import Session -from sqlalchemy import exc +from sqlalchemy import exc, func +from sqlalchemy.orm import Query, Session from src.app.exceptions.editions import DuplicateInsertException -from src.database.models import Edition from src.app.schemas.editions import EditionBase - from src.database.models import Edition +from .util import paginate -def get_edition_by_id(db: Session, edition_id: int) -> Edition: - """Get an edition given its primary key +def get_edition_by_name(db: Session, edition_name: str) -> Edition: + """Get an edition given its name Args: db (Session): connection with the database. - edition_id (int): the primary key of the edition you want to find + edition_name (str): the name of the edition you want to find Returns: Edition: an edition if found else an exception is raised """ - return db.query(Edition).where(Edition.edition_id == edition_id).one() + return db.query(Edition).where(Edition.name == edition_name).one() + + +def _get_editions_query(db: Session) -> Query: + return db.query(Edition) def get_editions(db: Session) -> list[Edition]: - """Get a list of all editions. + """Returns a list of all editions""" + return _get_editions_query(db).all() - Args: - db (Session): connection with the database. - Returns: - EditionList: an object with a list of all editions - """ - return db.query(Edition).all() +def get_editions_page(db: Session, page: int) -> list[Edition]: + """Returns a paginated list of all editions""" + return paginate(_get_editions_query(db), page).all() def create_edition(db: Session, edition: EditionBase) -> Edition: @@ -42,23 +43,29 @@ def create_edition(db: Session, edition: EditionBase) -> Edition: Returns: Edition: the newly made edition object. """ - new_edition: Edition = Edition(year=edition.year) + new_edition: Edition = Edition(year=edition.year, name=edition.name) db.add(new_edition) try: db.commit() db.refresh(new_edition) return new_edition except exc.SQLAlchemyError as exception: - raise DuplicateInsertException(exception) + raise DuplicateInsertException(exception) from exception -def delete_edition(db: Session, edition_id: int): +def delete_edition(db: Session, edition_name: str): """Delete an edition. Args: db (Session): connection with the database. - edition_id (int): the primary key of the edition that needs to be deleted + edition_name (str): the primary key of the edition that needs to be deleted """ - edition_to_delete = get_edition_by_id(db, edition_id) + edition_to_delete = get_edition_by_name(db, edition_name) db.delete(edition_to_delete) db.commit() + + +def latest_edition(db: Session) -> Edition: + """Returns the latest edition from the database""" + max_edition_id = db.query(func.max(Edition.edition_id)).scalar() + return db.query(Edition).where(Edition.edition_id == max_edition_id).one() diff --git a/backend/src/database/crud/invites.py b/backend/src/database/crud/invites.py index b8b82c0fe..9a794832b 100644 --- a/backend/src/database/crud/invites.py +++ b/backend/src/database/crud/invites.py @@ -1,8 +1,9 @@ from uuid import UUID -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, Query from src.app.exceptions.parsing import MalformedUUIDError +from src.database.crud.util import paginate from src.database.models import Edition, InviteLink @@ -14,15 +15,36 @@ def create_invite_link(db: Session, edition: Edition, email_address: str) -> Inv return link -def delete_invite_link(db: Session, invite_link: InviteLink): +def delete_invite_link(db: Session, invite_link: InviteLink, commit: bool = True): """Delete an invite link from the database""" db.delete(invite_link) - db.commit() + + if commit: + db.commit() + + +def _get_pending_invites_for_edition_query(db: Session, edition: Edition) -> Query: + """Return the query for all InviteLinks linked to a given edition""" + return db.query(InviteLink).where(InviteLink.edition == edition).order_by(InviteLink.invite_link_id) + + +def get_pending_invites_for_edition(db: Session, edition: Edition) -> list[InviteLink]: + """Returns a list with all InviteLinks linked to a given edition""" + return _get_pending_invites_for_edition_query(db, edition).all() + + +def get_pending_invites_for_edition_page(db: Session, edition: Edition, page: int) -> list[InviteLink]: + """Returns a paginated list with all InviteLinks linked to a given edition""" + return paginate(_get_pending_invites_for_edition_query(db, edition), page).all() -def get_all_pending_invites(db: Session, edition: Edition) -> list[InviteLink]: - """Return a list of all invite links in a given edition""" - return db.query(InviteLink).where(InviteLink.edition == edition).all() +def get_optional_invite_link_by_edition_and_email(db: Session, edition: Edition, email: str) -> InviteLink | None: + """Return an optional invite link by edition and target_email""" + return db\ + .query(InviteLink)\ + .where(InviteLink.edition == edition)\ + .where(InviteLink.target_email == email)\ + .one_or_none() def get_invite_link_by_uuid(db: Session, invite_uuid: str | UUID) -> InviteLink: diff --git a/backend/src/database/crud/projects.py b/backend/src/database/crud/projects.py new file mode 100644 index 000000000..68ac90f66 --- /dev/null +++ b/backend/src/database/crud/projects.py @@ -0,0 +1,122 @@ +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session, Query + +from src.app.schemas.projects import InputProject, QueryParamsProjects +from src.database.crud.util import paginate +from src.database.models import Project, Edition, Student, ProjectRole, Skill, User, Partner + + +def _get_projects_for_edition_query(db: Session, edition: Edition) -> Query: + return db.query(Project).where(Project.edition == edition).order_by(Project.project_id) + + +def get_projects_for_edition(db: Session, edition: Edition) -> list[Project]: + """Returns a list of all projects from a certain edition from the database""" + return _get_projects_for_edition_query(db, edition).all() + + +def get_projects_for_edition_page(db: Session, edition: Edition, + search_params: QueryParamsProjects, user: User) -> list[Project]: + """Returns a paginated list of all projects from a certain edition from the database""" + query = _get_projects_for_edition_query(db, edition).where( + Project.name.contains(search_params.name)) + if search_params.coach: + query = query.where(Project.project_id.in_([user_project.project_id for user_project in user.projects])) + projects: list[Project] = paginate(query, search_params.page).all() + + return projects + + +def add_project(db: Session, edition: Edition, input_project: InputProject) -> Project: + """ + Add a project to the database + If there are partner names that are not already in the database, add them + """ + skills_obj = [db.query(Skill).where(Skill.skill_id == skill).one() + for skill in input_project.skills] + coaches_obj = [db.query(User).where(User.user_id == coach).one() + for coach in input_project.coaches] + partners_obj = [] + for partner in input_project.partners: + try: + partners_obj.append(db.query(Partner).where( + Partner.name == partner).one()) + except NoResultFound: + partner_obj = Partner(name=partner) + db.add(partner_obj) + partners_obj.append(partner_obj) + project = Project(name=input_project.name, number_of_students=input_project.number_of_students, + edition_id=edition.edition_id, skills=skills_obj, coaches=coaches_obj, partners=partners_obj) + + db.add(project) + db.commit() + return project + + +def get_project(db: Session, project_id: int) -> Project: + """Query a specific project from the database through its ID""" + return db.query(Project).where(Project.project_id == project_id).one() + + +def delete_project(db: Session, project_id: int): + """Delete a specific project from the database""" + proj_roles = db.query(ProjectRole).where( + ProjectRole.project_id == project_id).all() + for proj_role in proj_roles: + db.delete(proj_role) + + project = get_project(db, project_id) + db.delete(project) + db.commit() + + +def patch_project(db: Session, project_id: int, input_project: InputProject): + """ + Change some fields of a Project in the database + If there are partner names that are not already in the database, add them + """ + project = db.query(Project).where(Project.project_id == project_id).one() + + skills_obj = [db.query(Skill).where(Skill.skill_id == skill).one() + for skill in input_project.skills] + coaches_obj = [db.query(User).where(User.user_id == coach).one() + for coach in input_project.coaches] + partners_obj = [] + for partner in input_project.partners: + try: + partners_obj.append(db.query(Partner).where( + Partner.name == partner).one()) + except NoResultFound: + partner_obj = Partner(name=partner) + db.add(partner_obj) + partners_obj.append(partner_obj) + + project.name = input_project.name + project.number_of_students = input_project.number_of_students + project.skills = skills_obj + project.coaches = coaches_obj + project.partners = partners_obj + db.commit() + + +def get_conflict_students(db: Session, edition: Edition) -> list[tuple[Student, list[Project]]]: + """ + Query all students that are causing conflicts for a certain edition + Return a ConflictStudent for each student that causes a conflict + This class contains a student together with all projects they are causing a conflict for + """ + students = db.query(Student).where(Student.edition == edition).all() + conflict_students = [] + projs = [] + for student in students: + if len(student.project_roles) > 1: + proj_ids = db.query(ProjectRole.project_id).where( + ProjectRole.student_id == student.student_id).all() + for proj_id in proj_ids: + proj_id = proj_id[0] + proj = db.query(Project).where( + Project.project_id == proj_id).one() + projs.append(proj) + conflict_student = (student, projs) + conflict_students.append(conflict_student) + return conflict_students diff --git a/backend/src/database/crud/projects_students.py b/backend/src/database/crud/projects_students.py new file mode 100644 index 000000000..5a3ea28b3 --- /dev/null +++ b/backend/src/database/crud/projects_students.py @@ -0,0 +1,49 @@ +from sqlalchemy.orm import Session + +from src.database.models import Project, ProjectRole, Skill, User, Student + + +def remove_student_project(db: Session, project: Project, student_id: int): + """Remove a student from a project in the database""" + proj_role = db.query(ProjectRole).where( + ProjectRole.student_id == student_id).where(ProjectRole.project == project).one() + db.delete(proj_role) + db.commit() + + +def add_student_project(db: Session, project: Project, student_id: int, skill_id: int, drafter_id: int): + """Add a student to a project in the database""" + + # check if all parameters exist in the database + db.query(Skill).where(Skill.skill_id == skill_id).one() + db.query(User).where(User.user_id == drafter_id).one() + db.query(Student).where(Student.student_id == student_id).one() + + proj_role = ProjectRole(student_id=student_id, project_id=project.project_id, skill_id=skill_id, + drafter_id=drafter_id) + db.add(proj_role) + db.commit() + + +def change_project_role(db: Session, project: Project, student_id: int, skill_id: int, drafter_id: int): + """Change the role of a student in a project and update the drafter""" + + # check if all parameters exist in the database + db.query(Skill).where(Skill.skill_id == skill_id).one() + db.query(User).where(User.user_id == drafter_id).one() + db.query(Student).where(Student.student_id == student_id).one() + + proj_role = db.query(ProjectRole).where( + ProjectRole.student_id == student_id).where(ProjectRole.project == project).one() + proj_role.drafter_id = drafter_id + proj_role.skill_id = skill_id + db.commit() + + +def confirm_project_role(db: Session, project: Project, student_id: int): + """Confirm a project role""" + proj_role = db.query(ProjectRole).where(ProjectRole.student_id == student_id) \ + .where(ProjectRole.project == project).one() + + proj_role.definitive = True + db.commit() diff --git a/backend/src/database/crud/register.py b/backend/src/database/crud/register.py index 60fda3ea7..427a2264a 100644 --- a/backend/src/database/crud/register.py +++ b/backend/src/database/crud/register.py @@ -3,23 +3,34 @@ from src.database.models import AuthEmail, CoachRequest, User, Edition -def create_user(db: Session, name: str, email: str) -> User: +def create_user(db: Session, name: str, commit: bool = True) -> User: """Create a user""" - new_user: User = User(name=name, email=email) + new_user: User = User(name=name) db.add(new_user) - db.commit() + + if commit: + db.commit() + return new_user -def create_coach_request(db: Session, user: User, edition: Edition) -> CoachRequest: + +def create_coach_request(db: Session, user: User, edition: Edition, commit: bool = True) -> CoachRequest: """Create a coach request""" coach_request: CoachRequest = CoachRequest(user=user, edition=edition) db.add(coach_request) - db.commit() + + if commit: + db.commit() + return coach_request -def create_auth_email(db: Session, user: User, pw_hash: str) -> AuthEmail: + +def create_auth_email(db: Session, user: User, pw_hash: str, email: str, commit: bool = True) -> AuthEmail: """Create a authentication for email""" - auth_email : AuthEmail = AuthEmail(user=user, pw_hash = pw_hash) + auth_email: AuthEmail = AuthEmail(user=user, pw_hash=pw_hash, email=email) db.add(auth_email) - db.commit() + + if commit: + db.commit() + return auth_email diff --git a/backend/src/database/crud/skills.py b/backend/src/database/crud/skills.py index f1592f4e2..4d9e72289 100644 --- a/backend/src/database/crud/skills.py +++ b/backend/src/database/crud/skills.py @@ -1,6 +1,7 @@ from sqlalchemy.orm import Session -from src.database.models import Skill + from src.app.schemas.skills import SkillBase +from src.database.models import Skill def get_skills(db: Session) -> list[Skill]: @@ -15,6 +16,11 @@ def get_skills(db: Session) -> list[Skill]: return db.query(Skill).all() +def get_skills_by_ids(db: Session, skill_ids) -> list[Skill]: + """Get all skills from list of skill ids""" + return db.query(Skill).where(Skill.skill_id.in_(skill_ids)).all() + + def create_skill(db: Session, skill: SkillBase) -> Skill: """Add a new skill into the database. diff --git a/backend/src/database/crud/students.py b/backend/src/database/crud/students.py new file mode 100644 index 000000000..17d61aa43 --- /dev/null +++ b/backend/src/database/crud/students.py @@ -0,0 +1,81 @@ +from datetime import datetime +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import func +from src.database.crud.util import paginate +from src.database.enums import DecisionEnum, EmailStatusEnum +from src.database.models import Edition, Skill, Student, DecisionEmail +from src.app.schemas.students import CommonQueryParams, EmailsSearchQueryParams + + +def get_student_by_id(db: Session, student_id: int) -> Student: + """Get a student by id""" + + return db.query(Student).where(Student.student_id == student_id).one() + + +def set_definitive_decision_on_student(db: Session, student: Student, decision: DecisionEnum) -> None: + """set a definitive decision on a student""" + + student.decision = decision + db.commit() + + +def delete_student(db: Session, student: Student) -> None: + """Delete a student from the database""" + db.delete(student) + db.commit() + + +def get_students(db: Session, edition: Edition, + commons: CommonQueryParams, skills: list[Skill] = None) -> list[Student]: + """Get students""" + query = db.query(Student)\ + .where(Student.edition == edition)\ + .where((Student.first_name + ' ' + Student.last_name).contains(commons.name))\ + + if commons.alumni: + query = query.where(Student.alumni) + + if commons.student_coach: + query = query.where(Student.wants_to_be_student_coach) + + if skills is None: + skills = [] + + for skill in skills: + query = query.where(Student.skills.contains(skill)) + + return paginate(query, commons.page).all() + + +def get_emails(db: Session, student: Student) -> list[DecisionEmail]: + """Get all emails send to a student""" + return db.query(DecisionEmail).where(DecisionEmail.student_id == student.student_id).all() + + +def create_email(db: Session, student: Student, email_status: EmailStatusEnum) -> DecisionEmail: + """Create a new email in the database""" + email: DecisionEmail = DecisionEmail( + student=student, decision=email_status, date=datetime.now()) + db.add(email) + db.commit() + return email + + +def get_last_emails_of_students(db: Session, edition: Edition, commons: EmailsSearchQueryParams) -> list[DecisionEmail]: + """get last email of all students that got an email""" + last_emails = db.query(DecisionEmail.email_id, func.max(DecisionEmail.date))\ + .join(Student)\ + .where(Student.edition == edition)\ + .where((Student.first_name + ' ' + Student.last_name).contains(commons.name))\ + .group_by(DecisionEmail.student_id).subquery() + + emails = db.query(DecisionEmail).join( + last_emails, DecisionEmail.email_id == last_emails.c.email_id + ) + + if commons.email_status: + emails = emails.where(DecisionEmail.decision.in_(commons.email_status)) + + emails = emails.order_by(DecisionEmail.student_id) + return paginate(emails, commons.page).all() diff --git a/backend/src/database/crud/suggestions.py b/backend/src/database/crud/suggestions.py new file mode 100644 index 000000000..be3853b8d --- /dev/null +++ b/backend/src/database/crud/suggestions.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session + +from src.database.models import Suggestion +from src.database.enums import DecisionEnum + + +def create_suggestion(db: Session, user_id: int | None, student_id: int | None, + decision: DecisionEnum, argumentation: str) -> Suggestion: + """ + Create a new suggestion in the database + """ + suggestion: Suggestion = Suggestion( + student_id=student_id, coach_id=user_id, suggestion=decision, argumentation=argumentation) + db.add(suggestion) + db.commit() + return suggestion + + +def get_suggestions_of_student(db: Session, student_id: int | None) -> list[Suggestion]: + """Give all suggestions of a student""" + return db.query(Suggestion).where(Suggestion.student_id == student_id).all() + + +def get_own_suggestion(db: Session, student_id: int | None, user_id: int | None) -> Suggestion | None: + """Get the suggestion you made for a student""" + # This isn't even possible but it pleases Mypy + if student_id is None or user_id is None: + return None + + return db.query(Suggestion).where(Suggestion.student_id == student_id).where( + Suggestion.coach_id == user_id).one_or_none() + + +def get_suggestion_by_id(db: Session, suggestion_id: int) -> Suggestion: + """Give a suggestion based on the ID""" + return db.query(Suggestion).where(Suggestion.suggestion_id == suggestion_id).one() + + +def delete_suggestion(db: Session, suggestion: Suggestion) -> None: + """Delete a suggestion from the database""" + db.delete(suggestion) + db.commit() + + +def update_suggestion(db: Session, suggestion: Suggestion, decision: DecisionEnum, argumentation: str) -> None: + """Update a suggestion""" + suggestion.suggestion = decision + suggestion.argumentation = argumentation + db.commit() + + +def get_suggestions_of_student_by_type(db: Session, student_id: int | None, + type_suggestion: DecisionEnum) -> list[Suggestion]: + """Give all suggestions of a student by type""" + return db.query(Suggestion) \ + .where(Suggestion.student_id == student_id) \ + .where(Suggestion.suggestion == type_suggestion).all() diff --git a/backend/src/database/crud/users.py b/backend/src/database/crud/users.py index 20d0ddd08..d8091f1f2 100644 --- a/backend/src/database/crud/users.py +++ b/backend/src/database/crud/users.py @@ -1,107 +1,189 @@ -from sqlalchemy.orm import Session -from src.database.models import user_editions, User, Edition, CoachRequest +from sqlalchemy.orm import Session, Query +from src.app.schemas.users import FilterParameters +from src.database.crud.editions import get_edition_by_name +from src.database.crud.editions import get_editions +from src.database.crud.util import paginate +from src.database.models import user_editions, User, Edition, CoachRequest, AuthEmail, AuthGitHub, AuthGoogle -def get_all_admins(db: Session) -> list[User]: - """ - Get all admins - """ - return db.query(User).where(User.admin).all() +def get_user_edition_names(db: Session, user: User) -> list[str]: + """Get all names of the editions this user can see""" + # For admins: return all editions - otherwise, all editions this user is verified coach in + source = user.editions if not user.admin else get_editions(db) + + editions = [] + # Name & year are non-nullable in the database, so it can never be None, + # but MyPy doesn't seem to grasp that concept just yet so we have to check it + # Could be a oneliner/list comp but that's a bit less readable + # Return from newest to oldest + for edition in sorted(source, key=lambda e: e.year or -1, reverse=True): + if edition.name is not None: + editions.append(edition.name) + return editions -def get_all_users(db: Session) -> list[User]: + +def get_users_filtered_page(db: Session, params: FilterParameters): """ - Get all users (coaches + admins) + Get users and filter by optional parameters: + :param admin: only return admins / only return non-admins + :param edition_name: only return users who are coach of the given edition + :param exclude_edition_name: only return users who are not coach of the given edition + :param name: a string which the user's name must contain + :param page: the page to return + + Note: When the admin parameter is set, edition_name and exclude_edition_name will be ignored. """ - return db.query(User).all() + query = db.query(User) + if params.name is not None: + query = query.where(User.name.contains(params.name)) -def get_users_from_edition(db: Session, edition_id: int) -> list[User]: - """ - Get all coaches from the given edition - """ + if params.admin is not None: + query = query.filter(User.admin.is_(params.admin)) + # If admin parameter is set, edition & exclude_edition is ignored + return paginate(query, params.page).all() - return db.query(User).join(user_editions).filter(user_editions.c.edition_id == edition_id).all() + if params.edition is not None: + edition = get_edition_by_name(db, params.edition) + query = query \ + .join(user_editions) \ + .filter(user_editions.c.edition_id == edition.edition_id) -def get_admins_from_edition(db: Session, edition_id: int) -> list[User]: - """ - Get all admins from the given edition - """ + if params.exclude_edition is not None: + exclude_edition = get_edition_by_name(db, params.exclude_edition) + + query = query.filter( + User.user_id.not_in( + db.query(user_editions.c.user_id).where(user_editions.c.edition_id == exclude_edition.edition_id) + ) + ) - return db.query(User).where(User.admin).join(user_editions).filter(user_editions.c.edition_id == edition_id).all() + return paginate(query, params.page).all() def edit_admin_status(db: Session, user_id: int, admin: bool): """ Edit the admin-status of a user """ - user = db.query(User).where(User.user_id == user_id).one() user.admin = admin db.add(user) db.commit() -def add_coach(db: Session, user_id: int, edition_id: int): +def add_coach(db: Session, user_id: int, edition_name: str): """ Add user as coach for the given edition """ - user = db.query(User).where(User.user_id == user_id).one() - edition = db.query(Edition).where(Edition.edition_id == edition_id).one() + edition = db.query(Edition).where(Edition.name == edition_name).one() user.editions.append(edition) + db.commit() -def remove_coach(db, user_id, edition_id): +def remove_coach(db: Session, user_id: int, edition_name: str): """ Remove user as coach for the given edition """ - - db.execute(user_editions.delete(), {"user_id": user_id, "edition_id": edition_id}) + edition = db.query(Edition).where(Edition.name == edition_name).one() + db.query(user_editions) \ + .where(user_editions.c.user_id == user_id) \ + .where(user_editions.c.edition_id == edition.edition_id) \ + .delete() + db.commit() -def delete_user_as_coach(db: Session, edition_id: int, user_id: int): +def remove_coach_all_editions(db: Session, user_id: int): """ - Add user as admin for the given edition if not already coach + Remove user as coach from all editions """ + db.query(user_editions).where(user_editions.c.user_id == user_id).delete() + db.commit() + + +def _get_requests_query(db: Session, user_name: str = "") -> Query: + return db.query(CoachRequest).join(User).where(User.name.contains(user_name)) - user = db.query(User).where(User.user_id == user_id).one() - edition = db.query(Edition).where(Edition.edition_id == edition_id).one() - user.editions.remove(edition) +def get_requests(db: Session) -> list[CoachRequest]: + """ + Get all userrequests + """ + return _get_requests_query(db).all() -def get_all_requests(db: Session): + +def get_requests_page(db: Session, page: int, user_name: str = "") -> list[CoachRequest]: """ Get all userrequests """ + return paginate(_get_requests_query(db, user_name), page).all() + - return db.query(CoachRequest).join(User).all() +def _get_requests_for_edition_query(db: Session, edition: Edition, user_name: str = "") -> Query: + return db.query(CoachRequest) \ + .where(CoachRequest.edition_id == edition.edition_id) \ + .join(User) \ + .where(User.name.contains(user_name)) \ + .join(AuthEmail, isouter=True) \ + .join(AuthGitHub, isouter=True) \ + .join(AuthGoogle, isouter=True) -def get_all_requests_from_edition(db: Session, edition_id: int): +def get_requests_for_edition(db: Session, edition_name: str = "") -> list[CoachRequest]: """ Get all userrequests from a given edition """ + return _get_requests_for_edition_query(db, get_edition_by_name(db, edition_name)).all() - return db.query(CoachRequest).where(CoachRequest.edition_id == edition_id).join(User).all() + +def get_requests_for_edition_page( + db: Session, + edition_name: str, + page: int, + user_name: str = "" +) -> list[CoachRequest]: + """ + Get all userrequests from a given edition + """ + return paginate(_get_requests_for_edition_query(db, get_edition_by_name(db, edition_name), user_name), page).all() def accept_request(db: Session, request_id: int): """ Remove request and add user as coach """ - request = db.query(CoachRequest).where(CoachRequest.request_id == request_id).one() - add_coach(db, request.user_id, request.edition_id) + edition = db.query(Edition).where(Edition.edition_id == request.edition_id).one() + add_coach(db, request.user_id, edition.name) db.query(CoachRequest).where(CoachRequest.request_id == request_id).delete() + db.commit() def reject_request(db: Session, request_id: int): """ Remove request """ - db.query(CoachRequest).where(CoachRequest.request_id == request_id).delete() + db.commit() + + +def remove_request_if_exists(db: Session, user_id: int, edition_name: str): + """Remove a pending request for a user if there is one, otherwise do nothing""" + edition = db.query(Edition).where(Edition.name == edition_name).one() + db.query(CoachRequest).where(CoachRequest.user_id == user_id)\ + .where(CoachRequest.edition_id == edition.edition_id).delete() + + +def get_user_by_email(db: Session, email: str) -> User: + """Find a user by their email address""" + auth_email = db.query(AuthEmail).where(AuthEmail.email == email).one() + return db.query(User).where(User.user_id == auth_email.user_id).one() + + +def get_user_by_id(db: Session, user_id: int) -> User: + """Find a user by their id""" + return db.query(User).where(User.user_id == user_id).one() diff --git a/backend/src/database/crud/util.py b/backend/src/database/crud/util.py new file mode 100644 index 000000000..ca4a97020 --- /dev/null +++ b/backend/src/database/crud/util.py @@ -0,0 +1,8 @@ +from sqlalchemy.orm import Query + +import settings + + +def paginate(query: Query, page: int) -> Query: + """Given a query, apply pagination and return the given page based on the page size""" + return query.slice(page * settings.DB_PAGE_SIZE, (page + 1) * settings.DB_PAGE_SIZE) diff --git a/backend/src/database/engine.py b/backend/src/database/engine.py index 89aa9a4d1..624332c2b 100644 --- a/backend/src/database/engine.py +++ b/backend/src/database/engine.py @@ -25,6 +25,6 @@ host=settings.DB_HOST, port=settings.DB_PORT, database=settings.DB_NAME - )) + ), pool_pre_ping=True) DBSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/src/database/enums.py b/backend/src/database/enums.py index bcd6a4762..5af6831a3 100644 --- a/backend/src/database/enums.py +++ b/backend/src/database/enums.py @@ -14,6 +14,23 @@ class DecisionEnum(enum.Enum): NO = 3 +@enum.unique +class EmailStatusEnum(enum.IntEnum): + """Enum for the status attached to an email sent to a student""" + # Nothing happened (undecided/screening) + APPLIED = 0 + # We're looking for a project (maybe) + AWAITING_PROJECT = 1 + # Can participate (yes) + APPROVED = 2 + # Student signed the contract + CONTRACT_CONFIRMED = 3 + # Student indicated they don't want to participate anymore + CONTRACT_DECLINED = 4 + # We've rejected the student ourselves (no) + REJECTED = 5 + + @enum.unique class RoleEnum(enum.Enum): """Enum for the different roles a user can have""" diff --git a/backend/src/database/models.py b/backend/src/database/models.py index 0ea5073cf..e09fa58e5 100644 --- a/backend/src/database/models.py +++ b/backend/src/database/models.py @@ -13,11 +13,11 @@ from uuid import uuid4, UUID -from sqlalchemy import Column, Integer, Enum, ForeignKey, Text, Boolean, DateTime, Table +from sqlalchemy import Column, Integer, Enum, ForeignKey, Text, Boolean, DateTime, Table, UniqueConstraint from sqlalchemy.orm import declarative_base, relationship from sqlalchemy_utils import UUIDType # type: ignore -from src.database.enums import DecisionEnum, QuestionEnum +from src.database.enums import DecisionEnum, EmailStatusEnum, QuestionEnum Base = declarative_base() @@ -28,6 +28,7 @@ class AuthEmail(Base): email_auth_id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.user_id"), nullable=False) + email = Column(Text, unique=True, nullable=False) pw_hash = Column(Text, nullable=False) user: User = relationship("User", back_populates="email_auth", uselist=False) @@ -39,6 +40,7 @@ class AuthGitHub(Base): gh_auth_id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.user_id"), nullable=False) + email = Column(Text, unique=True, nullable=False) user: User = relationship("User", back_populates="github_auth", uselist=False) @@ -49,6 +51,7 @@ class AuthGoogle(Base): google_auth_id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.user_id"), nullable=False) + email = Column(Text, unique=True, nullable=False) user: User = relationship("User", back_populates="google_auth", uselist=False) @@ -74,7 +77,7 @@ class DecisionEmail(Base): email_id = Column(Integer, primary_key=True) student_id = Column(Integer, ForeignKey("students.student_id"), nullable=False) - decision = Column(Enum(DecisionEnum), nullable=False) + decision = Column(Enum(EmailStatusEnum), nullable=False) date = Column(DateTime, nullable=False) student: Student = relationship("Student", back_populates="emails", uselist=False) @@ -85,6 +88,7 @@ class Edition(Base): __tablename__ = "editions" edition_id = Column(Integer, primary_key=True) + name = Column(Text, unique=True, nullable=False) year = Column(Integer, unique=True, nullable=False) invite_links: list[InviteLink] = relationship("InviteLink", back_populates="edition") @@ -216,7 +220,7 @@ class Student(Base): wants_to_be_student_coach = Column(Boolean, nullable=False, default=False) edition_id = Column(Integer, ForeignKey("editions.edition_id")) - emails: list[DecisionEmail] = relationship("DecisionEmail", back_populates="student") + emails: list[DecisionEmail] = relationship("DecisionEmail", back_populates="student", cascade="all, delete-orphan") project_roles: list[ProjectRole] = relationship("ProjectRole", back_populates="student") skills: list[Skill] = relationship("Skill", secondary="student_skills", back_populates="students") suggestions: list[Suggestion] = relationship("Suggestion", back_populates="student") @@ -273,6 +277,9 @@ class QuestionFileAnswer(Base): class Suggestion(Base): """A suggestion left by a coach about a student""" __tablename__ = "suggestions" + __table_args__=( + UniqueConstraint('coach_id', 'student_id', name='unique_coach_student_suggestion'), + ) suggestion_id = Column(Integer, primary_key=True) student_id = Column(Integer, ForeignKey("students.student_id"), nullable=False) @@ -288,9 +295,8 @@ class User(Base): """Users of the tool (only admins & coaches)""" __tablename__ = "users" - user_id = Column(Integer, primary_key=True) + user_id: int = Column(Integer, primary_key=True) name = Column(Text, nullable=False) - email = Column(Text, unique=True, nullable=False) admin = Column(Boolean, nullable=False, default=False) coach_request: CoachRequest = relationship("CoachRequest", back_populates="user", uselist=False) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cda2243b8..aa3b62db6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -40,7 +40,13 @@ def database_session(tables: None) -> Generator[Session, None, None]: # Clean up connections & rollback transactions session.close() - transaction.rollback() + + # Transactions can be invalidated when an exception is raised + # which causes warnings when running the tests + # Check if a transaction is still valid before rolling back + if transaction.is_valid: + transaction.rollback() + connection.close() diff --git a/backend/tests/fill_database.py b/backend/tests/fill_database.py index 37dc40d7c..930e2577a 100644 --- a/backend/tests/fill_database.py +++ b/backend/tests/fill_database.py @@ -4,22 +4,22 @@ from src.database.models import (User, AuthEmail, Skill, Student, Edition, CoachRequest, DecisionEmail, InviteLink, Partner, Project, ProjectRole, Suggestion) -from src.database.enums import DecisionEnum +from src.database.enums import DecisionEnum, EmailStatusEnum from src.app.logic.security import get_password_hash def fill_database(db: Session): """A function to fill the database with fake data that can easly be used when testing""" # Editions - edition: Edition = Edition(year=2022) + edition: Edition = Edition(year=2022, name="ed2022") db.add(edition) db.commit() # Users - admin: User = User(name="admin", email="admin@ngmail.com", admin=True) - coach1: User = User(name="coach1", email="coach1@noutlook.be") - coach2: User = User(name="coach2", email="coach2@noutlook.be") - request: User = User(name="request", email="request@ngmail.com") + admin: User = User(name="admin", admin=True) + coach1: User = User(name="coach1") + coach2: User = User(name="coach2") + request: User = User(name="request") db.add(admin) db.add(coach1) db.add(coach2) @@ -28,10 +28,10 @@ def fill_database(db: Session): # AuthEmail pw_hash = get_password_hash("wachtwoord") - auth_email_admin: AuthEmail = AuthEmail(user=admin, pw_hash=pw_hash) - auth_email_coach1: AuthEmail = AuthEmail(user=coach1, pw_hash=pw_hash) - auth_email_coach2: AuthEmail = AuthEmail(user=coach2, pw_hash=pw_hash) - auth_email_request: AuthEmail = AuthEmail(user=request, pw_hash=pw_hash) + auth_email_admin: AuthEmail = AuthEmail(user=admin, email="admin@ngmail.com", pw_hash=pw_hash) + auth_email_coach1: AuthEmail = AuthEmail(user=coach1, email="coach1@noutlook.be", pw_hash=pw_hash) + auth_email_coach2: AuthEmail = AuthEmail(user=coach2, email="coach2@noutlook.be", pw_hash=pw_hash) + auth_email_request: AuthEmail = AuthEmail(user=request, email="request@ngmail.com", pw_hash=pw_hash) db.add(auth_email_admin) db.add(auth_email_coach1) db.add(auth_email_coach2) @@ -188,19 +188,19 @@ def fill_database(db: Session): # DecisionEmail decision_email1: DecisionEmail = DecisionEmail( - decision=DecisionEnum.NO, student=student29, date=date.today()) + decision=EmailStatusEnum.REJECTED, student=student29, date=date.today()) decision_email2: DecisionEmail = DecisionEmail( - decision=DecisionEnum.YES, student=student09, date=date.today()) + decision=EmailStatusEnum.APPROVED, student=student09, date=date.today()) decision_email3: DecisionEmail = DecisionEmail( - decision=DecisionEnum.YES, student=student10, date=date.today()) + decision=EmailStatusEnum.APPROVED, student=student10, date=date.today()) decision_email4: DecisionEmail = DecisionEmail( - decision=DecisionEnum.YES, student=student11, date=date.today()) + decision=EmailStatusEnum.APPROVED, student=student11, date=date.today()) decision_email5: DecisionEmail = DecisionEmail( - decision=DecisionEnum.YES, student=student12, date=date.today()) + decision=EmailStatusEnum.APPROVED, student=student12, date=date.today()) decision_email6: DecisionEmail = DecisionEmail( - decision=DecisionEnum.MAYBE, student=student06, date=date.today()) + decision=EmailStatusEnum.AWAITING_PROJECT, student=student06, date=date.today()) decision_email7: DecisionEmail = DecisionEmail( - decision=DecisionEnum.MAYBE, student=student26, date=date.today()) + decision=EmailStatusEnum.AWAITING_PROJECT, student=student26, date=date.today()) db.add(decision_email1) db.add(decision_email2) db.add(decision_email3) diff --git a/backend/tests/test_database/test_crud/test_invites.py b/backend/tests/test_database/test_crud/test_invites.py index 251fac8ae..7cead2745 100644 --- a/backend/tests/test_database/test_crud/test_invites.py +++ b/backend/tests/test_database/test_crud/test_invites.py @@ -4,52 +4,58 @@ import sqlalchemy.exc from sqlalchemy.orm import Session +from settings import DB_PAGE_SIZE from src.app.exceptions.parsing import MalformedUUIDError -from src.database.crud.invites import create_invite_link, delete_invite_link, get_all_pending_invites, \ +from src.database.crud.invites import ( + create_invite_link, + delete_invite_link, + get_pending_invites_for_edition, + get_pending_invites_for_edition_page, get_invite_link_by_uuid +) from src.database.models import Edition, InviteLink def test_create_invite_link(database_session: Session): """Test creation of new invite links""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Db empty - assert len(get_all_pending_invites(database_session, edition)) == 0 + assert len(get_pending_invites_for_edition(database_session, edition)) == 0 # Create new link create_invite_link(database_session, edition, "test@ema.il") - assert len(get_all_pending_invites(database_session, edition)) == 1 + assert len(get_pending_invites_for_edition(database_session, edition)) == 1 def test_delete_invite_link(database_session: Session): """Test deletion of existing invite links""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Create new link new_link = create_invite_link(database_session, edition, "test@ema.il") - assert len(get_all_pending_invites(database_session, edition)) == 1 + assert len(get_pending_invites_for_edition(database_session, edition)) == 1 delete_invite_link(database_session, new_link) - assert len(get_all_pending_invites(database_session, edition)) == 0 + assert len(get_pending_invites_for_edition(database_session, edition)) == 0 def test_get_all_pending_invites_empty(database_session: Session): """Test fetching all invites for a given edition when db is empty""" - edition_one = Edition(year=2022) - edition_two = Edition(year=2023) + edition_one = Edition(year=2022, name="ed2022") + edition_two = Edition(year=2023, name="ed2023") database_session.add(edition_one) database_session.add(edition_two) database_session.commit() # Db empty - assert len(get_all_pending_invites(database_session, edition_one)) == 0 - assert len(get_all_pending_invites(database_session, edition_two)) == 0 + assert len(get_pending_invites_for_edition(database_session, edition_one)) == 0 + assert len(get_pending_invites_for_edition(database_session, edition_two)) == 0 def test_get_all_pending_invites_one_present(database_session: Session): @@ -57,8 +63,8 @@ def test_get_all_pending_invites_one_present(database_session: Session): Test fetching all invites for two editions when only one of them has valid entries """ - edition_one = Edition(year=2022) - edition_two = Edition(year=2023) + edition_one = Edition(year=2022, name="ed2022") + edition_two = Edition(year=2023, name="ed2023") database_session.add(edition_one) database_session.add(edition_two) database_session.commit() @@ -68,16 +74,16 @@ def test_get_all_pending_invites_one_present(database_session: Session): database_session.add(link_one) database_session.commit() - assert len(get_all_pending_invites(database_session, edition_one)) == 1 + assert len(get_pending_invites_for_edition(database_session, edition_one)) == 1 # Other edition still empty - assert len(get_all_pending_invites(database_session, edition_two)) == 0 + assert len(get_pending_invites_for_edition(database_session, edition_two)) == 0 def test_get_all_pending_invites_two_present(database_session: Session): """Test fetching all links for two editions when both of them have data""" - edition_one = Edition(year=2022) - edition_two = Edition(year=2023) + edition_one = Edition(year=2022, name="ed2022") + edition_two = Edition(year=2023, name="ed2023") database_session.add(edition_one) database_session.add(edition_two) database_session.commit() @@ -89,13 +95,27 @@ def test_get_all_pending_invites_two_present(database_session: Session): database_session.add(link_two) database_session.commit() - assert len(get_all_pending_invites(database_session, edition_one)) == 1 - assert len(get_all_pending_invites(database_session, edition_two)) == 1 + assert len(get_pending_invites_for_edition(database_session, edition_one)) == 1 + assert len(get_pending_invites_for_edition(database_session, edition_two)) == 1 + + +def test_get_all_pending_invites_pagination(database_session: Session): + """Test fetching all links for two editions when both of them have data""" + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(InviteLink(target_email=f"{i}@example.com", edition=edition)) + database_session.commit() + + assert len(get_pending_invites_for_edition_page(database_session, edition, 0)) == DB_PAGE_SIZE + assert len(get_pending_invites_for_edition_page(database_session, edition, 1)) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE def test_get_invite_link_by_uuid_existing(database_session: Session): """Test fetching links by uuid's when it exists""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() @@ -113,7 +133,7 @@ def test_get_invite_link_by_uuid_non_existing(database_session: Session): with pytest.raises(sqlalchemy.exc.NoResultFound): get_invite_link_by_uuid(database_session, "123e4567-e89b-12d3-a456-426614174011") - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() diff --git a/backend/tests/test_database/test_crud/test_projects.py b/backend/tests/test_database/test_crud/test_projects.py new file mode 100644 index 000000000..c9cd146e8 --- /dev/null +++ b/backend/tests/test_database/test_crud/test_projects.py @@ -0,0 +1,229 @@ +import pytest +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session + +from settings import DB_PAGE_SIZE +from src.app.schemas.projects import InputProject, QueryParamsProjects +import src.database.crud.projects as crud +from src.database.models import Edition, Partner, Project, User, Skill, ProjectRole, Student + + +@pytest.fixture +def database_with_data(database_session: Session) -> Session: + """fixture for adding data to the database""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + user: User = User(name="coach1") + database_session.add(user) + project1 = Project(name="project1", edition=edition, number_of_students=2) + project2 = Project(name="project2", edition=edition, number_of_students=3) + project3 = Project(name="super nice project", + edition=edition, number_of_students=3, coaches=[user]) + database_session.add(project1) + database_session.add(project2) + database_session.add(project3) + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3]) + student02: Student = Student(first_name="Isabella", last_name="Christensen", preferred_name="Isabella", + email_address="isabella.christensen@example.com", phone_number="98389723", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill2]) + project_role1: ProjectRole = ProjectRole( + student=student01, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + project_role2: ProjectRole = ProjectRole( + student=student01, project=project2, skill=skill3, drafter=user, argumentation="argmunet") + project_role3: ProjectRole = ProjectRole( + student=student02, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + database_session.add(project_role1) + database_session.add(project_role2) + database_session.add(project_role3) + database_session.commit() + + return database_session + + +@pytest.fixture +def current_edition(database_with_data: Session) -> Edition: + """fixture to get the latest edition""" + return database_with_data.query(Edition).all()[-1] + + +def test_get_all_projects_empty(database_session: Session): + """test get all projects but there are none""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + projects: list[Project] = crud.get_projects_for_edition( + database_session, edition) + assert len(projects) == 0 + + +def test_get_all_projects(database_with_data: Session, current_edition: Edition): + """test get all projects""" + projects: list[Project] = crud.get_projects_for_edition( + database_with_data, current_edition) + assert len(projects) == 3 + + +def test_get_all_projects_pagination(database_session: Session): + """test get all projects paginated""" + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add( + Project(name=f"Project {i}", edition=edition, number_of_students=5)) + database_session.commit() + + assert len(crud.get_projects_for_edition_page(database_session, + edition, QueryParamsProjects(page=0), user=None)) == DB_PAGE_SIZE + assert len(crud.get_projects_for_edition_page(database_session, edition, QueryParamsProjects(page=1), user=None)) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE + + +def test_get_project_search_name(database_with_data: Session): + """test get project with a specific name""" + edition: Edition = database_with_data.query(Edition).all()[0] + projects: list[Project] = crud.get_projects_for_edition_page( + database_with_data, edition, QueryParamsProjects(name="nice"), user=None) + assert len(projects) == 1 + assert projects[0].name == "super nice project" + + +def test_get_project_search_coach(database_with_data: Session): + """test get projects that you are a coach""" + edition: Edition = database_with_data.query(Edition).all()[0] + user: User = database_with_data.query(User).all()[0] + projects: list[Project] = crud.get_projects_for_edition_page( + database_with_data, edition, QueryParamsProjects(coach=True), user=user) + assert len(projects) == 1 + + +def test_add_project_partner_do_not_exist_yet(database_with_data: Session, current_edition: Edition): + """tests add a project when the project don't exist yet""" + non_existing_proj: InputProject = InputProject(name="project1", number_of_students=2, skills=[1, 3], + partners=["ugent"], coaches=[1]) + assert len(database_with_data.query(Partner).where( + Partner.name == "ugent").all()) == 0 + new_project: Project = crud.add_project( + database_with_data, current_edition, non_existing_proj) + assert new_project == database_with_data.query(Project).where( + Project.project_id == new_project.project_id).one() + new_partner: Partner = database_with_data.query( + Partner).where(Partner.name == "ugent").one() + + assert new_partner in new_project.partners + assert new_project.name == "project1" + assert new_project.edition == current_edition + assert new_project.number_of_students == 2 + assert len(new_project.coaches) == 1 + assert new_project.coaches[0].user_id == 1 + assert len(new_project.skills) == 2 + assert new_project.skills[0].skill_id == 1 + assert new_project.skills[1].skill_id == 3 + + +def test_add_project_partner_do_exist(database_with_data: Session, current_edition: Edition): + """tests add a project when the project exist already """ + existing_proj: InputProject = InputProject(name="project1", number_of_students=2, skills=[1, 3], + partners=["ugent"], coaches=[1]) + database_with_data.add(Partner(name="ugent")) + assert len(database_with_data.query(Partner).where( + Partner.name == "ugent").all()) == 1 + new_project: Project = crud.add_project( + database_with_data, current_edition, existing_proj) + assert new_project == database_with_data.query(Project).where( + Project.project_id == new_project.project_id).one() + partner: Partner = database_with_data.query( + Partner).where(Partner.name == "ugent").one() + + assert partner in new_project.partners + assert new_project.name == "project1" + assert new_project.edition == current_edition + assert new_project.number_of_students == 2 + assert len(new_project.coaches) == 1 + assert new_project.coaches[0].user_id == 1 + assert len(new_project.skills) == 2 + assert new_project.skills[0].skill_id == 1 + assert new_project.skills[1].skill_id == 3 + + +def test_get_ghost_project(database_with_data: Session): + """test project that don't exist""" + with pytest.raises(NoResultFound): + crud.get_project(database_with_data, 500) + + +def test_get_project(database_with_data: Session): + """test get project""" + project: Project = crud.get_project(database_with_data, 1) + assert project.name == "project1" + assert project.number_of_students == 2 + + +def test_delete_project_no_project_roles(database_with_data: Session, current_edition): + """test delete a project that don't has project roles""" + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.project_id == 3).all()) == 0 + assert len(crud.get_projects_for_edition( + database_with_data, current_edition)) == 3 + crud.delete_project(database_with_data, 3) + assert len(crud.get_projects_for_edition( + database_with_data, current_edition)) == 2 + assert 3 not in [project.project_id for project in crud.get_projects_for_edition( + database_with_data, current_edition)] + + +def test_delete_project_with_project_roles(database_with_data: Session, current_edition): + """test delete a project that has project roles""" + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.project_id == 1).all()) > 0 + assert len(crud.get_projects_for_edition( + database_with_data, current_edition)) == 3 + crud.delete_project(database_with_data, 1) + assert len(crud.get_projects_for_edition( + database_with_data, current_edition)) == 2 + assert 1 not in [project.project_id for project in crud.get_projects_for_edition( + database_with_data, current_edition)] + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.project_id == 1).all()) == 0 + + +def test_patch_project(database_with_data: Session, current_edition: Edition): + """tests patch a project""" + proj: InputProject = InputProject(name="projec1", number_of_students=2, skills=[1, 3], + partners=["ugent"], coaches=[1]) + proj_patched: InputProject = InputProject(name="project1", number_of_students=2, skills=[1, 3], + partners=["ugent"], coaches=[1]) + + assert len(database_with_data.query(Partner).where( + Partner.name == "ugent").all()) == 0 + new_project: Project = crud.add_project( + database_with_data, current_edition, proj) + assert new_project == database_with_data.query(Project).where( + Project.project_id == new_project.project_id).one() + new_partner: Partner = database_with_data.query( + Partner).where(Partner.name == "ugent").one() + crud.patch_project(database_with_data, new_project.project_id, + proj_patched) + + assert new_partner in new_project.partners + assert new_project.name == "project1" + + +def test_get_conflict_students(database_with_data: Session, current_edition: Edition): + """test if the right ConflictStudent is given""" + conflicts: list[(Student, list[Project])] = crud.get_conflict_students( + database_with_data, current_edition) + assert len(conflicts) == 1 + assert conflicts[0][0].student_id == 1 + assert len(conflicts[0][1]) == 2 + assert conflicts[0][1][0].project_id == 1 + assert conflicts[0][1][1].project_id == 2 diff --git a/backend/tests/test_database/test_crud/test_projects_students.py b/backend/tests/test_database/test_crud/test_projects_students.py new file mode 100644 index 000000000..854637a6c --- /dev/null +++ b/backend/tests/test_database/test_crud/test_projects_students.py @@ -0,0 +1,97 @@ +import pytest +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session + +from src.database.crud.projects_students import ( + remove_student_project, add_student_project, change_project_role) +from src.database.models import Edition, Project, User, Skill, ProjectRole, Student + + +@pytest.fixture +def database_with_data(database_session: Session) -> Session: + """fixture for adding data to the database""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + project1 = Project(name="project1", edition=edition, number_of_students=2) + project2 = Project(name="project2", edition=edition, number_of_students=3) + project3 = Project(name="project3", edition=edition, number_of_students=3) + database_session.add(project1) + database_session.add(project2) + database_session.add(project3) + user: User = User(name="coach1") + database_session.add(user) + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3]) + student02: Student = Student(first_name="Isabella", last_name="Christensen", preferred_name="Isabella", + email_address="isabella.christensen@example.com", phone_number="98389723", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill2]) + project_role1: ProjectRole = ProjectRole( + student=student01, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + project_role2: ProjectRole = ProjectRole( + student=student01, project=project2, skill=skill3, drafter=user, argumentation="argmunet") + project_role3: ProjectRole = ProjectRole( + student=student02, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + database_session.add(project_role1) + database_session.add(project_role2) + database_session.add(project_role3) + database_session.commit() + + return database_session + + +def test_remove_student_from_project(database_with_data: Session): + """test removing a student form a project""" + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.student_id == 1).all()) == 2 + project: Project = database_with_data.query( + Project).where(Project.project_id == 1).one() + remove_student_project(database_with_data, project, 1) + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.student_id == 1).all()) == 1 + + +def test_remove_student_from_project_not_assigned_to(database_with_data: Session): + """test removing a student form a project that don't exist""" + project: Project = database_with_data.query( + Project).where(Project.project_id == 2).one() + with pytest.raises(NoResultFound): + remove_student_project(database_with_data, project, 2) + + +def test_add_student_project(database_with_data: Session): + """tests add student to a project""" + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.student_id == 2).all()) == 1 + project: Project = database_with_data.query( + Project).where(Project.project_id == 2).one() + add_student_project(database_with_data, project, 2, 2, 1) + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.student_id == 2).all()) == 2 + + +def test_change_project_role(database_with_data: Session): + """test change project role""" + assert len(database_with_data.query(ProjectRole).where( + ProjectRole.student_id == 2).all()) == 1 + project: Project = database_with_data.query( + Project).where(Project.project_id == 1).one() + project_role: ProjectRole = database_with_data.query(ProjectRole).where( + ProjectRole.project_id == 1).where(ProjectRole.student_id == 2).one() + assert project_role.skill_id == 1 + change_project_role(database_with_data, project, 2, 2, 1) + assert project_role.skill_id == 2 + + +def test_change_project_role_not_assigned_to(database_with_data: Session): + """test change project role""" + project: Project = database_with_data.query( + Project).where(Project.project_id == 2).one() + with pytest.raises(NoResultFound): + change_project_role(database_with_data, project, 2, 2, 1) diff --git a/backend/tests/test_database/test_crud/test_register.py b/backend/tests/test_database/test_crud/test_register.py index a12c713c9..b4f471b6c 100644 --- a/backend/tests/test_database/test_crud/test_register.py +++ b/backend/tests/test_database/test_crud/test_register.py @@ -1,39 +1,39 @@ from sqlalchemy.orm import Session +from src.database.crud.register import create_user, create_coach_request, create_auth_email from src.database.models import AuthEmail, CoachRequest, User, Edition -from src.database.crud.register import create_user, create_coach_request, create_auth_email def test_create_user(database_session: Session): """Tests for creating a user""" - create_user(database_session, "jos", "mail@email.com") + create_user(database_session, "jos") a = database_session.query(User).where(User.name == "jos").all() assert len(a) == 1 assert a[0].name == "jos" - assert a[0].email == "mail@email.com" + def test_react_coach_request(database_session: Session): """Tests for creating a coach request""" - edition = Edition(year = 2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() - u = create_user(database_session, "jos", "mail@email.com") - + u = create_user(database_session, "jos") create_coach_request(database_session, u, edition) a = database_session.query(CoachRequest).where(CoachRequest.user == u).all() assert len(a) == 1 - assert a[0].user_id == u.user_id + assert a[0].user_id == u.user_id assert u.coach_request == a[0] + def test_create_auth_email(database_session: Session): """Tests for creating a auth email""" - u = create_user(database_session, "jos", "mail@email.com") - create_auth_email(database_session, u, "wachtwoord") + u = create_user(database_session, "jos") + create_auth_email(database_session, u, "wachtwoord", "mail@email.com") a = database_session.query(AuthEmail).where(AuthEmail.user == u).all() - + assert len(a) == 1 assert a[0].user_id == u.user_id assert a[0].pw_hash == "wachtwoord" diff --git a/backend/tests/test_database/test_crud/test_students.py b/backend/tests/test_database/test_crud/test_students.py new file mode 100644 index 000000000..db9001edd --- /dev/null +++ b/backend/tests/test_database/test_crud/test_students.py @@ -0,0 +1,470 @@ +import datetime +import pytest +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound +from src.database.models import Student, User, Edition, Skill, DecisionEmail +from src.database.enums import DecisionEnum, EmailStatusEnum +from src.database.crud.students import (create_email, get_last_emails_of_students, get_student_by_id, + set_definitive_decision_on_student, + delete_student, get_students, get_emails) +from src.app.schemas.students import CommonQueryParams, EmailsSearchQueryParams + + +@pytest.fixture +def database_with_data(database_session: Session): + """A function to fill the database with fake data that can easly be used when testing""" + # Editions + edition: Edition = Edition(year=2022, name="ed22") + database_session.add(edition) + database_session.commit() + + # Users + admin: User = User(name="admin", admin=True) + coach1: User = User(name="coach1") + coach2: User = User(name="coach2") + database_session.add(admin) + database_session.add(coach1) + database_session.add(coach2) + database_session.commit() + + # Skill + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + skill4: Skill = Skill(name="skill4", description="important") + skill5: Skill = Skill(name="skill5", description="important") + skill6: Skill = Skill(name="skill6", description="something about skill6") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + database_session.add(skill4) + database_session.add(skill5) + database_session.add(skill6) + database_session.commit() + + # Student + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3, skill6]) + student30: Student = Student(first_name="Marta", last_name="Marquez", preferred_name="Marta", + email_address="marta.marquez@example.com", phone_number="967-895-285", alumni=False, + wants_to_be_student_coach=False, edition=edition, skills=[skill2, skill4, skill5]) + + database_session.add(student01) + database_session.add(student30) + database_session.commit() + + # DecisionEmail + decision_email: DecisionEmail = DecisionEmail( + student=student01, decision=EmailStatusEnum.APPROVED, date=datetime.datetime.now()) + database_session.add(decision_email) + database_session.commit() + + return database_session + + +def test_get_student_by_id(database_with_data: Session): + """Tests if you get the right student""" + student: Student = get_student_by_id(database_with_data, 1) + assert student.first_name == "Jos" + assert student.last_name == "Vermeulen" + assert student.student_id == 1 + assert student.email_address == "josvermeulen@mail.com" + + +def test_no_student(database_with_data: Session): + """Tests if you get an error for a not existing student""" + with pytest.raises(NoResultFound): + get_student_by_id(database_with_data, 5) + + +def test_definitive_decision_on_student_yes(database_with_data: Session): + """Tests for definitive decision yes""" + student: Student = get_student_by_id(database_with_data, 1) + set_definitive_decision_on_student( + database_with_data, student, DecisionEnum.YES) + assert student.decision == DecisionEnum.YES + + +def test_definitive_decision_on_student_maybe(database_with_data: Session): + """Tests for definitive decision maybe""" + student: Student = get_student_by_id(database_with_data, 1) + set_definitive_decision_on_student( + database_with_data, student, DecisionEnum.MAYBE) + assert student.decision == DecisionEnum.MAYBE + + +def test_definitive_decision_on_student_no(database_with_data: Session): + """Tests for definitive decision no""" + student: Student = get_student_by_id(database_with_data, 1) + set_definitive_decision_on_student( + database_with_data, student, DecisionEnum.NO) + assert student.decision == DecisionEnum.NO + + +def test_delete_student(database_with_data: Session): + """Tests for deleting a student""" + student: Student = get_student_by_id(database_with_data, 1) + delete_student(database_with_data, student) + with pytest.raises(NoResultFound): + get_student_by_id(database_with_data, 1) + + +def test_get_all_students(database_with_data: Session): + """test get all students""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + students = get_students(database_with_data, edition, CommonQueryParams()) + assert len(students) == 2 + + +def test_search_students_on_first_name(database_with_data: Session): + """test""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + students = get_students(database_with_data, edition, + CommonQueryParams(name="Jos")) + assert len(students) == 1 + + +def test_search_students_on_last_name(database_with_data: Session): + """tests search on last name""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + students = get_students(database_with_data, edition, + CommonQueryParams(name="Vermeulen")) + assert len(students) == 1 + + +def test_search_students_on_between_first_and_last_name(database_with_data: Session): + """tests search on between first- and last name""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + students = get_students(database_with_data, edition, + CommonQueryParams(name="os V")) + assert len(students) == 1 + + +def test_search_students_alumni(database_with_data: Session): + """tests search on alumni""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + students = get_students(database_with_data, edition, + CommonQueryParams(alumni=True)) + assert len(students) == 1 + + +def test_search_students_student_coach(database_with_data: Session): + """tests search on student coach""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + students = get_students(database_with_data, edition, + CommonQueryParams(student_coach=True)) + assert len(students) == 1 + + +def test_search_students_one_skill(database_with_data: Session): + """tests search on one skill""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + skill: Skill = database_with_data.query( + Skill).where(Skill.name == "skill1").one() + students = get_students(database_with_data, edition, + CommonQueryParams(), skills=[skill]) + assert len(students) == 1 + + +def test_search_students_multiple_skills(database_with_data: Session): + """tests search on multiple skills""" + edition: Edition = database_with_data.query( + Edition).where(Edition.edition_id == 1).one() + skills: list[Skill] = database_with_data.query( + Skill).where(Skill.description == "important").all() + students = get_students(database_with_data, edition, + CommonQueryParams(), skills=skills) + assert len(students) == 1 + + +def test_get_emails(database_with_data: Session): + """tests to get emails""" + student: Student = get_student_by_id(database_with_data, 1) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + student = get_student_by_id(database_with_data, 2) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 0 + + +def test_create_email_applied(database_with_data: Session): + """test create email applied""" + student: Student = get_student_by_id(database_with_data, 2) + create_email(database_with_data, student, EmailStatusEnum.APPLIED) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + assert emails[0].decision == EmailStatusEnum.APPLIED + + +def test_create_email_awaiting_project(database_with_data: Session): + """test create email awaiting project""" + student: Student = get_student_by_id(database_with_data, 2) + create_email(database_with_data, student, EmailStatusEnum.AWAITING_PROJECT) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + assert emails[0].decision == EmailStatusEnum.AWAITING_PROJECT + + +def test_create_email_approved(database_with_data: Session): + """test create email approved""" + student: Student = get_student_by_id(database_with_data, 2) + create_email(database_with_data, student, EmailStatusEnum.APPROVED) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + assert emails[0].decision == EmailStatusEnum.APPROVED + + +def test_create_email_contract_confirmed(database_with_data: Session): + """test create email contract confirmed""" + student: Student = get_student_by_id(database_with_data, 2) + create_email(database_with_data, student, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + + +def test_create_email_contract_declined(database_with_data: Session): + """test create email contract declined""" + student: Student = get_student_by_id(database_with_data, 2) + create_email(database_with_data, student, + EmailStatusEnum.CONTRACT_DECLINED) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_DECLINED + + +def test_create_email_rejected(database_with_data: Session): + """test create email rejected""" + student: Student = get_student_by_id(database_with_data, 2) + create_email(database_with_data, student, EmailStatusEnum.REJECTED) + emails: list[DecisionEmail] = get_emails(database_with_data, student) + assert len(emails) == 1 + assert emails[0].decision == EmailStatusEnum.REJECTED + + +def test_get_last_emails_of_students(database_with_data: Session): + """tests get last email of all students that got an email""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.REJECTED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + edition2: Edition = Edition(year=2023, name="ed2023") + database_with_data.add(edition) + student: Student = Student(first_name="Mehmet", last_name="Dizdar", preferred_name="Mehmet", + email_address="mehmet.dizdar@example.com", phone_number="(787)-938-6216", alumni=True, + wants_to_be_student_coach=False, edition=edition2, skills=[]) + database_with_data.add(student) + database_with_data.commit() + create_email(database_with_data, student, + EmailStatusEnum.REJECTED) + + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[])) + assert len(emails) == 2 + assert emails[0].student_id == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + assert emails[1].student_id == 2 + assert emails[1].decision == EmailStatusEnum.REJECTED + + +def test_get_last_emails_of_students_filter_applied(database_with_data: Session): + """tests get all emails where last emails is applied""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student2, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[EmailStatusEnum.APPLIED])) + + assert len(emails) == 1 + assert emails[0].student_id == 2 + assert emails[0].decision == EmailStatusEnum.APPLIED + + +def test_get_last_emails_of_students_filter_awaiting_project(database_with_data: Session): + """tests get all emails where last emails is awaiting project""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.AWAITING_PROJECT) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[EmailStatusEnum.AWAITING_PROJECT])) + + assert len(emails) == 1 + assert emails[0].student_id == 2 + assert emails[0].decision == EmailStatusEnum.AWAITING_PROJECT + + +def test_get_last_emails_of_students_filter_approved(database_with_data: Session): + """tests get all emails where last emails is approved""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.APPROVED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[EmailStatusEnum.APPROVED])) + + assert len(emails) == 1 + assert emails[0].student_id == 2 + assert emails[0].decision == EmailStatusEnum.APPROVED + + +def test_get_last_emails_of_students_filter_contract_confirmed(database_with_data: Session): + """tests get all emails where last emails is contract confirmed""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[EmailStatusEnum.CONTRACT_CONFIRMED])) + + assert len(emails) == 1 + assert emails[0].student_id == 2 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + + +def test_get_last_emails_of_students_filter_contract_declined(database_with_data: Session): + """tests get all emails where last emails is contract declined""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.CONTRACT_DECLINED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[EmailStatusEnum.CONTRACT_DECLINED])) + + assert len(emails) == 1 + assert emails[0].student_id == 2 + assert emails[0].decision == EmailStatusEnum.CONTRACT_DECLINED + + +def test_get_last_emails_of_students_filter_rejected(database_with_data: Session): + """tests get all emails where last emails is rejected""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.REJECTED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[EmailStatusEnum.REJECTED])) + + assert len(emails) == 1 + assert emails[0].student_id == 2 + assert emails[0].decision == EmailStatusEnum.REJECTED + + +def test_get_last_emails_of_students_first_name(database_with_data: Session): + """tests get all emails where last emails is first name""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.REJECTED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(name="Jos", email_status=[])) + + assert len(emails) == 1 + assert emails[0].student_id == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + + +def test_get_last_emails_of_students_last_name(database_with_data: Session): + """tests get all emails where last emails is last name""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.REJECTED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(name="Vermeulen", email_status=[])) + + assert len(emails) == 1 + assert emails[0].student_id == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + + +def test_get_last_emails_of_students_between_first_and_last_name(database_with_data: Session): + """tests get all emails where last emails is between first- and last name""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student1, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student2, + EmailStatusEnum.REJECTED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(name="os V", email_status=[])) + + assert len(emails) == 1 + assert emails[0].student_id == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + + +def test_get_last_emails_of_students_filter_mutliple_status(database_with_data: Session): + """tests get all emails where last emails is applied""" + student1: Student = get_student_by_id(database_with_data, 1) + student2: Student = get_student_by_id(database_with_data, 2) + edition: Edition = database_with_data.query(Edition).all()[0] + create_email(database_with_data, student2, + EmailStatusEnum.APPLIED) + create_email(database_with_data, student1, + EmailStatusEnum.CONTRACT_CONFIRMED) + emails: list[DecisionEmail] = get_last_emails_of_students( + database_with_data, edition, EmailsSearchQueryParams(email_status=[ + EmailStatusEnum.APPLIED, + EmailStatusEnum.CONTRACT_CONFIRMED + ])) + + assert len(emails) == 2 + assert emails[0].student_id == 1 + assert emails[0].decision == EmailStatusEnum.CONTRACT_CONFIRMED + assert emails[1].student_id == 2 + assert emails[1].decision == EmailStatusEnum.APPLIED diff --git a/backend/tests/test_database/test_crud/test_suggestions.py b/backend/tests/test_database/test_crud/test_suggestions.py new file mode 100644 index 000000000..822663149 --- /dev/null +++ b/backend/tests/test_database/test_crud/test_suggestions.py @@ -0,0 +1,318 @@ +import pytest +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.exc import NoResultFound + +from src.database.models import Suggestion, Student, User, Edition, Skill + +from src.database.crud.suggestions import (create_suggestion, get_suggestions_of_student, + get_suggestion_by_id, get_own_suggestion, delete_suggestion, + update_suggestion, + get_suggestions_of_student_by_type) +from src.database.enums import DecisionEnum + + +@pytest.fixture +def database_with_data(database_session: Session): + """A function to fill the database with fake data that can easly be used when testing""" + # Editions + edition: Edition = Edition(year=2022, name="ed22") + database_session.add(edition) + database_session.commit() + + # Users + admin: User = User(name="admin", admin=True) + coach1: User = User(name="coach1") + coach2: User = User(name="coach2") + database_session.add(admin) + database_session.add(coach1) + database_session.add(coach2) + database_session.commit() + + # Skill + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + skill4: Skill = Skill(name="skill4", description="something about skill4") + skill5: Skill = Skill(name="skill5", description="something about skill5") + skill6: Skill = Skill(name="skill6", description="something about skill6") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + database_session.add(skill4) + database_session.add(skill5) + database_session.add(skill6) + database_session.commit() + + # Student + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3, skill6]) + student30: Student = Student(first_name="Marta", last_name="Marquez", preferred_name="Marta", + email_address="marta.marquez@example.com", phone_number="967-895-285", alumni=True, + wants_to_be_student_coach=False, edition=edition, skills=[skill2, skill4, skill5]) + + database_session.add(student01) + database_session.add(student30) + database_session.commit() + + # Suggestion + suggestion1: Suggestion = Suggestion( + student=student01, coach=admin, argumentation="Good student", suggestion=DecisionEnum.YES) + database_session.add(suggestion1) + database_session.commit() + return database_session + + +def test_create_suggestion_yes(database_with_data: Session): + """Test creat a yes suggestion""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + new_suggestion = create_suggestion( + database_with_data, user.user_id, student.student_id, DecisionEnum.YES, "This is a good student") + + suggestion: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).one() + + assert new_suggestion == suggestion + + assert suggestion.coach == user + assert suggestion.student == student + assert suggestion.suggestion == DecisionEnum.YES + assert suggestion.argumentation == "This is a good student" + + +def test_create_suggestion_no(database_with_data: Session): + """Test create a no suggestion""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + new_suggestion = create_suggestion( + database_with_data, user.user_id, student.student_id, DecisionEnum.NO, "This is a not good student") + + suggestion: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).one() + + assert new_suggestion == suggestion + + assert suggestion.coach == user + assert suggestion.student == student + assert suggestion.suggestion == DecisionEnum.NO + assert suggestion.argumentation == "This is a not good student" + + +def test_create_suggestion_maybe(database_with_data: Session): + """Test create a maybe suggestion""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + new_suggestion = create_suggestion( + database_with_data, user.user_id, student.student_id, DecisionEnum.MAYBE, "Idk if it's good student") + + suggestion: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).one() + + assert new_suggestion == suggestion + + assert suggestion.coach == user + assert suggestion.student == student + assert suggestion.suggestion == DecisionEnum.MAYBE + assert suggestion.argumentation == "Idk if it's good student" + + +def test_get_own_suggestion_existing(database_with_data: Session): + """Test getting your own suggestion""" + user: User = database_with_data.query( + User).where(User.name == "coach1").one() + student1: Student = database_with_data.query(Student).where( + Student.email_address == "josvermeulen@mail.com").one() + + suggestion = create_suggestion(database_with_data, user.user_id, student1.student_id, DecisionEnum.YES, "args") + + assert get_own_suggestion(database_with_data, student1.student_id, user.user_id) == suggestion + + +def test_get_own_suggestion_non_existing(database_with_data: Session): + """Test getting your own suggestion when it doesn't exist""" + user: User = database_with_data.query( + User).where(User.name == "coach1").one() + student1: Student = database_with_data.query(Student).where( + Student.email_address == "josvermeulen@mail.com").one() + + assert get_own_suggestion(database_with_data, student1.student_id, user.user_id) is None + + +def test_get_own_suggestion_fields_none(database_with_data: Session): + """Test getting your own suggestion when either of the fields are None + This is really only to increase coverage, the case isn't possible in practice + """ + user: User = database_with_data.query( + User).where(User.name == "coach1").one() + student1: Student = database_with_data.query(Student).where( + Student.email_address == "josvermeulen@mail.com").one() + create_suggestion(database_with_data, user.user_id, student1.student_id, DecisionEnum.YES, "args") + + assert get_own_suggestion(database_with_data, None, user.user_id) is None + assert get_own_suggestion(database_with_data, student1.student_id, None) is None + + +def test_one_coach_two_students(database_with_data: Session): + """Test that one coach can write multiple suggestions""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").one() + student1: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").one() + student2: Student = database_with_data.query(Student).where( + Student.email_address == "josvermeulen@mail.com").one() + + create_suggestion(database_with_data, user.user_id, + student1.student_id, DecisionEnum.YES, "This is a good student") + create_suggestion(database_with_data, user.user_id, student2.student_id, + DecisionEnum.NO, "This is a not good student") + + suggestion1: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student1.student_id).one() + assert suggestion1.coach == user + assert suggestion1.student == student1 + assert suggestion1.suggestion == DecisionEnum.YES + assert suggestion1.argumentation == "This is a good student" + + suggestion2: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student2.student_id).one() + assert suggestion2.coach == user + assert suggestion2.student == student2 + assert suggestion2.suggestion == DecisionEnum.NO + assert suggestion2.argumentation == "This is a not good student" + + +def test_multiple_suggestions_about_same_student(database_with_data: Session): + """Test get multiple suggestions about the same student""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + create_suggestion(database_with_data, user.user_id, student.student_id, + DecisionEnum.MAYBE, "Idk if it's good student") + with pytest.raises(IntegrityError): + create_suggestion(database_with_data, user.user_id, + student.student_id, DecisionEnum.YES, "This is a good student") + + +def test_get_suggestions_of_student(database_with_data: Session): + """Test get all suggestions of a student""" + + user1: User = database_with_data.query( + User).where(User.name == "coach1").first() + user2: User = database_with_data.query( + User).where(User.name == "coach2").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + create_suggestion(database_with_data, user1.user_id, student.student_id, + DecisionEnum.MAYBE, "Idk if it's good student") + create_suggestion(database_with_data, user2.user_id, + student.student_id, DecisionEnum.YES, "This is a good student") + suggestions_student = get_suggestions_of_student( + database_with_data, student.student_id) + + assert len(suggestions_student) == 2 + assert suggestions_student[0].student == student + assert suggestions_student[1].student == student + + +def test_get_suggestion_by_id(database_with_data: Session): + """Test get suggestion by id""" + suggestion: Suggestion = get_suggestion_by_id(database_with_data, 1) + assert suggestion.student_id == 1 + assert suggestion.coach_id == 1 + assert suggestion.suggestion == DecisionEnum.YES + assert suggestion.argumentation == "Good student" + + +def test_get_suggestion_by_id_non_existing(database_with_data: Session): + """Test you get an error when you search an id that don't exist""" + with pytest.raises(NoResultFound): + get_suggestion_by_id(database_with_data, 900) + + +def test_delete_suggestion(database_with_data: Session): + """Test delete suggestion""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + create_suggestion(database_with_data, user.user_id, + student.student_id, DecisionEnum.YES, "This is a good student") + suggestion: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).one() + + delete_suggestion(database_with_data, suggestion) + + suggestions: list[Suggestion] = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).all() + assert len(suggestions) == 0 + + +def test_update_suggestion(database_with_data: Session): + """Test update suggestion""" + + user: User = database_with_data.query( + User).where(User.name == "coach1").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + create_suggestion(database_with_data, user.user_id, + student.student_id, DecisionEnum.YES, "This is a good student") + suggestion: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).one() + + update_suggestion(database_with_data, suggestion, + DecisionEnum.NO, "Not that good student") + + new_suggestion: Suggestion = database_with_data.query(Suggestion).where( + Suggestion.coach == user).where(Suggestion.student_id == student.student_id).one() + assert new_suggestion.suggestion == DecisionEnum.NO + assert new_suggestion.argumentation == "Not that good student" + + +def test_get_suggestions_of_student_by_type(database_with_data: Session): + """Tests get suggestion of a student by type of suggestion""" + user1: User = database_with_data.query( + User).where(User.name == "coach1").first() + user2: User = database_with_data.query( + User).where(User.name == "coach2").first() + user3: User = database_with_data.query( + User).where(User.name == "admin").first() + student: Student = database_with_data.query(Student).where( + Student.email_address == "marta.marquez@example.com").first() + + create_suggestion(database_with_data, user1.user_id, student.student_id, + DecisionEnum.MAYBE, "Idk if it's good student") + create_suggestion(database_with_data, user2.user_id, + student.student_id, DecisionEnum.YES, "This is a good student") + create_suggestion(database_with_data, user3.user_id, + student.student_id, DecisionEnum.NO, "This is not a good student") + suggestions_student_yes = get_suggestions_of_student_by_type( + database_with_data, student.student_id, DecisionEnum.YES) + suggestions_student_no = get_suggestions_of_student_by_type( + database_with_data, student.student_id, DecisionEnum.NO) + suggestions_student_maybe = get_suggestions_of_student_by_type( + database_with_data, student.student_id, DecisionEnum.MAYBE) + assert len(suggestions_student_yes) == 1 + assert len(suggestions_student_no) == 1 + assert len(suggestions_student_maybe) == 1 diff --git a/backend/tests/test_database/test_crud/test_users.py b/backend/tests/test_database/test_crud/test_users.py index 69e3b274b..df2ce9887 100644 --- a/backend/tests/test_database/test_crud/test_users.py +++ b/backend/tests/test_database/test_crud/test_users.py @@ -1,30 +1,37 @@ import pytest from sqlalchemy.orm import Session -from src.database import models import src.database.crud.users as users_crud +from settings import DB_PAGE_SIZE +from src.app.schemas.users import FilterParameters +from src.database import models from src.database.models import user_editions, CoachRequest @pytest.fixture -def data(database_session: Session) -> dict[str, int]: +def data(database_session: Session) -> dict[str, str]: """Fill database with dummy data""" # Create users - user1 = models.User(name="user1", email="user1@mail.com", admin=True) - + user1 = models.User(name="user1", admin=True) database_session.add(user1) - user2 = models.User(name="user2", email="user2@mail.com", admin=False) + user2 = models.User(name="user2", admin=False) database_session.add(user2) # Create editions - edition1 = models.Edition(year=1) + edition1 = models.Edition(year=1, name="ed1") database_session.add(edition1) - edition2 = models.Edition(year=2) + edition2 = models.Edition(year=2, name="ed2") database_session.add(edition2) database_session.commit() + email_auth1 = models.AuthEmail(user_id=user1.user_id, email="user1@mail.com", pw_hash="HASH1") + github_auth1 = models.AuthGitHub(user_id=user2.user_id, gh_auth_id=123, email="user2@mail.com") + database_session.add(email_auth1) + database_session.add(github_auth1) + database_session.commit() + # Create coach roles database_session.execute(models.user_editions.insert(), [ {"user_id": user1.user_id, "edition_id": edition1.edition_id}, @@ -34,8 +41,9 @@ def data(database_session: Session) -> dict[str, int]: return {"user1": user1.user_id, "user2": user2.user_id, - "edition1": edition1.edition_id, - "edition2": edition2.edition_id, + "edition1": edition1.name, + "edition2": edition2.name, + "email1": "user1@mail.com" } @@ -43,54 +51,351 @@ def test_get_all_users(database_session: Session, data: dict[str, int]): """Test get request for users""" # get all users - users = users_crud.get_all_users(database_session) + users = users_crud.get_users_filtered_page(database_session, FilterParameters()) assert len(users) == 2, "Wrong length" user_ids = [user.user_id for user in users] assert data["user1"] in user_ids assert data["user2"] in user_ids -def test_get_all_admins(database_session: Session, data: dict[str, int]): +def test_get_all_users_paginated(database_session: Session): + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=False)) + database_session.commit() + + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(page=0))) == DB_PAGE_SIZE + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(page=1))) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE + + +def test_get_all_users_paginated_filter_name(database_session: Session): + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=False)) + if "1" in str(i): + count += 1 + database_session.commit() + + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(page=0, name="1"))) == count + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(page=1, name="1"))) == max( + count - round( + DB_PAGE_SIZE * 1.5), 0) + + +def test_get_all_admins(database_session: Session, data: dict[str, str]): """Test get request for admins""" # get all admins - users = users_crud.get_all_admins(database_session) + users = users_crud.get_users_filtered_page(database_session, FilterParameters(admin=True)) assert len(users) == 1, "Wrong length" assert data["user1"] == users[0].user_id -def test_get_all_users_from_edition(database_session: Session, data: dict[str, int]): +def test_get_all_admins_paginated(database_session: Session): + admins = [] + for i in range(round(DB_PAGE_SIZE * 3)): + user = models.User(name=f"User {i}", admin=i % 2 == 0) + database_session.add(user) + if i % 2 == 0: + admins.append(user) + database_session.commit() + + count = len(admins) + users = users_crud.get_users_filtered_page(database_session, FilterParameters(page=0, admin=True)) + assert len(users) == min(count, DB_PAGE_SIZE) + for user in users: + assert user in admins + + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(page=1, admin=True))) == \ + min(count - DB_PAGE_SIZE, DB_PAGE_SIZE) + + +def test_get_all_non_admins_paginated(database_session: Session): + non_admins = [] + for i in range(round(DB_PAGE_SIZE * 3)): + user = models.User(name=f"User {i}", admin=i % 2 == 0) + database_session.add(user) + if i % 2 != 0: + non_admins.append(user) + database_session.commit() + + count = len(non_admins) + users = users_crud.get_users_filtered_page(database_session, FilterParameters(page=0, admin=False)) + assert len(users) == min(count, DB_PAGE_SIZE) + for user in users: + assert user in non_admins + + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(page=1, admin=False))) == \ + min(count - DB_PAGE_SIZE, DB_PAGE_SIZE) + + +def test_get_all_admins_paginated_filter_name(database_session: Session): + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=i % 2 == 0)) + if "1" in str(i) and i % 2 == 0: + count += 1 + database_session.commit() + + assert len( + users_crud.get_users_filtered_page(database_session, FilterParameters(page=0, name="1", admin=True))) == count + assert len( + users_crud.get_users_filtered_page(database_session, FilterParameters(page=1, name="1", admin=True))) == max( + count - round( + DB_PAGE_SIZE * 1.5), 0) + + +def test_get_user_edition_names_empty(database_session: Session): + """Test getting all editions from a user when there are none""" + user = models.User(name="test") + database_session.add(user) + database_session.commit() + + # No editions yet + editions = users_crud.get_user_edition_names(database_session, user) + assert len(editions) == 0 + + +def test_get_user_edition_names_admin(database_session: Session): + """Test getting all editions for an admin""" + user = models.User(name="test", admin=True) + database_session.add(user) + + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + + # Not added to edition yet, but admin can see it anyway + editions = users_crud.get_user_edition_names(database_session, user) + assert len(editions) == 1 + + +def test_get_user_edition_names_coach(database_session: Session): + """Test getting all editions for a coach when they aren't empty""" + user = models.User(name="test") + database_session.add(user) + + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + + # No editions yet + editions = users_crud.get_user_edition_names(database_session, user) + assert len(editions) == 0 + + # Add user to a new edition + user.editions.append(edition) + database_session.add(user) + database_session.commit() + + # No editions yet + editions = users_crud.get_user_edition_names(database_session, user) + assert editions == [edition.name] + + +def test_get_all_users_from_edition(database_session: Session, data: dict[str, str]): """Test get request for users of a given edition""" # get all users from edition - users = users_crud.get_users_from_edition(database_session, data["edition1"]) + users = users_crud.get_users_filtered_page(database_session, FilterParameters(edition=data["edition1"])) assert len(users) == 2, "Wrong length" user_ids = [user.user_id for user in users] assert data["user1"] in user_ids assert data["user2"] in user_ids - users = users_crud.get_users_from_edition(database_session, data["edition2"]) + users = users_crud.get_users_filtered_page(database_session, FilterParameters(edition=data["edition2"])) assert len(users) == 1, "Wrong length" assert data["user2"] == users[0].user_id -def test_get_admins_from_edition(database_session: Session, data: dict[str, int]): - """Test get request for admins of a given edition""" +def test_get_all_users_for_edition_paginated(database_session: Session): + edition_1 = models.Edition(year=2022, name="ed2022") + edition_2 = models.Edition(year=2023, name="ed2023") + database_session.add(edition_1) + database_session.add(edition_2) + database_session.commit() - # get all admins from edition - users = users_crud.get_admins_from_edition(database_session, data["edition1"]) - assert len(users) == 1, "Wrong length" - assert data["user1"] == users[0].user_id + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_1.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_2.edition_id}, + ]) + database_session.commit() - users = users_crud.get_admins_from_edition(database_session, data["edition2"]) - assert len(users) == 0, "Wrong length" + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(edition=edition_1.name, + page=0))) == DB_PAGE_SIZE + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(edition=edition_1.name, page=1))) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE + assert len(users_crud.get_users_filtered_page(database_session, FilterParameters(edition=edition_2.name, + page=0))) == DB_PAGE_SIZE + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(edition=edition_2.name, page=1))) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE + + +def test_get_all_users_for_edition_paginated_filter_name(database_session: Session): + edition_1 = models.Edition(year=2022, name="ed2022") + edition_2 = models.Edition(year=2023, name="ed2023") + database_session.add(edition_1) + database_session.add(edition_2) + database_session.commit() + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_1.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_2.edition_id}, + ]) + if "1" in str(i): + count += 1 + database_session.commit() + + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(edition=edition_1.name, page=0, name="1"))) == \ + min(count, DB_PAGE_SIZE) + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(edition=edition_1.name, page=1, name="1"))) == \ + max(count - DB_PAGE_SIZE, 0) + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(edition=edition_2.name, page=0, name="1"))) == \ + min(count, DB_PAGE_SIZE) + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(edition=edition_2.name, page=1, name="1"))) == \ + max(count - DB_PAGE_SIZE, 0) + + +def test_get_all_users_excluded_edition_paginated(database_session: Session): + edition_a = models.Edition(year=2022, name="edA") + edition_b = models.Edition(year=2023, name="edB") + database_session.add(edition_a) + database_session.add(edition_b) + database_session.commit() + + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_a.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_b.edition_id}, + ]) + database_session.commit() + + a_users = users_crud.get_users_filtered_page(database_session, + FilterParameters(page=0, exclude_edition="edB", name="")) + assert len(a_users) == DB_PAGE_SIZE + for user in a_users: + assert "b" not in user.name + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(page=1, exclude_edition="edB", name=""))) == \ + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + + b_users = users_crud.get_users_filtered_page(database_session, + FilterParameters(page=0, exclude_edition="edA", name="")) + assert len(b_users) == DB_PAGE_SIZE + for user in b_users: + assert "a" not in user.name + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(page=1, exclude_edition="edA", name=""))) == \ + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + + +def test_get_all_users_excluded_edition_paginated_filter_name(database_session: Session): + edition_a = models.Edition(year=2022, name="edA") + edition_b = models.Edition(year=2023, name="edB") + database_session.add(edition_a) + database_session.add(edition_b) + database_session.commit() + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_a.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_b.edition_id}, + ]) + if "1" in str(i): + count += 1 + database_session.commit() + + a_users = users_crud.get_users_filtered_page(database_session, + FilterParameters(page=0, exclude_edition="edB", name="1")) + assert len(a_users) == min(count, DB_PAGE_SIZE) + for user in a_users: + assert "b" not in user.name + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(page=1, exclude_edition="edB", name="1"))) == \ + max(count - DB_PAGE_SIZE, 0) + + b_users = users_crud.get_users_filtered_page(database_session, + FilterParameters(page=0, exclude_edition="edA", name="1")) + assert len(b_users) == min(count, DB_PAGE_SIZE) + for user in b_users: + assert "a" not in user.name + assert len(users_crud.get_users_filtered_page(database_session, + FilterParameters(page=1, exclude_edition="edA", name="1"))) == \ + max(count - DB_PAGE_SIZE, 0) + + +def test_get_all_users_for_edition_excluded_edition_paginated(database_session: Session): + edition_a = models.Edition(year=2022, name="edA") + edition_b = models.Edition(year=2023, name="edB") + database_session.add(edition_a) + database_session.add(edition_b) + database_session.commit() + + correct_users = [] + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_a.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_b.edition_id}, + ]) + if i % 2: + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_b.edition_id}, + ]) + else: + correct_users.append(user_1) + + database_session.commit() + + users = users_crud.get_users_filtered_page(database_session, FilterParameters(page=0, exclude_edition="edB", + edition="edA")) + assert len(users) == len(correct_users) + for user in users: + assert user in correct_users def test_edit_admin_status(database_session: Session): """Test changing the admin status of a user""" # Create user - user = models.User(name="user1", email="user1@mail.com", admin=False) + user = models.User(name="user1", admin=False) database_session.add(user) database_session.commit() @@ -105,16 +410,16 @@ def test_add_coach(database_session: Session): """Test adding a user as coach""" # Create user - user = models.User(name="user1", email="user1@mail.com", admin=False) + user = models.User(name="user1", admin=False) database_session.add(user) # Create edition - edition = models.Edition(year=1) + edition = models.Edition(year=1, name="ed1") database_session.add(edition) database_session.commit() - users_crud.add_coach(database_session, user.user_id, edition.edition_id) + users_crud.add_coach(database_session, user.user_id, edition.name) coach = database_session.query(user_editions).one() assert coach.user_id == user.user_id assert coach.edition_id == edition.edition_id @@ -124,36 +429,69 @@ def test_remove_coach(database_session: Session): """Test removing a user as coach""" # Create user - user = models.User(name="user1", email="user1@mail.com", admin=False) - database_session.add(user) + user1 = models.User(name="user1", admin=False) + database_session.add(user1) + user2 = models.User(name="user2", admin=False) + database_session.add(user2) # Create edition - edition = models.Edition(year=1) + edition = models.Edition(year=1, name="ed1") database_session.add(edition) database_session.commit() # Create coach role database_session.execute(models.user_editions.insert(), [ - {"user_id": user.user_id, "edition_id": edition.edition_id} + {"user_id": user1.user_id, "edition_id": edition.edition_id}, + {"user_id": user2.user_id, "edition_id": edition.edition_id} + ]) + + users_crud.remove_coach(database_session, user1.user_id, edition.name) + assert len(database_session.query(user_editions).all()) == 1 + + +def test_remove_coach_all_editions(database_session: Session): + """Test removing a user as coach from all editions""" + + # Create user + user1 = models.User(name="user1", admin=False) + database_session.add(user1) + user2 = models.User(name="user2", admin=False) + database_session.add(user2) + + # Create edition + edition1 = models.Edition(year=1, name="ed1") + edition2 = models.Edition(year=2, name="ed2") + edition3 = models.Edition(year=3, name="ed3") + database_session.add(edition1) + database_session.add(edition2) + database_session.add(edition3) + + database_session.commit() + + # Create coach role + database_session.execute(models.user_editions.insert(), [ + {"user_id": user1.user_id, "edition_id": edition1.edition_id}, + {"user_id": user1.user_id, "edition_id": edition2.edition_id}, + {"user_id": user1.user_id, "edition_id": edition3.edition_id}, + {"user_id": user2.user_id, "edition_id": edition2.edition_id}, ]) - users_crud.remove_coach(database_session, user.user_id, edition.edition_id) - assert len(database_session.query(user_editions).all()) == 0 + users_crud.remove_coach_all_editions(database_session, user1.user_id) + assert len(database_session.query(user_editions).all()) == 1 def test_get_all_requests(database_session: Session): """Test get request for all userrequests""" - # Create user - user1 = models.User(name="user1", email="user1@mail.com") - user2 = models.User(name="user2", email="user2@mail.com") + user1 = models.User(name="user1") + user2 = models.User(name="user2") database_session.add(user1) database_session.add(user2) # Create edition - edition1 = models.Edition(year=1) - edition2 = models.Edition(year=2) + edition1 = models.Edition(year=1, name="ed1") + edition2 = models.Edition(year=2, name="ed2") database_session.add(edition1) database_session.add(edition2) @@ -167,7 +505,7 @@ def test_get_all_requests(database_session: Session): database_session.commit() - requests = users_crud.get_all_requests(database_session) + requests = users_crud.get_requests(database_session) assert len(requests) == 2 assert request1 in requests assert request2 in requests @@ -176,18 +514,53 @@ def test_get_all_requests(database_session: Session): assert user2 in users +def test_get_requests_paginated(database_session: Session): + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(CoachRequest(user=user, edition=edition)) + database_session.commit() + + assert len(users_crud.get_requests_page(database_session, 0)) == DB_PAGE_SIZE + assert len(users_crud.get_requests_page(database_session, 1)) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE + + +def test_get_requests_paginated_filter_user_name(database_session: Session): + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(CoachRequest(user=user, edition=edition)) + if "1" in str(i): + count += 1 + database_session.commit() + + assert len(users_crud.get_requests_page(database_session, 0, "1")) == \ + min(DB_PAGE_SIZE, count) + assert len(users_crud.get_requests_page(database_session, 1, "1")) == \ + max(count - DB_PAGE_SIZE, 0) + + def test_get_all_requests_from_edition(database_session: Session): """Test get request for all userrequests of a given edition""" # Create user - user1 = models.User(name="user1", email="user1@mail.com") - user2 = models.User(name="user2", email="user2@mail.com") + user1 = models.User(name="user1") + user2 = models.User(name="user2") database_session.add(user1) database_session.add(user2) # Create edition - edition1 = models.Edition(year=1) - edition2 = models.Edition(year=2) + edition1 = models.Edition(year=1, name="ed1") + edition2 = models.Edition(year=2, name="ed2") database_session.add(edition1) database_session.add(edition2) @@ -201,24 +574,59 @@ def test_get_all_requests_from_edition(database_session: Session): database_session.commit() - requests = users_crud.get_all_requests_from_edition(database_session, edition1.edition_id) + requests = users_crud.get_requests_for_edition(database_session, edition1.name) assert len(requests) == 1 assert requests[0].user == user1 - requests = users_crud.get_all_requests_from_edition(database_session, edition2.edition_id) + requests = users_crud.get_requests_for_edition(database_session, edition2.name) assert len(requests) == 1 assert requests[0].user == user2 +def test_get_requests_for_edition_paginated(database_session: Session): + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(CoachRequest(user=user, edition=edition)) + database_session.commit() + + assert len(users_crud.get_requests_for_edition_page(database_session, edition.name, 0)) == DB_PAGE_SIZE + assert len(users_crud.get_requests_for_edition_page(database_session, edition.name, 1)) == round( + DB_PAGE_SIZE * 1.5 + ) - DB_PAGE_SIZE + + +def test_get_requests_for_edition_paginated_filter_user_name(database_session: Session): + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(CoachRequest(user=user, edition=edition)) + if "1" in str(i): + count += 1 + database_session.commit() + + assert len(users_crud.get_requests_for_edition_page(database_session, edition.name, 0, "1")) == \ + min(DB_PAGE_SIZE, count) + assert len(users_crud.get_requests_for_edition_page(database_session, edition.name, 1, "1")) == \ + max(count - DB_PAGE_SIZE, 0) + + def test_accept_request(database_session: Session): """Test accepting a coach request""" # Create user - user1 = models.User(name="user1", email="user1@mail.com") + user1 = models.User(name="user1") database_session.add(user1) # Create edition - edition1 = models.Edition(year=1) + edition1 = models.Edition(year=1, name="ed1") database_session.add(edition1) database_session.commit() @@ -241,22 +649,56 @@ def test_reject_request_new_user(database_session: Session): """Test rejecting a coach request""" # Create user - user1 = models.User(name="user1", email="user1@mail.com") + user1 = models.User(name="user1") database_session.add(user1) # Create edition - edition1 = models.Edition(year=1) + edition1 = models.Edition(year=1, name="ed2022") database_session.add(edition1) - database_session.commit() # Create request request1 = models.CoachRequest(user_id=user1.user_id, edition_id=edition1.edition_id) database_session.add(request1) - database_session.commit() users_crud.reject_request(database_session, request1.request_id) requests = database_session.query(CoachRequest).all() assert len(requests) == 0 + + +def test_remove_request_if_exists_exists(database_session: Session): + """Test deleting a request when it exists""" + user = models.User(name="user1") + database_session.add(user) + + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + + request = models.CoachRequest(user_id=user.user_id, edition_id=edition.edition_id) + database_session.add(request) + database_session.commit() + + assert database_session.query(CoachRequest).count() == 1 + + # Remove the request + users_crud.remove_request_if_exists(database_session, user.user_id, edition.name) + + assert database_session.query(CoachRequest).count() == 0 + + +def test_remove_request_if_not_exists(database_session: Session): + """Test deleting a request when it doesn't exist""" + user = models.User(name="user1") + database_session.add(user) + + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + + # Remove the request + # If the test succeeds then it means no error was raised, even though the request + # doesn't exist + users_crud.remove_request_if_exists(database_session, user.user_id, edition.name) diff --git a/backend/tests/test_database/test_models.py b/backend/tests/test_database/test_models.py index 2a177bc76..662217c4e 100644 --- a/backend/tests/test_database/test_models.py +++ b/backend/tests/test_database/test_models.py @@ -4,12 +4,13 @@ def test_user_coach_request(database_session: Session): - edition = models.Edition(year=2022) + """Test sending a coach request""" + edition = models.Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Passing as user_id - user = models.User(name="name", email="email1") + user = models.User(name="name") database_session.add(user) database_session.commit() @@ -20,7 +21,7 @@ def test_user_coach_request(database_session: Session): assert req.user == user # Check if passing as user instead of user_id works - user = models.User(name="name", email="email2") + user = models.User(name="name") database_session.add(user) database_session.commit() @@ -32,6 +33,7 @@ def test_user_coach_request(database_session: Session): def test_project_partners(database_session: Session): + """Test adding a partner to a project""" project = models.Project(name="project") database_session.add(project) database_session.commit() diff --git a/backend/tests/test_fill_database.py b/backend/tests/test_fill_database.py index e702d1de6..d68444d8e 100644 --- a/backend/tests/test_fill_database.py +++ b/backend/tests/test_fill_database.py @@ -3,4 +3,4 @@ def test_fill_database(database_session: Session): """Test that fill_database don't give an error""" - fill_database(database_session) \ No newline at end of file + fill_database(database_session) diff --git a/backend/tests/test_logic/test_register.py b/backend/tests/test_logic/test_register.py index e58f1f0e5..02dea4cd5 100644 --- a/backend/tests/test_logic/test_register.py +++ b/backend/tests/test_logic/test_register.py @@ -9,10 +9,9 @@ from src.app.exceptions.register import FailedToAddNewUserException - def test_create_request(database_session: Session): """Tests if a normal request can be created""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) invite_link: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") @@ -34,7 +33,7 @@ def test_create_request(database_session: Session): def test_duplicate_user(database_session: Session): """Tests if there is a duplicate, it's not created in the database""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) invite_link1: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") @@ -45,14 +44,38 @@ def test_duplicate_user(database_session: Session): pw="wachtwoord1", uuid=invite_link1.uuid) nu2 = NewUser(name="user2", email="email@email.com", pw="wachtwoord2", uuid=invite_link2.uuid) - create_request(database_session, nu1, edition) - with pytest.raises(FailedToAddNewUserException): + + # These two have to be nested transactions because they share the same database_session, + # and otherwise the second one rolls the first one back + # Making them nested transactions creates a savepoint so only that part is rolled back + with database_session.begin_nested(): + create_request(database_session, nu1, edition) + + with pytest.raises(FailedToAddNewUserException), database_session.begin_nested(): create_request(database_session, nu2, edition) + # Verify that second user wasn't added + # the first addition was successful, the second wasn't + users = database_session.query(User).all() + assert len(users) == 1 + assert users[0].name == nu1.name + + emails = database_session.query(AuthEmail).all() + assert len(emails) == 1 + assert emails[0].user == users[0] + + requests = database_session.query(CoachRequest).all() + assert len(requests) == 1 + assert requests[0].user == users[0] + + # Verify that the link wasn't removed + links = database_session.query(InviteLink).all() + assert len(links) == 1 + def test_use_same_uuid_multiple_times(database_session: Session): """Tests that you can't use the same UUID multiple times""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) invite_link: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") @@ -68,7 +91,7 @@ def test_use_same_uuid_multiple_times(database_session: Session): def test_not_a_correct_email(database_session: Session): """Tests when the email is not a correct email adress, it's get the right error""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() with pytest.raises(ValueError): diff --git a/backend/tests/test_logic/test_security.py b/backend/tests/test_logic/test_security.py index 7bc3b1178..148a9c4eb 100644 --- a/backend/tests/test_logic/test_security.py +++ b/backend/tests/test_logic/test_security.py @@ -2,6 +2,7 @@ def test_hashing(): + """Test the hashing of passwords""" password = "I love inside jokes. I’d love to be a part of one someday" hashed = security.get_password_hash(password) diff --git a/backend/tests/test_routers/test_editions/test_editions/test_editions.py b/backend/tests/test_routers/test_editions/test_editions/test_editions.py index 86643afa8..9c3a2856c 100644 --- a/backend/tests/test_routers/test_editions/test_editions/test_editions.py +++ b/backend/tests/test_routers/test_editions/test_editions/test_editions.py @@ -1,6 +1,7 @@ from sqlalchemy.orm import Session from starlette import status +from settings import DB_PAGE_SIZE from src.database.models import Edition from tests.utils.authorization import AuthClient @@ -10,9 +11,9 @@ def test_get_editions(database_session: Session, auth_client: AuthClient): Args: database_session (Session): a connection with the database - auth_client (AuthClient): a client used to do rest calls + auth_client (AuthClient): a client used to do rest calls """ - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() @@ -25,55 +26,73 @@ def test_get_editions(database_session: Session, auth_client: AuthClient): response = response.json() assert response["editions"][0]["year"] == 2022 assert response["editions"][0]["editionId"] == 1 + assert response["editions"][0]["name"] == "ed2022" -def test_get_edition_by_id_admin(database_session: Session, auth_client: AuthClient): +def test_get_editions_paginated(database_session: Session, auth_client: AuthClient): + """Perform tests on getting paginated editions""" + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(Edition(name=f"Project {i}", year=i)) + database_session.commit() + + auth_client.admin() + + response = auth_client.get("/editions?page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['editions']) == DB_PAGE_SIZE + response = auth_client.get("/editions?page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['editions']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + + +def test_get_edition_by_name_admin(database_session: Session, auth_client: AuthClient): """Test getting an edition as an admin""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() - response = auth_client.get(f"/editions/{edition.edition_id}") + response = auth_client.get(f"/editions/{edition.name}") assert response.status_code == status.HTTP_200_OK -def test_get_edition_by_id_coach(database_session: Session, auth_client: AuthClient): +def test_get_edition_by_name_coach(database_session: Session, auth_client: AuthClient): """Perform tests on getting editions by ids Args: database_session (Session): a connection with the database - auth_client (AuthClient): a client used to do rest calls + auth_client (AuthClient): a client used to do rest calls """ - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() auth_client.coach(edition) # Make the get request - response = auth_client.get(f"/editions/{edition.edition_id}") + response = auth_client.get(f"/editions/{edition.name}") assert response.status_code == status.HTTP_200_OK assert response.json()["year"] == 2022 assert response.json()["editionId"] == edition.edition_id + assert response.json()["name"] == edition.name -def test_get_edition_by_id_unauthorized(database_session: Session, auth_client: AuthClient): +def test_get_edition_by_name_unauthorized(database_session: Session, auth_client: AuthClient): """Test getting an edition without access token""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() - assert auth_client.get(f"/editions/1").status_code == status.HTTP_401_UNAUTHORIZED + assert auth_client.get("/editions/ed2022").status_code == status.HTTP_401_UNAUTHORIZED -def test_get_edition_by_id_not_coach(database_session: Session, auth_client: AuthClient): +def test_get_edition_by_name_not_coach(database_session: Session, auth_client: AuthClient): """Test getting an edition without being a coach in it""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) - coach_edition = Edition(year=2021) + coach_edition = Edition(year=2021, name="ed2021") database_session.add(coach_edition) database_session.commit() @@ -81,7 +100,7 @@ def test_get_edition_by_id_not_coach(database_session: Session, auth_client: Aut # Sign in as a coach in a different edition auth_client.coach(coach_edition) - assert auth_client.get(f"/editions/{edition.edition_id}").status_code == status.HTTP_403_FORBIDDEN + assert auth_client.get(f"/editions/{edition.name}").status_code == status.HTTP_403_FORBIDDEN def test_create_edition_admin(database_session: Session, auth_client: AuthClient): @@ -89,87 +108,131 @@ def test_create_edition_admin(database_session: Session, auth_client: AuthClient auth_client.admin() # Verify that editions doesn't exist yet - assert auth_client.get("/editions/1/").status_code == status.HTTP_404_NOT_FOUND + assert auth_client.get("/editions/ed2022/").status_code == status.HTTP_404_NOT_FOUND # Make the post request - response = auth_client.post("/editions/", json={"year": 2022}) + response = auth_client.post("/editions/", json={"year": 2022, "name": "ed2022"}) assert response.status_code == status.HTTP_201_CREATED assert auth_client.get("/editions/").json()["editions"][0]["year"] == 2022 assert auth_client.get("/editions/").json()["editions"][0]["editionId"] == 1 - assert auth_client.get("/editions/1/").status_code == status.HTTP_200_OK + assert auth_client.get("/editions/").json()["editions"][0]["name"] == "ed2022" + assert auth_client.get("/editions/ed2022/").status_code == status.HTTP_200_OK def test_create_edition_unauthorized(database_session: Session, auth_client: AuthClient): """Test creating an edition without any credentials""" - assert auth_client.post("/editions/", json={"year": 2022}).status_code == status.HTTP_401_UNAUTHORIZED + assert auth_client.post("/editions/", json={"year": 2022, "name": "ed2022"}).status_code == status.HTTP_401_UNAUTHORIZED def test_create_edition_coach(database_session: Session, auth_client: AuthClient): """Test creating an edition as a coach""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() auth_client.coach(edition) - assert auth_client.post("/editions/", json={"year": 2022}).status_code == status.HTTP_403_FORBIDDEN + assert auth_client.post("/editions/", json={"year": 2022, "name": "ed2022"}).status_code == status.HTTP_403_FORBIDDEN def test_create_edition_existing_year(database_session: Session, auth_client: AuthClient): """Test that creating an edition for a year that already exists throws an error""" auth_client.admin() - response = auth_client.post("/editions/", json={"year": 2022}) + response = auth_client.post("/editions/", json={"year": 2022, "name": "ed2022"}) assert response.status_code == status.HTTP_201_CREATED # Try to make an edition in the same year - response = auth_client.post("/editions/", json={"year": 2022}) + response = auth_client.post("/editions/", json={"year": 2022, "name": "ed2022"}) assert response.status_code == status.HTTP_409_CONFLICT +def test_create_edition_malformed(database_session: Session, auth_client: AuthClient): + auth_client.admin() + + response = auth_client.post("/editions/", json={"year": 2023, "name": "Life is fun"}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_delete_edition_admin(database_session: Session, auth_client: AuthClient): """Perform tests on deleting editions Args: database_session (Session): a connection with the database - auth_client (AuthClient): a client used to do rest calls + auth_client (AuthClient): a client used to do rest calls """ auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Make the delete request - response = auth_client.delete(f"/editions/{edition.edition_id}") + response = auth_client.delete(f"/editions/{edition.name}") assert response.status_code == status.HTTP_204_NO_CONTENT def test_delete_edition_unauthorized(database_session: Session, auth_client: AuthClient): """Test deleting an edition without any credentials""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Make the delete request - assert auth_client.delete(f"/editions/{edition.edition_id}").status_code == status.HTTP_401_UNAUTHORIZED + assert auth_client.delete(f"/editions/{edition.name}").status_code == status.HTTP_401_UNAUTHORIZED def test_delete_edition_coach(database_session: Session, auth_client: AuthClient): """Test deleting an edition as a coach""" - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() auth_client.coach(edition) # Make the delete request - assert auth_client.delete(f"/editions/{edition.edition_id}").status_code == status.HTTP_403_FORBIDDEN + assert auth_client.delete(f"/editions/{edition.name}").status_code == status.HTTP_403_FORBIDDEN def test_delete_edition_non_existing(database_session: Session, auth_client: AuthClient): """Delete an edition that doesn't exist""" auth_client.admin() - response = auth_client.delete("/edition/1") + response = auth_client.delete("/edition/doesnotexist") assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_editions_limited_permission(database_session: Session, auth_client: AuthClient): + """A coach should only see the editions they are drafted for""" + edition = Edition(year=2022, name="ed2022") + edition2 = Edition(year=2023, name="ed2023") + database_session.add(edition) + database_session.add(edition2) + database_session.commit() + + auth_client.coach(edition) + + # Make the get request + response = auth_client.get("/editions/") + + assert response.status_code == status.HTTP_200_OK + response = response.json() + assert response["editions"][0]["year"] == 2022 + assert response["editions"][0]["editionId"] == 1 + assert response["editions"][0]["name"] == "ed2022" + assert len(response["editions"]) == 1 + + +def test_get_edition_by_name_coach_not_assigned(database_session: Session, auth_client: AuthClient): + """A coach not assigned to the edition should not be able to see it""" + edition = Edition(year=2022, name="ed2022") + edition2 = Edition(year=2023, name="ed2023") + database_session.add(edition) + database_session.add(edition2) + database_session.commit() + + auth_client.coach(edition) + + # Make the get request + response = auth_client.get(f"/editions/{edition2.name}") + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/tests/test_routers/test_editions/test_invites/test_invites.py b/backend/tests/test_routers/test_editions/test_invites/test_invites.py index d82b481fa..64f6958b3 100644 --- a/backend/tests/test_routers/test_editions/test_invites/test_invites.py +++ b/backend/tests/test_routers/test_editions/test_invites/test_invites.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from starlette import status +from settings import DB_PAGE_SIZE from src.database.models import Edition, InviteLink from tests.utils.authorization import AuthClient @@ -11,10 +12,10 @@ def test_get_empty_invites(database_session: Session, auth_client: AuthClient): """Test endpoint for getting invites when db is empty""" auth_client.admin() - database_session.add(Edition(year=2022)) + database_session.add(Edition(year=2022, name="ed2022")) database_session.commit() - response = auth_client.get("/editions/1/invites") + response = auth_client.get("/editions/ed2022/invites") assert response.status_code == status.HTTP_200_OK assert response.json() == {"inviteLinks": []} @@ -23,76 +24,99 @@ def test_get_empty_invites(database_session: Session, auth_client: AuthClient): def test_get_invites(database_session: Session, auth_client: AuthClient): """Test endpoint for getting invites when db is not empty""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() database_session.add(InviteLink(target_email="test@ema.il", edition=edition)) database_session.commit() - response = auth_client.get("/editions/1/invites") + response = auth_client.get("/editions/ed2022/invites") + print(response.json()) assert response.status_code == status.HTTP_200_OK json = response.json() assert len(json["inviteLinks"]) == 1 link = json["inviteLinks"][0] assert link["id"] == 1 assert link["email"] == "test@ema.il" - assert link["editionId"] == 1 + + +def test_get_invites_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting paginated invites when db is not empty""" + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(InviteLink(target_email=f"{i}@example.com", edition=edition)) + database_session.commit() + + auth_client.admin() + + response = auth_client.get("/editions/ed2022/invites?page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['inviteLinks']) == DB_PAGE_SIZE + response = auth_client.get("/editions/ed2022/invites?page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['inviteLinks']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE def test_create_invite_valid(database_session: Session, auth_client: AuthClient): """Test endpoint for creating invites when data is valid""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Create POST request - response = auth_client.post("/editions/1/invites/", data=dumps({"email": "test@ema.il"})) + response = auth_client.post("/editions/ed2022/invites/", data=dumps({"email": "test@ema.il"})) assert response.status_code == status.HTTP_201_CREATED json = response.json() assert "mailTo" in json assert json["mailTo"].startswith("mailto:test@ema.il") + assert "inviteLink" in json # New entry made in database - json = auth_client.get("/editions/1/invites/").json() + json = auth_client.get("/editions/ed2022/invites/").json() assert len(json["inviteLinks"]) == 1 new_uuid = json["inviteLinks"][0]["uuid"] - assert auth_client.get(f"/editions/1/invites/{new_uuid}/").status_code == status.HTTP_200_OK + assert auth_client.get(f"/editions/ed2022/invites/{new_uuid}/").status_code == status.HTTP_200_OK def test_create_invite_invalid(database_session: Session, auth_client: AuthClient): """Test endpoint for creating invites when data is invalid""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Invalid POST will send invalid status code - response = auth_client.post("/editions/1/invites/", data=dumps({"email": "invalid field"})) + response = auth_client.post("/editions/ed2022/invites/", data=dumps({"email": "invalid field"})) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY # Verify that no new entry was made after the error - assert len(auth_client.get("/editions/1/invites/").json()["inviteLinks"]) == 0 + assert len(auth_client.get("/editions/ed2022/invites/").json()["inviteLinks"]) == 0 def test_delete_invite_invalid(database_session: Session, auth_client: AuthClient): """Test endpoint for deleting invites when uuid is malformed""" auth_client.admin() - assert auth_client.delete("/editions/1/invites/1").status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + database_session.add(Edition(year=2022, name="ed2022")) + database_session.commit() + + assert auth_client.delete("/editions/ed2022/invites/1").status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_delete_invite_valid(database_session: Session, auth_client: AuthClient): """Test endpoint for deleting invites when uuid is valid""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() debug_uuid = "123e4567-e89b-12d3-a456-426614174000" # Not present yet - assert auth_client.delete(f"/editions/1/invites/{debug_uuid}").status_code == status.HTTP_404_NOT_FOUND + assert auth_client.delete(f"/editions/ed2022/invites/{debug_uuid}").status_code == status.HTTP_404_NOT_FOUND # Create new entry in db invite_link = InviteLink(target_email="test@ema.il", edition=edition, uuid=UUID(debug_uuid)) @@ -100,38 +124,38 @@ def test_delete_invite_valid(database_session: Session, auth_client: AuthClient) database_session.commit() # Remove - assert auth_client.delete(f"/editions/1/invites/{invite_link.uuid}").status_code == status.HTTP_204_NO_CONTENT + assert auth_client.delete(f"/editions/ed2022/invites/{invite_link.uuid}").status_code == status.HTTP_204_NO_CONTENT # Not found anymore - assert auth_client.get(f"/editions/1/invites/{invite_link.uuid}/").status_code == status.HTTP_404_NOT_FOUND + assert auth_client.get(f"/editions/ed2022/invites/{invite_link.uuid}/").status_code == status.HTTP_404_NOT_FOUND def test_get_invite_malformed_uuid(database_session: Session, auth_client: AuthClient): """Test endpoint for fetching invites when uuid is malformed""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() # Verify malformed uuid (1) - assert auth_client.get("/editions/1/invites/1/").status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert auth_client.get("/editions/ed2022/invites/1/").status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_get_invite_non_existing(database_session: Session, auth_client: AuthClient): """Test endpoint for fetching invites when uuid is valid but doesn't exist""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() assert auth_client.get( - "/editions/1/invites/123e4567-e89b-12d3-a456-426614174000").status_code == status.HTTP_404_NOT_FOUND + "/editions/ed2022/invites/123e4567-e89b-12d3-a456-426614174000").status_code == status.HTTP_404_NOT_FOUND def test_get_invite_present(database_session: Session, auth_client: AuthClient): """Test endpoint to fetch an invite when one is present""" auth_client.admin() - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() @@ -143,8 +167,22 @@ def test_get_invite_present(database_session: Session, auth_client: AuthClient): database_session.commit() # Found the correct result now - response = auth_client.get(f"/editions/1/invites/{debug_uuid}") + response = auth_client.get(f"/editions/ed2022/invites/{debug_uuid}") json = response.json() assert response.status_code == status.HTTP_200_OK assert json["uuid"] == debug_uuid assert json["email"] == "test@ema.il" + + +def test_create_invite_valid_old_edition(database_session: Session, auth_client: AuthClient): + """Test endpoint for creating invites when data is valid, but the edition is read-only""" + auth_client.admin() + edition = Edition(year=2022, name="ed2022") + edition2 = Edition(year=2023, name="ed2023") + database_session.add(edition) + database_session.add(edition2) + database_session.commit() + + # Create POST request + response = auth_client.post("/editions/ed2022/invites/", data=dumps({"email": "test@ema.il"})) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/backend/tests/test_routers/test_editions/test_projects/__init__.py b/backend/tests/test_routers/test_editions/test_projects/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_routers/test_editions/test_projects/test_projects.py b/backend/tests/test_routers/test_editions/test_projects/test_projects.py new file mode 100644 index 000000000..556604c90 --- /dev/null +++ b/backend/tests/test_routers/test_editions/test_projects/test_projects.py @@ -0,0 +1,333 @@ +import pytest +from sqlalchemy.orm import Session +from starlette import status + +from settings import DB_PAGE_SIZE +from src.database.models import Edition, Project, User, Skill, ProjectRole, Student, Partner +from tests.utils.authorization import AuthClient + + +@pytest.fixture +def database_with_data(database_session: Session) -> Session: + """fixture for adding data to the database""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + project1 = Project(name="project1", edition=edition, number_of_students=2) + project2 = Project(name="project2", edition=edition, number_of_students=3) + project3 = Project(name="super nice project", edition=edition, number_of_students=3) + database_session.add(project1) + database_session.add(project2) + database_session.add(project3) + user: User = User(name="coach1") + database_session.add(user) + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3]) + student02: Student = Student(first_name="Isabella", last_name="Christensen", preferred_name="Isabella", + email_address="isabella.christensen@example.com", phone_number="98389723", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill2]) + project_role1: ProjectRole = ProjectRole( + student=student01, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + project_role2: ProjectRole = ProjectRole( + student=student01, project=project2, skill=skill3, drafter=user, argumentation="argmunet") + project_role3: ProjectRole = ProjectRole( + student=student02, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + database_session.add(project_role1) + database_session.add(project_role2) + database_session.add(project_role3) + database_session.commit() + + return database_session + + +@pytest.fixture +def current_edition(database_with_data: Session) -> Edition: + """fixture to get the latest edition""" + return database_with_data.query(Edition).all()[-1] + + +def test_get_projects(database_with_data: Session, auth_client: AuthClient): + """Tests get all projects""" + auth_client.admin() + response = auth_client.get("/editions/ed2022/projects") + json = response.json() + + assert len(json['projects']) == 3 + assert json['projects'][0]['name'] == "project1" + assert json['projects'][1]['name'] == "project2" + assert json['projects'][2]['name'] == "super nice project" + + +def test_get_projects_paginated(database_session: Session, auth_client: AuthClient): + """test get all projects paginated""" + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(Project(name=f"Project {i}", edition=edition, number_of_students=5)) + database_session.commit() + + auth_client.admin() + + response = auth_client.get("/editions/ed2022/projects?page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['projects']) == DB_PAGE_SIZE + response = auth_client.get("/editions/ed2022/projects?page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['projects']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + + +def test_get_project(database_with_data: Session, auth_client: AuthClient): + """Tests get a specific project""" + auth_client.admin() + response = auth_client.get("/editions/ed2022/projects/1") + assert response.status_code == status.HTTP_200_OK + json = response.json() + assert json['name'] == 'project1' + + +def test_delete_project(database_with_data: Session, auth_client: AuthClient): + """Tests delete a project""" + auth_client.admin() + response = auth_client.get("/editions/ed2022/projects/1") + assert response.status_code == status.HTTP_200_OK + response = auth_client.delete("/editions/ed2022/projects/1") + assert response.status_code == status.HTTP_204_NO_CONTENT + response = auth_client.get("/editions/ed2022/projects/1") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_ghost_project(database_with_data: Session, auth_client: AuthClient): + """Tests delete a project that doesn't exist""" + auth_client.admin() + response = auth_client.get("/editions/ed2022/projects/400") + assert response.status_code == status.HTTP_404_NOT_FOUND + response = auth_client.delete("/editions/ed2022/projects/400") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_create_project(database_with_data: Session, auth_client: AuthClient): + """Tests creating a project""" + auth_client.admin() + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + assert len(json['projects']) == 3 + assert len(database_with_data.query(Partner).all()) == 0 + + response = \ + auth_client.post("/editions/ed2022/projects/", + json={"name": "test", + "number_of_students": 5, + "skills": [1, 1, 1, 1, 1], "partners": ["ugent"], "coaches": [1]}) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()['name'] == 'test' + assert response.json()["partners"][0]["name"] == "ugent" + + assert len(database_with_data.query(Partner).all()) == 1 + + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + + assert len(json['projects']) == 4 + assert json['projects'][3]['name'] == "test" + + +def test_create_project_same_partner(database_with_data: Session, auth_client: AuthClient): + """Tests that creating a project doesn't create a partner if the partner already exists""" + auth_client.admin() + assert len(database_with_data.query(Partner).all()) == 0 + + auth_client.post("/editions/ed2022/projects/", + json={"name": "test1", + "number_of_students": 2, + "skills": [1, 2], "partners": ["ugent"], "coaches": [1]}) + auth_client.post("/editions/ed2022/projects/", + json={"name": "test2", + "number_of_students": 2, + "skills": [1, 2], "partners": ["ugent"], "coaches": [1]}) + assert len(database_with_data.query(Partner).all()) == 1 + + +def test_create_project_non_existing_skills(database_with_data: Session, auth_client: AuthClient): + """Tests creating a project with non-existing skills""" + auth_client.admin() + response = auth_client.get('/editions/ed2022/projects') + + json = response.json() + assert len(json['projects']) == 3 + + assert len(database_with_data.query(Skill).where( + Skill.skill_id == 100).all()) == 0 + + response = auth_client.post("/editions/ed2022/projects/", + json={"name": "test1", + "number_of_students": 1, + "skills": [100], "partners": ["ugent"], "coaches": [1]}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + assert len(json['projects']) == 3 + + +def test_create_project_non_existing_coach(database_with_data: Session, auth_client: AuthClient): + """Tests creating a project with a coach that doesn't exist""" + auth_client.admin() + response = auth_client.get('/editions/ed2022/projects') + + json = response.json() + assert len(json['projects']) == 3 + + assert len(database_with_data.query(Student).where( + Student.edition_id == 10).all()) == 0 + + response = auth_client.post("/editions/ed2022/projects/", + json={"name": "test2", + "number_of_students": 1, + "skills": [100], "partners": ["ugent"], "coaches": [10]}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + assert len(json['projects']) == 3 + + +def test_create_project_no_name(database_with_data: Session, auth_client: AuthClient): + """Tests creating a project that has no name""" + auth_client.admin() + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + assert len(json['projects']) == 3 + response = \ + auth_client.post("/editions/ed2022/projects/", + # project has no name + json={ + "number_of_students": 5, + "skills": [], "partners": [], "coaches": []}) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + assert len(json['projects']) == 3 + + +def test_patch_project(database_with_data: Session, auth_client: AuthClient): + """Tests patching a project""" + auth_client.admin() + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + + assert len(json['projects']) == 3 + + response = auth_client.patch("/editions/ed2022/projects/1", + json={"name": "patched", + "number_of_students": 5, + "skills": [1, 1, 1, 1, 1], "partners": ["ugent"], "coaches": [1]}) + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = auth_client.get('/editions/ed2022/projects') + json = response.json() + + assert len(json['projects']) == 3 + assert json['projects'][0]['name'] == 'patched' + + +def test_patch_project_non_existing_skills(database_with_data: Session, auth_client: AuthClient): + """Tests patching a project with non-existing skills""" + auth_client.admin() + assert len(database_with_data.query(Skill).where( + Skill.skill_id == 100).all()) == 0 + + response = auth_client.patch("/editions/ed2022/projects/1", + json={"name": "test1", + "number_of_students": 1, + "skills": [100], "partners": ["ugent"], "coaches": [1]}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get("/editions/ed2022/projects/1") + json = response.json() + assert 100 not in json["skills"] + + +def test_patch_project_non_existing_coach(database_with_data: Session, auth_client: AuthClient): + """Tests patching a project with a coach that doesn't exist""" + auth_client.admin() + assert len(database_with_data.query(Student).where( + Student.edition_id == 10).all()) == 0 + + response = auth_client.patch("/editions/ed2022/projects/1", + json={"name": "test2", + "number_of_students": 1, + "skills": [100], "partners": ["ugent"], "coaches": [10]}) + assert response.status_code == status.HTTP_404_NOT_FOUND + response = auth_client.get("/editions/ed2022/projects/1") + json = response.json() + assert 10 not in json["coaches"] + + +def test_patch_wrong_project(database_session: Session, auth_client: AuthClient): + """Tests patching with wrong project info""" + auth_client.admin() + database_session.add(Edition(year=2022, name="ed2022")) + project = Project(name="project", edition_id=1, + project_id=1, number_of_students=2) + database_session.add(project) + database_session.commit() + + response = \ + auth_client.patch("/editions/ed2022/projects/1", + json={"name": "patched", + "skills": [], "partners": [], "coaches": []}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response2 = auth_client.get('/editions/ed2022/projects') + json = response2.json() + + assert len(json['projects']) == 1 + assert json['projects'][0]['name'] == 'project' + + +def test_create_project_old_edition(database_with_data: Session, auth_client: AuthClient): + """test create a project for a readonly edition""" + auth_client.admin() + database_with_data.add(Edition(year=2023, name="ed2023")) + database_with_data.commit() + + response = \ + auth_client.post("/editions/ed2022/projects/", + json={"name": "test", + "number_of_students": 5, + "skills": [1, 1, 1, 1, 1], "partners": ["ugent"], "coaches": [1]}) + + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_search_project_name(database_with_data: Session, auth_client: AuthClient): + """test search project on name""" + auth_client.admin() + response = auth_client.get("/editions/ed2022/projects/?name=super") + assert len(response.json()["projects"]) == 1 + assert response.json()["projects"][0]["name"] == "super nice project" + + +def test_search_project_coach(database_with_data: Session, auth_client: AuthClient): + """test search project on coach""" + auth_client.admin() + user: User = database_with_data.query(User).where(User.name == "Pytest Admin").one() + auth_client.post("/editions/ed2022/projects/", + json={"name": "test", + "number_of_students": 2, + "skills": [1, 1, 1, 1, 1], "partners": ["ugent"], "coaches": [user.user_id]}) + response = auth_client.get("/editions/ed2022/projects/?coach=true") + print(response.json()) + assert len(response.json()["projects"]) == 1 + assert response.json()["projects"][0]["name"] == "test" + assert response.json()["projects"][0]["coaches"][0]["userId"] == user.user_id diff --git a/backend/tests/test_routers/test_editions/test_projects/test_students/__init__.py b/backend/tests/test_routers/test_editions/test_projects/test_students/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py b/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py new file mode 100644 index 000000000..a2c431e61 --- /dev/null +++ b/backend/tests/test_routers/test_editions/test_projects/test_students/test_students.py @@ -0,0 +1,380 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from starlette import status + +from src.database.models import Edition, Project, User, Skill, ProjectRole, Student +from tests.utils.authorization import AuthClient + + +@pytest.fixture +def database_with_data(database_session: Session) -> Session: + """fixture for adding data to the database""" + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + skill4: Skill = Skill(name="skill4", description="something about skill4") + skill5: Skill = Skill(name="skill5", description="something about skill5") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + database_session.add(skill4) + database_session.add(skill5) + project1 = Project(name="project1", edition=edition, number_of_students=4, skills=[skill1, skill2, skill3, skill4, skill5]) + project2 = Project(name="project2", edition=edition, number_of_students=3, skills=[skill1, skill2, skill3, skill4]) + project3 = Project(name="project3", edition=edition, number_of_students=3, skills=[skill1, skill2, skill3]) + database_session.add(project1) + database_session.add(project2) + database_session.add(project3) + user: User = User(name="coach1") + database_session.add(user) + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3, skill4]) + student02: Student = Student(first_name="Isabella", last_name="Christensen", preferred_name="Isabella", + email_address="isabella.christensen@example.com", phone_number="98389723", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill2, skill4]) + student03: Student = Student(first_name="Lotte", last_name="Buss", preferred_name="Lotte", + email_address="lotte.buss@example.com", phone_number="0284-0749932", alumni=False, + wants_to_be_student_coach=False, edition=edition, skills=[skill2, skill3, skill4]) + student04: Student = Student(first_name="Max", last_name="Tester", preferred_name="Mxa", + email_address="max.test@example.com", phone_number="0284-1356832", alumni=False, + wants_to_be_student_coach=False, edition=edition, skills=[skill5]) + database_session.add(student01) + database_session.add(student02) + database_session.add(student03) + database_session.add(student04) + project_role1: ProjectRole = ProjectRole( + student=student01, project=project1, skill=skill1, drafter=user, argumentation="argmunet") + project_role2: ProjectRole = ProjectRole( + student=student01, project=project2, skill=skill3, drafter=user, argumentation="argmunet") + project_role3: ProjectRole = ProjectRole( + student=student02, project=project1, skill=skill2, drafter=user, argumentation="argmunet") + project_role4: ProjectRole = ProjectRole( + student=student04, project=project1, skill=skill5, drafter=user, argumentation="argmunet", definitive=True) + database_session.add(project_role1) + database_session.add(project_role2) + database_session.add(project_role3) + database_session.add(project_role4) + database_session.commit() + + return database_session + + +@pytest.fixture +def current_edition(database_with_data: Session) -> Edition: + """fixture to get the latest edition""" + return database_with_data.query(Edition).all()[-1] + + +def test_add_student_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """tests add a student to a project""" + auth_client.coach(current_edition) + + resp = auth_client.post( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 3}) + + assert resp.status_code == status.HTTP_201_CREATED + + response2 = auth_client.get('/editions/ed2022/projects') + json = response2.json() + + assert len(json['projects'][0]['projectRoles']) == 4 + assert json['projects'][0]['projectRoles'][3]['skillId'] == 3 + + +def test_add_ghost_student_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests adding a non-existing student to a project""" + auth_client.coach(current_edition) + + student10: list[Student] = database_with_data.query( + Student).where(Student.student_id == 10).all() + assert len(student10) == 0 + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + resp = auth_client.post( + "/editions/ed2022/projects/1/students/10", json={"skill_id": 3}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + +def test_add_student_project_non_existing_skill(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests adding a non-existing student to a project""" + auth_client.coach(current_edition) + + skill10: list[Skill] = database_with_data.query( + Skill).where(Skill.skill_id == 10).all() + assert len(skill10) == 0 + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + resp = auth_client.post( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 10}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + +def test_add_student_to_ghost_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests adding a student to a project that doesn't exist""" + auth_client.coach(current_edition) + project10: list[Project] = database_with_data.query( + Project).where(Project.project_id == 10).all() + assert len(project10) == 0 + + resp = auth_client.post( + "/editions/ed2022/projects/10/students/1", json={"skill_id": 1}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_add_incomplete_data_student_project(database_session: Session, auth_client: AuthClient): + """Tests adding a student with incomplete data""" + + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + project = Project(name="project", edition_id=1, + project_id=1, number_of_students=2) + database_session.add(project) + database_session.commit() + + auth_client.coach(edition) + resp = auth_client.post( + "/editions/ed2022/projects/1/students/1", json={}) + + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response2 = auth_client.get('/editions/ed2022/projects') + json = response2.json() + + assert len(json['projects'][0]['projectRoles']) == 0 + + +def test_change_student_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests changing a student's project""" + auth_client.coach(current_edition) + + resp1 = auth_client.patch( + "/editions/ed2022/projects/1/students/1", json={"skill_id": 4}) + + assert resp1.status_code == status.HTTP_204_NO_CONTENT + + response2 = auth_client.get('/editions/ed2022/projects') + json = response2.json() + + assert len(json['projects'][0]['projectRoles']) == 3 + assert json['projects'][0]['projectRoles'][0]['skillId'] == 4 + + +def test_change_incomplete_data_student_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests changing a student's project with incomplete data""" + auth_client.coach(current_edition) + + resp1 = auth_client.patch( + "/editions/ed2022/projects/1/students/1", json={}) + + assert resp1.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response2 = auth_client.get('/editions/ed2022/projects') + json = response2.json() + + assert len(json['projects'][0]['projectRoles']) == 3 + assert json['projects'][0]['projectRoles'][0]['skillId'] == 1 + + +def test_change_ghost_student_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests changing a non-existing student of a project""" + auth_client.coach(current_edition) + + student10: list[Student] = database_with_data.query( + Student).where(Student.student_id == 10).all() + assert len(student10) == 0 + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + resp = auth_client.patch( + "/editions/ed2022/projects/1/students/10", json={"skill_id": 4}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + +def test_change_student_project_non_existing_skill(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests deleting a student from a project that isn't assigned""" + auth_client.coach(current_edition) + + skill10: list[Skill] = database_with_data.query( + Skill).where(Skill.skill_id == 10).all() + assert len(skill10) == 0 + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + resp = auth_client.patch( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 10}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + +def test_change_student_project_ghost_drafter(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests changing a drafter of a ProjectRole to a non-existing one""" + auth_client.coach(current_edition) + user10: list[User] = database_with_data.query( + User).where(User.user_id == 10).all() + assert len(user10) == 0 + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + resp = auth_client.patch( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 4}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + response = auth_client.get('/editions/ed2022/projects/1') + json = response.json() + assert len(json['projectRoles']) == 3 + + +def test_change_student_to_ghost_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests changing a student of a project that doesn't exist""" + auth_client.coach(current_edition) + project10: list[Project] = database_with_data.query( + Project).where(Project.project_id == 10).all() + assert len(project10) == 0 + + resp = auth_client.patch( + "/editions/ed2022/projects/10/students/1", json={"skill_id": 1}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_student_project(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Tests deleting a student from a project""" + auth_client.coach(current_edition) + resp = auth_client.delete("/editions/ed2022/projects/1/students/1") + + assert resp.status_code == status.HTTP_204_NO_CONTENT + + response2 = auth_client.get('/editions/ed2022/projects') + json = response2.json() + + assert len(json['projects'][0]['projectRoles']) == 2 + + +def test_delete_student_project_empty(database_session: Session, auth_client: AuthClient): + """Tests deleting a student from a project that isn't assigned""" + + edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + project = Project(name="project", edition_id=1, + project_id=1, number_of_students=2) + database_session.add(project) + database_session.commit() + + auth_client.coach(edition) + resp = auth_client.delete("/editions/ed2022/projects/1/students/1") + + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_conflicts(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Test getting the conflicts""" + auth_client.coach(current_edition) + response = auth_client.get("/editions/ed2022/projects/conflicts") + json = response.json() + assert len(json['conflictStudents']) == 1 + assert json['conflictStudents'][0]['student']['studentId'] == 1 + assert len(json['conflictStudents'][0]['projects']) == 2 + + +def test_add_student_project_old_edition(database_with_data: Session, auth_client: AuthClient): + """tests add a student to a project from an old edition""" + auth_client.admin() + database_with_data.add(Edition(year=2023, name="ed2023")) + database_with_data.commit() + + resp = auth_client.post( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 1, "drafter_id": 1}) + + assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +def test_add_student_same_project_role(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """Two different students can't have the same project_role""" + auth_client.coach(current_edition) + + resp = auth_client.post( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 2}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + +def test_add_student_project_wrong_project_skill(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """A project_role can't be created if the project doesn't require the skill""" + auth_client.coach(current_edition) + + resp = auth_client.post( + "/editions/ed2022/projects/3/students/3", json={"skill_id": 4}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + +def test_add_student_project_wrong_student_skill(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """A project_role can't be created if the student doesn't have the skill""" + auth_client.coach(current_edition) + + resp = auth_client.post( + "/editions/ed2022/projects/1/students/2", json={"skill_id": 1}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + +def test_add_student_project_already_confirmed(database_with_data: Session, current_edition: Edition, auth_client: AuthClient): + """A project_role can't be cre created if the student involved has already been confirmed elsewhere""" + auth_client.coach(current_edition) + + resp = auth_client.post("/editions/ed2022/projects/1/students/4", json={"skill_id": 3}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + +def test_confirm_project_role(database_with_data: Session, auth_client: AuthClient): + """Confirm a project role for a student without conflicts""" + auth_client.admin() + resp = auth_client.post( + "/editions/ed2022/projects/1/students/3", json={"skill_id": 3}) + + assert resp.status_code == status.HTTP_201_CREATED + + response2 = auth_client.post( + "/editions/ed2022/projects/1/students/3/confirm") + + assert response2.status_code == status.HTTP_204_NO_CONTENT + pr = database_with_data.query(ProjectRole).where(ProjectRole.student_id == 3) \ + .where(ProjectRole.project_id == 1).one() + assert pr.definitive is True + + +def test_confirm_project_role_conflict(database_with_data: Session, auth_client: AuthClient): + """A student who is part of a conflict can't have their project_role confirmed""" + auth_client.admin() + response2 = auth_client.post( + "/editions/ed2022/projects/1/students/1/confirm") + + assert response2.status_code == status.HTTP_409_CONFLICT diff --git a/backend/tests/test_routers/test_editions/test_register/test_register.py b/backend/tests/test_routers/test_editions/test_register/test_register.py index aa167443b..6d75d523c 100644 --- a/backend/tests/test_routers/test_editions/test_register/test_register.py +++ b/backend/tests/test_routers/test_editions/test_register/test_register.py @@ -3,38 +3,39 @@ from starlette.testclient import TestClient -from src.database.models import Edition, InviteLink, User +from src.database.models import Edition, InviteLink, User, AuthEmail def test_ok(database_session: Session, test_client: TestClient): """Tests a registeration is made""" - edition: Edition = Edition(year=2022) + edition: Edition = Edition(year=2022, name="ed2022") invite_link: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") database_session.add(edition) database_session.add(invite_link) database_session.commit() - response = test_client.post("/editions/1/register/email", json={ + response = test_client.post("/editions/ed2022/register/email", json={ "name": "Joskes vermeulen", "email": "jw@gmail.com", "pw": "test", "uuid": str(invite_link.uuid)}) assert response.status_code == status.HTTP_201_CREATED user: User = database_session.query(User).where( - User.email == "jw@gmail.com").one() - assert user.name == "Joskes vermeulen" + User.name == "Joskes vermeulen").one() + user_auth: AuthEmail = database_session.query(AuthEmail).where(AuthEmail.email == "jw@gmail.com").one() + assert user.user_id == user_auth.user_id -def test_use_uuid_multipli_times(database_session: Session, test_client: TestClient): +def test_use_uuid_multiple_times(database_session: Session, test_client: TestClient): """Tests that you can't use the same UUID multiple times""" - edition: Edition = Edition(year=2022) + edition: Edition = Edition(year=2022, name="ed2022") invite_link: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") database_session.add(edition) database_session.add(invite_link) database_session.commit() - test_client.post("/editions/1/register/email", json={ + test_client.post("/editions/ed2022/register/email", json={ "name": "Joskes vermeulen", "email": "jw@gmail.com", "pw": "test", "uuid": str(invite_link.uuid)}) - response = test_client.post("/editions/1/register/email", json={ + response = test_client.post("/editions/ed2022/register/email", json={ "name": "Joske Vermeulen", "email": "jw2@gmail.com", "pw": "test", "uuid": str(invite_link.uuid)}) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -42,37 +43,37 @@ def test_use_uuid_multipli_times(database_session: Session, test_client: TestCli def test_no_valid_uuid(database_session: Session, test_client: TestClient): """Tests that no valid uuid, can't make a account""" - edition: Edition = Edition(year=2022) + edition: Edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() - response = test_client.post("/editions/1/register/email", json={ + response = test_client.post("/editions/ed2022/register/email", json={ "name": "Joskes vermeulen", "email": "jw@gmail.com", "pw": "test", "uuid": "550e8400-e29b-41d4-a716-446655440000"}) assert response.status_code == status.HTTP_404_NOT_FOUND users: list[User] = database_session.query( - User).where(User.email == "jw@gmail.com").all() + User).where(User.name == "Joskes vermeulen").all() assert len(users) == 0 def test_no_edition(database_session: Session, test_client: TestClient): """Tests if there is no edition it gets the right error code""" - response = test_client.post("/editions/1/register/email", json={ + response = test_client.post("/editions/ed2022/register/email", json={ "name": "Joskes vermeulen", "email": "jw@gmail.com", "pw": "test"}) assert response.status_code == status.HTTP_404_NOT_FOUND def test_not_a_correct_email(database_session: Session, test_client: TestClient): """Tests when the email isn't correct, it gets the right error code""" - database_session.add(Edition(year=2022)) + database_session.add(Edition(year=2022, name="ed2022")) database_session.commit() - response = test_client.post("/editions/1/register/email", + response = test_client.post("/editions/ed2022/register/email", json={"name": "Joskes vermeulen", "email": "jw", "pw": "test"}) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_duplicate_user(database_session: Session, test_client: TestClient): """Tests when there is a duplicate, it gets the right error code""" - edition: Edition = Edition(year=2022) + edition: Edition = Edition(year=2022, name="ed2022") invite_link1: InviteLink = InviteLink( edition=edition, target_email="jw@gmail.com") invite_link2: InviteLink = InviteLink( @@ -81,10 +82,26 @@ def test_duplicate_user(database_session: Session, test_client: TestClient): database_session.add(invite_link1) database_session.add(invite_link2) database_session.commit() - test_client.post("/editions/1/register/email", + test_client.post("/editions/ed2022/register/email", json={"name": "Joskes vermeulen", "email": "jw@gmail.com", "pw": "test", "uuid": str(invite_link1.uuid)}) - response = test_client.post("/editions/1/register/email", json={ + response = test_client.post("/editions/ed2022/register/email", json={ "name": "Joske vermeulen", "email": "jw@gmail.com", "pw": "test1", "uuid": str(invite_link2.uuid)}) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_old_edition(database_session: Session, test_client: TestClient): + """Tests trying to make a registration for a read-only edition""" + edition: Edition = Edition(year=2022, name="ed2022") + edition3: Edition = Edition(year=2023, name="ed2023") + invite_link: InviteLink = InviteLink( + edition=edition, target_email="jw@gmail.com") + database_session.add(edition) + database_session.add(edition3) + database_session.add(invite_link) + database_session.commit() + response = test_client.post("/editions/ed2022/register/email", json={ + "name": "Joskes vermeulen", "email": "jw@gmail.com", "pw": "test", + "uuid": str(invite_link.uuid)}) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/backend/tests/test_routers/test_editions/test_students/__init__.py b/backend/tests/test_routers/test_editions/test_students/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_routers/test_editions/test_students/test_students.py b/backend/tests/test_routers/test_editions/test_students/test_students.py new file mode 100644 index 000000000..59ee28291 --- /dev/null +++ b/backend/tests/test_routers/test_editions/test_students/test_students.py @@ -0,0 +1,614 @@ +import datetime +import pytest +from sqlalchemy.orm import Session +from starlette import status +from settings import DB_PAGE_SIZE +from src.database.enums import DecisionEnum, EmailStatusEnum +from src.database.models import Student, Edition, Skill, DecisionEmail + +from tests.utils.authorization import AuthClient + + +@pytest.fixture +def database_with_data(database_session: Session) -> Session: + """A fixture to fill the database with fake data that can easly be used when testing""" + + # Editions + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + + # Skill + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + skill4: Skill = Skill(name="skill4", description="something about skill4") + skill5: Skill = Skill(name="skill5", description="something about skill5") + skill6: Skill = Skill(name="skill6", description="something about skill6") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + database_session.add(skill4) + database_session.add(skill5) + database_session.add(skill6) + database_session.commit() + + # Student + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3, skill6]) + student30: Student = Student(first_name="Marta", last_name="Marquez", preferred_name="Marta", + email_address="marta.marquez@example.com", phone_number="967-895-285", alumni=False, + wants_to_be_student_coach=False, edition=edition, skills=[skill2, skill4, skill5]) + + database_session.add(student01) + database_session.add(student30) + database_session.commit() + + return database_session + + +def test_set_definitive_decision_no_authorization(database_with_data: Session, auth_client: AuthClient): + """tests that you have to be logged in""" + assert auth_client.put( + "/editions/ed2022/students/2/decision").status_code == status.HTTP_401_UNAUTHORIZED + + +def test_set_definitive_decision_coach(database_with_data: Session, auth_client: AuthClient): + """tests that a coach can't set a definitive decision""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.put( + "/editions/ed2022/students/2/decision").status_code == status.HTTP_403_FORBIDDEN + + +def test_set_definitive_decision_on_ghost(database_with_data: Session, auth_client: AuthClient): + """tests that you get a 404 if a student don't exicist""" + auth_client.admin() + assert auth_client.put( + "/editions/ed2022/students/100/decision").status_code == status.HTTP_404_NOT_FOUND + + +def test_set_definitive_decision_wrong_body(database_with_data: Session, auth_client: AuthClient): + """tests you got a 422 if you give a wrong body""" + auth_client.admin() + assert auth_client.put( + "/editions/ed2022/students/1/decision").status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_set_definitive_decision_yes(database_with_data: Session, auth_client: AuthClient): + """tests that an admin can set a yes""" + auth_client.admin() + assert auth_client.put("/editions/ed2022/students/1/decision", + json={"decision": 1}).status_code == status.HTTP_204_NO_CONTENT + student: Student = database_with_data.query( + Student).where(Student.student_id == 1).one() + assert student.decision == DecisionEnum.YES + + +def test_set_definitive_decision_no(database_with_data: Session, auth_client: AuthClient): + """tests that an admin can set a no""" + auth_client.admin() + assert auth_client.put("/editions/ed2022/students/1/decision", + json={"decision": 3}).status_code == status.HTTP_204_NO_CONTENT + student: Student = database_with_data.query( + Student).where(Student.student_id == 1).one() + assert student.decision == DecisionEnum.NO + + +def test_set_definitive_decision_maybe(database_with_data: Session, auth_client: AuthClient): + """tests that an admin can set a maybe""" + auth_client.admin() + assert auth_client.put("/editions/ed2022/students/1/decision", + json={"decision": 2}).status_code == status.HTTP_204_NO_CONTENT + student: Student = database_with_data.query( + Student).where(Student.student_id == 1).one() + assert student.decision == DecisionEnum.MAYBE + + +def test_delete_student_no_authorization(database_with_data: Session, auth_client: AuthClient): + """tests that you have to be logged in""" + assert auth_client.delete( + "/editions/ed2022/students/2").status_code == status.HTTP_401_UNAUTHORIZED + + +def test_delete_student_coach(database_with_data: Session, auth_client: AuthClient): + """tests that a coach can't delete a student""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.delete( + "/editions/ed2022/students/2").status_code == status.HTTP_403_FORBIDDEN + students: Student = database_with_data.query( + Student).where(Student.student_id == 1).all() + assert len(students) == 1 + + +def test_delete_ghost(database_with_data: Session, auth_client: AuthClient): + """tests that you can't delete a student that don't excist""" + auth_client.admin() + assert auth_client.delete( + "/editions/ed2022/students/100").status_code == status.HTTP_404_NOT_FOUND + students: Student = database_with_data.query( + Student).where(Student.student_id == 1).all() + assert len(students) == 1 + + +def test_delete(database_with_data: Session, auth_client: AuthClient): + """tests an admin can delete a student""" + auth_client.admin() + assert auth_client.delete( + "/editions/ed2022/students/1").status_code == status.HTTP_204_NO_CONTENT + students: Student = database_with_data.query( + Student).where(Student.student_id == 1).all() + assert len(students) == 0 + + +def test_get_student_by_id_no_autorization(database_with_data: Session, auth_client: AuthClient): + """tests you have to be logged in to get a student by id""" + assert auth_client.get( + "/editions/ed2022/students/1").status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_student_by_id(database_with_data: Session, auth_client: AuthClient): + """tests you can get a student by id""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.get( + "/editions/ed2022/students/1").status_code == status.HTTP_200_OK + + +def test_get_student_by_id_wrong_edition(database_with_data: Session, auth_client: AuthClient): + """tests you can get a student by id""" + edition: Edition = Edition(year=2023, name="ed2023") + database_with_data.add(edition) + database_with_data.commit() + auth_client.coach(edition) + assert auth_client.get( + "/editions/ed2023/students/1").status_code == status.HTTP_404_NOT_FOUND + + +def test_get_students_no_autorization(database_with_data: Session, auth_client: AuthClient): + """tests you have to be logged in to get all students""" + assert auth_client.get( + "/editions/ed2022/students/").status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_all_students(database_with_data: Session, auth_client: AuthClient): + """tests get all students""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 2 + + +def test_get_all_students_pagination(database_with_data: Session, auth_client: AuthClient): + """tests get all students with pagination""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + student: Student = Student(first_name=f"Student {i}", last_name="Vermeulen", preferred_name=f"{i}", + email_address=f"student{i}@mail.com", phone_number=f"0487/0{i}.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[]) + database_with_data.add(student) + database_with_data.commit() + response = auth_client.get("/editions/ed2022/students/?page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['students']) == DB_PAGE_SIZE + response = auth_client.get("/editions/ed2022/students/?page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['students']) == max( + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 2, 0) # +2 because there were already 2 students in the database + + +def test_get_first_name_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer first name""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/?name=Jos") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + + +def test_get_first_name_student_pagination(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer first name with pagination""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + student: Student = Student(first_name=f"Student {i}", last_name="Vermeulen", preferred_name=f"{i}", + email_address=f"student{i}@mail.com", phone_number=f"0487/0{i}.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[]) + database_with_data.add(student) + database_with_data.commit() + response = auth_client.get( + "/editions/ed2022/students/?name=Student&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == DB_PAGE_SIZE + response = auth_client.get( + "/editions/ed2022/students/?name=Student&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['students']) == max( + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE, 0) + + +def test_get_last_name_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer last name""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get( + "/editions/ed2022/students/?name=Vermeulen") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + + +def test_get_last_name_students_pagination(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer last name with pagination""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + student: Student = Student(first_name="Jos", last_name=f"Student {i}", preferred_name=f"{i}", + email_address=f"student{i}@mail.com", phone_number=f"0487/0{i}.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[]) + database_with_data.add(student) + database_with_data.commit() + response = auth_client.get( + "/editions/ed2022/students/?name=Student&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == DB_PAGE_SIZE + response = auth_client.get( + "/editions/ed2022/students/?name=Student&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['students']) == max( + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE, 0) + + +def test_get_between_first_and_last_name_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer first- and last name""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get( + "/editions/ed2022/students/?name=os V") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + + +def test_get_alumni_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer alumni""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/?alumni=true") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + + +def test_get_alumni_students_pagination(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer alumni with pagination""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + student: Student = Student(first_name="Jos", last_name=f"Student {i}", preferred_name=f"{i}", + email_address=f"student{i}@mail.com", phone_number=f"0487/0{i}.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[]) + database_with_data.add(student) + database_with_data.commit() + response = auth_client.get( + "/editions/ed2022/students/?alumni=true&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == DB_PAGE_SIZE + response = auth_client.get( + "/editions/ed2022/students/?alumni=true&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['students']) == max( + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1, 0) # +1 because there is already is one + + +def test_get_student_coach_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer student coach""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/?student_coach=true") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + + +def test_get_student_coach_students_pagination(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer student coach with pagination""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + for i in range(round(DB_PAGE_SIZE * 1.5)): + student: Student = Student(first_name="Jos", last_name=f"Student {i}", preferred_name=f"{i}", + email_address=f"student{i}@mail.com", phone_number=f"0487/0{i}.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[]) + database_with_data.add(student) + database_with_data.commit() + response = auth_client.get( + "/editions/ed2022/students/?student_coach=true&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == DB_PAGE_SIZE + response = auth_client.get( + "/editions/ed2022/students/?student_coach=true&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['students']) == max( + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1, 0) # +1 because there is already is one + + +def test_get_one_skill_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer one skill""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/?skill_ids=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + assert response.json()["students"][0]["firstName"] == "Jos" + + +def test_get_multiple_skill_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer multiple skills""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get( + "/editions/ed2022/students/?skill_ids=4&skill_ids=5") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 1 + assert response.json()["students"][0]["firstName"] == "Marta" + + +def test_get_multiple_skill_students_no_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer multiple skills, but that student don't excist""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get( + "/editions/ed2022/students/?skill_ids=4&skill_ids=6") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 0 + + +def test_get_ghost_skill_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer one skill that don't excist""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/?skill_ids=100") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 0 + + +def test_get_one_real_one_ghost_skill_students(database_with_data: Session, auth_client: AuthClient): + """tests get students based on query paramer one skill that excist and one that don't excist""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get( + "/editions/ed2022/students/?skill_ids=4&skill_ids=100") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["students"]) == 0 + + +def test_get_emails_student_no_authorization(database_with_data: Session, auth_client: AuthClient): + """tests that you can't get the mails of a student when you aren't logged in""" + response = auth_client.get("/editions/ed2022/students/1/emails") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_emails_student_coach(database_with_data: Session, auth_client: AuthClient): + """tests that a coach can't get the mails of a student""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.get("/editions/ed2022/students/1/emails") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_get_emails_student_admin(database_with_data: Session, auth_client: AuthClient): + """tests that an admin can get the mails of a student""" + auth_client.admin() + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [1], "email_status": 1}) + response = auth_client.get("/editions/ed2022/students/1/emails") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["emails"]) == 1 + assert response.json()["student"]["studentId"] == 1 + response = auth_client.get("/editions/ed2022/students/2/emails") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["emails"]) == 0 + assert response.json()["student"]["studentId"] == 2 + + +def test_post_email_no_authorization(database_with_data: Session, auth_client: AuthClient): + """tests user need to be loged in""" + response = auth_client.post("/editions/ed2022/students/emails") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_post_email_coach(database_with_data: Session, auth_client: AuthClient): + """tests user can't be a coach""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.post("/editions/ed2022/students/emails") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_post_email_applied(database_with_data: Session, auth_client: AuthClient): + """test create email applied""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 0}) + assert response.status_code == status.HTTP_201_CREATED + assert EmailStatusEnum( + response.json()["studentEmails"][0]["emails"][0]["decision"]) == EmailStatusEnum.APPLIED + + +def test_post_email_awaiting_project(database_with_data: Session, auth_client: AuthClient): + """test create email awaiting project""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 1}) + assert response.status_code == status.HTTP_201_CREATED + assert EmailStatusEnum( + response.json()["studentEmails"][0]["emails"][0]["decision"]) == EmailStatusEnum.AWAITING_PROJECT + + +def test_post_email_approved(database_with_data: Session, auth_client: AuthClient): + """test create email applied""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 2}) + assert response.status_code == status.HTTP_201_CREATED + assert EmailStatusEnum( + response.json()["studentEmails"][0]["emails"][0]["decision"]) == EmailStatusEnum.APPROVED + + +def test_post_email_contract_confirmed(database_with_data: Session, auth_client: AuthClient): + """test create email contract confirmed""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 3}) + assert response.status_code == status.HTTP_201_CREATED + assert EmailStatusEnum( + response.json()["studentEmails"][0]["emails"][0]["decision"]) == EmailStatusEnum.CONTRACT_CONFIRMED + + +def test_post_email_contract_declined(database_with_data: Session, auth_client: AuthClient): + """test create email contract declined""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 4}) + assert response.status_code == status.HTTP_201_CREATED + assert EmailStatusEnum( + response.json()["studentEmails"][0]["emails"][0]["decision"]) == EmailStatusEnum.CONTRACT_DECLINED + + +def test_post_email_rejected(database_with_data: Session, auth_client: AuthClient): + """test create email rejected""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 5}) + assert response.status_code == status.HTTP_201_CREATED + print(response.json()) + assert EmailStatusEnum( + response.json()["studentEmails"][0]["emails"][0]["decision"]) == EmailStatusEnum.REJECTED + + +def test_creat_email_for_ghost(database_with_data: Session, auth_client: AuthClient): + """test create email for student that don't exist""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [100], "email_status": 5}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_creat_email_student_in_other_edition(database_with_data: Session, auth_client: AuthClient): + """test creat an email for a student not in this edition""" + edition: Edition = Edition(year=2023, name="ed2023") + database_with_data.add(edition) + student: Student = Student(first_name="Mehmet", last_name="Dizdar", preferred_name="Mehmet", + email_address="mehmet.dizdar@example.com", phone_number="(787)-938-6216", alumni=True, + wants_to_be_student_coach=False, edition=edition, skills=[]) + database_with_data.add(student) + database_with_data.commit() + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [3], "email_status": 5}) + print(response.json()) + assert response.status_code == status.HTTP_201_CREATED + assert len(response.json()["studentEmails"]) == 0 + + +def test_get_emails_no_authorization(database_with_data: Session, auth_client: AuthClient): + """test get emails not loged in""" + response = auth_client.get("/editions/ed2022/students/emails") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_emails_coach(database_with_data: Session, auth_client: AuthClient): + """test get emails logged in as coach""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + response = auth_client.post("/editions/ed2022/students/emails") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_get_emails(database_with_data: Session, auth_client: AuthClient): + """test get emails""" + auth_client.admin() + response = auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [1], "email_status": 3}) + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 5}) + response = auth_client.get("/editions/ed2022/students/emails") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["studentEmails"]) == 2 + assert response.json()["studentEmails"][0]["student"]["studentId"] == 1 + assert response.json()["studentEmails"][0]["emails"][0]["decision"] == 3 + assert response.json()["studentEmails"][1]["student"]["studentId"] == 2 + assert response.json()["studentEmails"][1]["emails"][0]["decision"] == 5 + + +def test_emails_filter_first_name(database_with_data: Session, auth_client: AuthClient): + """test get emails with filter first name""" + auth_client.admin() + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [1], "email_status": 1}) + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 1}) + response = auth_client.get( + "/editions/ed2022/students/emails/?name=Jos") + assert len(response.json()["studentEmails"]) == 1 + assert response.json()["studentEmails"][0]["student"]["firstName"] == "Jos" + + +def test_emails_filter_last_name(database_with_data: Session, auth_client: AuthClient): + """test get emails with filter last name""" + auth_client.admin() + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [1], "email_status": 1}) + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 1}) + response = auth_client.get( + "/editions/ed2022/students/emails/?name=Vermeulen") + assert len(response.json()["studentEmails"]) == 1 + assert response.json()[ + "studentEmails"][0]["student"]["lastName"] == "Vermeulen" + + +def test_emails_filter_between_first_and_last_name(database_with_data: Session, auth_client: AuthClient): + """test get emails with filter last name""" + auth_client.admin() + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [1], "email_status": 1}) + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 1}) + response = auth_client.get( + "/editions/ed2022/students/emails/?name=os V") + assert len(response.json()["studentEmails"]) == 1 + assert response.json()[ + "studentEmails"][0]["student"]["firstName"] == "Jos" + assert response.json()[ + "studentEmails"][0]["student"]["lastName"] == "Vermeulen" + + +def test_emails_filter_emailstatus(database_with_data: Session, auth_client: AuthClient): + """test to get all email status, and you only filter on the email send""" + auth_client.admin() + for i in range(0, 6): + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": i}) + response = auth_client.get( + f"/editions/ed2022/students/emails/?email_status={i}") + print(response.json()) + assert len(response.json()["studentEmails"]) == 1 + if i > 0: + response = auth_client.get( + f"/editions/ed2022/students/emails/?email_status={i-1}") + assert len(response.json()["studentEmails"]) == 0 + + +def test_emails_filter_emailstatus_multiple_status(database_with_data: Session, auth_client: AuthClient): + """test to get all email status with multiple status""" + auth_client.admin() + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [2], "email_status": 1}) + auth_client.post("/editions/ed2022/students/emails", + json={"students_id": [1], "email_status": 3}) + response = auth_client.get( + "/editions/ed2022/students/emails/?email_status=3&email_status=1") + assert len(response.json()["studentEmails"]) == 2 + assert response.json()["studentEmails"][0]["student"]["studentId"] == 1 + assert response.json()["studentEmails"][1]["student"]["studentId"] == 2 diff --git a/backend/tests/test_routers/test_editions/test_students/test_suggestions/__init__.py b/backend/tests/test_routers/test_editions/test_students/test_suggestions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py b/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py new file mode 100644 index 000000000..487af0c5c --- /dev/null +++ b/backend/tests/test_routers/test_editions/test_students/test_suggestions/test_suggestions.py @@ -0,0 +1,243 @@ +import pytest +from sqlalchemy.orm import Session +from starlette import status +from src.database.enums import DecisionEnum +from src.database.models import Suggestion, Student, User, Edition, Skill + +from tests.utils.authorization import AuthClient + + +@pytest.fixture +def database_with_data(database_session: Session) -> Session: + """A fixture to fill the database with fake data that can easly be used when testing""" + + # Editions + edition: Edition = Edition(year=2022, name="ed2022") + database_session.add(edition) + database_session.commit() + + # Users + coach1: User = User(name="coach1", editions=[edition]) + database_session.add(coach1) + database_session.commit() + + # Skill + skill1: Skill = Skill(name="skill1", description="something about skill1") + skill2: Skill = Skill(name="skill2", description="something about skill2") + skill3: Skill = Skill(name="skill3", description="something about skill3") + skill4: Skill = Skill(name="skill4", description="something about skill4") + skill5: Skill = Skill(name="skill5", description="something about skill5") + skill6: Skill = Skill(name="skill6", description="something about skill6") + database_session.add(skill1) + database_session.add(skill2) + database_session.add(skill3) + database_session.add(skill4) + database_session.add(skill5) + database_session.add(skill6) + database_session.commit() + + # Student + student01: Student = Student(first_name="Jos", last_name="Vermeulen", preferred_name="Joske", + email_address="josvermeulen@mail.com", phone_number="0487/86.24.45", alumni=True, + wants_to_be_student_coach=True, edition=edition, skills=[skill1, skill3, skill6]) + student30: Student = Student(first_name="Marta", last_name="Marquez", preferred_name="Marta", + email_address="marta.marquez@example.com", phone_number="967-895-285", alumni=True, + wants_to_be_student_coach=False, edition=edition, skills=[skill2, skill4, skill5]) + + database_session.add(student01) + database_session.add(student30) + database_session.commit() + + # Suggestion + suggestion1: Suggestion = Suggestion( + student=student01, coach=coach1, argumentation="Good student", suggestion=DecisionEnum.YES) + database_session.add(suggestion1) + database_session.commit() + return database_session + + +def test_new_suggestion(database_with_data: Session, auth_client: AuthClient): + """Tests creating a new suggestion""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + resp = auth_client.post("/editions/ed2022/students/2/suggestions/", + json={"suggestion": 1, "argumentation": "test"}) + assert resp.status_code == status.HTTP_201_CREATED + suggestions: list[Suggestion] = database_with_data.query( + Suggestion).where(Suggestion.student_id == 2).all() + assert len(suggestions) == 1 + assert DecisionEnum(resp.json()["suggestion"] + ["suggestion"]) == suggestions[0].suggestion + assert resp.json()[ + "suggestion"]["argumentation"] == suggestions[0].argumentation + + +def test_overwrite_suggestion(database_with_data: Session, auth_client: AuthClient): + """Tests that when you've already made a suggestion earlier, the existing one is replaced""" + # Create initial suggestion + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + auth_client.post("/editions/ed2022/students/2/suggestions/", + json={"suggestion": 1, "argumentation": "test"}) + + suggestions: list[Suggestion] = database_with_data.query( + Suggestion).where(Suggestion.student_id == 2).all() + assert len(suggestions) == 1 + + # Send a new request + arg = "overwritten" + resp = auth_client.post("/editions/ed2022/students/2/suggestions/", + json={"suggestion": 2, "argumentation": arg}) + assert resp.status_code == status.HTTP_201_CREATED + suggestions: list[Suggestion] = database_with_data.query( + Suggestion).where(Suggestion.student_id == 2).all() + assert len(suggestions) == 1 + assert suggestions[0].argumentation == arg + + +def test_new_suggestion_not_authorized(database_with_data: Session, auth_client: AuthClient): + """Tests when not authorized you can't add a new suggestion""" + + assert auth_client.post("/editions/ed2022/students/2/suggestions/", json={ + "suggestion": 1, "argumentation": "test"}).status_code == status.HTTP_401_UNAUTHORIZED + suggestions: list[Suggestion] = database_with_data.query( + Suggestion).where(Suggestion.student_id == 2).all() + assert len(suggestions) == 0 + + +def test_get_suggestions_of_student_not_authorized(database_with_data: Session, auth_client: AuthClient): + """Tests if you don't have the right access, you get the right HTTP code""" + + assert auth_client.get("/editions/ed2022/students/29/suggestions/", headers={"Authorization": "auth"}, json={ + "suggestion": 1, "argumentation": "Ja"}).status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_suggestions_of_ghost(database_with_data: Session, auth_client: AuthClient): + """Tests if the student don't exist, you get a 404""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + res = auth_client.get( + "/editions/ed2022/students/9000/suggestions/") + assert res.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_suggestions_of_student(database_with_data: Session, auth_client: AuthClient): + """Tests to get the suggestions of a student""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.post("/editions/ed2022/students/2/suggestions/", json={ + "suggestion": 1, "argumentation": "Ja"}).status_code == status.HTTP_201_CREATED + auth_client.admin() + assert auth_client.post("/editions/ed2022/students/2/suggestions/", json={ + "suggestion": 3, "argumentation": "Neen"}).status_code == status.HTTP_201_CREATED + res = auth_client.get( + "/editions/1/students/2/suggestions/") + assert res.status_code == status.HTTP_200_OK + res_json = res.json() + assert len(res_json["suggestions"]) == 2 + assert res_json["suggestions"][0]["suggestion"] == 1 + assert res_json["suggestions"][0]["argumentation"] == "Ja" + assert res_json["suggestions"][1]["suggestion"] == 3 + assert res_json["suggestions"][1]["argumentation"] == "Neen" + + +def test_delete_ghost_suggestion(database_with_data: Session, auth_client: AuthClient): + """Tests that you get the correct status code when you delete a not existing suggestion""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.delete( + "/editions/ed2022/students/1/suggestions/8000").status_code == status.HTTP_404_NOT_FOUND + + +def test_delete_not_autorized(database_with_data: Session, auth_client: AuthClient): + """Tests that you have to be loged in for deleating a suggestion""" + assert auth_client.delete( + "/editions/ed2022/students/1/suggestions/8000").status_code == status.HTTP_401_UNAUTHORIZED + + +def test_delete_suggestion_admin(database_with_data: Session, auth_client: AuthClient): + """Test that an admin can update suggestions""" + auth_client.admin() + assert auth_client.delete( + "/editions/ed2022/students/1/suggestions/1").status_code == status.HTTP_204_NO_CONTENT + suggestions: Suggestion = database_with_data.query( + Suggestion).where(Suggestion.suggestion_id == 1).all() + assert len(suggestions) == 0 + + +def test_delete_suggestion_coach_their_review(database_with_data: Session, auth_client: AuthClient): + """Tests that a coach can delete their own suggestion""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + new_suggestion = auth_client.post("/editions/ed2022/students/2/suggestions/", + json={"suggestion": 1, "argumentation": "test"}) + assert new_suggestion.status_code == status.HTTP_201_CREATED + suggestion_id = new_suggestion.json()["suggestion"]["suggestionId"] + assert auth_client.delete( + f"/editions/ed2022/students/1/suggestions/{suggestion_id}").status_code == status.HTTP_204_NO_CONTENT + suggestions: Suggestion = database_with_data.query( + Suggestion).where(Suggestion.suggestion_id == suggestion_id).all() + assert len(suggestions) == 0 + + +def test_delete_suggestion_coach_other_review(database_with_data: Session, auth_client: AuthClient): + """Tests that a coach can't delete other coaches their suggestions""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.delete( + "/editions/ed2022/students/1/suggestions/1").status_code == status.HTTP_403_FORBIDDEN + suggestions: Suggestion = database_with_data.query( + Suggestion).where(Suggestion.suggestion_id == 1).all() + assert len(suggestions) == 1 + + +def test_update_ghost_suggestion(database_with_data: Session, auth_client: AuthClient): + """Tests a suggestion that don't exist """ + auth_client.admin() + assert auth_client.put("/editions/ed2022/students/1/suggestions/8000", json={ + "suggestion": 1, "argumentation": "test"}).status_code == status.HTTP_404_NOT_FOUND + + +def test_update_not_autorized(database_with_data: Session, auth_client: AuthClient): + """Tests update when not autorized""" + assert auth_client.put("/editions/ed2022/students/1/suggestions/8000", json={ + "suggestion": 1, "argumentation": "test"}).status_code == status.HTTP_401_UNAUTHORIZED + + +def test_update_suggestion_admin(database_with_data: Session, auth_client: AuthClient): + """Test that an admin can update suggestions""" + auth_client.admin() + assert auth_client.put("/editions/ed2022/students/1/suggestions/1", json={ + "suggestion": 3, "argumentation": "test"}).status_code == status.HTTP_204_NO_CONTENT + suggestion: Suggestion = database_with_data.query( + Suggestion).where(Suggestion.suggestion_id == 1).one() + assert suggestion.suggestion == DecisionEnum.NO + assert suggestion.argumentation == "test" + + +def test_update_suggestion_coach_their_review(database_with_data: Session, auth_client: AuthClient): + """Tests that a coach can update their own suggestion""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + new_suggestion = auth_client.post("/editions/ed2022/students/2/suggestions/", + json={"suggestion": 1, "argumentation": "test"}) + assert new_suggestion.status_code == status.HTTP_201_CREATED + suggestion_id = new_suggestion.json()["suggestion"]["suggestionId"] + assert auth_client.put(f"/editions/ed2022/students/1/suggestions/{suggestion_id}", json={ + "suggestion": 3, "argumentation": "test"}).status_code == status.HTTP_204_NO_CONTENT + suggestion: Suggestion = database_with_data.query( + Suggestion).where(Suggestion.suggestion_id == suggestion_id).one() + assert suggestion.suggestion == DecisionEnum.NO + assert suggestion.argumentation == "test" + + +def test_update_suggestion_coach_other_review(database_with_data: Session, auth_client: AuthClient): + """Tests that a coach can't update other coaches their suggestions""" + edition: Edition = database_with_data.query(Edition).all()[0] + auth_client.coach(edition) + assert auth_client.put("/editions/ed2022/students/1/suggestions/1", json={ + "suggestion": 3, "argumentation": "test"}).status_code == status.HTTP_403_FORBIDDEN + suggestion: Suggestion = database_with_data.query( + Suggestion).where(Suggestion.suggestion_id == 1).one() + assert suggestion.suggestion != DecisionEnum.NO + assert suggestion.argumentation != "test" diff --git a/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py b/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py index 30cc5d3d9..a75d0a789 100644 --- a/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py +++ b/backend/tests/test_routers/test_editions/test_webhooks/test_webhooks.py @@ -13,7 +13,7 @@ @pytest.fixture def edition(database_session: Session) -> Edition: - edition = Edition(year=2022) + edition = Edition(year=2022, name="ed2022") database_session.add(edition) database_session.commit() return edition @@ -29,7 +29,7 @@ def webhook(edition: Edition, database_session: Session) -> WebhookURL: def test_new_webhook(auth_client: AuthClient, edition: Edition): auth_client.admin() - response = auth_client.post(f"/editions/{edition.edition_id}/webhooks/") + response = auth_client.post(f"/editions/{edition.name}/webhooks/") assert response.status_code == status.HTTP_201_CREATED assert 'uuid' in response.json() assert UUID(response.json()['uuid']) @@ -37,7 +37,7 @@ def test_new_webhook(auth_client: AuthClient, edition: Edition): def test_new_webhook_invalid_edition(auth_client: AuthClient, edition: Edition): auth_client.admin() - response = auth_client.post(f"/editions/0/webhooks/") + response = auth_client.post("/editions/invalid/webhooks/") assert response.status_code == status.HTTP_404_NOT_FOUND @@ -50,7 +50,7 @@ def test_webhook(test_client: TestClient, webhook: WebhookURL, database_session: wants_to_be_student_coach=False, phone_number="0477002266", ) - response = test_client.post(f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", json=event) + response = test_client.post(f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=event) assert response.status_code == status.HTTP_201_CREATED student: Student = database_session.query(Student).first() @@ -59,52 +59,65 @@ def test_webhook(test_client: TestClient, webhook: WebhookURL, database_session: assert student.first_name == "Bob" assert student.last_name == "Klonck" assert student.preferred_name == "Jhon" - assert student.wants_to_be_student_coach == False + assert student.wants_to_be_student_coach is False assert student.phone_number == "0477002266" def test_webhook_bad_format(test_client: TestClient, webhook: WebhookURL): + """Test a badly formatted webhook input""" response = test_client.post( - f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", + f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=WEBHOOK_EVENT_BAD_FORMAT ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_webhook_duplicate_email(test_client: TestClient, webhook: WebhookURL, mocker): + """Test entering a duplicate email address""" mocker.patch('builtins.open', new_callable=mock_open()) event: dict = create_webhook_event( email_address="test@gmail.com", ) - response = test_client.post(f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", json=event) + response = test_client.post(f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=event) assert response.status_code == status.HTTP_201_CREATED event: dict = create_webhook_event( email_address="test@gmail.com", ) - response = test_client.post(f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", json=event) + response = test_client.post(f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=event) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_webhook_duplicate_phone(test_client: TestClient, webhook: WebhookURL, mocker): + """Test entering a duplicate phone number""" mocker.patch('builtins.open', new_callable=mock_open()) event: dict = create_webhook_event( phone_number="0477002266", ) - response = test_client.post(f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", json=event) + response = test_client.post(f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=event) assert response.status_code == status.HTTP_201_CREATED event: dict = create_webhook_event( phone_number="0477002266", ) - response = test_client.post(f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", json=event) + response = test_client.post(f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=event) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_webhook_missing_question(test_client: TestClient, webhook: WebhookURL, mocker): + """Test submitting a form with a question missing""" mocker.patch('builtins.open', new_callable=mock_open()) response = test_client.post( - f"/editions/{webhook.edition_id}/webhooks/{webhook.uuid}", + f"/editions/{webhook.edition.name}/webhooks/{webhook.uuid}", json=WEBHOOK_MISSING_QUESTION ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_new_webhook_old_edition(database_session: Session, auth_client: AuthClient, edition: Edition): + database_session.add(Edition(year=2023, name="ed2023")) + database_session.commit() + + auth_client.admin() + response = auth_client.post(f"/editions/{edition.name}/webhooks/") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/backend/tests/test_routers/test_login/test_login.py b/backend/tests/test_routers/test_login/test_login.py index 895ce3d8a..166098cee 100644 --- a/backend/tests/test_routers/test_login/test_login.py +++ b/backend/tests/test_routers/test_login/test_login.py @@ -7,6 +7,7 @@ def test_login_non_existing(test_client: TestClient): + """Test logging in without an existing account""" form = { "username": "this user", "password": "does not exist" @@ -16,16 +17,17 @@ def test_login_non_existing(test_client: TestClient): def test_login_existing(database_session: Session, test_client: TestClient): + """Test logging in with an existing account""" email = "test@ema.il" password = "password" # Create new user & auth entries in db - user = User(name="test", email=email) + user = User(name="test") database_session.add(user) database_session.commit() - auth = AuthEmail(pw_hash=security.get_password_hash(password)) + auth = AuthEmail(pw_hash=security.get_password_hash(password), email=email) auth.user = user database_session.add(auth) database_session.commit() @@ -40,16 +42,17 @@ def test_login_existing(database_session: Session, test_client: TestClient): def test_login_existing_wrong_credentials(database_session: Session, test_client: TestClient): + """Test logging in with existing, but wrong credentials""" email = "test@ema.il" password = "password" # Create new user & auth entries in db - user = User(name="test", email=email) + user = User(name="test") database_session.add(user) database_session.commit() - auth = AuthEmail(pw_hash=security.get_password_hash(password)) + auth = AuthEmail(pw_hash=security.get_password_hash(password), email=email) auth.user = user database_session.add(auth) database_session.commit() diff --git a/backend/tests/test_routers/test_skills/test_skills.py b/backend/tests/test_routers/test_skills/test_skills.py index bc9b51b06..a4a93717e 100644 --- a/backend/tests/test_routers/test_skills/test_skills.py +++ b/backend/tests/test_routers/test_skills/test_skills.py @@ -11,10 +11,10 @@ def test_get_skills(database_session: Session, auth_client: AuthClient): Args: database_session (Session): a connection with the database - auth_client (AuthClient): a client used to do rest calls + auth_client (AuthClient): a client used to do rest calls """ auth_client.admin() - skill = Skill(name="Backend", description = "Must know react") + skill = Skill(name="Backend", description="Must know react") database_session.add(skill) database_session.commit() @@ -32,7 +32,7 @@ def test_create_skill(database_session: Session, auth_client: AuthClient): Args: database_session (Session): a connection with the database - auth_client (AuthClient): a client used to do rest calls + auth_client (AuthClient): a client used to do rest calls """ auth_client.admin() @@ -48,11 +48,11 @@ def test_delete_skill(database_session: Session, auth_client: AuthClient): Args: database_session (Session): a connection with the database - auth_client (AuthClient): a client used to do rest calls + auth_client (AuthClient): a client used to do rest calls """ auth_client.admin() - skill = Skill(name="Backend", description = "Must know react") + skill = Skill(name="Backend", description="Must know react") database_session.add(skill) database_session.commit() database_session.refresh(skill) diff --git a/backend/tests/test_routers/test_users/test_users.py b/backend/tests/test_routers/test_users/test_users.py index 7191e968c..38db1dfcf 100644 --- a/backend/tests/test_routers/test_users/test_users.py +++ b/backend/tests/test_routers/test_users/test_users.py @@ -3,9 +3,9 @@ import pytest from sqlalchemy.orm import Session -from starlette.testclient import TestClient from starlette import status +from settings import DB_PAGE_SIZE from src.database import models from src.database.models import user_editions, CoachRequest from tests.utils.authorization import AuthClient @@ -15,20 +15,26 @@ def data(database_session: Session) -> dict[str, str | int]: """Fill database with dummy data""" # Create users - user1 = models.User(name="user1", email="user1@mail.com", admin=True) + user1 = models.User(name="user1", admin=True) database_session.add(user1) - user2 = models.User(name="user2", email="user2@mail.com", admin=False) + user2 = models.User(name="user2", admin=False) database_session.add(user2) # Create editions - edition1 = models.Edition(year=1) + edition1 = models.Edition(year=1, name="ed1") database_session.add(edition1) - edition2 = models.Edition(year=2) + edition2 = models.Edition(year=2, name="ed2") database_session.add(edition2) database_session.commit() + email_auth1 = models.AuthEmail(user_id=user1.user_id, email="user1@mail.com", pw_hash="HASH1") + github_auth1 = models.AuthGitHub(user_id=user2.user_id, gh_auth_id=123, email="user2@mail.com") + database_session.add(email_auth1) + database_session.add(github_auth1) + database_session.commit() + # Create coach roles database_session.execute(models.user_editions.insert(), [ {"user_id": user1.user_id, "edition_id": edition1.edition_id}, @@ -38,8 +44,12 @@ def data(database_session: Session) -> dict[str, str | int]: return {"user1": user1.user_id, "user2": user2.user_id, - "edition1": edition1.edition_id, - "edition2": edition2.edition_id, + "edition1": edition1.name, + "edition2": edition2.name, + "email1": email_auth1.email, + "email2": github_auth1.email, + "auth_type1": "email", + "auth_type2": "github" } @@ -56,6 +66,53 @@ def test_get_all_users(database_session: Session, auth_client: AuthClient, data: assert data["user2"] in user_ids +def test_get_all_users_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of users""" + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=False)) + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users?page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == DB_PAGE_SIZE + response = auth_client.get("/users?page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1 + # +1 because Authclient.admin() also creates one user. + + +def test_get_all_users_paginated_filter_name(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of users with filter for name""" + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=False)) + if "1" in str(i): + count += 1 + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users?page=0&name=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == min(DB_PAGE_SIZE, count) + response = auth_client.get("/users?page=1&name=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == max(count - round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE, 0) + + +def test_get_users_response(database_session: Session, auth_client: AuthClient, data: dict[str, str]): + """Test the response model of a user""" + auth_client.admin() + response = auth_client.get("/users") + users = response.json()["users"] + user1 = [user for user in users if user["userId"] == data["user1"]][0] + assert user1["auth"]["email"] == data["email1"] + assert user1["auth"]["authType"] == data["auth_type1"] + user2 = [user for user in users if user["userId"] == data["user2"]][0] + assert user2["auth"]["email"] == data["email2"] + assert user2["auth"]["authType"] == data["auth_type2"] + + def test_get_all_admins(database_session: Session, auth_client: AuthClient, data: dict[str, str | int]): """Test endpoint for getting a list of admins""" auth_client.admin() @@ -67,6 +124,90 @@ def test_get_all_admins(database_session: Session, auth_client: AuthClient, data assert [data["user1"]] == user_ids +def test_get_all_admins_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of paginated admins""" + count = 0 + for i in range(round(DB_PAGE_SIZE * 3)): + database_session.add(models.User(name=f"User {i}", admin=i % 2 == 0)) + if i % 2 == 0: + count += 1 + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users?admin=true&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == min(count + 1, DB_PAGE_SIZE) + response = auth_client.get("/users?admin=true&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == min(count - DB_PAGE_SIZE + 1, DB_PAGE_SIZE + 1) + # +1 because Authclient.admin() also creates one user. + + +def test_get_all_non_admins_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of paginated admins""" + non_admins = [] + for i in range(round(DB_PAGE_SIZE * 3)): + user = models.User(name=f"User {i}", admin=i % 2 == 0) + database_session.add(user) + database_session.commit() + if i % 2 != 0: + non_admins.append(user.user_id) + database_session.commit() + + auth_client.admin() + + count = len(non_admins) + response = auth_client.get("/users?admin=false&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == min(count, DB_PAGE_SIZE) + for user in response.json()["users"]: + assert user["userId"] in non_admins + + response = auth_client.get("/users?admin=false&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == max(count - DB_PAGE_SIZE, 0) + + +def test_get_all_admins_paginated_filter_name(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of paginated admins with filter for name""" + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=True)) + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users?admin=true&page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == DB_PAGE_SIZE + response = auth_client.get("/users?admin=true&page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1 + + +def test_get_all_non_admins_paginated_filter_name(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of paginated admins""" + non_admins = [] + for i in range(round(DB_PAGE_SIZE * 3)): + user = models.User(name=f"User {i}", admin=i % 2 == 0) + database_session.add(user) + database_session.commit() + if i % 2 != 0 and "1" in str(i): + non_admins.append(user.user_id) + database_session.commit() + + auth_client.admin() + + count = len(non_admins) + response = auth_client.get("/users?admin=false&page=0&name=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == min(count, DB_PAGE_SIZE) + for user in response.json()["users"]: + assert user["userId"] in non_admins + + response = auth_client.get("/users?admin=false&page=1&name=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == max(count - DB_PAGE_SIZE, 0) + + def test_get_users_from_edition(database_session: Session, auth_client: AuthClient, data: dict[str, str | int]): """Test endpoint for getting a list of users from a given edition""" auth_client.admin() @@ -77,18 +218,164 @@ def test_get_users_from_edition(database_session: Session, auth_client: AuthClie assert [data["user2"]] == user_ids +def test_get_all_users_for_edition_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of users""" + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=False, editions=[edition])) + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users?page=0&edition_name=ed2022") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == DB_PAGE_SIZE + response = auth_client.get("/users?page=1&edition_name=ed2022") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1 + # +1 because Authclient.admin() also creates one user. + + +def test_get_all_users_for_edition_paginated_filter_user(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a list of users and filter on name""" + edition = models.Edition(year=2022, name="ed2022") + database_session.add(edition) + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + database_session.add(models.User(name=f"User {i}", admin=False, editions=[edition])) + if "1" in str(i): + count += 1 + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users?page=0&edition_name=ed2022&name=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == min(count , DB_PAGE_SIZE) + response = auth_client.get("/users?page=1&edition_name=ed2022&name=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['users']) == max(count - DB_PAGE_SIZE, 0) + + def test_get_admins_from_edition(database_session: Session, auth_client: AuthClient, data: dict[str, str | int]): - """Test endpoint for getting a list of admins from a given edition""" + """Test endpoint for getting a list of admins, edition should be ignored""" auth_client.admin() # All admins from edition response = auth_client.get(f"/users?admin=true&edition={data['edition1']}") assert response.status_code == status.HTTP_200_OK - user_ids = [user["userId"] for user in response.json()['users']] - assert [data["user1"]] == user_ids + assert len(response.json()['users']) == 2 response = auth_client.get(f"/users?admin=true&edition={data['edition2']}") assert response.status_code == status.HTTP_200_OK - assert len(response.json()['users']) == 0 + assert len(response.json()['users']) == 2 + + +def test_get_all_users_excluded_edition_paginated(database_session: Session, auth_client: AuthClient): + auth_client.admin() + edition_a = models.Edition(year=2022, name="edA") + edition_b = models.Edition(year=2023, name="edB") + database_session.add(edition_a) + database_session.add(edition_b) + database_session.commit() + + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_a.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_b.edition_id}, + ]) + database_session.commit() + + a_users = auth_client.get(f"/users?page=0&exclude_edition=edB").json()["users"] + assert len(a_users) == DB_PAGE_SIZE + for user in a_users: + assert "b" not in user["name"] + assert len(auth_client.get(f"/users?page=1&exclude_edition=edB").json()["users"]) == \ + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1 # auth_client is not a coach + + b_users = auth_client.get(f"/users?page=0&exclude_edition=edA").json()["users"] + assert len(b_users) == DB_PAGE_SIZE + for user in b_users: + assert "a" not in user["name"] + assert len(auth_client.get(f"/users?page=1&exclude_edition=edA").json()["users"]) == \ + round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + 1 # auth_client is not a coach + + +def test_get_all_users_excluded_edition_paginated_filter_name(database_session: Session, auth_client: AuthClient): + auth_client.admin() + edition_a = models.Edition(year=2022, name="edA") + edition_b = models.Edition(year=2023, name="edB") + database_session.add(edition_a) + database_session.add(edition_b) + database_session.commit() + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_a.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_b.edition_id}, + ]) + if "1" in str(i): + count += 1 + database_session.commit() + + a_users = auth_client.get(f"/users?page=0&exclude_edition=edB&name=1").json()["users"] + assert len(a_users) == min(count, DB_PAGE_SIZE) + for user in a_users: + assert "b" not in user["name"] + assert len(auth_client.get(f"/users?page=1&exclude_edition=edB&name=1").json()["users"]) == \ + max(count - DB_PAGE_SIZE, 0) + + b_users = auth_client.get(f"/users?page=0&exclude_edition=edA&name=1").json()["users"] + assert len(b_users) == min(count, DB_PAGE_SIZE) + for user in b_users: + assert "a" not in user["name"] + assert len(auth_client.get(f"/users?page=1&exclude_edition=edA&name=1").json()["users"]) == \ + max(count - DB_PAGE_SIZE, 0) + + +def test_get_all_users_for_edition_excluded_edition_paginated(database_session: Session, auth_client: AuthClient): + auth_client.admin() + edition_a = models.Edition(year=2022, name="edA") + edition_b = models.Edition(year=2023, name="edB") + database_session.add(edition_a) + database_session.add(edition_b) + database_session.commit() + + correct_users_id = [] + for i in range(round(DB_PAGE_SIZE * 1.5)): + user_1 = models.User(name=f"User {i} - a", admin=False) + user_2 = models.User(name=f"User {i} - b", admin=False) + database_session.add(user_1) + database_session.add(user_2) + database_session.commit() + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_a.edition_id}, + {"user_id": user_2.user_id, "edition_id": edition_b.edition_id}, + ]) + if i % 2: + database_session.execute(models.user_editions.insert(), [ + {"user_id": user_1.user_id, "edition_id": edition_b.edition_id}, + ]) + else: + correct_users_id.append(user_1.user_id) + + database_session.commit() + + users = auth_client.get(f"/users?page=0&exclude_edition=edB&edition=edA").json()["users"] + assert len(users) == len(correct_users_id) + for user in users: + assert user["userId"] in correct_users_id def test_get_users_invalid(database_session: Session, auth_client: AuthClient, data: dict[str, str | int]): @@ -98,15 +385,12 @@ def test_get_users_invalid(database_session: Session, auth_client: AuthClient, d response = auth_client.get("/users?admin=INVALID") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - response = auth_client.get("/users?edition=INVALID") - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - def test_edit_admin_status(database_session: Session, auth_client: AuthClient): """Test endpoint for editing the admin status of a user""" auth_client.admin() # Create user - user = models.User(name="user1", email="user1@mail.com", admin=False) + user = models.User(name="user1", admin=False) database_session.add(user) database_session.commit() @@ -125,17 +409,17 @@ def test_add_coach(database_session: Session, auth_client: AuthClient): """Test endpoint for adding coaches""" auth_client.admin() # Create user - user = models.User(name="user1", email="user1@mail.com", admin=False) + user = models.User(name="user1", admin=False) database_session.add(user) # Create edition - edition = models.Edition(year=1) + edition = models.Edition(year=1, name="ed1") database_session.add(edition) database_session.commit() # Add coach - response = auth_client.post(f"/users/{user.user_id}/editions/{edition.edition_id}") + response = auth_client.post(f"/users/{user.user_id}/editions/{edition.name}") assert response.status_code == status.HTTP_204_NO_CONTENT coach = database_session.query(user_editions).one() assert coach.user_id == user.user_id @@ -146,11 +430,11 @@ def test_remove_coach(database_session: Session, auth_client: AuthClient): """Test endpoint for removing coaches""" auth_client.admin() # Create user - user = models.User(name="user1", email="user1@mail.com") + user = models.User(name="user1") database_session.add(user) # Create edition - edition = models.Edition(year=1) + edition = models.Edition(year=1, name="ed1") database_session.add(edition) database_session.commit() @@ -162,25 +446,59 @@ def test_remove_coach(database_session: Session, auth_client: AuthClient): database_session.commit() # Remove coach - response = auth_client.delete(f"/users/{user.user_id}/editions/{edition.edition_id}") + response = auth_client.delete(f"/users/{user.user_id}/editions/{edition.name}") assert response.status_code == status.HTTP_204_NO_CONTENT coach = database_session.query(user_editions).all() assert len(coach) == 0 +def test_remove_coach_all_editions(database_session: Session, auth_client: AuthClient): + """Test removing a user as coach from all editions""" + auth_client.admin() + + # Create user + user1 = models.User(name="user1", admin=False) + database_session.add(user1) + user2 = models.User(name="user2", admin=False) + database_session.add(user2) + + # Create edition + edition1 = models.Edition(year=1, name="ed1") + edition2 = models.Edition(year=2, name="ed2") + edition3 = models.Edition(year=3, name="ed3") + database_session.add(edition1) + database_session.add(edition2) + database_session.add(edition3) + + database_session.commit() + + # Create coach role + database_session.execute(models.user_editions.insert(), [ + {"user_id": user1.user_id, "edition_id": edition1.edition_id}, + {"user_id": user1.user_id, "edition_id": edition2.edition_id}, + {"user_id": user1.user_id, "edition_id": edition3.edition_id}, + {"user_id": user2.user_id, "edition_id": edition2.edition_id}, + ]) + + response = auth_client.delete(f"/users/{user1.user_id}/editions") + assert response.status_code == status.HTTP_204_NO_CONTENT + coach = database_session.query(user_editions).all() + assert len(coach) == 1 + + def test_get_all_requests(database_session: Session, auth_client: AuthClient): """Test endpoint for getting all userrequests""" auth_client.admin() # Create user - user1 = models.User(name="user1", email="user1@mail.com") - user2 = models.User(name="user2", email="user2@mail.com") + user1 = models.User(name="user1") + user2 = models.User(name="user2") database_session.add(user1) database_session.add(user2) # Create edition - edition1 = models.Edition(year=1) - edition2 = models.Edition(year=2) + edition1 = models.Edition(year=1, name="ed1") + edition2 = models.Edition(year=2, name="ed2") database_session.add(edition1) database_session.add(edition2) @@ -194,7 +512,7 @@ def test_get_all_requests(database_session: Session, auth_client: AuthClient): database_session.commit() - response = auth_client.get(f"/users/requests") + response = auth_client.get("/users/requests") assert response.status_code == status.HTTP_200_OK user_ids = [request["user"]["userId"] for request in response.json()['requests']] assert len(user_ids) == 2 @@ -202,19 +520,60 @@ def test_get_all_requests(database_session: Session, auth_client: AuthClient): assert user2.user_id in user_ids +def test_get_all_requests_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a paginated list of requests""" + edition = models.Edition(year=2022, name="ed2022") + + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(models.CoachRequest(user=user, edition=edition)) + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users/requests?page=0") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == DB_PAGE_SIZE + response = auth_client.get("/users/requests?page=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + + +def test_get_all_requests_paginated_filter_name(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a paginated list of requests""" + edition = models.Edition(year=2022, name="ed2022") + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(models.CoachRequest(user=user, edition=edition)) + if "1" in str(i): + count += 1 + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users/requests?page=0&user=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == min(DB_PAGE_SIZE, count) + response = auth_client.get("/users/requests?page=1&user=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == max(count-DB_PAGE_SIZE, 0) + + def test_get_all_requests_from_edition(database_session: Session, auth_client: AuthClient): """Test endpoint for getting all userrequests of a given edition""" auth_client.admin() # Create user - user1 = models.User(name="user1", email="user1@mail.com") - user2 = models.User(name="user2", email="user2@mail.com") + user1 = models.User(name="user1") + user2 = models.User(name="user2") database_session.add(user1) database_session.add(user2) # Create edition - edition1 = models.Edition(year=1) - edition2 = models.Edition(year=2) + edition1 = models.Edition(year=1, name="ed1") + edition2 = models.Edition(year=2, name="ed2") database_session.add(edition1) database_session.add(edition2) @@ -228,28 +587,69 @@ def test_get_all_requests_from_edition(database_session: Session, auth_client: A database_session.commit() - response = auth_client.get(f"/users/requests?edition={edition1.edition_id}") + response = auth_client.get(f"/users/requests?edition={edition1.name}") assert response.status_code == status.HTTP_200_OK requests = response.json()['requests'] assert len(requests) == 1 assert user1.user_id == requests[0]["user"]["userId"] - response = auth_client.get(f"/users/requests?edition={edition2.edition_id}") + response = auth_client.get(f"/users/requests?edition={edition2.name}") assert response.status_code == status.HTTP_200_OK requests = response.json()['requests'] assert len(requests) == 1 assert user2.user_id == requests[0]["user"]["userId"] +def test_get_all_requests_for_edition_paginated(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a paginated list of requests""" + edition = models.Edition(year=2022, name="ed2022") + + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(models.CoachRequest(user=user, edition=edition)) + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users/requests?page=0&edition_name=ed2022") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == DB_PAGE_SIZE + response = auth_client.get("/users/requests?page=1&edition_name=ed2022") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == round(DB_PAGE_SIZE * 1.5) - DB_PAGE_SIZE + + +def test_get_all_requests_for_edition_paginated_filter_name(database_session: Session, auth_client: AuthClient): + """Test endpoint for getting a paginated list of requests""" + edition = models.Edition(year=2022, name="ed2022") + + count = 0 + for i in range(round(DB_PAGE_SIZE * 1.5)): + user = models.User(name=f"User {i}", admin=False) + database_session.add(user) + database_session.add(models.CoachRequest(user=user, edition=edition)) + if "1" in str(i): + count += 1 + database_session.commit() + + auth_client.admin() + response = auth_client.get("/users/requests?page=0&edition_name=ed2022&user=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == min(DB_PAGE_SIZE, count) + response = auth_client.get("/users/requests?page=1&edition_name=ed2022&user=1") + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['requests']) == max(count-DB_PAGE_SIZE, 0) + + def test_accept_request(database_session, auth_client: AuthClient): """Test endpoint for accepting a coach request""" auth_client.admin() # Create user - user1 = models.User(name="user1", email="user1@mail.com") + user1 = models.User(name="user1") database_session.add(user1) # Create edition - edition1 = models.Edition(year=1) + edition1 = models.Edition(year=1, name="ed1") database_session.add(edition1) database_session.commit() @@ -271,11 +671,11 @@ def test_reject_request(database_session, auth_client: AuthClient): """Test endpoint for rejecting a coach request""" auth_client.admin() # Create user - user1 = models.User(name="user1", email="user1@mail.com") + user1 = models.User(name="user1") database_session.add(user1) # Create edition - edition1 = models.Edition(year=1) + edition1 = models.Edition(year=1, name="ed1") database_session.add(edition1) database_session.commit() diff --git a/backend/tests/test_schemas/test_validators.py b/backend/tests/test_schemas/test_validators.py index 1612da822..770d621e0 100644 --- a/backend/tests/test_schemas/test_validators.py +++ b/backend/tests/test_schemas/test_validators.py @@ -5,6 +5,7 @@ def test_email_address(): + """Test the validation of email addresses""" with pytest.raises(ValidationException): validate_email_format("test") diff --git a/backend/tests/test_utils/test_mailto.py b/backend/tests/test_utils/test_mailto.py index 5ed6a1822..cd802e171 100644 --- a/backend/tests/test_utils/test_mailto.py +++ b/backend/tests/test_utils/test_mailto.py @@ -2,6 +2,7 @@ def test_mailto(): + """Test generating mailto links""" # Basic assert generate_mailto_string(recipient="me", subject="subject", diff --git a/backend/tests/utils/authorization/auth_client.py b/backend/tests/utils/authorization/auth_client.py index 2b120c772..900c996b6 100644 --- a/backend/tests/utils/authorization/auth_client.py +++ b/backend/tests/utils/authorization/auth_client.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from starlette.testclient import TestClient -from src.app.logic.security import create_access_token +from src.app.logic.security import create_tokens from src.database.models import User, Edition @@ -29,7 +29,7 @@ def invalid(self): def admin(self): """Sign in as an admin for all future requests""" # Create a new user in the db - admin = User(name="Pytest Admin", email="admin@pytest.email", admin=True) + admin = User(name="Pytest Admin", admin=True) self.session.add(admin) self.session.commit() @@ -40,7 +40,7 @@ def coach(self, edition: Edition): Assigns the coach to the edition """ # Create a new user in the db - coach = User(name="Pytest Coach", email="coach@pytest.email", admin=False) + coach = User(name="Pytest Coach", admin=False) # Link the coach to the edition coach.editions.append(edition) @@ -53,10 +53,8 @@ def login(self, user: User): """Sign in as a user for all future requests""" self.user = user - access_token_expires = timedelta(hours=24) - access_token = create_access_token( - data={"sub": str(user.user_id)}, expires_delta=access_token_expires - ) + # Since an authclient is created for every test, the access_token will most likely not run out + access_token, _refresh_token = create_tokens(user) # Add auth headers into dict self.headers = {"Authorization": f"Bearer {access_token}"} diff --git a/files/module_overview_frontend.md b/files/module_overview_frontend.md index ce981ae77..d534b7f23 100644 --- a/files/module_overview_frontend.md +++ b/files/module_overview_frontend.md @@ -2,12 +2,20 @@ ## components -Here are all components we need for the frontend. Each component is in its own folder. +Here are all components we need for the frontend. Components are organized into folders that group related components together. This structure helps keeping the directory clean. -## utils/api +## contexts -These scrips make request to the API. +The contexts module contains our custom React context providers. + +## data + +This module contains our enums and interfaces. + +## utils + +This module has all functions that are not directly React-related, and a series of utility functions to make our code cleaner. This includes API requests, logic, and functions that interact with LocalStorage and SessionStorage. ## views -Here are all the views we have in the frontend. +Here are all the views (pages) we have in the frontend. Every view is a very simple component, because they are split up into smaller components that can be found in the [components](#components) module. diff --git a/files/sysadmin_guide.md b/files/sysadmin_guide.md index bfb7d952a..41bbdfd1c 100644 --- a/files/sysadmin_guide.md +++ b/files/sysadmin_guide.md @@ -34,7 +34,7 @@ To be able to login via ssh a sysadmin should add your ssh key to the `authorize [frontend] $ echo "" >> /home/frontend/.ssh/authorized_keys ``` -See [Generating SSH Keys](#generating-ssh-keys) for instructions on how to generate an ssh key. +See [Generating SSH Keys](#generating-ssh-keys) for instructions on how to generate an ssh key. #### Installing Node and Yarn @@ -112,7 +112,7 @@ Navigate to the frontend folder in the cloned repository and install the depende Then create the `.env` file. ```shell -[frontend] $ echo "REACT_APP_BASE_URL=https://" > .env +[frontend] $ echo "REACT_APP_BASE_URL=https://" > .env ``` Then build the frontend. @@ -146,7 +146,7 @@ The mariadb version available in the apt repositories (Currently 10.3) is too ou [admin] $ sudo apt install mariadb-server ``` -Once the server is installed **make sure** to run the `mariadb-secure-installation` script. This is **very important!** The database will be insecure if not run. +Once the server is installed **make sure** to run the `mariadb-secure-installation` script. This is **very important!** The database will be insecure if not run. #### Configuring Database @@ -199,8 +199,7 @@ To be able to login via ssh a sysadmin should add your ssh key to the `authorize [backend] $ echo "" >> /home/backend/.ssh/authorized_keys ``` -See [Generating SSH Keys](#generating-ssh-keys) for instructions on how to generate an ssh key. - +See [Generating SSH Keys](#generating-ssh-keys) for instructions on how to generate an ssh key. #### Installing Python @@ -283,7 +282,11 @@ We will be installing dependencies in a virtual environment. You can create and [backend] $ . venv-osoc/bin/activate ``` -To manage dependecies we currently use 2 separate requirements files. Only `requirements.txt` has to be installed. The other one is for development setups. +To manage dependecies we currently use poetry. You can install it using the following command. + +```shell +(venv-osoc) [backend] $ pip3 install poetry +``` Make sure the mariadb libraries are installed. @@ -293,7 +296,7 @@ Make sure the mariadb libraries are installed. ```shell (venv-osoc) [backend] $ cd osoc/backend -(venv-osoc) [backend] $ pip3 install -r requirements.txt +(venv-osoc) [backend] $ python3 -m poetry install --no-dev ``` #### Configuring the application @@ -375,11 +378,11 @@ upstream backend { server { server_name example.com; listen 80 - + location / { root /home/frontend/osoc/frontend/build; index index.html; - + try_files $uri $uri/ =404; } diff --git a/files/user_manual.md b/files/user_manual.md index 2756901d7..5baf11504 100644 --- a/files/user_manual.md +++ b/files/user_manual.md @@ -8,12 +8,130 @@ After you have registered yourself and have been approved by one of the administ There are different ways to log in, depending on the way in which you have registered yourself. **Please note: you can only log in through the method you have registered yourself with.** -## Email +### Email + 1. Fill in your email address and password in the corresponding fields. 2. Click the "Log in" button. -## GitHub +### GitHub + 1. Click the "Log in" button with the GitHub logo. -## Google +### Google + 1. Click the "Log in" button with the Google logo. + +## Admins + +This section is for admins. It contains all features to manage users. A user is someone who uses the tool (this does not include students). A user can be coach of one or more editions. He can only see data and work (making suggestions...) on these editions. A user can be admin of the tool. An admin can see/edit/delete all data from all editions and manage other users. This role is only for fully trusted people. An admin doesn't need to be coach from an edition to participate in the selection process. + +The management is split into two pages. The first one is to manage coaches of the currently selected edition. The other is to manage admins. Both pages can be found in the **Users** tab in the navigation bar. + +### Coaches + +The coaches pages is used to manage all coaches of the current selected edition. The page consists of three main parts. + +#### Invite a user + +At the top left, you can invite someone via an invite link. You can choose between creating an email to the person or copying the link. The new user can use this link to make an account. Once the person is registered, you can accept (or reject) him at the **Requests** section (see below). + +1. Type the email address of the person you want to invite in the input field. +2. Click the **Send invite** button to create an email to the given address OR Use the dropdown of the button and click **Copy invite link** to copy the link. You can choose via which way you provide the new user the invite link. + +#### Requests + +At the top middle of the page, you find a dropdown labeled **Requests**. When you expand the dropdown, you can see a list of all pending user requests. These are all users who used an invite link to create an account, and haven't been accepted (or declined) yet. + +Note: the list only contains requests from the current selected edition. Each edition has its own requests. + +The list can be filtered by name. Each row of the table contains the name and email address of a person. The email contains an icon indicating whether the person registered via email, GitHub or Google. Next to each row there are two buttons to accept or reject the person. When a person is accepted, he will automatically be added as coach to the current edition. + +#### Coaches + +A the centre of the page, you can find a list of all users who are coach in the current edition. As in the Requests list, each row contains the name and email address of a user. The list can be filtered by name. + +Next to the email address, there is a button to remove the user as coach from the currently selected edition. Once clicked, you get two choices: + +- **Remove from all editions**: The user will be removed as coach from all editions. If the user is not an admin, he won't be able to see any data from any edition anymore +- **Remove from {Edition name}**: The user will be removed as coach from the current selected edition. He will still be able to see data from any other edition wherefrom he is coach. + +At the top right of the list, there is a button to add a user as coach to the selected edition. This can be used if a user of a previous edition needs to be a coach in the current edition. You can only add existing users via this button. Once clicked, you see a prompt to search for a user's name. After typing the name of the user, a list of users whose name contains the typed text will be shown. You can select the desired user, check if the email and register-method are correct and add him as coach to the current edition. A user who is added as coach will be able to see all data of the current edition and participate in the selection process. + +### Admins + +This page consists of a list of all users who are admin. An admin can see all editions and change all data (including deleting a whole edition). Each row in the list contains the name and email (including register-method) of every admin. The list can be filtered by name via the input field. + +Next to the email address, there is a button to remove a user as admin. Once clicked, you get two choices: + +- **Remove admin**: Remove the given user as admin. He will stay coach for editions whereto he was assigned +- **Remove as admin and coach**: Remove the given user as admin and remove him as coach from every edition. The user won't be able to see any data from any edition. + +At the top right of the list, there is a button to add a user as admin. Once clicked, you see a prompt to search for a user's name. After typing the name of the user, a list of users whose names contain the typed text will be shown. You can select the desired user, check if the email and register-method are correct and add him as admin. + +**Warning**: A user who is added as admin will be able to edit and delete all data. He will be able to add and remove other admins. + +## Editions + +This section contains all actions related to managing your editions. + +### Viewing a list of available editions + +In the navbar, you should see an **Editions** button. When clicked, this button brings you to "/editions", which we'll call the "Editions Page". Admins can see _all_ editions, coaches can only see the editions they're coach of. + +This page lists all editions, and contains buttons for: + +- [Creating new editions](#creating-a-new-edition-admin-only) +- [Deleting editions](#deleting-an-edition-admin-only) + +### Creating a new edition (Admin-only) + +In order to create new editions, head over to the Editions Page (see [Viewing a list of available editions](#viewing-a-list-of-available-editions)). In the top-right of the page, you should see a "+ Create Edition"-button. + +- Click the "+ Create Edition"-button +- Fill in the fields in the form presented to you +- Click the "Submit"-button + +You've now created a new edition to which you can add coaches, projects, and students. + +### Deleting an edition (Admin-only) + +In order to delete editions, head over to the Editions Page (see [Viewing a list of available editions](#viewing-a-list-of-available-editions)). Every entry in the list will have a "Delete Edition" on the right. + +- Click the "Delete Edition"-button of the edition you want to delete +- Follow the on-screen instructions + +**Warning**: Deleting an edition is a **very dangerous operation** and **can not be undone**. As none of the linked data can be viewed without an edition, this means that deleting an edition _also_ deletes: + +- All projects linked to this edition +- All students linked to this edition + +### Changing the current edition + +We have made a component to quickly go to a page from a previous edition. In the navbar, there's a dropdown of which the label is the name of the currently selected edition. + +_**Note**: This dropdown is hidden if you cannot see any editions, as it would be empty. If you are an admin, create a new edition. If you are a coach, wait for an admin to add you to an edition._ + +- Click the dropdown in the navbar to open it +- In the dropdown, click on the edition you'd like to switch to + +You have now set another edition as your "current edition". This means that navigating through the navbar will show results for that specific edition. + +## Projects + +This section contains all actions related to managing projects. + +### Viewing the grid of all projects + +You can navigate to the "Projects page" by clicking the **Projects** button. Here you can see all the projects that belong to the current edition. In the short overview of a project you can see the partners, coaches and the number of students needed for this project. + +You can also filter on project name and on the projects where you are a coach of. To filter on name enter a name in the search field and press enter or click the **Search** button. To filter on your own projects toggle the **Only own projects** switch. + +To get more search results click the **Load more projects** button located at the bottom of the search results + +### Detailed view of a project + +To get more in depth with a project click the title of the project. This will take you to the "Project page" of that specific project. + +### Delete a project + +To delete a project click the **trash** button located on the top right of a project card. A pop up will appear to confirm your decision. Press **Delete** again to delete the project or cancel the delete operation diff --git a/files/user_manual.pdf b/files/user_manual.pdf index e62e2df73..daa802363 100644 Binary files a/files/user_manual.pdf and b/files/user_manual.pdf differ diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 6ebee9a67..2f78e518e 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,3 +1,4 @@ */.js node_modules -build \ No newline at end of file +build +docs \ No newline at end of file diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 525c87f6b..2b9333731 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -28,6 +28,7 @@ "react/prop-types": "off", "no-use-before-define": "off", "no-unused-vars": "off", + "@typescript-eslint/no-explicit-any": ["error"], "@typescript-eslint/no-unused-vars": ["error"], "@typescript-eslint/no-use-before-define": ["error"] } diff --git a/frontend/.gitignore b/frontend/.gitignore index 4b293975d..6865e19be 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,4 +28,7 @@ yarn-error.log* # Visual Studio Code .vscode/ -.env \ No newline at end of file +.env + +# Documentation +docs \ No newline at end of file diff --git a/frontend/.tool-versions b/frontend/.tool-versions new file mode 100644 index 000000000..009455657 --- /dev/null +++ b/frontend/.tool-versions @@ -0,0 +1 @@ +nodejs 16.14.2 diff --git a/frontend/README.md b/frontend/README.md index 1fe9b538c..c3f3b5f44 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -10,7 +10,8 @@ npm install --global yarn ``` -:heavy_exclamation_mark: Do **not** use `npm` anymore! Yarn and npm shouldn't be used at the same time. +:heavy_exclamation_mark: Do **not** use `npm` anymore! Yarn and npm shouldn't be used at the same +time. ```bash # Installing new package @@ -25,46 +26,62 @@ yarn install ## Setting up Prettier and ESLint -This directory contains configuration files for `Prettier` and `ESLint`, and depending on your IDE you may have to install or configure these in order for this to work. +This directory contains configuration files for `Prettier` and `ESLint`, and depending on your IDE +you may have to install or configure these in order for this to work. ### Prettier -Prettier is a code formatter that enforces your code to follow a specific style. Examples include automatically adding semicolons (;) at the end of every line, converting single-quoted strings ('a') to double-quoted strings (`"a"`), etc. +Prettier is a code formatter that enforces your code to follow a specific style. Examples include +automatically adding semicolons (;) at the end of every line, converting single-quoted strings ('a') +to double-quoted strings (`"a"`), etc. ### ESLint -ESLint is, as the name suggests, a linter that reviews your code for bad practices and ugly constructions. +ESLint is, as the name suggests, a linter that reviews your code for bad practices and ugly +constructions. ### JetBrains WebStorm -When using WebStorm, Prettier and ESLint are supported by default. ESLint is enabled automatically if a `.eslintrc` file is present, but you _do_ have to enable Prettier in the settings. +When using WebStorm, Prettier and ESLint are supported by default. ESLint is enabled automatically +if a `.eslintrc` file is present, but you _do_ have to enable Prettier in the settings. -1. Make sure the packages were installed by running `yarn install`, as `Prettier` and `ESLint` are among them. -2. In the search bar, type in "Prettier" (or navigate to `Languages & Frameworks > JavaScript > Prettier` manually). -3. If the `Prettier package`-field is still empty, click the dropdown. WebStorm should automatically list the Prettier from your local `node-modules` directory. +1. Make sure the packages were installed by running `yarn install`, as `Prettier` and `ESLint` are + among them. +2. In the search bar, type in "Prettier" (or navigate + to `Languages & Frameworks > JavaScript > Prettier` manually). +3. If the `Prettier package`-field is still empty, click the dropdown. WebStorm should automatically + list the Prettier from your local `node-modules` directory. 4. Select the `On 'Reformat Code' action` and `On save` checkboxes. ![Prettier WebStorm configuration](md-assets/readme/webstorm-prettier.png) ### Visual Studio Code -Visual Studio Code requires an extension for Prettier and ESLint to work, as they are not present in the editor. +Visual Studio Code requires an extension for Prettier and ESLint to work, as they are not present in +the editor. -1. Make sure the packages were installed by running `yarn install`, as `Prettier` and `ESLint` are among them +1. Make sure the packages were installed by running `yarn install`, as `Prettier` and `ESLint` are + among them -2. Install the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode). +2. Install + the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + . -2. Install the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). +2. Install + the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + . 3. Select `Prettier` as the default formatter in the `Editor: Default Formatter` dropdown option. - ![VSCode: Default Formatter setting](md-assets/readme/vscode-default-formatter.png) + ![VSCode: Default Formatter setting](md-assets/readme/vscode-default-formatter.png) 4. Enable the `Editor: Format On Save` option. - ![VSCode: Format On Save setting](md-assets/readme/vscode-format-on-save.png) + ![VSCode: Format On Save setting](md-assets/readme/vscode-format-on-save.png) -5. The path to the `Prettier` config file, and the module in `node_modules` should be detected **automatically**. In case it isn't (see [Try it out!](#try-it-out), you can always fill in the fields in `Prettier: Config Path` and `Prettier: Prettier Path`. +5. The path to the `Prettier` config file, and the module in `node_modules` should be detected ** + automatically**. In case it isn't (see [Try it out!](#try-it-out), you can always fill in the + fields in `Prettier: Config Path` and `Prettier: Prettier Path`. ### Try it out! @@ -74,18 +91,19 @@ To test if your new settings work, you can try the following: 2. In that file, add the following piece of code: - ```typescript - export const x = 5 // Don't add a semicolon here - - export function test() { - // "variable" is never used, and never reassigned - let variable = "something"; - } - ``` + ```typescript + export const x = 5 // Don't add a semicolon here + + export function test() { + // "variable" is never used, and never reassigned + let variable = "something"; + } + ``` 3. Save the file by pressing `ctrl + s` 4. Prettier: you should see a semicolon being added at the end of the line automatically -5. ESLint: you should get a warning on `variable` telling you that it was never used, and also that it should be marked as `const` because it's never reassigned. +5. ESLint: you should get a warning on `variable` telling you that it was never used, and also that + it should be marked as `const` because it's never reassigned. 6. Don't forget to remove the `test.ts` file again :) ## Available Scripts @@ -106,6 +124,11 @@ Launches the test runner. ### `yarn build` -Builds the app for production to the `build` folder. It correctly bundles React in production mode and optimizes the build for the best performance. +Builds the app for production to the `build` folder. It correctly bundles React in production mode +and optimizes the build for the best performance. The build is minified and the filenames include the hashes. + +### `yarn docs` + +Auto-generates documentation for our code. The output can be found in the `/docs` folder. diff --git a/frontend/frontend_guide.md b/frontend/frontend_guide.md new file mode 100644 index 000000000..f126158c1 --- /dev/null +++ b/frontend/frontend_guide.md @@ -0,0 +1,544 @@ +# Frontend Guide + +General guidelines & style conventions relating to frontend development. Don't do this, but do that! + +A lot of these guidelines revolve around directory structure and cleaner components. We don't want `.tsx`-files with thousands of `classNames` scattered all over the place, but there are way better solutions as you'll see in this guide. + +## Practical: Making API calls to the backend + +There is a pre-defined `axiosInstance` for you to use, which has things like the base url pre-configured. + +_Note that all logic should go to separate files, so the import path here suggests the file is in `/frontend/src/utils/api/`._ + +Don't do + +```ts +import axios from "axios"; + +async function someApiCall() { + await axios.get("https://sel2-3.ugent.be/api/students", headers={"Authorization": `Bearer ${token}`); +} +``` + +but do + +```ts +import { axiosInstance } from "./api"; + +async function someApiCall() { + // Note that we can now leave out the base url & headers! + // Code is cleaner, shorter, and you can't make mistakes against the url anymore + await axiosInstance.get("/students"); +} +``` + +## Practical: TypeScript interfaces for API responses + +In order to typehint the response of an API call, you can create an `interface`. This will tell TypeScript that certain fields are present, and what their type is, and it allows you to interact with the response as if it would be a class instance. Your IDE will also give intelligent code completion when using these interfaces. + +**Interfaces that are only relevant to one file should be defined at the top of the file, interfaces relevant to multiple files should be moved to another directory**. + +```ts +// /data/interfaces/students.ts + +// This interface can probably be used in multiple places, so it should be in a publicly accessible file +export interface Student { + id: Number; + name: string; + age: Number; +} +``` + +```ts +// /utils/api/students.ts + +// This interface is specific to one API call, no need to expose it to other files +interface GetStudentsResponse { + students: Student[]; // <- Yes, you can use interfaces as types in other interfaces +} + +async function getStudents(): GetStudentsResponse { + const response = await axiosInstance.get("/students"); + + // The "as [interface]"-keyword will tell TypeScript that the response has the structure of [interface] + return response as GetStudentsResponse; +} +``` + +### Note: you only have to add the fields that you care about + +An interface can be seen as a "view" on an object, pulling some fields out. There's no need to create an interface for the entire API response body if you only need one field. + +Of course, if an existing interface already has the field(s) you need, there's no need to make a smaller one just for the sake of leaving some fields out. This only applies to creating **new** interfaces for specific responses. + +In the example below, `Student` has an `id`- and an `age`-field, but neither of them are used. There's no need to include them in the interface. If there would already be a `Student` interface, with **all** fields, you can use the existing interface instead of making a custom view. + +Don't do + +```ts +interface StudentResponse { + id: Number; // <- Unused field + name: string; + age: Number; // <- Unused field + // ... +} + +async function getStudentName(id: Number): string { + const student = (await axiosInstance.get(`/students/${id}`)) as StudentResponse; + return student.name; +} +``` + +but do + +```ts +interface StudentResponse { + name: string; // <- Only field we use +} + +async function getStudentName(id: Number): string { + const student = (await axiosInstance.get(`/students/${id}`)) as StudentResponse; + return student.name; +} +``` + +or, if an interface already exists, do + +```ts +// "Student" already exists and has a lot of fields, "name" is one of those fields +// We don't care about the rest, but it's not necessary to make a new interface +// if the existing one can be used +// (it might even make the code more readable to use the old/generalized interface) +import { Student } from "../../data/interfaces/students"; + +async function getStudentName(id: Number): string { + const student = (await axiosInstance.get(`/students/${id}`)) as Student; + return student.name; +} +``` + +## Use `async-await` instead of `.then()` + +The old way of handling asynchronous code used `function().then()`. The issue with this is that it becomes quite ugly very quickly, and also leads to the so-called "callback hell". The code executed _after_ the `.then()` **must** be placed inside of it, otherwise there are no guarantees about when or how it will execute. Further, you can't `return` from these callbacks, so things like returning the result of an API call are basically impossible (except for very ugly solutions like creating `Promise`-instances). + +You should only use `.then()` when you really have no other choice. For example: when interacting with something that doesn't _allow_ `async function`s. + +Don't do + +```ts +function callbackHell(): Response { + apiCall().then(response => { + // How do you make "callbackHell()" return "response"? + // "return" doesn't work here, as we are inside of a lambda + otherApiCall().then(otherResponse => { + // How do you make "callbackHell()" return "otherResponse"? + // "return" doesn't work here either, as we are still inside of a lambda + }); + }); +} +``` + +but do + +```ts +// Note the "async" keyword +async function asyncAwaitHeaven(): Promise { + const response = await apiCall(); + const otherResponse = await otherApiCall(); // This line is only executed after the previous one is finished + + // You can now very easily return the response of the API call like you normally would + return response; +} +``` + +Similarly, `.catch()` can be replaced by the `try-catch` pattern you're probably more familiar with. + +Don't do + +```ts +function thenCatch() { + apiCall() + .then(response => console.log(response)) + .catch(exception => console.log(exception)); +} +``` + +but do + +```ts +async function asyncCatch() { + try { + const response = await apiCall(); + console.log(response); + } catch (exception) { + console.log(exception); + } +} +``` + +## Moving pages to separate directories + +Don't do + +``` +views + - LoginPage.tsx + - HomePage.tsx +``` + +but do + +``` +views + - LoginPage + - LoginPage.tsx + - HomePage + - HomePage.tsx +``` + +## Keep `.css`-files next to the `.tsx`-files they are for + +Don't do + +``` +css-files + - App.css + +views + - App.tsx +``` + +but do + +``` +views + - App + - App.tsx + - App.css +``` + +This keeps the directories clean, so we don't end up with a thousand `.css` files scattered throughout the repository. If a file is next to a component, you can instantly find one if you have the other. + +## Use `react-bootstrap`-components instead of adding unnecessary Bootstrap `className`s + +`react-bootstrap` has a lot of built-in components that do some basic & commonly-used functionality for you. It's cleaner to use these components than to make a `
` because it keeps the code more readable. + +Don't do + +```tsx +export default function Component() { + return ( +
+
{/* ... */}
+
+ ); +} +``` + +but do + +```tsx +import Button from "react-bootstrap/Button"; +import Container from "react-bootstrap/Container"; + +export default function Component() { + return ( + + + + ); +} +``` + +### Note: only import what you need + +Docs on importing components can be found [here](https://react-bootstrap.github.io/getting-started/introduction/#importing-components). + +Don't do + +```tsx +// This pulls the entire library to only use this one component +import { Button } from "react-bootstrap"; +``` + +but do + +```tsx +// This navigates to the location of the component and only imports that +import Button from "react-bootstrap/Button"; +``` + +More info & tutorials about `react-bootstrap`, including a list of all available components, can be found on their [website](https://react-bootstrap.github.io/getting-started/introduction/). + +## Use `styled-components` instead of unnecessary `.css`-files, -classes, and inline styling + +If you create a `.css`-class that you only apply to a single element (or a couple of elements in the same isolated component), it's better to create a `styled-component`. This keeps the `.tsx`-files clean, as there are no unnecessary `className`s on every element. It also makes the code a bit easier to read, as every component now has a name instead of being a `div`. The same goes for inline `style`-tags. **Don't do this**. + +As you'll hopefully see in the example below, the resulting code is a lot calmer and less chaotic. + +Only create `.css`-files if you really have to. + +The name of this file should be `styles.ts`, and be present next to the `.tsx`-file it's for. If you would somehow end up with multiple `styles` in the same directory, prefix them with the name of the component (`Component.styles.ts`). + +Don't do + +```css +/* Component.css */ +.page-content { + color: red; + background-color: blue; +} + +/* ... */ +``` + +```tsx +// Component.tsx +import "./Component.css" + +export default function Component() { + return ( +
+ { /* ... More divs with a lot more classNames here */ } +
+ ); +} +``` + +but do + +```ts +// styles.ts +import styled from "styled-components"; + +// You can create a component for every tag there is, +// I'm just using a
in this example here. +// styled.h3`` also works just fine. +export const PageContent = styled.div` + color: red; + background-color: blue; + font-weight: bold; +`; +``` + +```tsx +// Component.tsx +import { PageContent } from "./styles"; + +export default function Component() { + // Notice how there are no classNames or inline styles, the code is a lot less hectic + return {/* more styled-components here */}; +} +``` + +Directory structure: + +``` +components + - SomePage + - Component + - Component.tsx + - Component.css + - styles.ts +``` + +### Note: you can also turn `react-bootstrap`-components into `styled-components` to keep the code even cleaner. + +Combining the previous tip with this one. To create a `styled-component` from a `react-bootstrap`-component (or any other component you have made), by passing the component as an argument to the `styled`-function. + +```ts +// styles.ts +import Container from "react-bootstrap/Container"; +import styled from "styled-components"; + +export const BoldContainer = styled(Container)` + font-weight: bold; +`; +``` + +More info & tutorials on `styled-components` can be found on their [website](https://styled-components.com/docs/basics). + +## Split every page & component down into small components + +We don't want massive `.tsx` files with hundreds of `
`s and 500 indents. Split every independent part down into small components that each have their own directory and isolated `.css` file. + +Don't do + +``` +views + - SomePage + - SomePage.tsx + - SomePage.css +``` + +```tsx +// SomePage.tsx +export default function SomePage() { + return ( +
+
// Page header, lots of code here
+
// Page footer, also a lot of code here
+
+ ); +} +``` + +but do + +``` +components + - SomePage <- Components only related to this page should also go in a separate directory to split them from the rest + - Header + - Header.tsx + - Header.css + - styles.ts + - Footer + - Footer.tsx + - Footer.css + - styles.ts +views + - SomePage + - SomePage.tsx + - SomePage.css +``` + +```tsx +// SomePage.tsx +export default function SomePage() { + return ( +
+
+
+
+ ); +} +``` + +```tsx +// Header.tsx +export default function Header() { + return
{/* Either more small components here, or an acceptable amount of code */}
; +} +``` + +```tsx +// Footer.tsx +export default function Footer() { + return
{/* Either more small components here, or an acceptable amount of code */}
; +} +``` + +## Use `index.ts`-files to re-export components and keep import paths short + +In JavaScript (and, as a result, also TypeScript) `index.(j/t)s` can be used to export specific functions from a module, keeping the rest hidden. + +If a piece of code isn't required outside of its module, don't export it in the `index.ts`-file. This way, only the "public" code is exported (and visible to outsider modules), while local logic is kept local. + +You can create as many `index.ts`-files as you want, which can all re-export nested components, functions, `const` variables, and more. The only rule you should follow is that you shouldn't re-export something to a level where you don't need it anymore. If `/components/Header/Button` is only used in the `Header`, don't include it in the `index.ts`-file from `Header`. This would "include" it in the Header module, even though it's not used outside of it. Keep the component **private** to the module. + +Don't do + +```ts +// This import means "/Button/Button.ts", so we are mentioning Button twice +// even though it's obvious that that's what we're trying to import +import Button from "./Button/Button"; +``` + +but do + +```ts +// /Button/index.ts +export { default as Button } from "./Button"; +``` + +```tsx +// The "/Button/index.ts"-file allows us to import from the name of the MODULE +// rather than going all the way to the ".ts(x)"-file +import { Button } from "./Button"; +``` + +Directory structure: + +``` +components + - Footer + - Button + - Button.tsx + - index.ts // Re-exports the Button: export { default as Button } from "./Button"; + - index.ts // Re-exports the Footer: export { default as Footer } from "./Footer"; +``` + +## Move logic to separate files + +Just as we did in the backend, the main components don't need to handle the logic that they execute. They merely call functions defined elsewhere. + +Don't do + +```tsx +// Component.tsx +import { axiosInstance } from "../utils/api"; + +export default function Component() { + return ( + // This makes the .tsx less readable + + ); + } + + return ( + <> + + Add admin + + + + + + Add Admin + + + { + setSelected(selected[0] as User); + setError(""); + }} + renderMenu={(results, menuProps) => { + const { + newSelectionPrefix, + paginationText, + renderMenuItemChildren, + ...props + } = menuProps; + return ( + + {results.map((result, index) => { + const user = result as User; + return ( + + +
+
+ ); + })} +
+ ); + }} + /> + + +
+ + {addButton} + + {error} + +
+
+ + ); +} diff --git a/frontend/src/components/AdminsComponents/AdminList.tsx b/frontend/src/components/AdminsComponents/AdminList.tsx new file mode 100644 index 000000000..b383c3de6 --- /dev/null +++ b/frontend/src/components/AdminsComponents/AdminList.tsx @@ -0,0 +1,58 @@ +import { User } from "../../utils/api/users/users"; +import { SpinnerContainer } from "../UsersComponents/Requests/styles"; +import { Spinner } from "react-bootstrap"; +import { AdminsTable } from "./styles"; +import React from "react"; +import { AdminListItem } from "./index"; + +/** + * List of [[AdminListItem]]s which represents all admins. + * @param props.admins List of all users who are admin. + * @param props.loading Data is being fetched. + * @param props.gotData Data is received. + * @param props.refresh Function which will be called after deleting an admin. + * @constructor + */ +export default function AdminList(props: { + admins: User[]; + loading: boolean; + gotData: boolean; + refresh: () => void; + getMoreAdmins: (page: number) => void; + moreAdminsAvailable: boolean; +}) { + if (props.loading) { + return ( + + + + ); + } else if (props.admins.length === 0) { + if (props.gotData) { + return
No admins
; + } else { + return null; + } + } + + const body = ( + + {props.admins.map(admin => ( + + ))} + + ); + + return ( + + + + Name + Email + Remove + + + {body} + + ); +} diff --git a/frontend/src/components/AdminsComponents/AdminListItem.tsx b/frontend/src/components/AdminsComponents/AdminListItem.tsx new file mode 100644 index 000000000..5b081916e --- /dev/null +++ b/frontend/src/components/AdminsComponents/AdminListItem.tsx @@ -0,0 +1,23 @@ +import { User } from "../../utils/api/users/users"; +import React from "react"; +import { RemoveAdmin } from "./index"; +import { EmailAndAuth } from "../GeneralComponents"; + +/** + * An item from [[AdminList]]. Contains the credentials of an admin and a button to remove the admin. + * @param props.admin The user which is represented. + * @param props.refresh A function which will be called after removing an admin. + */ +export default function AdminItem(props: { admin: User; refresh: () => void }) { + return ( + + {props.admin.name} + + + + + + + + ); +} diff --git a/frontend/src/components/AdminsComponents/RemoveAdmin.tsx b/frontend/src/components/AdminsComponents/RemoveAdmin.tsx new file mode 100644 index 000000000..e62ccefba --- /dev/null +++ b/frontend/src/components/AdminsComponents/RemoveAdmin.tsx @@ -0,0 +1,92 @@ +import { User } from "../../utils/api/users/users"; +import React, { useState } from "react"; +import { removeAdmin, removeAdminAndCoach } from "../../utils/api/users/admins"; +import { Button, Modal } from "react-bootstrap"; +import { ModalContentWarning } from "./styles"; +import { Error } from "../UsersComponents/Requests/styles"; + +/** + * Button and popup to remove a user as admin (and as coach). + * @param props.admin The user which can be removed. + * @param props.refresh A function which is called when the user is removed as admin. + */ +export default function RemoveAdmin(props: { admin: User; refresh: () => void }) { + const [show, setShow] = useState(false); + const [error, setError] = useState(""); + + const handleClose = () => setShow(false); + const handleShow = () => { + setShow(true); + setError(""); + }; + + async function removeUserAsAdmin(userId: number, removeCoach: boolean) { + try { + let removed; + if (removeCoach) { + removed = await removeAdminAndCoach(userId); + } else { + removed = await removeAdmin(userId); + } + + if (removed) { + props.refresh(); + } else { + setError("Something went wrong. Failed to remove admin"); + } + } catch (error) { + setError("Something went wrong. Failed to remove admin"); + } + } + + return ( + <> + + + + + + Remove Admin + + +

{props.admin.name}

+

{props.admin.auth.email}

+

+ Remove admin: {props.admin.name} will stay coach for assigned editions +

+
+ + + + + {error} + +
+
+ + ); +} diff --git a/frontend/src/components/AdminsComponents/index.ts b/frontend/src/components/AdminsComponents/index.ts new file mode 100644 index 000000000..7b7bf52bc --- /dev/null +++ b/frontend/src/components/AdminsComponents/index.ts @@ -0,0 +1,4 @@ +export { default as AddAdmin } from "./AddAdmin"; +export { default as AdminList } from "./AdminList"; +export { default as AdminListItem } from "./AdminListItem"; +export { default as RemoveAdmin } from "./RemoveAdmin"; diff --git a/frontend/src/components/AdminsComponents/styles.ts b/frontend/src/components/AdminsComponents/styles.ts new file mode 100644 index 000000000..394500255 --- /dev/null +++ b/frontend/src/components/AdminsComponents/styles.ts @@ -0,0 +1,24 @@ +import styled from "styled-components"; +import { Button, Table } from "react-bootstrap"; + +export const Warning = styled.div` + color: var(--osoc_red); +`; + +export const AdminsTable = styled(Table)``; + +export const ModalContentConfirm = styled.div` + border: 3px solid var(--osoc_green); + background-color: var(--osoc_blue); +`; + +export const ModalContentWarning = styled.div` + border: 3px solid var(--osoc_red); + background-color: var(--osoc_blue); +`; + +export const AddAdminButton = styled(Button).attrs({ + size: "sm", +})` + float: right; +`; diff --git a/frontend/src/components/CommonComps/LoadSpinner/LoadSpinner.tsx b/frontend/src/components/CommonComps/LoadSpinner/LoadSpinner.tsx new file mode 100644 index 000000000..3d2fec943 --- /dev/null +++ b/frontend/src/components/CommonComps/LoadSpinner/LoadSpinner.tsx @@ -0,0 +1,15 @@ +import { Spinner } from "react-bootstrap"; +import { SpinnerContainer } from "./styles"; +/** + * + * @param show: whether to show the spinner or not + * @returns a spinner to display when data is being fetched + */ +export default function LoadSpinner({ show }: { show: boolean }) { + if (!show) return null; + return ( + + + + ); +} diff --git a/frontend/src/components/CommonComps/LoadSpinner/index.ts b/frontend/src/components/CommonComps/LoadSpinner/index.ts new file mode 100644 index 000000000..4e1c2fe09 --- /dev/null +++ b/frontend/src/components/CommonComps/LoadSpinner/index.ts @@ -0,0 +1 @@ +export { default } from "./LoadSpinner"; diff --git a/frontend/src/components/CommonComps/LoadSpinner/styles.ts b/frontend/src/components/CommonComps/LoadSpinner/styles.ts new file mode 100644 index 000000000..1ae848123 --- /dev/null +++ b/frontend/src/components/CommonComps/LoadSpinner/styles.ts @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const SpinnerContainer = styled.div` + display: flex; + justify-content: center; + margin: 20px; +`; diff --git a/frontend/src/components/CommonComps/index.ts b/frontend/src/components/CommonComps/index.ts new file mode 100644 index 000000000..507a08be1 --- /dev/null +++ b/frontend/src/components/CommonComps/index.ts @@ -0,0 +1 @@ +export { default as LoadSpinner } from "./LoadSpinner"; diff --git a/frontend/src/components/EditionsPage/DeleteEditionButton.tsx b/frontend/src/components/EditionsPage/DeleteEditionButton.tsx new file mode 100644 index 000000000..298464b7e --- /dev/null +++ b/frontend/src/components/EditionsPage/DeleteEditionButton.tsx @@ -0,0 +1,34 @@ +import { Edition } from "../../data/interfaces"; +import { DeleteButton } from "./styles"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons/faTriangleExclamation"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; +import React, { useState } from "react"; +import DeleteEditionModal from "./DeleteEditionModal/DeleteEditionModal"; + +interface Props { + edition: Edition; +} + +export default function DeleteEditionButton(props: Props) { + const { role } = useAuth(); + const [showModal, setShowModal] = useState(false); + + // Only admins can see this button + if (role !== Role.ADMIN) { + return null; + } + + function handleClick() { + setShowModal(true); + } + + return ( + + Delete this edition + + + ); +} diff --git a/frontend/src/components/EditionsPage/DeleteEditionModal/DeleteEditionModal.css b/frontend/src/components/EditionsPage/DeleteEditionModal/DeleteEditionModal.css new file mode 100644 index 000000000..7108413f0 --- /dev/null +++ b/frontend/src/components/EditionsPage/DeleteEditionModal/DeleteEditionModal.css @@ -0,0 +1,25 @@ +.modal-dialog { + background-color: var(--osoc_blue); +} + +.modal-header { + background-color: var(--osoc_blue); +} + +.modal-body { + background-color: var(--background_color); + display: flex; + flex-direction: column; +} + +.modal-footer { + background-color: var(--osoc_blue); +} + +.modal-content { + background-color: var(--osoc_blue); +} + +.form-text { + color: white; +} diff --git a/frontend/src/components/EditionsPage/DeleteEditionModal/DeleteEditionModal.tsx b/frontend/src/components/EditionsPage/DeleteEditionModal/DeleteEditionModal.tsx new file mode 100644 index 000000000..f472d8b1a --- /dev/null +++ b/frontend/src/components/EditionsPage/DeleteEditionModal/DeleteEditionModal.tsx @@ -0,0 +1,152 @@ +import { Edition } from "../../../data/interfaces"; +import Modal from "react-bootstrap/Modal"; +import React, { useState } from "react"; +import Button from "react-bootstrap/Button"; +import "./DeleteEditionModal.css"; +import Form from "react-bootstrap/Form"; +import InfoMessage from "./InfoMessage"; +import Spinner from "react-bootstrap/Spinner"; +import { deleteEdition } from "../../../utils/api/editions"; +import { getCurrentEdition, setCurrentEdition } from "../../../utils/session-storage"; + +interface Props { + edition: Edition; + show: boolean; + setShow: (value: boolean) => void; +} + +/** + * Modal shown when trying to delete an edition + */ +export default function DeleteEditionModal(props: Props) { + const [disableConfirm, setDisableConfirm] = useState(true); + const [understandClicked, setUnderstandClicked] = useState(false); + const [confirmed, setConfirmed] = useState(false); + + function handleClose() { + props.setShow(false); + setUnderstandClicked(false); + setDisableConfirm(true); + } + + /** + * Confirm the deletion of this edition + */ + async function handleConfirm() { + // Show confirmation text while the request is being sent + setConfirmed(true); + + // Delete the request + const statusCode = await deleteEdition(props.edition.name); + + if (statusCode === 204) { + // Remove the edition as current + if (getCurrentEdition() === props.edition.name) { + setCurrentEdition(null); + } + + // Force-reload the page to re-request all data related to editions + // (stored in various places such as auth, ...) + window.location.reload(); + + // Hide the modal + props.setShow(false); + } + } + + /** + * Validate the data entered into the form + */ + function checkFormValid(name: string) { + if (name !== props.edition.name) { + setDisableConfirm(true); + } else { + setDisableConfirm(false); + } + } + + /** + * Called when the input field for the name of the edition changes + */ + function handleTextfieldChange(value: string) { + checkFormValid(value); + } + + return ( + // Obscure JS thing: clicking "close" on the modal propagates the "onClick" up + // to the open button, which re-opens it + // Explicitly deny propagation to stop this +
e.stopPropagation()}> + + + + {confirmed ? `Deleting ${props.edition.name}...` : "Not so fast!"} + + + {!understandClicked ? ( + // Show checkbox screen/information text + +
+ + + setUnderstandClicked(e.target.checked)} + /> + + +
+ ) : !confirmed ? ( + // Checkbox screen was passed, show the other screen now + +
+ + You didn't think it would be that easy, did you? + +
+
+ + + Type the name of the edition in the field below + + handleTextfieldChange(e.target.value)} + /> + +
+
+ ) : ( + // Delete request is being sent + +

There's no turning back now!

+

+ The request has been sent. If you've changed your mind, all you can do + now is hope the request fails. +

+ +
+ )} + {/* Only show footer if not yet confirmed */} + {!confirmed && ( + + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/EditionsPage/DeleteEditionModal/InfoMessage.tsx b/frontend/src/components/EditionsPage/DeleteEditionModal/InfoMessage.tsx new file mode 100644 index 000000000..2435b7ec0 --- /dev/null +++ b/frontend/src/components/EditionsPage/DeleteEditionModal/InfoMessage.tsx @@ -0,0 +1,30 @@ +import Form from "react-bootstrap/Form"; +import React from "react"; + +interface Props { + editionName: string; +} + +export default function InfoMessage(props: Props) { + return ( + <> + + Deleting {props.editionName} has some serious consequences that{" "} + + can never be undone + + . + +
+
+ + This includes, but is not limited to, removal of: +
    +
  • the edition itself
  • +
  • all students linked to this edition
  • +
  • all projects linked to this edition
  • +
+
+ + ); +} diff --git a/frontend/src/components/EditionsPage/EditionRow.tsx b/frontend/src/components/EditionsPage/EditionRow.tsx new file mode 100644 index 000000000..aba87e499 --- /dev/null +++ b/frontend/src/components/EditionsPage/EditionRow.tsx @@ -0,0 +1,24 @@ +import { Edition } from "../../data/interfaces"; +import DeleteEditionButton from "./DeleteEditionButton"; +import { RowContainer } from "./styles"; + +interface Props { + edition: Edition; +} + +/** + * A row in the [[EditionsTable]] + */ +export default function EditionRow(props: Props) { + return ( + + +
+

{props.edition.name}

+ {props.edition.year} +
+ +
+ + ); +} diff --git a/frontend/src/components/EditionsPage/EditionsTable.tsx b/frontend/src/components/EditionsPage/EditionsTable.tsx new file mode 100644 index 000000000..c4284f66b --- /dev/null +++ b/frontend/src/components/EditionsPage/EditionsTable.tsx @@ -0,0 +1,46 @@ +import React, { useEffect, useState } from "react"; +import { StyledTable, LoadingSpinner } from "./styles"; +import { getEditions } from "../../utils/api/editions"; +import EditionRow from "./EditionRow"; +import EmptyEditionsTableMessage from "./EmptyEditionsTableMessage"; + +/** + * Table on the [[EditionsPage]] that renders a list of all editions + * that the user has access to. + * + * If the user is an admin, this will also render a delete button. + */ +export default function EditionsTable() { + const [loading, setLoading] = useState(true); + const [rows, setRows] = useState([]); + + async function loadEditions() { + const response = await getEditions(); + + const newRows: React.ReactNode[] = response.editions.map(edition => ( + + )); + + setRows(newRows); + setLoading(false); + } + + useEffect(() => { + loadEditions(); + }, []); + + // Still loading: display a spinner instead + if (loading) { + return ; + } + + if (rows.length === 0) { + return ; + } + + return ( + + {rows} + + ); +} diff --git a/frontend/src/components/EditionsPage/EmptyEditionsTableMessage.tsx b/frontend/src/components/EditionsPage/EmptyEditionsTableMessage.tsx new file mode 100644 index 000000000..ea7e5a97f --- /dev/null +++ b/frontend/src/components/EditionsPage/EmptyEditionsTableMessage.tsx @@ -0,0 +1,34 @@ +import { useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; +import React from "react"; + +/** + * Message shown when the [[EditionsTable]] is empty. + */ +export default function EmptyEditionsTableMessage() { + const { role } = useAuth(); + + let message: React.ReactNode; + + // Show a different message to admins & coaches + // admins are never part of any editions, coaches are never able to create an edition + if (role === Role.ADMIN) { + message = ( + <> + There are no editions yet. +
+ You can use the button above to create one. + + ); + } else { + message = ( + <> + It looks like you're not a part of any editions. +
+ Contact an admin to receive an invite. + + ); + } + + return
{message}
; +} diff --git a/frontend/src/components/EditionsPage/NewEditionButton.tsx b/frontend/src/components/EditionsPage/NewEditionButton.tsx new file mode 100644 index 000000000..5a482add7 --- /dev/null +++ b/frontend/src/components/EditionsPage/NewEditionButton.tsx @@ -0,0 +1,28 @@ +import { StyledNewEditionButton } from "./styles"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { useAuth } from "../../contexts"; +import { Role } from "../../data/enums"; + +interface Props { + onClick: () => void; +} + +/** + * Button to create a new edition, redirects to the [[CreateEditionPage]]. + */ +export default function NewEditionButton({ onClick }: Props) { + const { role } = useAuth(); + + // Only admins can create new editions + if (role !== Role.ADMIN) { + return null; + } + + return ( + + Create new edition + + ); +} diff --git a/frontend/src/components/EditionsPage/index.ts b/frontend/src/components/EditionsPage/index.ts new file mode 100644 index 000000000..fd2d2e6ae --- /dev/null +++ b/frontend/src/components/EditionsPage/index.ts @@ -0,0 +1,2 @@ +export { default as EditionsTable } from "./EditionsTable"; +export { default as NewEditionButton } from "./NewEditionButton"; diff --git a/frontend/src/components/EditionsPage/styles.ts b/frontend/src/components/EditionsPage/styles.ts new file mode 100644 index 000000000..adc52230a --- /dev/null +++ b/frontend/src/components/EditionsPage/styles.ts @@ -0,0 +1,39 @@ +import styled from "styled-components"; +import Spinner from "react-bootstrap/Spinner"; +import Table from "react-bootstrap/Table"; +import Button from "react-bootstrap/Button"; + +export const StyledTable = styled(Table).attrs(() => ({ + striped: true, + bordered: true, + hover: true, + variant: "dark", + className: "mx-0 mt-0 mb-5", +}))``; + +export const LoadingSpinner = styled(Spinner).attrs(() => ({ + animation: "border", + role: "status", + className: "mx-auto", +}))``; + +export const DeleteButton = styled(Button).attrs(() => ({ + variant: "danger", + className: "me-0 ms-auto my-auto", +}))``; + +export const RowContainer = styled.td.attrs(() => ({ + className: "p-3 d-flex", +}))``; + +export const StyledNewEditionButton = styled(Button).attrs(() => ({ + className: "ms-auto my-3", +}))` + background-color: var(--osoc_green); + border-color: var(--osoc_green); + + &:hover { + background-color: var(--osoc_orange); + border-color: var(--osoc_orange); + } +`; diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 811a5a337..5a7ba9bdd 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -1,6 +1,12 @@ import { FooterBox, FooterTitle } from "./styles"; import FooterLinks from "./FooterLinks"; +/** + * Footer placed at the bottom of the site, containing various links related + * to the application or our code. + * + * The footer is only displayed when signed in. + */ export default function Footer() { return ( diff --git a/frontend/src/components/Footer/FooterLinks.tsx b/frontend/src/components/Footer/FooterLinks.tsx index 6f2f1e97f..127bc33a7 100644 --- a/frontend/src/components/Footer/FooterLinks.tsx +++ b/frontend/src/components/Footer/FooterLinks.tsx @@ -4,11 +4,22 @@ import { BASE_URL } from "../../settings"; export default function FooterLinks() { return ( - + -

API

- Documentation +

Documentation

+ Backend API +
+ {/* This link is always production because we don't host the docs locally */} + Frontend +
+ + User Manual +
diff --git a/frontend/src/components/Footer/styles.ts b/frontend/src/components/Footer/styles.ts index ce8bf963c..efade287b 100644 --- a/frontend/src/components/Footer/styles.ts +++ b/frontend/src/components/Footer/styles.ts @@ -11,6 +11,8 @@ export const FooterTitle = styled.h3` export const FooterLink = styled.a` color: white; + transition: 200ms ease-out; + text-decoration: none; &:hover { color: var(--osoc_green); diff --git a/frontend/src/components/GeneralComponents/AuthTypeIcon.tsx b/frontend/src/components/GeneralComponents/AuthTypeIcon.tsx new file mode 100644 index 000000000..9f38b8d1a --- /dev/null +++ b/frontend/src/components/GeneralComponents/AuthTypeIcon.tsx @@ -0,0 +1,18 @@ +import { HiOutlineMail } from "react-icons/hi"; +import { AiFillGithub, AiFillGoogleCircle, AiOutlineQuestionCircle } from "react-icons/ai"; +import { AuthType } from "../../data/enums"; + +/** + * An icon representing the type of authentication + */ +export default function AuthTypeIcon(props: { type: AuthType }) { + switch (props.type) { + case AuthType.Email: + return ; + case AuthType.GitHub: + return ; + case AuthType.Google: + return ; + } + return ; +} diff --git a/frontend/src/components/GeneralComponents/EmailAndAuth.tsx b/frontend/src/components/GeneralComponents/EmailAndAuth.tsx new file mode 100644 index 000000000..61fe38a72 --- /dev/null +++ b/frontend/src/components/GeneralComponents/EmailAndAuth.tsx @@ -0,0 +1,21 @@ +import { AuthTypeDiv, EmailAndAuthDiv, EmailDiv } from "./styles"; +import AuthTypeIcon from "./AuthTypeIcon"; +import { User } from "../../utils/api/users/users"; + +/** + * Email adress + auth type icon of a given user. + * @param props.user The given user. + */ +export default function EmailAndAuth(props: { user: User | undefined }) { + if (props.user === undefined) { + return null; + } + return ( + + + + + {props.user.auth.email} + + ); +} diff --git a/frontend/src/components/GeneralComponents/MenuItem.tsx b/frontend/src/components/GeneralComponents/MenuItem.tsx new file mode 100644 index 000000000..849fd4fea --- /dev/null +++ b/frontend/src/components/GeneralComponents/MenuItem.tsx @@ -0,0 +1,18 @@ +import { User } from "../../utils/api/users/users"; +import { EmailDiv, NameDiv } from "./styles"; +import EmailAndAuth from "./EmailAndAuth"; + +/** + * An item from a dropdown menu containing a user's name and email. + * @param props.user The user which is represented. + */ +export default function UserMenuItem(props: { user: User }) { + return ( +
+ {props.user.name} + + + +
+ ); +} diff --git a/frontend/src/components/GeneralComponents/index.ts b/frontend/src/components/GeneralComponents/index.ts new file mode 100644 index 000000000..ba1b5d513 --- /dev/null +++ b/frontend/src/components/GeneralComponents/index.ts @@ -0,0 +1,3 @@ +export { default as MenuItem } from "./MenuItem"; +export { default as AuthTypeIcon } from "./AuthTypeIcon"; +export { default as EmailAndAuth } from "./EmailAndAuth"; diff --git a/frontend/src/components/GeneralComponents/styles.ts b/frontend/src/components/GeneralComponents/styles.ts new file mode 100644 index 000000000..6c570862f --- /dev/null +++ b/frontend/src/components/GeneralComponents/styles.ts @@ -0,0 +1,30 @@ +import styled from "styled-components"; +import { MenuItem } from "react-bootstrap-typeahead"; + +export const StyledMenuItem = styled(MenuItem)` + color: white; + transition: 200ms ease-out; + + &:hover { + background-color: var(--osoc_blue); + color: var(--osoc_green); + transition: 200ms ease-in; + } +`; + +export const NameDiv = styled.div` + float: left; +`; + +export const EmailDiv = styled.div` + float: right; +`; + +export const AuthTypeDiv = styled.div` + float: right; + margin-left: 5px; +`; + +export const EmailAndAuthDiv = styled.div` + width: fit-content; +`; diff --git a/frontend/src/components/LoginComponents/InputFields/Email/Email.tsx b/frontend/src/components/LoginComponents/InputFields/Email/Email.tsx new file mode 100644 index 000000000..31a98312e --- /dev/null +++ b/frontend/src/components/LoginComponents/InputFields/Email/Email.tsx @@ -0,0 +1,26 @@ +import { Input } from "../styles"; + +/** + * Input field for email addresses. + * @param email getter for the state of the email address + * @param setEmail setter for the state of the email address + */ +export default function Email({ + email, + setEmail, +}: { + email: string; + setEmail: (value: string) => void; +}) { + return ( +
+ setEmail(e.target.value)} + /> +
+ ); +} diff --git a/frontend/src/components/LoginComponents/InputFields/Email/index.ts b/frontend/src/components/LoginComponents/InputFields/Email/index.ts new file mode 100644 index 000000000..f2681e1e2 --- /dev/null +++ b/frontend/src/components/LoginComponents/InputFields/Email/index.ts @@ -0,0 +1 @@ +export { default } from "./Email"; diff --git a/frontend/src/components/LoginComponents/InputFields/Password/Password.tsx b/frontend/src/components/LoginComponents/InputFields/Password/Password.tsx new file mode 100644 index 000000000..7af91b167 --- /dev/null +++ b/frontend/src/components/LoginComponents/InputFields/Password/Password.tsx @@ -0,0 +1,34 @@ +import { Input } from "../styles"; + +/** + * Input field for passwords, authenticates when pressing the Enter key. + * @param password getter for the state of the password + * @param setPassword setter for the state of the password + * @param callLogIn callback that tries to authenticate the user + */ +export default function Password({ + password, + setPassword, + callLogIn, +}: { + password: string; + setPassword: (value: string) => void; + callLogIn: () => void; +}) { + return ( +
+ setPassword(e.target.value)} + onKeyPress={e => { + if (e.key === "Enter") { + callLogIn(); + } + }} + /> +
+ ); +} diff --git a/frontend/src/components/LoginComponents/InputFields/Password/index.ts b/frontend/src/components/LoginComponents/InputFields/Password/index.ts new file mode 100644 index 000000000..b345e9aef --- /dev/null +++ b/frontend/src/components/LoginComponents/InputFields/Password/index.ts @@ -0,0 +1 @@ +export { default } from "./Password"; diff --git a/frontend/src/components/LoginComponents/InputFields/styles.ts b/frontend/src/components/LoginComponents/InputFields/styles.ts new file mode 100644 index 000000000..d6dccb8d1 --- /dev/null +++ b/frontend/src/components/LoginComponents/InputFields/styles.ts @@ -0,0 +1,12 @@ +import styled from "styled-components"; + +export const Input = styled.input` + height: 40px; + width: 400px; + margin-top: 10px; + margin-bottom: 10px; + text-align: center; + font-size: 20px; + border-radius: 5px; + border-width: 0; +`; diff --git a/frontend/src/components/LoginComponents/SocialButtons/SocialButtons.tsx b/frontend/src/components/LoginComponents/SocialButtons/SocialButtons.tsx new file mode 100644 index 000000000..8ce39a557 --- /dev/null +++ b/frontend/src/components/LoginComponents/SocialButtons/SocialButtons.tsx @@ -0,0 +1,18 @@ +import { GoogleLoginButton, GithubLoginButton } from "react-social-login-buttons"; +import { SocialsContainer, Socials, GoogleLoginContainer } from "./styles"; + +/** + * Container for the _Sign in with Google_ and _Sign in with GitHub_ buttons. + */ +export default function SocialButtons() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/components/LoginComponents/SocialButtons/index.ts b/frontend/src/components/LoginComponents/SocialButtons/index.ts new file mode 100644 index 000000000..1aa8d729f --- /dev/null +++ b/frontend/src/components/LoginComponents/SocialButtons/index.ts @@ -0,0 +1 @@ +export { default } from "./SocialButtons"; diff --git a/frontend/src/components/LoginComponents/SocialButtons/styles.ts b/frontend/src/components/LoginComponents/SocialButtons/styles.ts new file mode 100644 index 000000000..8ed2a53d5 --- /dev/null +++ b/frontend/src/components/LoginComponents/SocialButtons/styles.ts @@ -0,0 +1,15 @@ +import styled from "styled-components"; + +export const SocialsContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +export const Socials = styled.div` + min-width: 230px; + height: fit-content; +`; +export const GoogleLoginContainer = styled.div` + margin-bottom: 15px; +`; diff --git a/frontend/src/components/LoginComponents/WelcomeText/WelcomeText.tsx b/frontend/src/components/LoginComponents/WelcomeText/WelcomeText.tsx new file mode 100644 index 000000000..d19300738 --- /dev/null +++ b/frontend/src/components/LoginComponents/WelcomeText/WelcomeText.tsx @@ -0,0 +1,17 @@ +import { WelcomeTextContainer } from "./styles"; + +/** + * Text displayed on the [[LoginPage]] to welcome the users to the application. + */ +export default function WelcomeText() { + return ( + +

Hi!

+

+ Welcome to the Open Summer of Code selections app. After you've logged in with your + account, we'll enable your account so you can get started. An admin will verify you + as soon as possible. +

+
+ ); +} diff --git a/frontend/src/components/LoginComponents/WelcomeText/index.ts b/frontend/src/components/LoginComponents/WelcomeText/index.ts new file mode 100644 index 000000000..e21432cf6 --- /dev/null +++ b/frontend/src/components/LoginComponents/WelcomeText/index.ts @@ -0,0 +1 @@ +export { default } from "./WelcomeText"; diff --git a/frontend/src/components/LoginComponents/WelcomeText/styles.ts b/frontend/src/components/LoginComponents/WelcomeText/styles.ts new file mode 100644 index 000000000..aebc86f01 --- /dev/null +++ b/frontend/src/components/LoginComponents/WelcomeText/styles.ts @@ -0,0 +1,9 @@ +import styled from "styled-components"; + +export const WelcomeTextContainer = styled.div` + max-width: 800px; + text-align: center; + justify-content: center; + margin: auto; + margin-bottom: 50px; +`; diff --git a/frontend/src/components/LoginComponents/index.ts b/frontend/src/components/LoginComponents/index.ts new file mode 100644 index 000000000..7455bfab5 --- /dev/null +++ b/frontend/src/components/LoginComponents/index.ts @@ -0,0 +1,4 @@ +export { default as WelcomeText } from "./WelcomeText"; +export { default as SocialButtons } from "./SocialButtons"; +export { default as Password } from "./InputFields/Password"; +export { default as Email } from "./InputFields/Email"; diff --git a/frontend/src/components/Navbar/Brand.tsx b/frontend/src/components/Navbar/Brand.tsx new file mode 100644 index 000000000..202f1158f --- /dev/null +++ b/frontend/src/components/Navbar/Brand.tsx @@ -0,0 +1,19 @@ +import { BSBrand } from "./styles"; + +/** + * React component that shows the OSOC logo & title in the [[Navbar]] + */ +export default function Brand() { + return ( + + {"OSOC{" "} + Open Summer of Code + + ); +} diff --git a/frontend/src/components/Navbar/EditionDropdown.tsx b/frontend/src/components/Navbar/EditionDropdown.tsx new file mode 100644 index 000000000..c6e7ceb78 --- /dev/null +++ b/frontend/src/components/Navbar/EditionDropdown.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import NavDropdown from "react-bootstrap/NavDropdown"; +import { StyledDropdownItem } from "./styles"; +import { useLocation, useNavigate } from "react-router-dom"; +import { getCurrentEdition, setCurrentEdition } from "../../utils/session-storage"; +import { getBestRedirect } from "../../utils/logic"; + +interface Props { + editions: string[]; +} + +/** + * Dropdown in the [[Navbar]] to change the current edition to another one + */ +export default function EditionDropdown(props: Props) { + const navItems: React.ReactNode[] = []; + const location = useLocation(); + const navigate = useNavigate(); + + // User can't access any editions yet, no point in rendering the dropdown either + // as it would just show "UNDEFINED" at the top + if (props.editions.length === 0) { + return null; + } + + // If anything went wrong loading the edition, default to the first one + // found in the list of editions + // This shouldn't happen, but just in case + // The list can never be empty because then we return null above ^ + const currentEdition = getCurrentEdition() || props.editions[0]; + + /** + * Change the route based on the edition + * This can't be a separate function because it uses hooks which may + * only be used in React components + */ + function handleSelect(edition: string) { + const destination = getBestRedirect(location.pathname, edition); + setCurrentEdition(edition); + + navigate(destination); + } + + // Load dropdown items dynamically + props.editions.forEach((edition: string) => { + navItems.push( + handleSelect(edition)} + > + {edition} + + ); + }); + + return {navItems}; +} diff --git a/frontend/src/components/Navbar/EditionNavLink.tsx b/frontend/src/components/Navbar/EditionNavLink.tsx new file mode 100644 index 000000000..bb165b178 --- /dev/null +++ b/frontend/src/components/Navbar/EditionNavLink.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +interface Props { + children: React.ReactNode; + currentEdition?: string | undefined; +} + +/** + * Wrapper component for a NavLink that should only be displayed if + * the current edition is defined. + * + * This means that when the user is not in any editions yet, the link + * will be hidden. + * + * Any number of NavLinks can be included as children, it's unnecessary to wrap every + * single link with this component. + * + * An example is the link that goes to the [[StudentsPage]]. + */ +export default function EditionNavLink({ children, currentEdition }: Props) { + if (!currentEdition) return null; + return <>{children}; +} diff --git a/frontend/src/components/Navbar/LogoutButton.tsx b/frontend/src/components/Navbar/LogoutButton.tsx new file mode 100644 index 000000000..b9945cfe1 --- /dev/null +++ b/frontend/src/components/Navbar/LogoutButton.tsx @@ -0,0 +1,24 @@ +import { LogOutText } from "./styles"; +import { logOut, useAuth } from "../../contexts"; +import { useNavigate } from "react-router-dom"; + +/** + * Button in the [[Navbar]] to log the user out + */ +export default function LogoutButton() { + const authContext = useAuth(); + const navigate = useNavigate(); + + /** + * Log the user out + */ + function handleLogout() { + // Unset auth state + logOut(authContext); + + // Redirect to login page + navigate("/login"); + } + + return Log Out; +} diff --git a/frontend/src/components/Navbar/Navbar.css b/frontend/src/components/Navbar/Navbar.css new file mode 100644 index 000000000..2214a5c9d --- /dev/null +++ b/frontend/src/components/Navbar/Navbar.css @@ -0,0 +1,10 @@ +.dropdown-menu { + background-color: var(--osoc_blue); + max-height: 200px; + overflow: auto; +} + +.dropdown-item.active { + background-color: transparent; + text-decoration: underline; +} diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx new file mode 100644 index 000000000..7d4a1dac9 --- /dev/null +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -0,0 +1,80 @@ +import { BSNavbar } from "./styles"; +import { useAuth } from "../../contexts"; +import Nav from "react-bootstrap/Nav"; +import EditionDropdown from "./EditionDropdown"; +import "./Navbar.css"; +import LogoutButton from "./LogoutButton"; +import { getCurrentEdition, setCurrentEdition } from "../../utils/session-storage"; +import { matchPath, useLocation } from "react-router-dom"; +import UsersDropdown from "./UsersDropdown"; +import NavbarBase from "./NavbarBase"; +import { LinkContainer } from "react-router-bootstrap"; +import EditionNavLink from "./EditionNavLink"; +import StudentsDropdown from "./StudentsDropdown"; + +/** + * Navbar component displayed at the top of the screen. + * If the user is not signed in, this is hidden automatically. + */ +export default function Navbar() { + const { isLoggedIn, editions, role } = useAuth(); + /** + * Important: DO NOT MOVE THIS LINE UNDERNEATH THE RETURN! + * Placing an early return above a React hook (in this case, useLocation) causes + * memory leaks & other wonky issues that we'd rather avoid. + * The hook is only used below the return, but it HAS to be called here. + */ + const location = useLocation(); + + // Only render base if not logged in + if (!isLoggedIn) { + return ; + } + + // User is logged in: safe to try and parse the location now + + // Try to get the editionId out of the URL if it exists + // this can not be done using useParams() because the Navbar is not inside + // a + const match = matchPath({ path: "/editions/:editionId/*" }, location.pathname); + // This is a TypeScript shortcut for 3 if-statements + let editionId = match && match.params && match.params.editionId; + + // Matched /editions/new path + if (editionId === "new") { + editionId = null; + } + + // If the current URL contains an edition, use that + // if not (eg. /editions), check SessionStorage + // otherwise, use the most-recent edition from the auth response + const currentEdition = editionId || getCurrentEdition() || editions[0]; + + // Set the value of the new edition in SessionStorage if useful + if (currentEdition) { + setCurrentEdition(currentEdition); + } + + return ( + + {/* Make Navbar responsive (hamburger menu) */} + + + + + + ); +} diff --git a/frontend/src/components/Navbar/NavbarBase.tsx b/frontend/src/components/Navbar/NavbarBase.tsx new file mode 100644 index 000000000..5c0c16680 --- /dev/null +++ b/frontend/src/components/Navbar/NavbarBase.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import Container from "react-bootstrap/Container"; +import { BSNavbar } from "./styles"; +import Brand from "./Brand"; + +/** + * Base component for the Navbar that is displayed at all times to allow + * basic navigation + */ +export default function NavbarBase({ children }: { children?: React.ReactNode }) { + return ( + + + + {children} + + + ); +} diff --git a/frontend/src/components/Navbar/StudentsDropdown.tsx b/frontend/src/components/Navbar/StudentsDropdown.tsx new file mode 100644 index 000000000..355e1c11b --- /dev/null +++ b/frontend/src/components/Navbar/StudentsDropdown.tsx @@ -0,0 +1,27 @@ +import NavDropdown from "react-bootstrap/NavDropdown"; +import { LinkContainer } from "react-router-bootstrap"; +import { StyledDropdownItem } from "./styles"; + +interface Props { + isLoggedIn: boolean; + currentEdition: string; +} + +/** + * Dropdown in the [[Navbar]] that allows navigation to the [[StudentsPage]] and [[MailOverviewPage]]. + * @constructor + */ +export default function StudentsDropdown(props: Props) { + if (!props.isLoggedIn) return null; + + return ( + + + Students + + + Email History + + + ); +} diff --git a/frontend/src/components/Navbar/UsersDropdown.tsx b/frontend/src/components/Navbar/UsersDropdown.tsx new file mode 100644 index 000000000..b36f08826 --- /dev/null +++ b/frontend/src/components/Navbar/UsersDropdown.tsx @@ -0,0 +1,35 @@ +import NavDropdown from "react-bootstrap/NavDropdown"; +import { StyledDropdownItem } from "./styles"; +import { Role } from "../../data/enums"; +import { LinkContainer } from "react-router-bootstrap"; +import EditionNavLink from "./EditionNavLink"; + +interface Props { + currentEdition: string; + role: Role | null; +} + +/** + * NavDropdown that links to the [[AdminsPage]] and [[UsersPage]]. + * This component is only rendered for admins. + */ +export default function UsersDropdown({ currentEdition, role }: Props) { + // Only admins can see the dropdown because coaches can't + // access these pages anyway + if (role !== Role.ADMIN) { + return null; + } + + return ( + + + Admins + + + + Coaches + + + + ); +} diff --git a/frontend/src/components/Navbar/index.ts b/frontend/src/components/Navbar/index.ts new file mode 100644 index 000000000..8d95d6656 --- /dev/null +++ b/frontend/src/components/Navbar/index.ts @@ -0,0 +1 @@ +export { default } from "./Navbar"; diff --git a/frontend/src/components/Navbar/styles.ts b/frontend/src/components/Navbar/styles.ts new file mode 100644 index 000000000..304fc980b --- /dev/null +++ b/frontend/src/components/Navbar/styles.ts @@ -0,0 +1,38 @@ +import styled from "styled-components"; +import Navbar from "react-bootstrap/Navbar"; +import NavDropdown from "react-bootstrap/NavDropdown"; + +export const BSNavbar = styled(Navbar).attrs(() => ({ + collapseOnSelect: true, + expand: "lg", + variant: "dark", +}))` + background-color: var(--osoc_blue); + width: 100vw; +`; + +export const BSBrand = styled(BSNavbar.Brand)` + font-weight: bold; +`; + +export const StyledDropdownItem = styled(NavDropdown.Item)` + color: white; + transition: 200ms ease-out; + background-color: transparent; + + &:hover { + background-color: transparent; + color: var(--osoc_green); + transition: 200ms ease-in; + } +`; + +export const LogOutText = styled(BSNavbar.Text)` + transition: 150ms ease-out; + + &:hover { + cursor: pointer; + color: rgba(255, 255, 255, 75%); + transition: 150ms ease-in; + } +`; diff --git a/frontend/src/components/OSOCLetters/OSOCLetters.tsx b/frontend/src/components/OSOCLetters/OSOCLetters.tsx index d4fedb382..72bc85d3f 100644 --- a/frontend/src/components/OSOCLetters/OSOCLetters.tsx +++ b/frontend/src/components/OSOCLetters/OSOCLetters.tsx @@ -4,7 +4,14 @@ import logoO2 from "../../images/letters/osoc_red_o.svg"; import logoC from "../../images/letters/osoc_c.svg"; import "./OSOCLetters.css"; -function OSOCLetters() { +/** + * Animated OSOC-letters, inspired by the ones found + * on the [OSOC website](https://osoc.be/). + * + * _Note: This component is currently not in use because the positioning + * of the letters causes issues. We have given priority to other parts of the application._ + */ +export default function OSOCLetters() { return (
logoO1 @@ -14,5 +21,3 @@ function OSOCLetters() {
); } - -export default OSOCLetters; diff --git a/frontend/src/components/OSOCLetters/index.ts b/frontend/src/components/OSOCLetters/index.ts new file mode 100644 index 000000000..4d35943ea --- /dev/null +++ b/frontend/src/components/OSOCLetters/index.ts @@ -0,0 +1 @@ +export { default } from "./OSOCLetters"; diff --git a/frontend/src/components/PrivateRoute/PrivateRoute.tsx b/frontend/src/components/PrivateRoute/PrivateRoute.tsx new file mode 100644 index 000000000..0eed20a03 --- /dev/null +++ b/frontend/src/components/PrivateRoute/PrivateRoute.tsx @@ -0,0 +1,20 @@ +import { useAuth } from "../../contexts/auth-context"; +import { Navigate, Outlet } from "react-router-dom"; + +/** + * React component that redirects to the [[LoginPage]] if not authenticated when + * trying to visit a route. + * + * Example usage: + * ```ts + * }> + * // These routes will only render if the user is authenticated + * + * + * + * ``` + */ +export default function PrivateRoute() { + const { isLoggedIn } = useAuth(); + return isLoggedIn ? : ; +} diff --git a/frontend/src/components/PrivateRoute/index.ts b/frontend/src/components/PrivateRoute/index.ts new file mode 100644 index 000000000..9b61d0e92 --- /dev/null +++ b/frontend/src/components/PrivateRoute/index.ts @@ -0,0 +1 @@ +export { default } from "./PrivateRoute"; diff --git a/frontend/src/components/ProjectsComponents/ConfirmDelete/ConfirmDelete.tsx b/frontend/src/components/ProjectsComponents/ConfirmDelete/ConfirmDelete.tsx new file mode 100644 index 000000000..1f728104a --- /dev/null +++ b/frontend/src/components/ProjectsComponents/ConfirmDelete/ConfirmDelete.tsx @@ -0,0 +1,36 @@ +import { StyledModal, ModalFooter, ModalHeader, Button, DeleteButton } from "./styles"; + +/** + * + * @param visible whether to display the confirm screen. + * @param handleClose what to do when the user closes the confirm screen. + * @param handleConfirm what to do when the user confirms the delete action. + * @param name the name of the project that is going to be deleted. + * @returns the modal the confirm the deletion of a project. + */ +export default function ConfirmDelete({ + visible, + handleClose, + handleConfirm, + name, +}: { + visible: boolean; + handleClose: () => void; + handleConfirm: () => void; + name: string; +}) { + return ( + + + Confirm delete + + + Are you sure you want to delete {name}? + + + + Delete + + + ); +} diff --git a/frontend/src/components/ProjectsComponents/ConfirmDelete/index.ts b/frontend/src/components/ProjectsComponents/ConfirmDelete/index.ts new file mode 100644 index 000000000..64a340b29 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/ConfirmDelete/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfirmDelete"; diff --git a/frontend/src/components/ProjectsComponents/ConfirmDelete/styles.ts b/frontend/src/components/ProjectsComponents/ConfirmDelete/styles.ts new file mode 100644 index 000000000..bca9b22dc --- /dev/null +++ b/frontend/src/components/ProjectsComponents/ConfirmDelete/styles.ts @@ -0,0 +1,32 @@ +import styled from "styled-components"; +import Modal from "react-bootstrap/Modal"; + +export const StyledModal = styled(Modal)` + color: white; + background-color: #00000060; + margin-top: 5%; + .modal-content { + background-color: #272741; + border-radius: 5px; + border-color: #f14a3b; + } +`; + +export const ModalHeader = styled(Modal.Header)` + border-bottom: 1px solid #131329; +`; +export const ModalFooter = styled(Modal.Footer)` + border-top: 1px solid #131329; +`; + +export const Button = styled.button` + border-radius: 5px; + border: none; + padding: 5px 10px; + background-color: #131329; + color: white; +`; + +export const DeleteButton = styled(Button)` + background-color: #f14a3b; +`; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedCoaches/AddedCoaches.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedCoaches/AddedCoaches.tsx new file mode 100644 index 000000000..6417e7964 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedCoaches/AddedCoaches.tsx @@ -0,0 +1,30 @@ +import { TiDeleteOutline } from "react-icons/ti"; +import { User } from "../../../../utils/api/users/users"; +import { AddedItem, ItemName, RemoveButton } from "../styles"; + +export default function AddedCoaches({ + coaches, + setCoaches, +}: { + coaches: User[]; + setCoaches: (coaches: User[]) => void; +}) { + return ( +
+ {coaches.map((element, _index) => ( + + {element.name} + { + const newItems = [...coaches]; + newItems.splice(_index, 1); + setCoaches(newItems); + }} + > + + + + ))} +
+ ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedCoaches/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedCoaches/index.ts new file mode 100644 index 000000000..fe048b5a7 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedCoaches/index.ts @@ -0,0 +1 @@ +export { default } from "./AddedCoaches"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedPartners/AddedPartners.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedPartners/AddedPartners.tsx new file mode 100644 index 000000000..0cf5b0040 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedPartners/AddedPartners.tsx @@ -0,0 +1,29 @@ +import { TiDeleteOutline } from "react-icons/ti"; +import { AddedItem, ItemName, RemoveButton } from "../styles"; + +export default function AddedPartners({ + items, + setItems, +}: { + items: string[]; + setItems: (items: string[]) => void; +}) { + return ( +
+ {items.map((element, _index) => ( + + {element} + { + const newItems = [...items]; + newItems.splice(_index, 1); + setItems(newItems); + }} + > + + + + ))} +
+ ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedPartners/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedPartners/index.ts new file mode 100644 index 000000000..ee4652a7d --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedPartners/index.ts @@ -0,0 +1 @@ +export { default } from "./AddedPartners"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/AddedSkills.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/AddedSkills.tsx new file mode 100644 index 000000000..1194c5c87 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/AddedSkills.tsx @@ -0,0 +1,98 @@ +import { SkillProject } from "../../../../data/interfaces/projects"; +import { Input } from "../styles"; +import { + AmountInput, + SkillContainer, + DescriptionContainer, + Delete, + TopContainer, + SkillName, +} from "./styles"; +import { TiDeleteOutline } from "react-icons/ti"; +import React from "react"; + +/** + * + * @param skills the state of the added skills + * @param setSkills used to update the added skills and there attributes + + * @returns a react component of all the added skills + */ +export default function AddedSkills({ + skills, + setSkills, +}: { + skills: SkillProject[]; + setSkills: (skills: SkillProject[]) => void; +}) { + /** + * This function is called when an input field is changed. + * @param event a react event + * @param index the index of the skill to change + * @param amount whether to update the amount (true) or to update the description (false) + */ + function updateSkills( + event: React.ChangeEvent, + index: number, + amount: boolean + ) { + const newList = skills.map((item, otherIndex) => { + if (index === otherIndex) { + if (amount && !isNaN(event.target.valueAsNumber)) { + return { + ...item, + amount: event.target.valueAsNumber, + }; + } + return { + ...item, + description: event.target.value, + }; + } + return item; + }); + setSkills(newList); + } + + return ( +
+ {skills.map((skill, index) => ( + + + {skill.skill} + + { + updateSkills(event, index, true); + }} + /> + { + const newSkills = [...skills]; + newSkills.splice(index, 1); + setSkills(newSkills); + }} + > + + + + + + { + updateSkills(event, index, false); + }} + /> + + + ))} +
+ ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/index.ts new file mode 100644 index 000000000..f3661f314 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/index.ts @@ -0,0 +1 @@ +export { default } from "./AddedSkills"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/styles.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/styles.ts new file mode 100644 index 000000000..9b4a4b069 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/AddedSkills/styles.ts @@ -0,0 +1,46 @@ +import styled from "styled-components"; + +export const SkillContainer = styled.div` + border-radius: 5px; + margin-top: 10px; + background-color: #1a1a36; + padding: 5px 10px; + width: min-content; + max-width: 75%; +`; + +export const TopContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +export const SkillName = styled.div` + overflow-x: auto; + text-overflow: ellipsis; +`; + +export const Delete = styled.button` + background-color: #f14a3b; + border: 0; + padding: 2.5px 2.5px; + border-radius: 1px; + color: white; + display: flex; + align-items: center; +`; + +export const DescriptionContainer = styled.div` + margin-bottom: 10px; + width: fit-content; +`; + +export const AmountInput = styled.input` + margin: 5px; + padding: 2px 10px; + background-color: #131329; + color: white; + border: none; + border-radius: 5px; + width: 100px; +`; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Coach/Coach.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Coach/Coach.tsx new file mode 100644 index 000000000..ab5a1ec47 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Coach/Coach.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Alert } from "react-bootstrap"; +import { useParams } from "react-router-dom"; +import { getCoaches } from "../../../../../utils/api/users/coaches"; +import { User } from "../../../../../utils/api/users/users"; +import { AddButton, Input, WarningContainer } from "../../styles"; + +export default function Coach({ + coach, + setCoach, + coaches, + setCoaches, +}: { + coach: string; + setCoach: (coach: string) => void; + coaches: User[]; + setCoaches: (coaches: User[]) => void; +}) { + const [showAlert, setShowAlert] = useState(false); + const [availableCoaches, setAvailableCoaches] = useState([]); + const params = useParams(); + const editionId = params.editionId!; + + useEffect(() => { + async function callCoaches() { + setAvailableCoaches((await getCoaches(editionId, coach, 0)).users); + } + callCoaches(); + }, [coach, editionId]); + + return ( +
+ { + setCoach(e.target.value); + }} + list="users" + placeholder="Coach" + /> + + {availableCoaches.map((availableCoach, _index) => { + return + + { + let coachToAdd = null; + availableCoaches.forEach(availableCoach => { + if (availableCoach.name === coach) { + coachToAdd = availableCoach; + } + }); + if (coachToAdd) { + if (!coaches.some(presentCoach => presentCoach.name === coach)) { + const newCoaches = [...coaches]; + newCoaches.push(coachToAdd); + setCoaches(newCoaches); + setShowAlert(false); + } + } else setShowAlert(true); + setCoach(""); + }} + > + Add coach + + + + +
+ ); +} + +function BadCoachAlert({ show, setShow }: { show: boolean; setShow: (state: boolean) => void }) { + if (show) { + return ( + setShow(false)} dismissible> + Please choose an option from the list + + ); + } + return null; +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Coach/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Coach/index.ts new file mode 100644 index 000000000..07c25c743 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Coach/index.ts @@ -0,0 +1 @@ +export { default } from "./Coach"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Name/Name.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Name/Name.tsx new file mode 100644 index 000000000..67ad8f7d9 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Name/Name.tsx @@ -0,0 +1,7 @@ +import { Input } from "../../styles"; + +export default function Name({ name, setName }: { name: string; setName: (name: string) => void }) { + return ( + setName(e.target.value)} placeholder="Project name" /> + ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Name/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Name/index.ts new file mode 100644 index 000000000..4e90e41d5 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Name/index.ts @@ -0,0 +1 @@ +export { default } from "./Name"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/NumberOfStudents/NumberOfStudents.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/NumberOfStudents/NumberOfStudents.tsx new file mode 100644 index 000000000..7586d250b --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/NumberOfStudents/NumberOfStudents.tsx @@ -0,0 +1,23 @@ +import { Input } from "../../styles"; + +export default function NumberOfStudents({ + numberOfStudents, + setNumberOfStudents, +}: { + numberOfStudents: number; + setNumberOfStudents: (numberOfStudents: number) => void; +}) { + return ( +
+ { + setNumberOfStudents(e.target.valueAsNumber); + }} + placeholder="Number of students" + /> +
+ ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/NumberOfStudents/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/NumberOfStudents/index.ts new file mode 100644 index 000000000..7594e8ecf --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/NumberOfStudents/index.ts @@ -0,0 +1 @@ +export { default } from "./NumberOfStudents"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Partner/Partner.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Partner/Partner.tsx new file mode 100644 index 000000000..a6096de81 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Partner/Partner.tsx @@ -0,0 +1,45 @@ +import { Input, AddButton } from "../../styles"; + +export default function Partner({ + partner, + setPartner, + partners, + setPartners, +}: { + partner: string; + setPartner: (partner: string) => void; + partners: string[]; + setPartners: (partners: string[]) => void; +}) { + const availablePartners = ["partner1", "partner2"]; // TODO get partners from API call + + return ( +
+ setPartner(e.target.value)} + list="partners" + placeholder="Partner" + /> + + + {availablePartners.map((availablePartner, _index) => { + return + + { + if (!partners.includes(partner) && partner.length > 0) { + const newPartners = [...partners]; + newPartners.push(partner); + setPartners(newPartners); + } + setPartner(""); + }} + > + Add partner + +
+ ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Partner/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Partner/index.ts new file mode 100644 index 000000000..ccf0fba3a --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Partner/index.ts @@ -0,0 +1 @@ +export { default } from "./Partner"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Skill/Skill.tsx b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Skill/Skill.tsx new file mode 100644 index 000000000..588e846d4 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Skill/Skill.tsx @@ -0,0 +1,50 @@ +import { SkillProject } from "../../../../../data/interfaces/projects"; +import { Input, AddButton } from "../../styles"; + +export default function Skill({ + skill, + setSkill, + skills, + setSkills, +}: { + skill: string; + setSkill: (skill: string) => void; + skills: SkillProject[]; + setSkills: (skills: SkillProject[]) => void; +}) { + const availableSkills = ["Frontend", "Backend", "Database", "Design"]; + + return ( +
+ setSkill(e.target.value)} + placeholder="Skill" + list="skills" + /> + + {availableSkills.map((availableCoach, _index) => { + return + + { + if (availableSkills.some(availableSkill => availableSkill === skill)) { + const newSkills = [...skills]; + const newSkill: SkillProject = { + skill: skill, + description: "", + amount: 1, + }; + newSkills.push(newSkill); + setSkills(newSkills); + } + setSkill(""); + }} + > + Add skill + +
+ ); +} diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Skill/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Skill/index.ts new file mode 100644 index 000000000..c5a820d40 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/Skill/index.ts @@ -0,0 +1 @@ +export { default } from "./Skill"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/index.ts new file mode 100644 index 000000000..b0235977d --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/InputFields/index.ts @@ -0,0 +1,5 @@ +export { default as NameInput } from "./Name"; +export { default as NumberOfStudentsInput } from "./NumberOfStudents"; +export { default as CoachInput } from "./Coach"; +export { default as SkillInput } from "./Skill"; +export { default as PartnerInput } from "./Partner"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/index.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/index.ts new file mode 100644 index 000000000..63f743cf3 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/index.ts @@ -0,0 +1,10 @@ +export { + NameInput, + NumberOfStudentsInput, + CoachInput, + SkillInput, + PartnerInput, +} from "./InputFields"; +export { default as AddedPartners } from "./AddedPartners"; +export { default as AddedCoaches } from "./AddedCoaches"; +export { default as AddedSkills } from "./AddedSkills"; diff --git a/frontend/src/components/ProjectsComponents/CreateProjectComponents/styles.ts b/frontend/src/components/ProjectsComponents/CreateProjectComponents/styles.ts new file mode 100644 index 000000000..d40fefa38 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/CreateProjectComponents/styles.ts @@ -0,0 +1,51 @@ +import styled from "styled-components"; + +export const Input = styled.input` + margin-top: 10px; + padding: 5px 10px; + background-color: #131329; + color: white; + border: none; + border-radius: 5px; +`; + +export const AddButton = styled.button` + padding: 5px 10px; + background-color: #00bfff; + color: white; + border: none; + margin-left: 5px; + border-radius: 5px; +`; + +export const RemoveButton = styled.button` + padding: 0px 2.5px; + background-color: #f14a3b; + color: white; + border: none; + margin-left: 10px; + border-radius: 1px; + display: flex; + align-items: center; +`; + +export const ItemName = styled.div` + overflow-x: auto; + text-overflow: ellipsis; +`; + +export const AddedItem = styled.div` + margin: 5px; + margin-left: 0; + padding: 5px; + background-color: #1a1a36; + width: fit-content; + max-width: 75%; + border-radius: 5px; + display: flex; +`; + +export const WarningContainer = styled.div` + max-width: fit-content; + margin-top: 10px; +`; diff --git a/frontend/src/components/ProjectsComponents/LoadSpinner/LoadSpinner.tsx b/frontend/src/components/ProjectsComponents/LoadSpinner/LoadSpinner.tsx new file mode 100644 index 000000000..d46544ab0 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/LoadSpinner/LoadSpinner.tsx @@ -0,0 +1,11 @@ +import { Spinner } from "react-bootstrap"; +import { SpinnerContainer } from "./styles"; + +export default function LoadSpinner({ show }: { show: boolean }) { + if (!show) return null; + return ( + + + + ); +} diff --git a/frontend/src/components/ProjectsComponents/LoadSpinner/index.ts b/frontend/src/components/ProjectsComponents/LoadSpinner/index.ts new file mode 100644 index 000000000..4e1c2fe09 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/LoadSpinner/index.ts @@ -0,0 +1 @@ +export { default } from "./LoadSpinner"; diff --git a/frontend/src/components/ProjectsComponents/LoadSpinner/styles.ts b/frontend/src/components/ProjectsComponents/LoadSpinner/styles.ts new file mode 100644 index 000000000..1ae848123 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/LoadSpinner/styles.ts @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const SpinnerContainer = styled.div` + display: flex; + justify-content: center; + margin: 20px; +`; diff --git a/frontend/src/components/ProjectsComponents/ProjectCard/ProjectCard.tsx b/frontend/src/components/ProjectsComponents/ProjectCard/ProjectCard.tsx new file mode 100644 index 000000000..ca815f2ee --- /dev/null +++ b/frontend/src/components/ProjectsComponents/ProjectCard/ProjectCard.tsx @@ -0,0 +1,107 @@ +import { + CardContainer, + CoachesContainer, + CoachContainer, + CoachText, + NumberOfStudents, + Delete, + TitleContainer, + Title, + OpenIcon, + ClientContainer, + Client, + Clients, +} from "./styles"; + +import { BsPersonFill } from "react-icons/bs"; +import { HiOutlineTrash } from "react-icons/hi"; + +import { useState } from "react"; + +import ConfirmDelete from "../ConfirmDelete"; +import { deleteProject } from "../../../utils/api/projects"; +import { useNavigate, useParams } from "react-router-dom"; + +import { Project } from "../../../data/interfaces"; +import { useAuth } from "../../../contexts"; +import { Role } from "../../../data/enums"; + +/** + * + * @param project a Project object + * @param refreshProjects what to do when a project is deleted. + * @returns a project card which is a small overview of a project. + */ +export default function ProjectCard({ + project, + refreshProjects, +}: { + project: Project; + refreshProjects: () => void; +}) { + // Used for the confirm screen. + const [show, setShow] = useState(false); + const handleClose = () => setShow(false); + const handleShow = () => setShow(true); + + // What to do when deleting a project. + const handleDelete = () => { + deleteProject(project.editionName, project.projectId); + setShow(false); + refreshProjects(); + }; + + const navigate = useNavigate(); + const params = useParams(); + const editionId = params.editionId!; + + const { role } = useAuth(); + + return ( + + + + navigate("/editions/" + editionId + "/projects/" + project.projectId) + } + > + {project.name} + <OpenIcon /> + + + {role === Role.ADMIN && ( + + + + )} + + + + + + + {project.partners.map((partner, _index) => ( + {partner.name} + ))} + + + {project.numberOfStudents} + + + + + + {project.coaches.map((coach, _index) => ( + + {coach.name} + + ))} + + + ); +} diff --git a/frontend/src/components/ProjectsComponents/ProjectCard/index.ts b/frontend/src/components/ProjectsComponents/ProjectCard/index.ts new file mode 100644 index 000000000..b45666a95 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/ProjectCard/index.ts @@ -0,0 +1 @@ +export { default } from "./ProjectCard"; diff --git a/frontend/src/components/ProjectsComponents/ProjectCard/styles.ts b/frontend/src/components/ProjectsComponents/ProjectCard/styles.ts new file mode 100644 index 000000000..d051fba64 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/ProjectCard/styles.ts @@ -0,0 +1,92 @@ +import { Modal } from "react-bootstrap"; +import styled from "styled-components"; +import { BsArrowUpRightSquare } from "react-icons/bs"; + +export const CardContainer = styled.div` + border: 2px solid #1a1a36; + border-radius: 5px; + margin: 10px 20px; + padding: 20px 20px 20px 20px; + background-color: #323252; + box-shadow: 5px 5px 15px #131329; +`; + +export const TitleContainer = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; +`; + +export const Title = styled.h2` + text-overflow: ellipsis; + overflow: hidden; + display: flex; + align-items: center; + :hover { + cursor: pointer; + } +`; + +export const OpenIcon = styled(BsArrowUpRightSquare)` + margin-left: 5px; + margin-top: 2px; + height: 20px; +`; + +export const ClientContainer = styled.div` + display: flex; + align-items: top; + justify-content: space-between; + color: lightgray; +`; + +export const Clients = styled.div` + display: flex; + overflow-x: auto; +`; + +export const Client = styled.h5` + margin-right: 10px; +`; + +export const NumberOfStudents = styled.div` + margin-left: 10px; + display: flex; + align-items: center; + margin-bottom: 4px; +`; + +export const CoachesContainer = styled.div` + display: flex; + margin-top: 20px; + overflow-x: auto; +`; + +export const CoachContainer = styled.div` + background-color: #1a1a36; + border-radius: 5px; + margin-right: 10px; + text-align: center; + padding: 7.5px 15px; + width: fit-content; + max-width: 20vw; +`; + +export const CoachText = styled.div` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const Delete = styled.button` + background-color: #f14a3b; + padding: 5px 5px; + border: 0; + border-radius: 1px; + max-height: 30px; + margin-left: 5%; + display: flex; + align-items: center; +`; + +export const PopUp = styled(Modal)``; diff --git a/frontend/src/components/ProjectsComponents/StudentPlaceholder/StudentPlaceholder.tsx b/frontend/src/components/ProjectsComponents/StudentPlaceholder/StudentPlaceholder.tsx new file mode 100644 index 000000000..ea2c5bf33 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/StudentPlaceholder/StudentPlaceholder.tsx @@ -0,0 +1,26 @@ +import { TiDeleteOutline } from "react-icons/ti"; +import { StudentPlace } from "../../../data/interfaces/projects"; +import { StudentPlaceContainer, AddStudent } from "./styles"; + +/** + * TODO this needs more work and is still mostly a placeholder. + * @param studentPlace gives some info about a specific place in a project. + * @returns a component to add a student to a project place or to view a student added to the project. + */ +export default function StudentPlaceholder({ studentPlace }: { studentPlace: StudentPlace }) { + if (studentPlace.available) { + return ( + + {studentPlace.skill} + + + ); + } else + return ( + + {studentPlace.skill} + {" " + studentPlace.name} + + + ); +} diff --git a/frontend/src/components/ProjectsComponents/StudentPlaceholder/index.ts b/frontend/src/components/ProjectsComponents/StudentPlaceholder/index.ts new file mode 100644 index 000000000..628878c9a --- /dev/null +++ b/frontend/src/components/ProjectsComponents/StudentPlaceholder/index.ts @@ -0,0 +1 @@ +export { default } from "./StudentPlaceholder"; diff --git a/frontend/src/components/ProjectsComponents/StudentPlaceholder/styles.ts b/frontend/src/components/ProjectsComponents/StudentPlaceholder/styles.ts new file mode 100644 index 000000000..e233461c3 --- /dev/null +++ b/frontend/src/components/ProjectsComponents/StudentPlaceholder/styles.ts @@ -0,0 +1,17 @@ +import styled from "styled-components"; + +import { AiOutlineUserAdd } from "react-icons/ai"; + +export const StudentPlaceContainer = styled.div` + margin-top: 30px; + padding: 20px; + background-color: #323252; + border-radius: 5px; + max-width: 50%; + display: flex; + align-items: center; +`; + +export const AddStudent = styled(AiOutlineUserAdd)` + margin-left: 10px; +`; diff --git a/frontend/src/components/ProjectsComponents/index.ts b/frontend/src/components/ProjectsComponents/index.ts new file mode 100644 index 000000000..a6c527b3a --- /dev/null +++ b/frontend/src/components/ProjectsComponents/index.ts @@ -0,0 +1,3 @@ +export { default as ProjectCard } from "./ProjectCard"; +export { default as StudentPlaceholder } from "./StudentPlaceholder"; +export { default as LoadSpinner } from "./LoadSpinner"; diff --git a/frontend/src/components/RegisterComponents/BadInviteLink/BadInviteLink.tsx b/frontend/src/components/RegisterComponents/BadInviteLink/BadInviteLink.tsx new file mode 100644 index 000000000..8ad2c2887 --- /dev/null +++ b/frontend/src/components/RegisterComponents/BadInviteLink/BadInviteLink.tsx @@ -0,0 +1,9 @@ +import { BadInvite } from "./styles"; + +/** + * Message displayed when the user tries to access a registration link + * that doesn't exist (anymore), for example `{{BASE_URL}}/register/this-is-not-a-valid-uuid`. + */ +export default function BadInviteLink() { + return Not a valid register url.; +} diff --git a/frontend/src/components/RegisterComponents/BadInviteLink/index.ts b/frontend/src/components/RegisterComponents/BadInviteLink/index.ts new file mode 100644 index 000000000..c32065aab --- /dev/null +++ b/frontend/src/components/RegisterComponents/BadInviteLink/index.ts @@ -0,0 +1 @@ +export { default } from "./BadInviteLink"; diff --git a/frontend/src/components/RegisterComponents/BadInviteLink/styles.ts b/frontend/src/components/RegisterComponents/BadInviteLink/styles.ts new file mode 100644 index 000000000..88d76fc1a --- /dev/null +++ b/frontend/src/components/RegisterComponents/BadInviteLink/styles.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +export const BadInvite = styled.div` + margin: auto; + margin-top: 10%; + max-width: 50%; + text-align: center; + font-size: 40px; + font-weight: 600; +`; diff --git a/frontend/src/components/RegisterComponents/InfoText/InfoText.tsx b/frontend/src/components/RegisterComponents/InfoText/InfoText.tsx new file mode 100644 index 000000000..e193eb9e4 --- /dev/null +++ b/frontend/src/components/RegisterComponents/InfoText/InfoText.tsx @@ -0,0 +1,17 @@ +import { TitleText, Info } from "./styles"; + +/** + * Message displayed on the [[RegisterPage]] to inform the user of what + * to do, and provides some additional info on the registration links. + */ +export default function InfoText() { + return ( +
+ Create an account + + Sign up with your social media account or email address. Your unique link is not + re-usable. + +
+ ); +} diff --git a/frontend/src/components/RegisterComponents/InfoText/index.ts b/frontend/src/components/RegisterComponents/InfoText/index.ts new file mode 100644 index 000000000..ef56695bb --- /dev/null +++ b/frontend/src/components/RegisterComponents/InfoText/index.ts @@ -0,0 +1 @@ +export { default } from "./InfoText"; diff --git a/frontend/src/components/RegisterComponents/InfoText/styles.ts b/frontend/src/components/RegisterComponents/InfoText/styles.ts new file mode 100644 index 000000000..319fc8495 --- /dev/null +++ b/frontend/src/components/RegisterComponents/InfoText/styles.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +export const TitleText = styled.h1` + margin-bottom: 10px; +`; + +export const Info = styled.div` + color: grey; + margin-bottom: 10px; +`; diff --git a/frontend/src/components/RegisterComponents/InputFields/ConfirmPassword/ConfirmPassword.tsx b/frontend/src/components/RegisterComponents/InputFields/ConfirmPassword/ConfirmPassword.tsx new file mode 100644 index 000000000..d8a92cf5f --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/ConfirmPassword/ConfirmPassword.tsx @@ -0,0 +1,34 @@ +import { Input } from "../styles"; + +/** + * Input field for passwords (confirmation), submits when pressing the Enter key. + * @param confirmPassword getter for the state of the password + * @param setConfirmPassword setter for the state of the password + * @param callRegister callback that tries to register the user + */ +export default function ConfirmPassword({ + confirmPassword, + setConfirmPassword, + callRegister, +}: { + confirmPassword: string; + setConfirmPassword: (value: string) => void; + callRegister: () => void; +}) { + return ( +
+ setConfirmPassword(e.target.value)} + onKeyPress={e => { + if (e.key === "Enter") { + callRegister(); + } + }} + /> +
+ ); +} diff --git a/frontend/src/components/RegisterComponents/InputFields/ConfirmPassword/index.ts b/frontend/src/components/RegisterComponents/InputFields/ConfirmPassword/index.ts new file mode 100644 index 000000000..4b91ec8f8 --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/ConfirmPassword/index.ts @@ -0,0 +1 @@ +export { default } from "./ConfirmPassword"; diff --git a/frontend/src/components/RegisterComponents/InputFields/Email/Email.tsx b/frontend/src/components/RegisterComponents/InputFields/Email/Email.tsx new file mode 100644 index 000000000..31a98312e --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/Email/Email.tsx @@ -0,0 +1,26 @@ +import { Input } from "../styles"; + +/** + * Input field for email addresses. + * @param email getter for the state of the email address + * @param setEmail setter for the state of the email address + */ +export default function Email({ + email, + setEmail, +}: { + email: string; + setEmail: (value: string) => void; +}) { + return ( +
+ setEmail(e.target.value)} + /> +
+ ); +} diff --git a/frontend/src/components/RegisterComponents/InputFields/Email/index.ts b/frontend/src/components/RegisterComponents/InputFields/Email/index.ts new file mode 100644 index 000000000..f2681e1e2 --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/Email/index.ts @@ -0,0 +1 @@ +export { default } from "./Email"; diff --git a/frontend/src/components/RegisterComponents/InputFields/Name/Name.tsx b/frontend/src/components/RegisterComponents/InputFields/Name/Name.tsx new file mode 100644 index 000000000..911bb8dce --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/Name/Name.tsx @@ -0,0 +1,26 @@ +import { Input } from "../styles"; + +/** + * Input field for the user's name. + * @param name getter for the state of the name + * @param setName setter for the state of the name + */ +export default function Name({ + name, + setName, +}: { + name: string; + setName: (value: string) => void; +}) { + return ( +
+ setName(e.target.value)} + /> +
+ ); +} diff --git a/frontend/src/components/RegisterComponents/InputFields/Name/index.ts b/frontend/src/components/RegisterComponents/InputFields/Name/index.ts new file mode 100644 index 000000000..4e90e41d5 --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/Name/index.ts @@ -0,0 +1 @@ +export { default } from "./Name"; diff --git a/frontend/src/components/RegisterComponents/InputFields/Password/Password.tsx b/frontend/src/components/RegisterComponents/InputFields/Password/Password.tsx new file mode 100644 index 000000000..fec45886d --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/Password/Password.tsx @@ -0,0 +1,27 @@ +import { Input } from "../styles"; + +/** + * Input field for passwords, authenticates when pressing the Enter key. + * @param password getter for the state of the password + * @param setPassword setter for the state of the password + * @param callLogIn callback that tries to authenticate the user + */ +export default function Password({ + password, + setPassword, +}: { + password: string; + setPassword: (value: string) => void; +}) { + return ( +
+ setPassword(e.target.value)} + /> +
+ ); +} diff --git a/frontend/src/components/RegisterComponents/InputFields/Password/index.ts b/frontend/src/components/RegisterComponents/InputFields/Password/index.ts new file mode 100644 index 000000000..b345e9aef --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/Password/index.ts @@ -0,0 +1 @@ +export { default } from "./Password"; diff --git a/frontend/src/components/RegisterComponents/InputFields/styles.ts b/frontend/src/components/RegisterComponents/InputFields/styles.ts new file mode 100644 index 000000000..d6dccb8d1 --- /dev/null +++ b/frontend/src/components/RegisterComponents/InputFields/styles.ts @@ -0,0 +1,12 @@ +import styled from "styled-components"; + +export const Input = styled.input` + height: 40px; + width: 400px; + margin-top: 10px; + margin-bottom: 10px; + text-align: center; + font-size: 20px; + border-radius: 5px; + border-width: 0; +`; diff --git a/frontend/src/components/RegisterComponents/SocialButtons/SocialButtons.tsx b/frontend/src/components/RegisterComponents/SocialButtons/SocialButtons.tsx new file mode 100644 index 000000000..6d19ea288 --- /dev/null +++ b/frontend/src/components/RegisterComponents/SocialButtons/SocialButtons.tsx @@ -0,0 +1,13 @@ +import { GoogleLoginButton, GithubLoginButton } from "react-social-login-buttons"; +import { SocialsContainer, Socials } from "./styles"; + +export default function SocialButtons() { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/RegisterComponents/SocialButtons/index.ts b/frontend/src/components/RegisterComponents/SocialButtons/index.ts new file mode 100644 index 000000000..1aa8d729f --- /dev/null +++ b/frontend/src/components/RegisterComponents/SocialButtons/index.ts @@ -0,0 +1 @@ +export { default } from "./SocialButtons"; diff --git a/frontend/src/components/RegisterComponents/SocialButtons/styles.ts b/frontend/src/components/RegisterComponents/SocialButtons/styles.ts new file mode 100644 index 000000000..4458c3f07 --- /dev/null +++ b/frontend/src/components/RegisterComponents/SocialButtons/styles.ts @@ -0,0 +1,14 @@ +import styled from "styled-components"; + +export const SocialsContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +export const Socials = styled.div` + justify-content: center; + display: flex; + min-width: 230px; + height: fit-content; +`; diff --git a/frontend/src/components/RegisterComponents/index.ts b/frontend/src/components/RegisterComponents/index.ts new file mode 100644 index 000000000..f60693215 --- /dev/null +++ b/frontend/src/components/RegisterComponents/index.ts @@ -0,0 +1,7 @@ +export { default as Email } from "./InputFields/Email"; +export { default as Name } from "./InputFields/Name"; +export { default as Password } from "./InputFields/Password"; +export { default as ConfirmPassword } from "./InputFields/ConfirmPassword"; +export { default as SocialButtons } from "./SocialButtons"; +export { default as InfoText } from "./InfoText"; +export { default as BadInviteLink } from "./BadInviteLink"; diff --git a/frontend/src/components/UsersComponents/Coaches/Coaches.tsx b/frontend/src/components/UsersComponents/Coaches/Coaches.tsx new file mode 100644 index 000000000..8581f8e5a --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/Coaches.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { CoachesTitle, CoachesContainer } from "./styles"; +import { User } from "../../../utils/api/users/users"; +import { Error, SpinnerContainer } from "../Requests/styles"; +import { CoachList, AddCoach } from "./CoachesComponents"; +import { Spinner } from "react-bootstrap"; +import { SearchInput } from "../../styles"; + +/** + * List of coaches of the given edition. + * This includes a searchfield and the option to remove and add coaches. + * @param props.edition The edition of which coaches are shown. + * @param props.coaches The list of coaches which need to be shown. + * @param props.getMoreCoaches A function to load more coaches. + * @param props.searchCoaches A function to set the filter for coaches' username. + * @param props.gotData All data is received. + * @param props.gettingData Waiting for data. + * @param props.error An error message. + * @param props.moreCoachesAvailable More unfetched coaches available. + * @param props.searchTerm Current filter for coaches' names. + * @param props.refreshCoaches A function which will be called when a coach is added. + * @param props.removeCoach A function which will be called when a user is deleted as coach. + */ +export default function Coaches(props: { + edition: string; + coaches: User[]; + getMoreCoaches: (page: number) => void; + searchCoaches: (word: string) => void; + gotData: boolean; + gettingData: boolean; + error: string; + moreCoachesAvailable: boolean; + searchTerm: string; + refreshCoaches: () => void; + removeCoach: (user: User) => void; +}) { + let table; + if (props.coaches.length === 0) { + if (props.gettingData) { + table = ( + + + + ); + } else if (props.gotData) { + table =
No coaches found
; + } else { + table = {props.error} ; + } + } else { + table = ( + + ); + } + + return ( + + Coaches + props.searchCoaches(e.target.value)} + /> + + {table} + + ); +} diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx new file mode 100644 index 000000000..d3b0026e6 --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/AddCoach.tsx @@ -0,0 +1,165 @@ +import { getUsersExcludeEdition, User } from "../../../../utils/api/users/users"; +import React, { useState } from "react"; +import { addCoachToEdition } from "../../../../utils/api/users/coaches"; +import { Button, Modal, Spinner } from "react-bootstrap"; +import { Error } from "../../Requests/styles"; +import { AddAdminButton, ModalContentConfirm } from "../../../AdminsComponents/styles"; +import { AsyncTypeahead, Menu } from "react-bootstrap-typeahead"; +import UserMenuItem from "../../../GeneralComponents/MenuItem"; +import { StyledMenuItem } from "../../../GeneralComponents/styles"; +import { EmailAndAuth } from "../../../GeneralComponents"; + +/** + * A button and popup to add a new coach to the given edition. + * The popup consists of a field to search for a user. + * @param props.edition The edition to which users need to be added. + * @param props.coachAdded A function which will be called when a user is added as coach. + */ +export default function AddCoach(props: { edition: string; refreshCoaches: () => void }) { + const [show, setShow] = useState(false); + const [selected, setSelected] = useState(undefined); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [gettingData, setGettingData] = useState(false); // Waiting for data + const [users, setUsers] = useState([]); // All users which are not a coach + const [searchTerm, setSearchTerm] = useState(""); // The word set in filter + + async function getData(page: number, filter: string | undefined = undefined) { + if (filter === undefined) { + filter = searchTerm; + } + setGettingData(true); + setError(""); + try { + const response = await getUsersExcludeEdition(props.edition, filter, page); + if (page === 0) { + setUsers(response.users); + } else { + setUsers(users.concat(response.users)); + } + + setGettingData(false); + } catch (exception) { + setError("Oops, something went wrong..."); + setGettingData(false); + } + } + + function filterData(searchTerm: string) { + setSearchTerm(searchTerm); + setUsers([]); + getData(0, searchTerm); + } + + const handleClose = () => { + setSelected(undefined); + setError(""); + setShow(false); + }; + const handleShow = () => { + setShow(true); + }; + + async function addCoach(user: User) { + setLoading(true); + setError(""); + let success = false; + try { + success = await addCoachToEdition(user.userId, props.edition); + if (!success) { + setError("Something went wrong. Failed to add coach"); + } + } catch (error) { + setError("Something went wrong. Failed to add coach"); + } + setLoading(false); + if (success) { + props.refreshCoaches(); + handleClose(); + } + } + + let addButton; + if (loading) { + addButton = ; + } else { + addButton = ( + + ); + } + + return ( + <> + + Add coach + + + + + + Add Coach + + + { + setSelected(selected[0] as User); + setError(""); + }} + renderMenu={(results, menuProps) => { + const { + newSelectionPrefix, + paginationText, + renderMenuItemChildren, + ...props + } = menuProps; + return ( + + {results.map((result, index) => { + const user = result as User; + return ( + + +
+
+ ); + })} +
+ ); + }} + /> + +
+ + {addButton} + + {error} + +
+
+ + ); +} diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx new file mode 100644 index 000000000..ba28fb538 --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachList.tsx @@ -0,0 +1,64 @@ +import { User } from "../../../../utils/api/users/users"; +import { SpinnerContainer } from "../../Requests/styles"; +import { Spinner } from "react-bootstrap"; +import { CoachesTable, ListDiv, RemoveTh } from "../styles"; +import React from "react"; +import InfiniteScroll from "react-infinite-scroller"; +import { CoachListItem } from "./index"; + +/** + * A list of [[CoachListItem]]s. + * @param props.coaches The list of coaches which needs to be shown. + * @param props.loading Data is not available yet. + * @param props.edition The edition. + * @param props.gotData All data is received. + * @param props.removeCoach A function which will be called when a coach is removed. + * @param props.getMoreCoaches A function to load more coaches. + * @param props.moreCoachesAvailable More unfetched coaches available. + */ +export default function CoachList(props: { + coaches: User[]; + loading: boolean; + edition: string; + gotData: boolean; + removeCoach: (coach: User) => void; + getMoreCoaches: (page: number) => void; + moreCoachesAvailable: boolean; +}) { + return ( + + + + + } + useWindow={false} + initialLoad={true} + > + + + + Name + Email + Remove from edition + + + + {props.coaches.map(coach => ( + + ))} + + + + + ); +} diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx new file mode 100644 index 000000000..0f0b6a094 --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/CoachListItem.tsx @@ -0,0 +1,34 @@ +import { User } from "../../../../utils/api/users/users"; +import React from "react"; +import RemoveCoach from "./RemoveCoach"; +import { RemoveTd } from "../styles"; +import { EmailAndAuth } from "../../../GeneralComponents"; + +/** + * An item from [[CoachList]] which represents one coach. + * This includes a button te remove the coach. + * @param props.coach The coach which is represented. + * @param props.edition The edition whereof the user is coach. + * @param props.removeCoach A function which will be called when the coach is removed. + */ +export default function CoachListItem(props: { + coach: User; + edition: string; + removeCoach: (coach: User) => void; +}) { + return ( + + {props.coach.name} + + + + + props.removeCoach(props.coach)} + /> + + + ); +} diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx new file mode 100644 index 000000000..c7660e857 --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/RemoveCoach.tsx @@ -0,0 +1,113 @@ +import { User } from "../../../../utils/api/users/users"; +import React, { useState } from "react"; +import { + removeCoachFromAllEditions, + removeCoachFromEdition, +} from "../../../../utils/api/users/coaches"; +import { Button, Modal, Spinner } from "react-bootstrap"; +import { DialogButton, ModalContent } from "../styles"; +import { Error } from "../../Requests/styles"; + +/** + * A button (part of [[CoachListItem]]) and popup to remove a user as coach from the given edition or all editions. + * The popup gives the choice between removing the user as coach from this edition or all editions. + * @param props.coach The coach which can be removed. + * @param props.edition The edition of which the coach can be removed. + * @param props.removeCoach A function which will be called when a user is removed as coach. + */ +export default function RemoveCoach(props: { + coach: User; + edition: string; + removeCoach: () => void; +}) { + const [show, setShow] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleClose = () => setShow(false); + + const handleShow = () => { + setShow(true); + setError(""); + }; + + /** + * Remove a coach from the current edition or all editions. + * @param userId The id of the coach + * @param allEditions Boolean whether the coach should be removed from all editions he's coach from. + */ + async function removeCoach(userId: number, allEditions: boolean) { + setLoading(true); + let removed = false; + try { + if (allEditions) { + removed = await removeCoachFromAllEditions(userId); + } else { + removed = await removeCoachFromEdition(userId, props.edition); + } + + if (removed) { + props.removeCoach(); + } else { + setError("Something went wrong. Failed to remove coach"); + setLoading(false); + } + } catch (error) { + setError("Something went wrong. Failed to remove coach"); + setLoading(false); + } + } + + let buttons; + if (loading) { + buttons = ; + } else { + buttons = ( +
+ { + removeCoach(props.coach.userId, true); + }} + > + Remove from all editions + + { + removeCoach(props.coach.userId, false); + }} + > + Remove from {props.edition} + + +
+ ); + } + + return ( + <> + + + + + + Remove Coach + + +

{props.coach.name}

+ {props.coach.auth.email} +
+ + {buttons} + {error} + +
+
+ + ); +} diff --git a/frontend/src/components/UsersComponents/Coaches/CoachesComponents/index.ts b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/index.ts new file mode 100644 index 000000000..94422da8c --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/CoachesComponents/index.ts @@ -0,0 +1,4 @@ +export { default as AddCoach } from "./AddCoach"; +export { default as CoachListItem } from "./CoachListItem"; +export { default as CoachList } from "./CoachList"; +export { default as RemoveCoach } from "./RemoveCoach"; diff --git a/frontend/src/components/UsersComponents/Coaches/index.ts b/frontend/src/components/UsersComponents/Coaches/index.ts new file mode 100644 index 000000000..881394497 --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/index.ts @@ -0,0 +1,2 @@ +export { default as Coaches } from "./Coaches"; +export * as CoachesComponents from "./CoachesComponents"; diff --git a/frontend/src/components/UsersComponents/Coaches/styles.ts b/frontend/src/components/UsersComponents/Coaches/styles.ts new file mode 100644 index 000000000..0d6f27b57 --- /dev/null +++ b/frontend/src/components/UsersComponents/Coaches/styles.ts @@ -0,0 +1,47 @@ +import styled from "styled-components"; +import { Button, Table } from "react-bootstrap"; + +export const CoachesContainer = styled.div` + min-width: 450px; + width: 80%; + max-width: 700px; + height: 500px; + margin: 10px auto auto; +`; + +export const CoachesTitle = styled.div` + padding-bottom: 3px; + padding-left: 3px; + width: 100px; + font-size: 25px; +`; + +export const CoachesTable = styled(Table)` + // TODO: make all tables in site uniform +`; + +export const ModalContent = styled.div` + border: 3px solid var(--osoc_red); + background-color: var(--osoc_blue); +`; + +export const RemoveTh = styled.th` + width: 200px; + text-align: center; +`; + +export const RemoveTd = styled.td` + text-align: center; + vertical-align: middle; +`; + +export const ListDiv = styled.div` + width: 100%; + height: 400px; + overflow: auto; + margin-top: 10px; +`; + +export const DialogButton = styled(Button)` + margin-right: 4px; +`; diff --git a/frontend/src/components/UsersComponents/InviteUser/InviteUser.css b/frontend/src/components/UsersComponents/InviteUser/InviteUser.css new file mode 100644 index 000000000..2b087a3bc --- /dev/null +++ b/frontend/src/components/UsersComponents/InviteUser/InviteUser.css @@ -0,0 +1,3 @@ +.email-field-error { + border: 2px solid red !important; +} diff --git a/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx b/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx new file mode 100644 index 000000000..68b4a09fd --- /dev/null +++ b/frontend/src/components/UsersComponents/InviteUser/InviteUser.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; +import { getInviteLink } from "../../../utils/api/users/users"; +import "./InviteUser.css"; +import { InviteInput, InviteContainer, Error, MessageDiv } from "./styles"; +import { ButtonsDiv } from "./InviteUserComponents"; + +/** + * A component to invite a user as coach to a given edition. + * Contains an input field for the email address of the new user. + * and a button to get a mailto link which contains the invite link, + * or to copy the invite link to clipboard. + * @param props.edition The edition whereto the person will be invited. + */ +export default function InviteUser(props: { edition: string }) { + const [email, setEmail] = useState(""); // The email address which is entered + const [valid, setValid] = useState(true); // The given email address is valid (or still being typed) + const [errorMessage, setErrorMessage] = useState(""); // An error message + const [loading, setLoading] = useState(false); // The invite link is being created + const [message, setMessage] = useState(""); // A message to confirm link created + + /** + * Change the content of the email field. + * Remove error and message (user is probably still typing). + * @param email The string set in the input filed. + */ + const changeEmail = function (email: string) { + setEmail(email); + setValid(true); + setErrorMessage(""); + setMessage(""); + }; + + /** + * Check if the form of the email is valid. + * Send a request to backend to get the invite link. + * Depending on the copyInvite parameter, the recieved invite link will be put in an mailto, + * or copied to the user's clipboard. + * @param copyInvite Boolean to indicate wether the invite should be copied to clipboard or a mailto should be created. + */ + const sendInvite = async (copyInvite: boolean) => { + if (/[^@\s]+@[^@\s]+\.[^@\s]+/.test(email)) { + setLoading(true); + try { + const response = await getInviteLink(props.edition, email); + if (copyInvite) { + await navigator.clipboard.writeText(response.inviteLink); + setMessage("Copied invite link for " + email); + } else { + window.open(response.mailTo); + setMessage("Created email for " + email); + } + setLoading(false); + setEmail(""); + } catch (error) { + setLoading(false); + setErrorMessage("Something went wrong"); + setMessage(""); + } + } else { + setValid(false); + setErrorMessage("Invalid email"); + setMessage(""); + } + }; + + return ( +
+ + changeEmail(e.target.value)} + /> + + + + {message} + {errorMessage} + +
+ ); +} diff --git a/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx b/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx new file mode 100644 index 000000000..97a8f3030 --- /dev/null +++ b/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/SendInviteButton.tsx @@ -0,0 +1,34 @@ +import { DropdownField, InviteButton } from "../styles"; +import React from "react"; +import { Button, ButtonGroup, Dropdown, Spinner } from "react-bootstrap"; + +/** + * A component to choice between sending an invite or copying it to clipboard. + * @param props.loading Invite is being created. Used to show a spinner. + * @param props.sendInvite A function to send/copy the link. + */ +export default function SendInviteButton(props: { + loading: boolean; + sendInvite: (copy: boolean) => void; +}) { + if (props.loading) { + return ; + } + return ( + + + + + + + + props.sendInvite(true)}> + Copy invite link + + + + + ); +} diff --git a/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/index.ts b/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/index.ts new file mode 100644 index 000000000..c99ca8d78 --- /dev/null +++ b/frontend/src/components/UsersComponents/InviteUser/InviteUserComponents/index.ts @@ -0,0 +1 @@ +export { default as ButtonsDiv } from "./SendInviteButton"; diff --git a/frontend/src/components/UsersComponents/InviteUser/index.ts b/frontend/src/components/UsersComponents/InviteUser/index.ts new file mode 100644 index 000000000..32de38c24 --- /dev/null +++ b/frontend/src/components/UsersComponents/InviteUser/index.ts @@ -0,0 +1,2 @@ +export { default as InviteUser } from "./InviteUser"; +export * as InviteUserComponents from "./InviteUserComponents"; diff --git a/frontend/src/components/UsersComponents/InviteUser/styles.ts b/frontend/src/components/UsersComponents/InviteUser/styles.ts new file mode 100644 index 000000000..d264261f6 --- /dev/null +++ b/frontend/src/components/UsersComponents/InviteUser/styles.ts @@ -0,0 +1,47 @@ +import styled from "styled-components"; +import { Dropdown } from "react-bootstrap"; + +export const InviteContainer = styled.div` + clear: both; +`; + +export const InviteInput = styled.input.attrs({ + name: "email", + placeholder: "Invite user by email", +})` + height: 30px; + width: 200px; + font-size: 13px; + margin-top: 10px; + margin-left: 10px; + margin-right: 5px; + text-align: center; + border-radius: 5px; + border-width: 0; + float: left; +`; + +export const MessageDiv = styled.div` + margin-left: 10px; + margin-top: 5px; + height: 15px; +`; + +export const Error = styled.div` + color: var(--osoc_red); +`; + +export const InviteButton = styled.div` + padding-top: 10px; +`; + +export const DropdownField = styled(Dropdown.Item)` + color: white; + transition: 200ms ease-out; + + &:hover { + background-color: var(--osoc_blue); + color: var(--osoc_green); + transition: 200ms ease-in; + } +`; diff --git a/frontend/src/components/UsersComponents/Requests/Requests.tsx b/frontend/src/components/UsersComponents/Requests/Requests.tsx new file mode 100644 index 000000000..59f201a3b --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/Requests.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import Collapsible from "react-collapsible"; +import { RequestsContainer, Error, SpinnerContainer, RequestListContainer } from "./styles"; +import { getRequests, Request } from "../../../utils/api/users/requests"; +import { RequestList, RequestsHeader } from "./RequestsComponents"; +import { Spinner } from "react-bootstrap"; +import { SearchInput } from "../../styles"; + +/** + * A collapsible component which contains all coach requests for a given edition. + * Every request can be accepted or rejected. + * @param props.edition The edition. + * @param props.refreshCoaches A function which will be called when a new coach is added + */ +export default function Requests(props: { edition: string; refreshCoaches: () => void }) { + const [requests, setRequests] = useState([]); // All requests after filter + const [gettingRequests, setGettingRequests] = useState(false); // Waiting for data + const [searchTerm, setSearchTerm] = useState(""); // The word set in the filter + const [gotData, setGotData] = useState(false); // Received data + const [open, setOpen] = useState(false); // Collapsible is open + const [error, setError] = useState(""); // Error message + const [moreRequestsAvailable, setMoreRequestsAvailable] = useState(true); // Endpoint has more requests available + + /** + * Remove a request from the list of requests (Request is accepter or rejected). + * When the request was accepted, the refreshCoaches will be called. + * @param accepted Boolean to say if a coach was accepted. + * @param request The request which was accepter or rejected. + */ + function removeRequest(accepted: boolean, request: Request) { + setRequests( + requests.filter(object => { + return object !== request; + }) + ); + if (accepted) { + props.refreshCoaches(); + } + } + + /** + * Request a page from the list of requests. + * An optional filter can be used to filter the username. + * If the filter is not used, the string saved in the "searchTerm" state will be used. + * @param page The page to load. + * @param filter Optional string to filter username. + */ + async function getData(page: number, filter: string | undefined = undefined) { + if (filter === undefined) { + filter = searchTerm; + } + setGettingRequests(true); + setError(""); + try { + const response = await getRequests(props.edition, filter, page); + if (response.requests.length === 0) { + setMoreRequestsAvailable(false); + } + if (page === 0) { + setRequests(response.requests); + } else { + setRequests(requests.concat(response.requests)); + } + + setGotData(true); + setGettingRequests(false); + } catch (exception) { + setError("Oops, something went wrong..."); + setGettingRequests(false); + } + } + + useEffect(() => { + if (!gotData && !gettingRequests && !error) { + getData(0); + } + }); + + /** + * Set the searchTerm and request the first page with this filter. + * The current list of requests will be resetted. + * @param searchTerm The string to filter coaches with by username. + */ + function filterRequests(searchTerm: string) { + setGotData(false); + setSearchTerm(searchTerm); + setRequests([]); + setMoreRequestsAvailable(true); + getData(0, searchTerm); + } + + let list; + if (requests.length === 0) { + if (gettingRequests) { + list = ( + + + + ); + } else if (gotData) { + list =
No requests found
; + } else { + list = {error}; + } + } else { + list = ( + + ); + } + + return ( + + } + onOpening={() => setOpen(true)} + onClosing={() => setOpen(false)} + > + filterRequests(e.target.value)} /> + {list} + + + ); +} diff --git a/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx b/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx new file mode 100644 index 000000000..5284ecf26 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/RequestsComponents/AcceptReject.tsx @@ -0,0 +1,66 @@ +import { AcceptButton, RejectButton } from "../styles"; +import { Request, acceptRequest, rejectRequest } from "../../../../utils/api/users/requests"; +import React, { useState } from "react"; +import { Spinner } from "react-bootstrap"; + +/** + * Component consisting of two buttons to accept or reject a coach request. + * @param props.request The request which can be accepted/rejected. + * @param props.removeRequest A function which will be called when a request is accepted/rejected. + */ +export default function AcceptReject(props: { + request: Request; + removeRequest: (coachAdded: boolean, request: Request) => void; +}) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function accept() { + setLoading(true); + let success = false; + try { + success = await acceptRequest(props.request.requestId); + if (!success) { + setError("Failed to accept"); + } + } catch (exception) { + setError("Failed to accept"); + } + setLoading(false); + if (success) { + props.removeRequest(true, props.request); + } + } + + async function reject() { + setLoading(true); + let success = false; + try { + success = await rejectRequest(props.request.requestId); + if (!success) { + setError("Failed to reject"); + } + } catch (exception) { + setError("Failed to reject"); + } + setLoading(false); + if (success) { + props.removeRequest(false, props.request); + } + } + + if (error) { + return
{error}
; + } + + if (loading) { + return ; + } + + return ( +
+ Accept + Reject +
+ ); +} diff --git a/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestList.tsx b/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestList.tsx new file mode 100644 index 000000000..f85315cb9 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestList.tsx @@ -0,0 +1,57 @@ +import { Request } from "../../../../utils/api/users/requests"; +import { AcceptRejectTh, RequestsTable, SpinnerContainer } from "../styles"; +import { Spinner } from "react-bootstrap"; +import React from "react"; +import RequestListItem from "./RequestListItem"; +import InfiniteScroll from "react-infinite-scroller"; +import { ListDiv } from "../../Coaches/styles"; + +/** + * A list of [[RequestListItem]]s. + * @param props.requests A list of requests which need to be shown. + * @param props.removeRequest A function which will be called when a request is accepted/rejected. + * @param props.moreRequestsAvailable Boolean to indicate whether more requests can be fetched + * @param props.getMoreRequests A function which will be called when more requests need to be loaded. + */ +export default function RequestList(props: { + requests: Request[]; + removeRequest: (coachAdded: boolean, request: Request) => void; + moreRequestsAvailable: boolean; + getMoreRequests: (page: number) => void; +}) { + return ( + + + + + } + useWindow={false} + initialLoad={true} + > + + + + Name + Email + Accept/Reject + + + + {props.requests.map(request => ( + + ))} + + + + + ); +} diff --git a/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestListItem.tsx b/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestListItem.tsx new file mode 100644 index 000000000..a7889d036 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestListItem.tsx @@ -0,0 +1,28 @@ +import { Request } from "../../../../utils/api/users/requests"; +import React from "react"; +import AcceptReject from "./AcceptReject"; +import { AcceptRejectTd } from "../styles"; +import { EmailAndAuth } from "../../../GeneralComponents"; + +/** + * An item from [[RequestList]] which represents one request. + * This includes two buttons to accept or reject the request. + * @param props.request The request which is represented. + * @param props.removeRequest A function which will be called when the request is accepted/rejected. + */ +export default function RequestListItem(props: { + request: Request; + removeRequest: (coachAdded: boolean, request: Request) => void; +}) { + return ( + + {props.request.user.name} + + + + + + + + ); +} diff --git a/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestsHeader.tsx b/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestsHeader.tsx new file mode 100644 index 000000000..1bca431f8 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/RequestsComponents/RequestsHeader.tsx @@ -0,0 +1,27 @@ +import { ClosedArrow, OpenArrow, RequestHeaderDiv, RequestHeaderTitle } from "../styles"; +import React from "react"; + +/** + * Arrow to indicate the status of the collapsible component. + * @param props.open Boolean to indicate if the collapsible is open. + */ +function Arrow(props: { open: boolean }) { + if (props.open) { + return ; + } else { + return ; + } +} + +/** + * The header of [[Requests]]. + * @param props.open Boolean to indicate if the collapsible is open. + */ +export default function RequestsHeader(props: { open: boolean }) { + return ( + + Requests + + + ); +} diff --git a/frontend/src/components/UsersComponents/Requests/RequestsComponents/index.ts b/frontend/src/components/UsersComponents/Requests/RequestsComponents/index.ts new file mode 100644 index 000000000..008b6b0b4 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/RequestsComponents/index.ts @@ -0,0 +1,4 @@ +export { default as AcceptReject } from "./AcceptReject"; +export { default as RequestListItem } from "./RequestListItem"; +export { default as RequestList } from "./RequestList"; +export { default as RequestsHeader } from "./RequestsHeader"; diff --git a/frontend/src/components/UsersComponents/Requests/index.ts b/frontend/src/components/UsersComponents/Requests/index.ts new file mode 100644 index 000000000..5c11e2e33 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/index.ts @@ -0,0 +1,2 @@ +export { default as PendingRequests } from "./Requests"; +export * as RequestsComponents from "./RequestsComponents"; diff --git a/frontend/src/components/UsersComponents/Requests/styles.ts b/frontend/src/components/UsersComponents/Requests/styles.ts new file mode 100644 index 000000000..a0bf15341 --- /dev/null +++ b/frontend/src/components/UsersComponents/Requests/styles.ts @@ -0,0 +1,85 @@ +import styled from "styled-components"; +import { Table, Button } from "react-bootstrap"; +import { BiDownArrow } from "react-icons/bi"; + +export const RequestHeaderDiv = styled.div` + display: inline-block; +`; + +export const RequestHeaderTitle = styled.div` + padding-bottom: 3px; + padding-left: 3px; + width: 100px; + font-size: 25px; + float: left; +`; + +export const OpenArrow = styled(BiDownArrow)` + margin-top: 13px; + margin-left: 10px; + offset-position: 0 30px; +`; + +export const ClosedArrow = styled(BiDownArrow)` + margin-top: 13px; + margin-left: 10px; + transform: rotate(-90deg); + offset: 0 30px; +`; + +export const RequestsTable = styled(Table)` + // TODO: make all tables in site uniform +`; + +export const RequestsContainer = styled.div` + min-width: 450px; + width: 80%; + max-width: 700px; + margin: 10px auto auto; +`; + +export const AcceptRejectTh = styled.th` + width: 200px; + text-align: center; +`; + +export const AcceptRejectTd = styled.td` + text-align: center; + vertical-align: middle; +`; + +export const AcceptButton = styled(Button)` + background-color: var(--osoc_green); + color: black; + padding-bottom: 3px; + padding-left: 3px; + padding-right: 3px; + width: 65px; +`; + +export const RejectButton = styled(Button)` + background-color: var(--osoc_red); + color: black; + margin-left: 3px; + padding-bottom: 3px; + padding-left: 3px; + padding-right: 3px; + width: 65px; +`; + +export const SpinnerContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin: 20px; +`; + +export const Error = styled.div` + color: var(--osoc_red); + width: 100%; + margin: auto; +`; + +export const RequestListContainer = styled.div` + height: 400px; +`; diff --git a/frontend/src/components/UsersComponents/index.ts b/frontend/src/components/UsersComponents/index.ts new file mode 100644 index 000000000..7390166ef --- /dev/null +++ b/frontend/src/components/UsersComponents/index.ts @@ -0,0 +1,3 @@ +export * as Coaches from "./Coaches"; +export * as InviteUser from "./InviteUser"; +export * as PendingRequests from "./Requests"; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 000000000..bd4445e73 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,11 @@ +export { default as AdminRoute } from "./AdminRoute"; +export { default as Footer } from "./Footer"; +export * as LoginComponents from "./LoginComponents"; +export { default as Navbar } from "./Navbar"; +export { default as OSOCLetters } from "./OSOCLetters"; +export { default as PrivateRoute } from "./PrivateRoute"; +export * as RegisterComponents from "./RegisterComponents"; +export { default as Modal } from "./ProjectsComponents/ConfirmDelete"; +export * as UsersComponents from "./UsersComponents"; +export * as AdminsComponents from "./AdminsComponents"; +export * as GeneralComponents from "./GeneralComponents"; diff --git a/frontend/src/components/navbar/NavBarElementss.tsx b/frontend/src/components/navbar/NavBarElementss.tsx deleted file mode 100644 index 1f76511f6..000000000 --- a/frontend/src/components/navbar/NavBarElementss.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// @ts-ignore -import styled from "styled-components"; -import { NavLink as Link } from "react-router-dom"; -import { FaBars } from "react-icons/fa"; -import "../../css-files/App.css"; - -export const Nav = styled.nav` - background: var(--osoc_blue); - height: 80px; - width: 100%; - display: flex; - position: relative; - justify-content: space-between; - padding: 0.5rem calc((100vw - 1000px) / 2); - z-index: 10; -`; - -export const NavLink = styled(Link)` - color: #fff; - display: flex; - align-items: center; - text-decoration: none; - padding: 0 1rem; - height: 100%; - cursor: pointer; - - &.active { - color: var(--osoc_red); - } -`; - -export const Bars = styled(FaBars)` - display: none; - color: #fff; - - @media screen and (max-width: 768px) { - display: block; - position: absolute; - top: 0; - right: 0; - transform: translate(-100%, 75%); - font-size: 1.8rem; - cursor: pointer; - } -`; - -export const NavMenu = styled.div` - display: flex; - margin-right: 30px; - - @media screen and (max-width: 768px) { - display: none; - } -`; - -export const NavBtn = styled.nav` - display: flex; - align-items: center; - margin-right: 24px; - - @media screen and (max-width: 768px) { - display: none; - } -`; - -export const NavBtnLink = styled(Link)` - border-radius: 4px; - background: var(--osoc_red); - padding: 10px 22px; - color: #fff; - border: none; - outline: none; - cursor: pointer; - transition: all 0.2s ease-in-out; - text-decoration: none; - - &:hover { - transition: all 0.2s ease-in-out; - background: #fff; - color: #010606; - } -`; diff --git a/frontend/src/components/navbar/index.tsx b/frontend/src/components/navbar/index.tsx deleted file mode 100644 index 4c9983bee..000000000 --- a/frontend/src/components/navbar/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import { Nav, NavLink, Bars, NavMenu } from "./NavBarElementss"; - -function NavBar({ token }: any, { setToken }: any) { - let hidden = "nav-hidden"; - if (token) { - hidden = "nav-links"; - } - - return ( - <> - - - ); -} - -export default NavBar; diff --git a/frontend/src/components/styles.ts b/frontend/src/components/styles.ts new file mode 100644 index 000000000..f7091348b --- /dev/null +++ b/frontend/src/components/styles.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +export const SearchInput = styled.input.attrs({ + placeholder: "Search", +})` + margin: 3px; + width: 150px; + font-size: 15px; + border-radius: 5px; + border-width: 0; +`; diff --git a/frontend/src/contexts/auth-context.tsx b/frontend/src/contexts/auth-context.tsx new file mode 100644 index 000000000..f31fc6ff2 --- /dev/null +++ b/frontend/src/contexts/auth-context.tsx @@ -0,0 +1,97 @@ +/** Context hook to maintain the authentication state of the user **/ +import { Role } from "../data/enums"; +import React, { useContext, ReactNode, useState } from "react"; +import { User } from "../data/interfaces"; +import { setCurrentEdition } from "../utils/session-storage"; + +/** + * Interface that holds the data stored in the AuthContext. + */ +export interface AuthContextState { + isLoggedIn: boolean | null; + setIsLoggedIn: (value: boolean | null) => void; + role: Role | null; + setRole: (value: Role | null) => void; + userId: number | null; + setUserId: (value: number | null) => void; + editions: string[]; + setEditions: (value: string[]) => void; +} + +/** + * Function to create a (placeholder) default value for the state. + * These values are never used, but React context hooks expect a default value + * so there is no way around it. + */ +function authDefaultState(): AuthContextState { + return { + isLoggedIn: null, + setIsLoggedIn: (_: boolean | null) => {}, + role: null, + setRole: (_: Role | null) => {}, + userId: null, + setUserId: (_: number | null) => {}, + editions: [], + setEditions: (_: string[]) => {}, + }; +} + +export const AuthContext = React.createContext(authDefaultState()); + +/** + * Custom React hook to use our authentication context. + */ +export function useAuth(): AuthContextState { + return useContext(AuthContext); +} + +/** + * Provider for auth that creates getters, setters, maintains state, and + * provides default values. + * + * This keeps the main [[App]] component code clean by handling this + * boilerplate here instead. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [isLoggedIn, setIsLoggedIn] = useState(null); + const [role, setRole] = useState(null); + const [editions, setEditions] = useState([]); + const [userId, setUserId] = useState(null); + + // Create AuthContext value + const authContextValue: AuthContextState = { + isLoggedIn: isLoggedIn, + setIsLoggedIn: setIsLoggedIn, + role: role, + setRole: setRole, + userId: userId, + setUserId: setUserId, + editions: editions, + setEditions: setEditions, + }; + + return {children}; +} + +/** + * Set the user's login data in the AuthContext + */ +export function logIn(user: User, authContext: AuthContextState) { + authContext.setUserId(user.userId); + authContext.setRole(user.admin ? Role.ADMIN : Role.COACH); + authContext.setEditions(user.editions); + authContext.setIsLoggedIn(true); +} + +/** + * Remove a user's login data from the AuthContext + */ +export function logOut(authContext: AuthContextState) { + authContext.setIsLoggedIn(false); + authContext.setUserId(null); + authContext.setRole(null); + authContext.setEditions([]); + + // Remove current edition from SessionStorage + setCurrentEdition(null); +} diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts new file mode 100644 index 000000000..133c24ae4 --- /dev/null +++ b/frontend/src/contexts/index.ts @@ -0,0 +1,3 @@ +import type { AuthContextState } from "./auth-context"; +export type { AuthContextState }; +export { AuthProvider, logIn, logOut, useAuth } from "./auth-context"; diff --git a/frontend/src/css-files/LogInButtons.css b/frontend/src/css-files/LogInButtons.css deleted file mode 100644 index b4bbf1fc4..000000000 --- a/frontend/src/css-files/LogInButtons.css +++ /dev/null @@ -1,48 +0,0 @@ -.login-buttons { - width: available; - margin-top: 60px; - display: flex; - justify-content: center; -} - -.login-button { - display: block; - position: relative; - width: 280px; - height: 40px; - font-size: 25px; - padding: 0.2rem; - background: white; - cursor: pointer; - color: var(--react_dark_grey); - border-radius: 10px; - border-color: black; - border-width: 2px; -} - -.login-button-content { - display: flex; - align-items: center; - margin-left: 10px; -} - -.email-login { - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 10px; -} - -.google-login { - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 10px; -} - -.github-login { - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 10px; -} diff --git a/frontend/src/data/enums/authType.ts b/frontend/src/data/enums/authType.ts new file mode 100644 index 000000000..53e5ffd73 --- /dev/null +++ b/frontend/src/data/enums/authType.ts @@ -0,0 +1,8 @@ +/** + * Enum for the different types of authentication. + */ +export enum AuthType { + Email = "email", + GitHub = "github", + Google = "google", +} diff --git a/frontend/src/data/enums/index.ts b/frontend/src/data/enums/index.ts new file mode 100644 index 000000000..e9e2bc5f0 --- /dev/null +++ b/frontend/src/data/enums/index.ts @@ -0,0 +1,4 @@ +export { LocalStorageKey } from "./local-storage"; +export { SessionStorageKey } from "./session-storage"; +export { Role } from "./role"; +export { AuthType } from "./authType"; diff --git a/frontend/src/data/enums/local-storage.ts b/frontend/src/data/enums/local-storage.ts new file mode 100644 index 000000000..0f1a4b2d3 --- /dev/null +++ b/frontend/src/data/enums/local-storage.ts @@ -0,0 +1,11 @@ +/** + * Enum for the keys in LocalStorage. + */ +export const enum LocalStorageKey { + /** + * Bearer token used to authorize the user's requests in the backend. + */ + ACCESS_TOKEN = "accessToken", + REFRESH_TOKEN = "refreshToken", + REFRESH_TOKEN_LOCK = "refreshTokenLock", +} diff --git a/frontend/src/data/enums/role.ts b/frontend/src/data/enums/role.ts new file mode 100644 index 000000000..e445d0726 --- /dev/null +++ b/frontend/src/data/enums/role.ts @@ -0,0 +1,8 @@ +/** + * Enum for the different levels of authority a user + * can have + */ +export const enum Role { + ADMIN, + COACH, +} diff --git a/frontend/src/data/enums/session-storage.ts b/frontend/src/data/enums/session-storage.ts new file mode 100644 index 000000000..2eb625515 --- /dev/null +++ b/frontend/src/data/enums/session-storage.ts @@ -0,0 +1,6 @@ +/** + * Enum for the keys in SessionStorage. + */ +export const enum SessionStorageKey { + CURRENT_EDITION = "currentEdition", +} diff --git a/frontend/src/data/index.ts b/frontend/src/data/index.ts new file mode 100644 index 000000000..36c589c64 --- /dev/null +++ b/frontend/src/data/index.ts @@ -0,0 +1,2 @@ +export * as Enums from "./enums"; +export * as Interfaces from "./interfaces"; diff --git a/frontend/src/data/interfaces/editions.ts b/frontend/src/data/interfaces/editions.ts new file mode 100644 index 000000000..db1e88e65 --- /dev/null +++ b/frontend/src/data/interfaces/editions.ts @@ -0,0 +1,7 @@ +/** + * Data about an edition in the application + */ +export interface Edition { + name: string; + year: number; +} diff --git a/frontend/src/data/interfaces/index.ts b/frontend/src/data/interfaces/index.ts new file mode 100644 index 000000000..61fd40869 --- /dev/null +++ b/frontend/src/data/interfaces/index.ts @@ -0,0 +1,3 @@ +export type { Edition } from "./editions"; +export type { User } from "./users"; +export type { Partner, Coach, Project } from "./projects"; diff --git a/frontend/src/data/interfaces/projects.ts b/frontend/src/data/interfaces/projects.ts new file mode 100644 index 000000000..6609d8574 --- /dev/null +++ b/frontend/src/data/interfaces/projects.ts @@ -0,0 +1,102 @@ +/** + * This file contains all interfaces used in projects pages. + */ + +/** + * Data about a partner. + */ +export interface Partner { + /** The name of the partner */ + name: string; +} + +/** + * Data about a coach. + */ +export interface Coach { + /** The name of the coach */ + name: string; + + /** The user's ID */ + userId: number; +} + +/** + * Data about a project. + * Such as a list of the partners and the coaches + */ +export interface Project { + /** The name of the project */ + name: string; + + /** How many students are needed for this project */ + numberOfStudents: number; + + /** The partners of this project */ + partners: Partner[]; + + /** The coaches of this project */ + coaches: Coach[]; + + /** The name of the edition this project belongs to */ + editionName: string; + + /** The project's ID */ + projectId: number; +} + +/** + * Used as an response object for multiple projects + */ +export interface Projects { + /** A list of projects */ + projects: Project[]; +} + +/** + * Used to add skills to a project + */ +export interface SkillProject { + /** The name of the skill */ + skill: string; + + /** More info about this skill in a specific project */ + description: string; + + /** Number of positions of this skill in a project */ + amount: number; +} + +/** + * Used when creating a new project + */ +export interface CreateProject { + /** The name of the new project */ + name: string; + + /** Number of students the project needs */ + number_of_students: number; + + /** The required skills for the project */ + skills: string[]; + + /** The partners that belong to this project */ + partners: string[]; + + /** The IDs of the users that will coach this project */ + coaches: number[]; +} + +/** + * Data about a place in a project + */ +export interface StudentPlace { + /** Whether or not this position is filled in */ + available: boolean; + + /** The skill needed for this place */ + skill: string; + + /** The name of the student if this place is filled in */ + name: string | undefined; +} diff --git a/frontend/src/data/interfaces/users.ts b/frontend/src/data/interfaces/users.ts new file mode 100644 index 000000000..1c653263d --- /dev/null +++ b/frontend/src/data/interfaces/users.ts @@ -0,0 +1,11 @@ +/** + * Data about a user using the application. + * Contains a list of edition names so that we can quickly check if + * they have access to a route or not. + */ +export interface User { + userId: number; + name: string; + admin: boolean; + editions: string[]; +} diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 1dd407a63..18cccfe12 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -3,3 +3,31 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; +import { configure } from "enzyme"; +import Adapter from "enzyme-adapter-react-16"; + +// Configure Enzyme adapter +configure({ adapter: new Adapter() }); + +// Mock Axios so the tests never make API calls +jest.mock("axios", () => { + return { + create: () => { + return { + defaults: { + baseURL: "", + }, + interceptors: { + request: { + use: jest.fn(), + }, + response: { + use: jest.fn(), + }, + }, + get: jest.fn(), + post: jest.fn(), + }; + }, + }; +}); diff --git a/frontend/src/tests/utils/contexts.tsx b/frontend/src/tests/utils/contexts.tsx new file mode 100644 index 000000000..6c6ba2aae --- /dev/null +++ b/frontend/src/tests/utils/contexts.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { AuthContext, AuthContextState } from "../../contexts/auth-context"; +import "@testing-library/jest-dom"; + +/** + * Initial state to return & modify before passing into the provider + */ +export function defaultAuthState(): AuthContextState { + return { + isLoggedIn: null, + setIsLoggedIn: jest.fn(), + role: null, + setRole: jest.fn(), + userId: null, + setUserId: jest.fn(), + editions: [], + setEditions: jest.fn(), + }; +} + +/** + * AuthProvider to be used in unit testing + */ + +export function TestAuthProvider({ + children, + state, +}: { + children: React.ReactNode; + state: AuthContextState; +}) { + return {children}; +} diff --git a/frontend/src/tests/utils/renderer.tsx b/frontend/src/tests/utils/renderer.tsx new file mode 100644 index 000000000..9daeec532 --- /dev/null +++ b/frontend/src/tests/utils/renderer.tsx @@ -0,0 +1,17 @@ +import { AuthContextState } from "../../contexts"; +import { TestAuthProvider } from "./contexts"; +import Router from "../../Router"; +import React from "react"; +import { render } from "@testing-library/react"; + +/** + * Custom renderer that adds a custom AuthProvider that can be + * manipulated on the fly to force different scenarios + */ +export function contextRender(state: AuthContextState) { + render( + + + + ); +} diff --git a/frontend/src/utils/api/api.ts b/frontend/src/utils/api/api.ts index 4ee8b6e30..8c48e6af0 100644 --- a/frontend/src/utils/api/api.ts +++ b/frontend/src/utils/api/api.ts @@ -1,5 +1,60 @@ -import axios from "axios"; +import axios, { AxiosError } from "axios"; import { BASE_URL } from "../../settings"; +import { + getAccessToken, + getRefreshTokenLock, + setAccessToken, + setRefreshToken, + setRefreshTokenLock, +} from "../local-storage/auth"; +import { refreshTokens } from "./auth"; export const axiosInstance = axios.create(); + axiosInstance.defaults.baseURL = BASE_URL; + +axiosInstance.interceptors.request.use(async config => { + // If the request is sent when a token is being refreshed, delay it for 100ms. + while (getRefreshTokenLock()) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + const accessToken = getAccessToken(); + if (accessToken) { + if (config.headers) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + } + return config; +}); + +axiosInstance.interceptors.response.use(undefined, async (error: AxiosError) => { + if (error.response?.status === 401) { + if (getRefreshTokenLock()) { + // If the token is already being refreshed, resend it (will be delayed until the token has been refreshed) + return axiosInstance(error.config); + } else { + setRefreshTokenLock(true); + try { + const tokens = await refreshTokens(); + + setAccessToken(tokens.access_token); + setRefreshToken(tokens.refresh_token); + + setRefreshTokenLock(false); + + return axiosInstance(error.config); + } catch (refreshError) { + if (axios.isAxiosError(refreshError)) { + const axiosError: AxiosError = refreshError; + if (axiosError.response?.status === 401) { + // refreshing failed with an unauthorized status + localStorage.clear(); + window.location.replace("/"); + } + } + } + setRefreshTokenLock(false); + } + } + throw error; +}); diff --git a/frontend/src/utils/api/auth.ts b/frontend/src/utils/api/auth.ts index f34d4480c..3ba6934dc 100644 --- a/frontend/src/utils/api/auth.ts +++ b/frontend/src/utils/api/auth.ts @@ -1,9 +1,38 @@ import axios from "axios"; import { axiosInstance } from "./api"; +import { User } from "../../data/interfaces"; +import { getRefreshToken } from "../local-storage"; /** - * Check if a registration url exists by sending a GET to it, - * if it returns a 200 then we know the url is valid. + * Check if a bearer token is valid. + * @param token the token to validate. + */ +export async function validateBearerToken(token: string | null): Promise { + // No token stored -> can't validate anything + if (token === null) return null; + + try { + // Add header manually here instead of setting the default + const config = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + + const response = await axiosInstance.get("/users/current", config); + return response.data as User; + } catch (error) { + if (axios.isAxiosError(error)) { + return null; + } else { + throw error; + } + } +} + +/** + * Function to check if a registration url exists by sending a GET request, + * if this returns a 200 then we know the url is valid. */ export async function validateRegistrationUrl(edition: string, uuid: string): Promise { try { @@ -17,3 +46,26 @@ export async function validateRegistrationUrl(edition: string, uuid: string): Pr } } } + +/** + * Interface containg the newly fetched tokens. + */ +export interface Tokens { + access_token: string; + refresh_token: string; +} + +/** + * Function to fetch the new tokens based on the refreshtoken. + * We use a separate axios intance here because this request would otherwise be blocked by our interceptor. + */ +export async function refreshTokens(): Promise { + // Don't use axiosInstance to pass interceptors. + const response = await axios.post("/login/refresh", null, { + baseURL: axiosInstance.defaults.baseURL, + headers: { + Authorization: `Bearer ${getRefreshToken()}`, + }, + }); + return response.data as Tokens; +} diff --git a/frontend/src/utils/api/editions.ts b/frontend/src/utils/api/editions.ts new file mode 100644 index 000000000..907bc3dda --- /dev/null +++ b/frontend/src/utils/api/editions.ts @@ -0,0 +1,52 @@ +import { axiosInstance } from "./api"; +import { Edition } from "../../data/interfaces"; +import axios, { AxiosResponse } from "axios"; + +interface EditionsResponse { + editions: Edition[]; +} + +interface EditionFields { + name: string; + year: number; +} + +/** + * Get all editions the user can see. + */ +export async function getEditions(): Promise { + const response = await axiosInstance.get("/editions/"); + return response.data as EditionsResponse; +} + +/** + * Get all edition names sorted the user can see + */ +export async function getSortedEditions(): Promise { + const response = await axiosInstance.get("/users/current"); + return response.data.editions; +} + +/** + * Delete an edition by name + */ +export async function deleteEdition(name: string): Promise { + const response = await axiosInstance.delete(`/editions/${name}`); + return response.status; +} + +/** + * Create a new edition with the given name and year + */ +export async function createEdition(name: string, year: number): Promise { + const payload: EditionFields = { name: name, year: year }; + try { + return await axiosInstance.post("/editions/", payload); + } catch (error) { + if (axios.isAxiosError(error) && error.response !== undefined) { + return error.response; + } else { + throw error; + } + } +} diff --git a/frontend/src/utils/api/index.ts b/frontend/src/utils/api/index.ts index c4dfe7af3..c8238c5c6 100644 --- a/frontend/src/utils/api/index.ts +++ b/frontend/src/utils/api/index.ts @@ -1 +1,2 @@ export { validateRegistrationUrl } from "./auth"; +export * as Users from "./users"; diff --git a/frontend/src/utils/api/login.ts b/frontend/src/utils/api/login.ts new file mode 100644 index 000000000..9272775c5 --- /dev/null +++ b/frontend/src/utils/api/login.ts @@ -0,0 +1,48 @@ +import axios from "axios"; +import { axiosInstance } from "./api"; +import { AuthContextState, logIn as ctxLogIn } from "../../contexts"; +import { User } from "../../data/interfaces"; +import { setAccessToken, setRefreshToken } from "../local-storage"; + +interface LoginResponse { + access_token: string; + refresh_token: string; + user: User; +} + +/** + * Function that logs the user in via their email and password. If email/password were + * valid, this will automatically set the [[AuthContextState]], and set the token in LocalStorage. + * @param auth reference to the [[AuthContextState]] + * @param email email entered + * @param password password entered + */ +export async function logIn( + auth: AuthContextState, + email: string, + password: string +): Promise { + const payload = new FormData(); + payload.append("username", email); + payload.append("password", password); + + try { + const response = await axiosInstance.post("/login/token", payload); + const login = response.data as LoginResponse; + + setAccessToken(login.access_token); + setRefreshToken(login.refresh_token); + + ctxLogIn(login.user, auth); + + return true; + } catch (error) { + if (axios.isAxiosError(error)) { + auth.setIsLoggedIn(false); + return false; + } else { + auth.setIsLoggedIn(null); + throw error; + } + } +} diff --git a/frontend/src/utils/api/projects.ts b/frontend/src/utils/api/projects.ts new file mode 100644 index 000000000..b3efdde13 --- /dev/null +++ b/frontend/src/utils/api/projects.ts @@ -0,0 +1,118 @@ +import axios from "axios"; +import { Projects, Project, CreateProject } from "../../data/interfaces/projects"; +import { axiosInstance } from "./api"; + +/** + * API call to get projects (and filter them) + * @param edition The edition name. + * @param name To filter on project name. + * @param ownProjects To filter on your own projects. + * @param page The requested page. + * @returns + */ +export async function getProjects( + edition: string, + name: string, + ownProjects: boolean, + page: number +): Promise { + try { + const response = await axiosInstance.get( + "/editions/" + + edition + + "/projects/?name=" + + name + + "&coach=" + + ownProjects.toString() + + "&page=" + + page.toString() + ); + const projects = response.data as Projects; + return projects; + } catch (error) { + if (axios.isAxiosError(error)) { + return null; + } else { + throw error; + } + } +} + +/** + * API call to get a specific project. + * @param edition The edition name. + * @param projectId The ID of the project. + * @returns A Project object when successful. + */ +export async function getProject(edition: string, projectId: number): Promise { + try { + const response = await axiosInstance.get("/editions/" + edition + "/projects/" + projectId); + const project = response.data as Project; + return project; + } catch (error) { + if (axios.isAxiosError(error)) { + return null; + } else { + throw error; + } + } +} + +/** + * API call to create a project. + * @param edition The edition name. + * @param name The name of the new project. + * @param numberOfStudents The amount of students needed for this project. + * @param skills The skills that are needed for this project. + * @param partners The partners of the project. + * @param coaches The coaches that will coach the project. + * @returns The newly created object. + */ +export async function createProject( + edition: string, + name: string, + numberOfStudents: number, + skills: string[], + partners: string[], + coaches: number[] +): Promise { + const payload: CreateProject = { + name: name, + number_of_students: numberOfStudents, + skills: skills, + partners: partners, + coaches: coaches, + }; + + try { + const response = await axiosInstance.post("editions/" + edition + "/projects/", payload); + const project = response.data as Project; + + return project; + } catch (error) { + if (axios.isAxiosError(error)) { + return null; + } else { + throw error; + } + } +} + +/** + * API call to delete a project. + * @param edition The edition name. + * @param projectId The ID of the project that needs to be deleted. + * @returns true if the deletion was successful or false if it failed. + */ +export async function deleteProject(edition: string, projectId: number): Promise { + try { + await axiosInstance.delete("/editions/" + edition + "/projects/" + projectId); + return true; + } catch (error) { + if (axios.isAxiosError(error)) { + return false; + } else { + throw error; + } + } +} diff --git a/frontend/src/utils/api/register.ts b/frontend/src/utils/api/register.ts new file mode 100644 index 000000000..372df866d --- /dev/null +++ b/frontend/src/utils/api/register.ts @@ -0,0 +1,37 @@ +import axios from "axios"; +import { axiosInstance } from "./api"; + +interface RegisterFields { + email: string; + name: string; + uuid: string; + pw: string; +} + +/** + * Function to register a user in the backend. + * @param edition the name of the edition that the user is registering for + * @param email the email entered + * @param name the name entered + * @param uuid the uuid of the invitation link that was used + * @param password the password entered + */ +export async function register( + edition: string, + email: string, + name: string, + uuid: string, + password: string +) { + const payload: RegisterFields = { email: email, name: name, uuid: uuid, pw: password }; + try { + await axiosInstance.post("/editions/" + edition + "/register/email", payload); + return true; + } catch (error) { + if (axios.isAxiosError(error)) { + return false; + } else { + throw error; + } + } +} diff --git a/frontend/src/utils/api/users/admins.ts b/frontend/src/utils/api/users/admins.ts new file mode 100644 index 000000000..cc6b8bf12 --- /dev/null +++ b/frontend/src/utils/api/users/admins.ts @@ -0,0 +1,44 @@ +import { UsersList } from "./users"; +import { axiosInstance } from "../api"; + +/** + * Get a page from all admins. + * @param page The requested page. + * @param name A string which every username should contain (can be empty). + */ +export async function getAdmins(page: number, name: string): Promise { + if (name) { + const response = await axiosInstance.get(`/users?page=${page}&admin=true&name=${name}`); + return response.data as UsersList; + } + const response = await axiosInstance.get(`/users?page=${page}&admin=true`); + return response.data as UsersList; +} + +/** + * Make the given user admin. + * @param {number} userId The id of the user. + */ +export async function addAdmin(userId: number): Promise { + const response = await axiosInstance.patch(`/users/${userId}`, { admin: true }); + return response.status === 204; +} + +/** + * Remove the given user as admin. + * @param {number} userId The id of the user. + */ +export async function removeAdmin(userId: number) { + const response = await axiosInstance.patch(`/users/${userId}`, { admin: false }); + return response.status === 204; +} + +/** + * Remove the given user as admin and remove him as coach for every edition. + * @param {number} userId The id of the user. + */ +export async function removeAdminAndCoach(userId: number) { + const response2 = await axiosInstance.delete(`/users/${userId}/editions`); + const response1 = await axiosInstance.patch(`/users/${userId}`, { admin: false }); + return response1.status === 204 && response2.status === 204; +} diff --git a/frontend/src/utils/api/users/coaches.ts b/frontend/src/utils/api/users/coaches.ts new file mode 100644 index 000000000..9762564fb --- /dev/null +++ b/frontend/src/utils/api/users/coaches.ts @@ -0,0 +1,48 @@ +import { UsersList } from "./users"; +import { axiosInstance } from "../api"; + +/** + * Get a page from all coaches from the given edition. + * @param edition The edition name. + * @param name The username to filter. + * @param page The requested page. + */ +export async function getCoaches(edition: string, name: string, page: number): Promise { + if (name) { + const response = await axiosInstance.get( + `/users/?edition=${edition}&page=${page}&name=${name}` + ); + return response.data as UsersList; + } + const response = await axiosInstance.get(`/users/?edition=${edition}&page=${page}`); + return response.data as UsersList; +} + +/** + * Remove a user as coach from the given edition. + * @param {number} userId The user's id. + * @param {string} edition The edition's name. + */ +export async function removeCoachFromEdition(userId: number, edition: string): Promise { + const response = await axiosInstance.delete(`/users/${userId}/editions/${edition}`); + return response.status === 204; +} + +/** + * Remove a user as coach from all editions. + * @param {number} userId The user's id. + */ +export async function removeCoachFromAllEditions(userId: number): Promise { + const response = await axiosInstance.delete(`/users/${userId}/editions`); + return response.status === 204; +} + +/** + * Add a user as coach to an edition. + * @param {number} userId The user's id. + * @param {string} edition The edition's name. + */ +export async function addCoachToEdition(userId: number, edition: string): Promise { + const response = await axiosInstance.post(`/users/${userId}/editions/${edition}`); + return response.status === 204; +} diff --git a/frontend/src/utils/api/users/index.ts b/frontend/src/utils/api/users/index.ts new file mode 100644 index 000000000..8e2828359 --- /dev/null +++ b/frontend/src/utils/api/users/index.ts @@ -0,0 +1,4 @@ +export * as Admins from "./admins"; +export * as Coaches from "./coaches"; +export * as Requests from "./requests"; +export * as Users from "./users"; diff --git a/frontend/src/utils/api/users/requests.ts b/frontend/src/utils/api/users/requests.ts new file mode 100644 index 000000000..b40888652 --- /dev/null +++ b/frontend/src/utils/api/users/requests.ts @@ -0,0 +1,56 @@ +import { User } from "./users"; +import { axiosInstance } from "../api"; + +/** + * Interface of a request + */ +export interface Request { + requestId: number; + user: User; +} + +/** + * Interface of a list of requests + */ +export interface GetRequestsResponse { + requests: Request[]; +} + +/** + * Get a page from all pending requests of a given edition. + * @param edition The edition's name. + * @param name String which every request's user's name needs to contain + * @param page The pagenumber to fetch. + */ +export async function getRequests( + edition: string, + name: string, + page: number +): Promise { + if (name) { + const response = await axiosInstance.get( + `/users/requests?edition=${edition}&page=${page}&user=${name}` + ); + return response.data as GetRequestsResponse; + } + const response = await axiosInstance.get(`/users/requests?edition=${edition}&page=${page}`); + return response.data as GetRequestsResponse; +} + +/** + * Accept a coach request. + * @param {number} requestId The id of the request. + */ +export async function acceptRequest(requestId: number): Promise { + const response = await axiosInstance.post(`/users/requests/${requestId}/accept`); + return response.status === 204; +} + +/** + * Reject a coach request. + * @param {number} requestId The id of the request.s + */ +export async function rejectRequest(requestId: number): Promise { + const response = await axiosInstance.post(`/users/requests/${requestId}/reject`); + return response.status === 204; +} diff --git a/frontend/src/utils/api/users/users.ts b/frontend/src/utils/api/users/users.ts new file mode 100644 index 000000000..bf790f2c6 --- /dev/null +++ b/frontend/src/utils/api/users/users.ts @@ -0,0 +1,76 @@ +import { axiosInstance } from "../api"; +import { AuthType } from "../../../data/enums"; + +/** + * Interface of a user. + */ +export interface User { + userId: number; + name: string; + admin: boolean; + auth: { + authType: AuthType; + email: string; + }; +} + +/** + * Interface of a list of users. + */ +export interface UsersList { + users: User[]; +} + +/** + * Interface of a mailto link. + */ +export interface MailTo { + mailTo: string; + inviteLink: string; +} + +/** + * Get an invite link for the given edition and email address. + * @param edition The edition whereto the email address will be invited. + * @param email The email address whereto the invite will be sent. + */ +export async function getInviteLink(edition: string, email: string): Promise { + const response = await axiosInstance.post(`/editions/${edition}/invites/`, { email: email }); + return response.data as MailTo; +} + +/** + * Get a page of all users who are not coach of the given edition. + * @param edition The edition which needs to be excluded. + * @param name The name which every user's name must contain (can be empty). + * @param page The requested page. + */ +export async function getUsersExcludeEdition( + edition: string, + name: string, + page: number +): Promise { + if (name) { + const response = await axiosInstance.get( + `/users/?page=${page}&exclude_edition=${edition}&name=${name}` + ); + console.log(response.data); + return response.data as UsersList; + } + const response = await axiosInstance.get(`/users/?exclude_edition=${edition}&page=${page}`); + return response.data as UsersList; +} + +/** + * Get a page of all users who are not an admin. + * @param name The name which every user's name must contain (can be empty). + * @param page The requested page. + */ +export async function getUsersNonAdmin(name: string, page: number): Promise { + if (name) { + const response = await axiosInstance.get(`/users/?page=${page}&admin=false&name=${name}`); + return response.data as UsersList; + } + const response = await axiosInstance.get(`/users/?admin=false&page=${page}`); + return response.data as UsersList; +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 000000000..b478e8580 --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,4 @@ +export * as Api from "./api"; +export * as LocalStorage from "./local-storage"; +export * as Logic from "./logic"; +export * as SessionStorage from "./session-storage"; diff --git a/frontend/src/utils/local-storage/auth.ts b/frontend/src/utils/local-storage/auth.ts new file mode 100644 index 000000000..53950e3c2 --- /dev/null +++ b/frontend/src/utils/local-storage/auth.ts @@ -0,0 +1,58 @@ +import { LocalStorageKey } from "../../data/enums"; + +/** + * Function to set a new value for the access token in LocalStorage. + */ +export function setAccessToken(value: string | null) { + setToken(LocalStorageKey.ACCESS_TOKEN, value); +} + +/** + * Function to set a new value for the refresh token in LocalStorage. + */ +export function setRefreshToken(value: string | null) { + setToken(LocalStorageKey.REFRESH_TOKEN, value); +} + +/** + * Function to set a new value for the refresh token lock in LocalStorage. + */ +export function setRefreshTokenLock(value: boolean | null) { + setToken(LocalStorageKey.REFRESH_TOKEN_LOCK, value ? "TRUE" : "FALSE"); +} + +function setToken(key: LocalStorageKey, value: string | null) { + if (value === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, value); + } +} + +/** + * Function to pull the user's access token out of LocalStorage. + * Returns `null` if there is no token in LocalStorage yet. + */ +export function getAccessToken(): string | null { + return getToken(LocalStorageKey.ACCESS_TOKEN); +} + +/** + * Function to pull the user's refresh token out of LocalStorage. + * Returns `null` if there is no token in LocalStorage yet. + */ +export function getRefreshToken(): string | null { + return getToken(LocalStorageKey.REFRESH_TOKEN); +} + +/** + * Function to check the refresh token lock in LocalStorage. + * Returns `null` if there is no value in LocalStorage yet. + */ +export function getRefreshTokenLock(): boolean { + return getToken(LocalStorageKey.REFRESH_TOKEN_LOCK) === "TRUE"; +} + +function getToken(key: LocalStorageKey) { + return localStorage.getItem(key); +} diff --git a/frontend/src/utils/local-storage/index.ts b/frontend/src/utils/local-storage/index.ts new file mode 100644 index 000000000..2583f0286 --- /dev/null +++ b/frontend/src/utils/local-storage/index.ts @@ -0,0 +1 @@ +export { getAccessToken, getRefreshToken, setAccessToken, setRefreshToken } from "./auth"; diff --git a/frontend/src/utils/logic/index.ts b/frontend/src/utils/logic/index.ts new file mode 100644 index 000000000..dfb0a10c0 --- /dev/null +++ b/frontend/src/utils/logic/index.ts @@ -0,0 +1 @@ +export { getBestRedirect } from "./routes"; diff --git a/frontend/src/utils/logic/registration.ts b/frontend/src/utils/logic/registration.ts new file mode 100644 index 000000000..d6d03a42c --- /dev/null +++ b/frontend/src/utils/logic/registration.ts @@ -0,0 +1,30 @@ +const Buffer = require("buffer/").Buffer; + +/** + * Decode a base64-encoded registration link + */ +export function decodeRegistrationLink( + url: string | undefined +): { edition: string; uuid: string } | null { + if (!url) return null; + + // Base64 decode + const decoded = Buffer.from(url, "base64").toString(); + + // Invalid link + if (!decoded.includes("/")) { + return null; + } + + try { + const [edition, uuid] = decoded.split("/"); + + return { + edition: edition, + uuid: uuid, + }; + } catch (e) { + console.error(e); + return null; + } +} diff --git a/frontend/src/utils/logic/routes.test.ts b/frontend/src/utils/logic/routes.test.ts new file mode 100644 index 000000000..0f370b567 --- /dev/null +++ b/frontend/src/utils/logic/routes.test.ts @@ -0,0 +1,39 @@ +import { getBestRedirect } from "./routes"; + +/** + * Note: all tests here also test the one with a trailing slash (/) because I'm paranoid + * about the asterisk matching it + */ + +test("/students stays there", () => { + expect(getBestRedirect("/editions/old/students", "new")).toEqual("/editions/new/students"); + expect(getBestRedirect("/editions/old/students/", "new")).toEqual("/editions/new/students"); +}); + +test("/students/:id goes to /students", () => { + expect(getBestRedirect("/editions/old/students/id", "new")).toEqual("/editions/new/students"); +}); + +test("/projects stays there", () => { + expect(getBestRedirect("/editions/old/projects", "new")).toEqual("/editions/new/projects"); + expect(getBestRedirect("/editions/old/projects/", "new")).toEqual("/editions/new/projects"); +}); + +test("/projects/:id goes to /projects", () => { + expect(getBestRedirect("/editions/old/projects/id", "new")).toEqual("/editions/new/projects"); +}); + +test("/users stays there", () => { + expect(getBestRedirect("/editions/old/users", "new")).toEqual("/editions/new/users"); + expect(getBestRedirect("/editions/old/users/", "new")).toEqual("/editions/new/users"); +}); + +test("/admins stays there", () => { + expect(getBestRedirect("/admins", "new")).toEqual("/admins"); + expect(getBestRedirect("/admins/", "new")).toEqual("/admins"); +}); + +test("/editions stays there", () => { + expect(getBestRedirect("/editions", "new")).toEqual("/editions"); + expect(getBestRedirect("/editions/", "new")).toEqual("/editions"); +}); diff --git a/frontend/src/utils/logic/routes.ts b/frontend/src/utils/logic/routes.ts new file mode 100644 index 000000000..68260dc15 --- /dev/null +++ b/frontend/src/utils/logic/routes.ts @@ -0,0 +1,35 @@ +import { matchPath } from "react-router-dom"; + +/** + * Get the best matching route to redirect to + * Boils down to the most-specific route that can be used across editions + */ +export function getBestRedirect(location: string, editionName: string): string { + // All /student/X routes should go to /students + if (matchPath({ path: "/editions/:edition/students/*" }, location)) { + return `/editions/${editionName}/students`; + } + + // All /project/X routes should go to /projects + if (matchPath({ path: "/editions/:edition/projects/*" }, location)) { + return `/editions/${editionName}/projects`; + } + + // /admins can stay where it is + if (matchPath({ path: "/admins" }, location)) { + return "/admins"; + } + + // All /users/X routes should go to /users + if (matchPath({ path: "/editions/:edition/users/*" }, location)) { + return `/editions/${editionName}/users`; + } + + // Being on the edition-specific page should keep you there + if (matchPath({ path: "/editions/:edition" }, location)) { + return `/editions/${editionName}`; + } + + // All the rest: go to /editions + return "/editions"; +} diff --git a/frontend/src/utils/session-storage/current-edition.ts b/frontend/src/utils/session-storage/current-edition.ts new file mode 100644 index 000000000..4086867ee --- /dev/null +++ b/frontend/src/utils/session-storage/current-edition.ts @@ -0,0 +1,20 @@ +import { SessionStorageKey } from "../../data/enums"; + +/** + * Return the edition currently stored in SessionStorage. + */ +export function getCurrentEdition(): string | null { + return sessionStorage.getItem(SessionStorageKey.CURRENT_EDITION); +} + +/** + * Set the edition in SessionStorage. + * If `null`, the current value is removed instead. + */ +export function setCurrentEdition(edition: string | null) { + if (edition === null) { + sessionStorage.removeItem(SessionStorageKey.CURRENT_EDITION); + } else { + sessionStorage.setItem(SessionStorageKey.CURRENT_EDITION, edition); + } +} diff --git a/frontend/src/utils/session-storage/index.ts b/frontend/src/utils/session-storage/index.ts new file mode 100644 index 000000000..2b2ae5a58 --- /dev/null +++ b/frontend/src/utils/session-storage/index.ts @@ -0,0 +1 @@ +export { getCurrentEdition, setCurrentEdition } from "./current-edition"; diff --git a/frontend/src/views/AdminsPage/AdminsPage.tsx b/frontend/src/views/AdminsPage/AdminsPage.tsx new file mode 100644 index 000000000..29ec9b2be --- /dev/null +++ b/frontend/src/views/AdminsPage/AdminsPage.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from "react"; +import { AdminsContainer } from "./styles"; +import { getAdmins } from "../../utils/api/users/admins"; +import { Error, SpinnerContainer } from "../../components/UsersComponents/Requests/styles"; +import { AddAdmin, AdminList } from "../../components/AdminsComponents"; +import { Spinner } from "react-bootstrap"; +import { User } from "../../utils/api/users/users"; +import { SearchInput } from "../../components/styles"; + +export default function AdminsPage() { + const [admins, setAdmins] = useState([]); + const [gettingData, setGettingData] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [gotData, setGotData] = useState(false); + const [error, setError] = useState(""); + const [moreAdminsAvailable, setMoreAdminsAvailable] = useState(true); + + async function getData(page: number, filter: string | undefined = undefined) { + if (filter === undefined) { + filter = searchTerm; + } + setGettingData(true); + setError(""); + try { + const response = await getAdmins(page, filter); + if (response.users.length !== 25) { + setMoreAdminsAvailable(false); + } + if (page === 0) { + setAdmins(response.users); + } else { + setAdmins(admins.concat(response.users)); + } + + setGotData(true); + setGettingData(false); + } catch (exception) { + setError("Oops, something went wrong..."); + setGettingData(false); + } + } + + useEffect(() => { + if (!gotData && !gettingData && !error) { + getData(0); + } + }); + + function filter(word: string) { + setGotData(false); + setSearchTerm(word); + setAdmins([]); + setMoreAdminsAvailable(true); + getData(0, word); + } + + function adminAdded(user: User) { + if (user.name.includes(searchTerm)) { + setAdmins([user].concat(admins)); + } + } + + let list; + if (admins.length === 0) { + if (gettingData) { + list = ( + + + + ); + } else if (gotData) { + list =
No admins found
; + } else { + list = {error}; + } + } else { + list = ( + getData(0)} + getMoreAdmins={getData} + moreAdminsAvailable={moreAdminsAvailable} + /> + ); + } + + return ( + + filter(e.target.value)} /> + + {list} + {error} + + ); +} diff --git a/frontend/src/views/AdminsPage/index.ts b/frontend/src/views/AdminsPage/index.ts new file mode 100644 index 000000000..4db32aafc --- /dev/null +++ b/frontend/src/views/AdminsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./AdminsPage"; diff --git a/frontend/src/views/AdminsPage/styles.ts b/frontend/src/views/AdminsPage/styles.ts new file mode 100644 index 000000000..be7039a25 --- /dev/null +++ b/frontend/src/views/AdminsPage/styles.ts @@ -0,0 +1,7 @@ +import styled from "styled-components"; + +export const AdminsContainer = styled.div` + width: 50%; + min-width: 600px; + margin: 10px auto auto; +`; diff --git a/frontend/src/views/CreateEditionPage/CreateEditionPage.tsx b/frontend/src/views/CreateEditionPage/CreateEditionPage.tsx new file mode 100644 index 000000000..bb7abeb53 --- /dev/null +++ b/frontend/src/views/CreateEditionPage/CreateEditionPage.tsx @@ -0,0 +1,138 @@ +import { Button, Form, Spinner } from "react-bootstrap"; +import React, { SyntheticEvent, useState } from "react"; +import { createEdition, getSortedEditions } from "../../utils/api/editions"; +import { useNavigate } from "react-router-dom"; +import { CreateEditionDiv, Error, FormGroup, ButtonDiv } from "./styles"; +import { useAuth } from "../../contexts"; +import { setCurrentEdition } from "../../utils/session-storage"; + +/** + * Page to create a new edition. + */ +export default function CreateEditionPage() { + const navigate = useNavigate(); + const { setEditions } = useAuth(); + + const currentYear = new Date().getFullYear(); + + const [name, setName] = useState(""); + const [year, setYear] = useState(currentYear.toString()); + const [nameError, setNameError] = useState(undefined); + const [yearError, setYearError] = useState(undefined); + const [error, setError] = useState(undefined); + const [loading, setLoading] = useState(false); + + async function sendEdition(name: string, year: number): Promise { + const response = await createEdition(name, year); + if (response.status === 201) { + const allEditions = await getSortedEditions(); + setEditions(allEditions); + setCurrentEdition(response.data.name); + return true; + } else if (response.status === 409) { + setNameError("Edition name already exists."); + } else if (response.status === 422) { + setNameError("Invalid edition name."); + } else { + setError("Something went wrong."); + } + return false; + } + + async function handleSubmit(event: SyntheticEvent) { + event.stopPropagation(); + event.preventDefault(); + let correct = true; + + // Edition name can't contain spaces and must be at least 5 long. + if (!/^([^ ]{5,})$/.test(name)) { + if (name.includes(" ")) { + setNameError("Edition name can't contain spaces."); + } else if (name.length < 5) { + setNameError("Edition name must be longer than 4 characters."); + } else { + setNameError("Invalid edition name."); + } + correct = false; + } + + const yearNumber = Number(year); + if (isNaN(yearNumber)) { + correct = false; + setYearError("Invalid year."); + } else { + if (yearNumber < currentYear) { + correct = false; + setYearError("New editions can't be in the past."); + } else if (yearNumber > 3000) { + correct = false; + setYearError("Invalid year."); + } + } + + let success = false; + if (correct) { + setLoading(true); + success = await sendEdition(name, yearNumber); + setLoading(false); + } + + if (success) { + // navigate must be at the end of the function + navigate("/editions/"); + } + } + + let submitButton; + if (loading) { + submitButton = ; + } else { + submitButton = ( + + ); + } + + return ( + +
+ + Edition name + { + setName(e.target.value); + setNameError(undefined); + setError(undefined); + }} + /> + {nameError} + + + + Edition year + { + setYear(e.target.value); + setYearError(undefined); + setError(undefined); + }} + /> + {yearError} + + {submitButton} + {error} +
+
+ ); +} diff --git a/frontend/src/views/CreateEditionPage/index.ts b/frontend/src/views/CreateEditionPage/index.ts new file mode 100644 index 000000000..730e93bb5 --- /dev/null +++ b/frontend/src/views/CreateEditionPage/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateEditionPage"; diff --git a/frontend/src/views/CreateEditionPage/styles.ts b/frontend/src/views/CreateEditionPage/styles.ts new file mode 100644 index 000000000..4cfef2415 --- /dev/null +++ b/frontend/src/views/CreateEditionPage/styles.ts @@ -0,0 +1,24 @@ +import styled from "styled-components"; +import { Form } from "react-bootstrap"; + +export const Error = styled.div` + color: var(--osoc_red); + width: 100%; + margin: 20px auto auto; +`; + +export const CreateEditionDiv = styled.div` + width: 80%; + max-width: 500px; + margin: auto; +`; + +export const FormGroup = styled(Form.Group)` + margin-top: 20px; +`; + +export const ButtonDiv = styled.div` + margin-top: 20px; + margin-bottom: 20px; + float: right; +`; diff --git a/frontend/src/views/EditionsPage/EditionsPage.tsx b/frontend/src/views/EditionsPage/EditionsPage.tsx new file mode 100644 index 000000000..f2f7dc0a4 --- /dev/null +++ b/frontend/src/views/EditionsPage/EditionsPage.tsx @@ -0,0 +1,19 @@ +import { EditionsTable, NewEditionButton } from "../../components/EditionsPage"; +import { EditionsPageContainer } from "./styles"; +import { useNavigate } from "react-router-dom"; + +/** + * Page where users can see all editions they can access, + * and admins can delete editions. + */ +export default function EditionsPage() { + const navigate = useNavigate(); + + return ( + +

Editions

+ navigate("/editions/new")} /> + +
+ ); +} diff --git a/frontend/src/views/EditionsPage/index.ts b/frontend/src/views/EditionsPage/index.ts new file mode 100644 index 000000000..c00395635 --- /dev/null +++ b/frontend/src/views/EditionsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./EditionsPage"; diff --git a/frontend/src/views/EditionsPage/styles.ts b/frontend/src/views/EditionsPage/styles.ts new file mode 100644 index 000000000..cfafda700 --- /dev/null +++ b/frontend/src/views/EditionsPage/styles.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; +import Container from "react-bootstrap/Container"; + +export const EditionsPageContainer = styled(Container).attrs(() => ({ + className: "mt-2", +}))` + display: flex; + flex-direction: column; + justify-content: center; + margin: auto; +`; diff --git a/frontend/src/views/ErrorPage.tsx b/frontend/src/views/ErrorPage.tsx deleted file mode 100644 index 126863917..000000000 --- a/frontend/src/views/ErrorPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -function ErrorPage() { - return

404: Page not found

; -} - -export default ErrorPage; diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx deleted file mode 100644 index 7c770aa74..000000000 --- a/frontend/src/views/Home.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; - -function Home() { - return ( -
-

Home

-
- ); -} - -export default Home; diff --git a/frontend/src/views/LoginPage/LoginPage.css b/frontend/src/views/LoginPage/LoginPage.css deleted file mode 100644 index c9f495933..000000000 --- a/frontend/src/views/LoginPage/LoginPage.css +++ /dev/null @@ -1,91 +0,0 @@ -.login-page-content-container { - height: fit-content; - text-align: center; - display: flex; - justify-content: center; - flex-direction: column; -} - -.socials-container { - display: flex; - justify-content: center; - align-items: center; -} - -.socials { - min-width: 230px; - height: fit-content; -} - -.google-login-container { - margin-bottom: 15px; -} - -.register-form-content-container { - height: fit-content; - text-align: center; - display: flex; - justify-content: center; - flex-direction: column; -} - -.register-form-input-fields { - text-align: center; -} - -.register-form-input-fields span { - font-size: 30px; -} - -.register-form-input-fields input[type="password"], -input[type="email"], -input[type="text"] { - height: 40px; - width: 400px; - margin-top: 10px; - margin-bottom: 10px; - text-align: center; - font-size: 20px; - border-radius: 10px; - border-width: 0; -} - -.register-button { - height: 40px; - width: 400px; - margin-top: 10px; - font-size: 20px; - cursor: pointer; - background: var(--osoc_green); - color: white; - border: none; - border-radius: 10px; -} - -.login-button { - width: 120px; - height: 35px; - cursor: pointer; - background: var(--osoc_green); - color: white; - border: none; - border-radius: 5px; -} - -.login { - display: flex; -} - -.socials-register { - display: flex; -} - -.border-right { - margin-right: 20px; - padding-right: 20px; - border-right: 2px solid rgba(182, 182, 182, 0.603); -} - -.no-account { - padding-bottom: 15px; -} diff --git a/frontend/src/views/LoginPage/LoginPage.tsx b/frontend/src/views/LoginPage/LoginPage.tsx index 10bd08078..c3818c93f 100644 --- a/frontend/src/views/LoginPage/LoginPage.tsx +++ b/frontend/src/views/LoginPage/LoginPage.tsx @@ -1,85 +1,70 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { axiosInstance } from "../../utils/api/api"; -import "./LoginPage.css"; -import { GoogleLoginButton, GithubLoginButton } from "react-social-login-buttons"; +import { logIn } from "../../utils/api/login"; -function LoginPage({ setToken }: any) { - function logIn() { - const payload = new FormData(); - payload.append("username", email); - payload.append("password", password); +import { Email, Password, SocialButtons, WelcomeText } from "../../components/LoginComponents"; - axiosInstance - .post("/login/token", payload) - .then((response: any) => { - setToken(response.data.accessToken); - }) - .then(() => navigate("/students")) - .catch(function (error: any) { - console.log(error); - }); - } +import { + EmailLoginContainer, + LoginButton, + LoginContainer, + LoginPageContainer, + NoAccount, + VerticalDivider, +} from "./styles"; +import { useAuth } from "../../contexts/auth-context"; + +/** + * Page where users can log in to the application. + */ +export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - + const authCtx = useAuth(); const navigate = useNavigate(); - return ( -
-
-
-

Hi!

-

- Welcome to the Open Summer of Code selections app. After you've logged in - with your account, we'll enable your account so you can get started. An - admin will verify you as quick as possible. -

-
-
-
-
-
- -
- -
-
-
+ useEffect(() => { + // If the user is already logged in, redirect them to + // the "editions" page instead of showing the login page + if (authCtx.isLoggedIn) { + navigate("/editions"); + } + }, [authCtx.isLoggedIn, navigate]); -
-
- setEmail(e.target.value)} - /> -
-
- setPassword(e.target.value)} - /> -
-
+ async function callLogIn() { + try { + const response = await logIn(authCtx, email, password); + if (response) navigate("/editions"); + else alert("Something went wrong when login in"); + } catch (error) { + console.log(error); + } + } + + return ( +
+ + + + + + + + + Don't have an account? Ask an admin for an invite link -
+
- + Log In
-
-
-
+ + +
); } - -export default LoginPage; diff --git a/frontend/src/views/LoginPage/index.ts b/frontend/src/views/LoginPage/index.ts new file mode 100644 index 000000000..f81523088 --- /dev/null +++ b/frontend/src/views/LoginPage/index.ts @@ -0,0 +1 @@ +export { default } from "./LoginPage"; diff --git a/frontend/src/views/LoginPage/styles.ts b/frontend/src/views/LoginPage/styles.ts new file mode 100644 index 000000000..fe3c9f764 --- /dev/null +++ b/frontend/src/views/LoginPage/styles.ts @@ -0,0 +1,44 @@ +import styled from "styled-components"; + +export const LoginPageContainer = styled.div` + height: fit-content; + text-align: center; + display: flex; + justify-content: center; + flex-direction: column; + margin: 4%; +`; + +export const LoginContainer = styled.div` + display: flex; + margin-right: auto; + margin-left: auto; +`; + +export const EmailLoginContainer = styled.div` + height: fit-content; + text-align: center; + display: flex; + justify-content: center; + flex-direction: column; +`; + +export const VerticalDivider = styled.div` + margin-right: 20px; + padding-right: 20px; + border-right: 2px solid rgba(182, 182, 182, 0.603); +`; + +export const NoAccount = styled.div` + padding-bottom: 15px; +`; + +export const LoginButton = styled.button` + width: 120px; + height: 35px; + cursor: pointer; + background: var(--osoc_green); + color: white; + border: none; + border-radius: 5px; +`; diff --git a/frontend/src/views/PendingPage/PendingPage.css b/frontend/src/views/PendingPage/PendingPage.css index b2a56fe3d..387c6e4bd 100644 --- a/frontend/src/views/PendingPage/PendingPage.css +++ b/frontend/src/views/PendingPage/PendingPage.css @@ -1,37 +1,3 @@ -/* -PendingPage - */ - -.pending-page-content-container { - height: fit-content; - position: absolute; - text-align: center; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} - -.pending-page-content-container span { - font-size: 40px; - display: block; - position: relative; -} - -.pending-page-content { - max-width: 1200px; - text-align: center; - height: fit-content; -} - -.pending-text-container { - width: fit-content; - display: inline-block; -} - -.pending-text-content { - display: flex; -} - .pending-dot-1 { animation: visible-switch-1 2s infinite linear; } diff --git a/frontend/src/views/PendingPage/PendingPage.tsx b/frontend/src/views/PendingPage/PendingPage.tsx index 313167da5..a343cbdb0 100644 --- a/frontend/src/views/PendingPage/PendingPage.tsx +++ b/frontend/src/views/PendingPage/PendingPage.tsx @@ -1,26 +1,35 @@ import React from "react"; import "./PendingPage.css"; -function PendingPage() { +import { + PendingPageContainer, + PendingContainer, + PendingTextContainer, + PendingText, +} from "./styles"; + +/** + * Page shown when your request to access an edition hasn't been accepted + * (or rejected) yet. + */ +export default function PendingPage() { return (
-
-
-
-
+ + + +

Your request is pending

.

.

.

-
-
+ +

Please wait until an admin approves your request!

-
-
+ +
); } - -export default PendingPage; diff --git a/frontend/src/views/PendingPage/index.ts b/frontend/src/views/PendingPage/index.ts new file mode 100644 index 000000000..72abd154f --- /dev/null +++ b/frontend/src/views/PendingPage/index.ts @@ -0,0 +1 @@ +export { default } from "./PendingPage"; diff --git a/frontend/src/views/PendingPage/styles.ts b/frontend/src/views/PendingPage/styles.ts new file mode 100644 index 000000000..02b9472ec --- /dev/null +++ b/frontend/src/views/PendingPage/styles.ts @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +export const PendingPageContainer = styled.div` + height: fit-content; + position: absolute; + text-align: center; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); +`; + +export const PendingContainer = styled.div` + max-width: 1200px; + text-align: center; + height: fit-content; +`; + +export const PendingTextContainer = styled.div` + width: fit-content; + display: inline-block; +`; + +export const PendingText = styled.div` + display: flex; +`; diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx deleted file mode 100644 index a335327f1..000000000 --- a/frontend/src/views/ProjectsPage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; - -function ProjectPage() { - return ( -
- This is the projects page -
- ); -} - -export default ProjectPage; diff --git a/frontend/src/views/RegisterForm/RegisterForm.tsx b/frontend/src/views/RegisterForm/RegisterForm.tsx deleted file mode 100644 index 9179c33ac..000000000 --- a/frontend/src/views/RegisterForm/RegisterForm.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { axiosInstance } from "../../utils/api/api"; - -import { GoogleLoginButton, GithubLoginButton } from "react-social-login-buttons"; - -interface RegisterFields { - email: string; - name: string; - uuid: string; - pw: string; -} - -function RegisterForm() { - function register(uuid: string) { - // Check if passwords are the same - if (password !== confirmPassword) { - alert("Passwords do not match"); - return; - } - // Basic email checker - if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { - alert("This is not a valid email"); - return; - } - - // TODO this has to change to get the edition the invite belongs to - const edition = "1"; - const payload: RegisterFields = { email: email, name: name, uuid: uuid, pw: password }; - - axiosInstance - .post("/editions/" + edition + "/register/email", payload) - .then((response: any) => console.log(response)) - .then(() => navigate("/pending")) - .catch(function (error: any) { - console.log(error); - }); - } - - const [email, setEmail] = useState(""); - const [name, setName] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - - const navigate = useNavigate(); - - const params = useParams(); - const uuid = params.uuid; - - const [validUuid, setUuid] = useState(false); - - axiosInstance.get("/editions/" + 1 + "/invites/" + uuid).then(response => { - if (response.data.uuid === uuid) { - setUuid(true); - } - }); - - if (validUuid && uuid) { - return ( -
-
-

Create an account

- -
- Sign up with your social media account or email address. Your unique link is - not useable again ({uuid}) -
-
-
- -
- -
-
- -

or

- -
-
- setEmail(e.target.value)} - /> -
-
- setName(e.target.value)} - /> -
-
- setPassword(e.target.value)} - /> -
-
- setConfirmPassword(e.target.value)} - /> -
-
-
- -
-
-
- ); - } else return
Not a valid register url
; -} - -export default RegisterForm; diff --git a/frontend/src/views/RegisterPage/RegisterPage.tsx b/frontend/src/views/RegisterPage/RegisterPage.tsx new file mode 100644 index 000000000..dcc831cdc --- /dev/null +++ b/frontend/src/views/RegisterPage/RegisterPage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; + +import { register } from "../../utils/api/register"; +import { validateRegistrationUrl } from "../../utils/api"; + +import { + Email, + Name, + Password, + ConfirmPassword, + SocialButtons, + InfoText, + BadInviteLink, +} from "../../components/RegisterComponents"; + +import { RegisterFormContainer, Or, RegisterButton } from "./styles"; +import { decodeRegistrationLink } from "../../utils/logic/registration"; +import PendingPage from "../PendingPage"; + +/** + * Page where a user can register a new account. If the uuid in the url is invalid, + * this renders the [[BadInviteLink]] component instead. + */ +export default function RegisterPage() { + const [validUuid, setValidUuid] = useState(false); + const [pending, setPending] = useState(false); + const params = useParams(); + const data = decodeRegistrationLink(params.uuid); + + // Form fields + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + useEffect(() => { + async function validateUuid() { + if (data) { + const response = await validateRegistrationUrl(data.edition, data.uuid); + if (response) { + setValidUuid(true); + } + } + } + if (!validUuid) { + validateUuid(); + } + }); + + async function callRegister(edition: string, uuid: string) { + // Check if passwords are the same + if (password !== confirmPassword) { + alert("Passwords do not match"); + return; + } + // Basic email checker + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + alert("This is not a valid email"); + return; + } + + try { + const response = await register(edition, email, name, uuid, password); + if (response) { + setPending(true); + } + } catch (error) { + console.log(error); + alert("Something went wrong when creating your account"); + } + } + + if (pending) { + return ; + } + + // Invalid link + if (!(validUuid && data)) { + return ; + } + + return ( +
+ + + + or + + + + callRegister(data.edition, data.uuid)} + /> +
+ callRegister(data.edition, data.uuid)}> + Register + +
+
+
+ ); +} diff --git a/frontend/src/views/RegisterPage/index.ts b/frontend/src/views/RegisterPage/index.ts new file mode 100644 index 000000000..926657ec0 --- /dev/null +++ b/frontend/src/views/RegisterPage/index.ts @@ -0,0 +1 @@ +export { default } from "./RegisterPage"; diff --git a/frontend/src/views/RegisterPage/styles.ts b/frontend/src/views/RegisterPage/styles.ts new file mode 100644 index 000000000..6fb8d13a7 --- /dev/null +++ b/frontend/src/views/RegisterPage/styles.ts @@ -0,0 +1,26 @@ +import styled from "styled-components"; + +export const RegisterFormContainer = styled.div` + height: fit-content; + text-align: center; + display: flex; + justify-content: center; + flex-direction: column; + margin: 4%; +`; + +export const Or = styled.h2` + margin: 10px; +`; + +export const RegisterButton = styled.button` + height: 40px; + width: 400px; + margin-top: 10px; + font-size: 20px; + cursor: pointer; + background: var(--osoc_green); + color: white; + border: none; + border-radius: 5px; +`; diff --git a/frontend/src/views/StudentsPage/StudentsPage.css b/frontend/src/views/StudentsPage/StudentsPage.css new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/views/Students.tsx b/frontend/src/views/StudentsPage/StudentsPage.tsx similarity index 81% rename from frontend/src/views/Students.tsx rename to frontend/src/views/StudentsPage/StudentsPage.tsx index d1a6b4009..5a158d93d 100644 --- a/frontend/src/views/Students.tsx +++ b/frontend/src/views/StudentsPage/StudentsPage.tsx @@ -1,4 +1,5 @@ import React from "react"; +import "./StudentsPage.css"; function Students() { return
This is the students page
; diff --git a/frontend/src/views/StudentsPage/index.ts b/frontend/src/views/StudentsPage/index.ts new file mode 100644 index 000000000..7bcdecdbb --- /dev/null +++ b/frontend/src/views/StudentsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./StudentsPage"; diff --git a/frontend/src/views/Users.tsx b/frontend/src/views/Users.tsx deleted file mode 100644 index 77d2905c0..000000000 --- a/frontend/src/views/Users.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -function Users() { - return
This is the users page
; -} - -export default Users; diff --git a/frontend/src/views/UsersPage/UsersPage.tsx b/frontend/src/views/UsersPage/UsersPage.tsx new file mode 100644 index 000000000..5e709bb0f --- /dev/null +++ b/frontend/src/views/UsersPage/UsersPage.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { UsersPageDiv, UsersHeader } from "./styles"; +import { Coaches } from "../../components/UsersComponents/Coaches"; +import { InviteUser } from "../../components/UsersComponents/InviteUser"; +import { PendingRequests } from "../../components/UsersComponents/Requests"; +import { User } from "../../utils/api/users/users"; +import { getCoaches } from "../../utils/api/users/coaches"; + +/** + * Page for admins to manage coach and admin settings. + */ +function UsersPage() { + // Note: The coaches are not in the coaches component because accepting a request needs to refresh the coaches list. + const [coaches, setCoaches] = useState([]); // All coaches from the selected edition + const [gettingData, setGettingData] = useState(false); // Waiting for data (used for spinner) + const [gotData, setGotData] = useState(false); // Received data + const [error, setError] = useState(""); // Error message + const [moreCoachesAvailable, setMoreCoachesAvailable] = useState(true); // Endpoint has more coaches available + const [searchTerm, setSearchTerm] = useState(""); // The word set in filter for coachlist + + const params = useParams(); + + /** + * Request a page from the list of coaches. + * An optional filter can be used to filter the username. + * If the filter is not used, the string saved in the "searchTerm" state will be used. + * @param page The page to load. + * @param filter Optional string to filter username. + */ + async function getCoachesData(page: number, filter: string | undefined = undefined) { + if (filter === undefined) { + filter = searchTerm; + } + setGettingData(true); + setError(""); + try { + const coachResponse = await getCoaches(params.editionId as string, filter, page); + if (coachResponse.users.length === 0) { + setMoreCoachesAvailable(false); + } + if (page === 0) { + setCoaches(coachResponse.users); + } else { + setCoaches(coaches.concat(coachResponse.users)); + } + + setGotData(true); + setGettingData(false); + } catch (exception) { + setError("Oops, something went wrong..."); + setGettingData(false); + } + } + + useEffect(() => { + if (!gotData && !gettingData && !error) { + getCoachesData(0); + } + }); + + /** + * Set the searchTerm and request the first page with this filter. + * The current list of coaches will be resetted. + * @param searchTerm The string to filter coaches with by username. + */ + function filterCoachesData(searchTerm: string) { + setGotData(false); + setSearchTerm(searchTerm); + setCoaches([]); + setMoreCoachesAvailable(true); + getCoachesData(0, searchTerm); + } + + /** + * Reset the list of coaches and get the first page. + * Used when a new coach is added. + */ + function refreshCoaches() { + setGotData(false); + setCoaches([]); + setMoreCoachesAvailable(true); + getCoachesData(0); + } + + /** + * Remove a coach from the list of coaches. + * @param coach The coach which needs to be deleted. + */ + function removeCoach(coach: User) { + setCoaches( + coaches.filter(object => { + return object !== coach; + }) + ); + } + + if (params.editionId === undefined) { + // If this happens, User should be redirected to error page + return
Error
; + } else { + return ( + +
+ +

Manage coaches from {params.editionId}

+
+
+ + + +
+ ); + } +} + +export default UsersPage; diff --git a/frontend/src/views/UsersPage/index.ts b/frontend/src/views/UsersPage/index.ts new file mode 100644 index 000000000..6f0687e25 --- /dev/null +++ b/frontend/src/views/UsersPage/index.ts @@ -0,0 +1 @@ +export { default } from "./UsersPage"; diff --git a/frontend/src/views/UsersPage/styles.ts b/frontend/src/views/UsersPage/styles.ts new file mode 100644 index 000000000..02aaf4d40 --- /dev/null +++ b/frontend/src/views/UsersPage/styles.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +export const UsersPageDiv = styled.div``; + +export const UsersHeader = styled.div` + padding-left: 10px; + margin-top: 10px; + float: left; + display: inline-block; +`; diff --git a/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx b/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx new file mode 100644 index 000000000..d90de2bf5 --- /dev/null +++ b/frontend/src/views/VerifyingTokenPage/VerifyingTokenPage.tsx @@ -0,0 +1,45 @@ +import { useEffect } from "react"; + +import { validateBearerToken } from "../../utils/api/auth"; +import { logIn, logOut, useAuth } from "../../contexts/auth-context"; +import { getAccessToken, getRefreshToken } from "../../utils/local-storage"; + +/** + * Placeholder page shown while the bearer token found in LocalStorage is being verified. + * If the token is valid, redirects to the application. Otherwise, redirects to the [[LoginPage]]. + */ +export default function VerifyingTokenPage() { + const authContext = useAuth(); + + useEffect(() => { + const verifyToken = async () => { + const accessToken = getAccessToken(); + const refreshToken = getRefreshToken(); + + if (accessToken === null || refreshToken === null) { + logOut(authContext); + return; + } + + const response = await validateBearerToken(accessToken); + + if (response === null) { + logOut(authContext); + } else { + // Token was valid, use it as the default request header + // and set all data in the AuthContext + logIn(response, authContext); + } + }; + + // Eslint doesn't like this, but it's the React way + verifyToken(); + }, [authContext]); + + // This will be replaced later on + return ( +
+

Loading...

+
+ ); +} diff --git a/frontend/src/views/VerifyingTokenPage/index.ts b/frontend/src/views/VerifyingTokenPage/index.ts new file mode 100644 index 000000000..12a3d4b87 --- /dev/null +++ b/frontend/src/views/VerifyingTokenPage/index.ts @@ -0,0 +1 @@ +export { default } from "./VerifyingTokenPage"; diff --git a/frontend/src/views/errors/ForbiddenPage/ForbiddenPage.tsx b/frontend/src/views/errors/ForbiddenPage/ForbiddenPage.tsx new file mode 100644 index 000000000..106412a04 --- /dev/null +++ b/frontend/src/views/errors/ForbiddenPage/ForbiddenPage.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { ErrorContainer } from "../styles"; + +/** + * Page shown to users when they try to access a resource they aren't + * authorized to. Examples include coaches performing admin actions, + * or coaches going to urls for editions they aren't part of. + */ +export default function ForbiddenPage() { + return ( + +

Stop right there!

+

You don't have access to that page.

+
+ ); +} diff --git a/frontend/src/views/errors/ForbiddenPage/index.ts b/frontend/src/views/errors/ForbiddenPage/index.ts new file mode 100644 index 000000000..fdbe26d7a --- /dev/null +++ b/frontend/src/views/errors/ForbiddenPage/index.ts @@ -0,0 +1 @@ +export { default } from "./ForbiddenPage"; diff --git a/frontend/src/views/errors/NotFoundPage/NotFoundPage.tsx b/frontend/src/views/errors/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 000000000..04a0c20fb --- /dev/null +++ b/frontend/src/views/errors/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { ErrorContainer } from "../styles"; + +/** + * Page shown when going to a url for a page that doesn't exist. + */ +export default function NotFoundPage() { + return ( + +

Oops! This is awkward...

+

You are looking for something that doesn't exist.

+
+ ); +} diff --git a/frontend/src/views/errors/NotFoundPage/index.ts b/frontend/src/views/errors/NotFoundPage/index.ts new file mode 100644 index 000000000..225a37766 --- /dev/null +++ b/frontend/src/views/errors/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { default } from "./NotFoundPage"; diff --git a/frontend/src/views/errors/index.ts b/frontend/src/views/errors/index.ts new file mode 100644 index 000000000..c46258f0f --- /dev/null +++ b/frontend/src/views/errors/index.ts @@ -0,0 +1,2 @@ +export { default as ForbiddenPage } from "./ForbiddenPage"; +export { default as NotFoundPage } from "./NotFoundPage"; diff --git a/frontend/src/views/errors/styles.ts b/frontend/src/views/errors/styles.ts new file mode 100644 index 000000000..9242e5ced --- /dev/null +++ b/frontend/src/views/errors/styles.ts @@ -0,0 +1,9 @@ +import styled from "styled-components"; + +export const ErrorContainer = styled.div` + text-align: center; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +`; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts new file mode 100644 index 000000000..0aa72e445 --- /dev/null +++ b/frontend/src/views/index.ts @@ -0,0 +1,11 @@ +export * as Errors from "./errors"; +export { default as LoginPage } from "./LoginPage"; +export { default as EditionsPage } from "./EditionsPage"; +export { default as CreateEditionPage } from "./CreateEditionPage"; +export { default as PendingPage } from "./PendingPage"; +export { ProjectsPage, ProjectDetailPage, CreateProjectPage } from "./projectViews"; +export { default as RegisterPage } from "./RegisterPage"; +export { default as StudentsPage } from "./StudentsPage"; +export { default as UsersPage } from "./UsersPage"; +export { default as AdminsPage } from "./AdminsPage"; +export { default as VerifyingTokenPage } from "./VerifyingTokenPage"; diff --git a/frontend/src/views/projectViews/CreateProjectPage/CreateProjectPage.tsx b/frontend/src/views/projectViews/CreateProjectPage/CreateProjectPage.tsx new file mode 100644 index 000000000..33d800399 --- /dev/null +++ b/frontend/src/views/projectViews/CreateProjectPage/CreateProjectPage.tsx @@ -0,0 +1,108 @@ +import { CreateProjectContainer, CreateButton, Label } from "./styles"; +import { createProject } from "../../../utils/api/projects"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { GoBack } from "../ProjectDetailPage/styles"; +import { BiArrowBack } from "react-icons/bi"; +import { + NameInput, + NumberOfStudentsInput, + CoachInput, + SkillInput, + PartnerInput, + AddedCoaches, + AddedPartners, + AddedSkills, +} from "../../../components/ProjectsComponents/CreateProjectComponents"; +import { SkillProject } from "../../../data/interfaces/projects"; +import { User } from "../../../utils/api/users/users"; + +/** + * React component of the create project page. + * @returns The create project page. + */ +export default function CreateProjectPage() { + const [name, setName] = useState(""); + const [numberOfStudents, setNumberOfStudents] = useState(1); + + // States for coaches + const [coach, setCoach] = useState(""); + const [coaches, setCoaches] = useState([]); + + // States for skills + const [skill, setSkill] = useState(""); + const [skills, setSkills] = useState([]); + + // States for partners + const [partner, setPartner] = useState(""); + const [partners, setPartners] = useState([]); + + const navigate = useNavigate(); + + const params = useParams(); + const editionId = params.editionId!; + + return ( + + navigate("/editions/" + editionId + "/projects/")}> + + Cancel + +

New Project

+ + + + + + + + + + + + + + + + + + + + { + const coachIds: number[] = []; + coaches.forEach(coachToAdd => { + coachIds.push(coachToAdd.userId); + }); + + const response = await createProject( + editionId, + name, + numberOfStudents!, + [], // Empty skills for now TODO + partners, + coachIds + ); + if (response) { + navigate("/editions/" + editionId + "/projects/"); + } else alert("Something went wrong :("); + }} + > + Create Project + +
+ ); +} diff --git a/frontend/src/views/projectViews/CreateProjectPage/index.ts b/frontend/src/views/projectViews/CreateProjectPage/index.ts new file mode 100644 index 000000000..f20b5ad36 --- /dev/null +++ b/frontend/src/views/projectViews/CreateProjectPage/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateProjectPage"; diff --git a/frontend/src/views/projectViews/CreateProjectPage/styles.ts b/frontend/src/views/projectViews/CreateProjectPage/styles.ts new file mode 100644 index 000000000..6cbd8c1c3 --- /dev/null +++ b/frontend/src/views/projectViews/CreateProjectPage/styles.ts @@ -0,0 +1,48 @@ +import styled from "styled-components"; + +export const CreateProjectContainer = styled.div` + margin: 20px; +`; + +export const Input = styled.input` + margin-top: 10px; + padding: 5px 10px; + background-color: #131329; + color: white; + border: none; + border-radius: 5px; +`; + +export const AddButton = styled.button` + padding: 5px 10px; + background-color: #00bfff; + color: white; + border: none; + margin-left: 5px; + border-radius: 5px; +`; + +export const RemoveButton = styled.button` + padding: 0px 2.5px; + background-color: #f14a3b; + color: white; + border: none; + margin-left: 10px; + border-radius: 1px; + display: flex; + align-items: center; +`; + +export const CreateButton = styled.button` + padding: 5px 10px; + background-color: #44dba4; + color: white; + border: none; + margin-top: 30px; + border-radius: 5px; +`; + +export const Label = styled.h5` + margin-top: 30px; + margin-bottom: 0px; +`; diff --git a/frontend/src/views/projectViews/ProjectDetailPage/ProjectDetailPage.tsx b/frontend/src/views/projectViews/ProjectDetailPage/ProjectDetailPage.tsx new file mode 100644 index 000000000..9c0184eef --- /dev/null +++ b/frontend/src/views/projectViews/ProjectDetailPage/ProjectDetailPage.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { Project } from "../../../data/interfaces"; + +import { getProject } from "../../../utils/api/projects"; +import { + GoBack, + ProjectContainer, + Client, + ClientContainer, + NumberOfStudents, + Title, +} from "./styles"; + +import { BiArrowBack } from "react-icons/bi"; +import { BsPersonFill } from "react-icons/bs"; + +import { StudentPlace } from "../../../data/interfaces/projects"; +import { StudentPlaceholder } from "../../../components/ProjectsComponents"; +import { + CoachContainer, + CoachesContainer, + CoachText, +} from "../../../components/ProjectsComponents/ProjectCard/styles"; + +/** + * @returns the detailed page of a project. Here you can add or remove students from the project. + */ +export default function ProjectDetailPage() { + const params = useParams(); + const projectId = parseInt(params.projectId!); + const editionId = params.editionId!; + + const [project, setProject] = useState(); + const [gotProject, setGotProject] = useState(false); + + const navigate = useNavigate(); + + const [students, setStudents] = useState([]); + + useEffect(() => { + async function callProjects() { + if (projectId) { + setGotProject(true); + const response = await getProject(editionId, projectId); + if (response) { + setProject(response); + + // TODO + // Generate student data + const studentsTemplate: StudentPlace[] = []; + for (let i = 0; i < response.numberOfStudents; i++) { + const student: StudentPlace = { + available: i % 2 === 0, + name: i % 2 === 0 ? undefined : "Tom", + skill: "Frontend", + }; + studentsTemplate.push(student); + } + setStudents(studentsTemplate); + } else navigate("/404-not-found"); + } + } + if (!gotProject) { + callProjects(); + } + }, [editionId, gotProject, navigate, projectId]); + + if (!project) return null; + + return ( +
+ + navigate("/editions/" + editionId + "/projects/")}> + + Overview + + + {project.name} + + + {project.partners.map((element, _index) => ( + {element.name} + ))} + + {project.numberOfStudents} + + + + + + {project.coaches.map((element, _index) => ( + + {element.name} + + ))} + + +
+ {students.map((element: StudentPlace, _index) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/views/projectViews/ProjectDetailPage/index.ts b/frontend/src/views/projectViews/ProjectDetailPage/index.ts new file mode 100644 index 000000000..445f2f4aa --- /dev/null +++ b/frontend/src/views/projectViews/ProjectDetailPage/index.ts @@ -0,0 +1 @@ +export { default } from "./ProjectDetailPage"; diff --git a/frontend/src/views/projectViews/ProjectDetailPage/styles.ts b/frontend/src/views/projectViews/ProjectDetailPage/styles.ts new file mode 100644 index 000000000..914cfd91b --- /dev/null +++ b/frontend/src/views/projectViews/ProjectDetailPage/styles.ts @@ -0,0 +1,37 @@ +import styled from "styled-components"; + +export const ProjectContainer = styled.div` + margin: 20px; +`; + +export const GoBack = styled.div` + display: flex; + align-items: center; + margin-bottom: 5px; + + :hover { + cursor: pointer; + } +`; + +export const Title = styled.h2` + text-overflow: ellipsis; + overflow: hidden; +`; + +export const ClientContainer = styled.div` + display: flex; + align-items: center; + color: lightgray; + overflow-x: auto; +`; + +export const Client = styled.h5` + margin-right: 1%; +`; + +export const NumberOfStudents = styled.div` + display: flex; + align-items: center; + margin-bottom: 4px; +`; diff --git a/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx b/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx new file mode 100644 index 000000000..1fe9daae8 --- /dev/null +++ b/frontend/src/views/projectViews/ProjectsPage/ProjectsPage.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { getProjects } from "../../../utils/api/projects"; +import { ProjectCard, LoadSpinner } from "../../../components/ProjectsComponents"; +import { + CardsGrid, + CreateButton, + SearchButton, + SearchField, + OwnProject, + ProjectsContainer, + LoadMoreContainer, + LoadMoreButton, +} from "./styles"; +import { Project } from "../../../data/interfaces"; +import { useNavigate, useParams } from "react-router-dom"; +import InfiniteScroll from "react-infinite-scroller"; +import { useAuth } from "../../../contexts"; +import { Role } from "../../../data/enums"; +/** + * @returns The projects overview page where you can see all the projects. + * You can filter on your own projects or filter on project name. + */ +export default function ProjectPage() { + const [projects, setProjects] = useState([]); + const [gotProjects, setGotProjects] = useState(false); + const [loading, setLoading] = useState(false); + const [moreProjectsAvailable, setMoreProjectsAvailable] = useState(true); // Endpoint has more coaches available + + // Keep track of the set filters + const [searchString, setSearchString] = useState(""); + const [ownProjects, setOwnProjects] = useState(false); + + const navigate = useNavigate(); + const [page, setPage] = useState(0); + + const params = useParams(); + const editionId = params.editionId!; + + const { role } = useAuth(); + + /** + * Used to fetch the projects + */ + async function callProjects(newPage: number) { + if (loading) return; + setLoading(true); + const response = await getProjects(editionId, searchString, ownProjects, newPage); + setGotProjects(true); + + if (response) { + if (response.projects.length === 0) { + setMoreProjectsAvailable(false); + } else { + setPage(page + 1); + setProjects(projects.concat(response.projects)); + } + } + setLoading(false); + } + + async function refreshProjects() { + setProjects([]); + setPage(0); + setMoreProjectsAvailable(true); + setGotProjects(false); + } + + useEffect(() => { + if (moreProjectsAvailable && !gotProjects) { + callProjects(0); + } + }); + + return ( +
+
+ setSearchString(e.target.value)} + placeholder="project name" + onKeyDown={e => { + if (e.key === "Enter") refreshProjects(); + }} + /> + Search + {role === Role.ADMIN && ( + navigate("/editions/" + editionId + "/projects/new")} + > + Create Project + + )} +
+ { + setOwnProjects(!ownProjects); + refreshProjects(); + }} + /> + + { + console.log("loading more" + newPage); + }} + hasMore={moreProjectsAvailable} + useWindow={false} + initialLoad={true} + > + + + {projects.map((project, _index) => ( + + ))} + + + + + + + {moreProjectsAvailable && ( + + { + if (moreProjectsAvailable) { + callProjects(page); + } + }} + > + Load more projects + + + )} +
+ ); +} diff --git a/frontend/src/views/projectViews/ProjectsPage/index.ts b/frontend/src/views/projectViews/ProjectsPage/index.ts new file mode 100644 index 000000000..7b601b450 --- /dev/null +++ b/frontend/src/views/projectViews/ProjectsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./ProjectsPage"; diff --git a/frontend/src/views/projectViews/ProjectsPage/styles.ts b/frontend/src/views/projectViews/ProjectsPage/styles.ts new file mode 100644 index 000000000..75f3b4804 --- /dev/null +++ b/frontend/src/views/projectViews/ProjectsPage/styles.ts @@ -0,0 +1,57 @@ +import styled from "styled-components"; +import { Form } from "react-bootstrap"; + +export const CardsGrid = styled.div` + display: grid; + grid-gap: 5px; + grid-template-columns: repeat(auto-fit, minmax(375px, 1fr)); + grid-auto-flow: dense; +`; + +export const SearchField = styled.input` + margin: 20px 5px 5px 20px; + padding: 5px 10px; + background-color: #131329; + color: white; + border: none; + border-radius: 5px; +`; + +export const SearchButton = styled.button` + padding: 5px 10px; + background-color: #00bfff; + color: white; + border: none; + border-radius: 5px; +`; + +export const CreateButton = styled.button` + margin-left: 25px; + padding: 5px 10px; + background-color: #44dba4; + color: white; + border: none; + border-radius: 5px; +`; +export const OwnProject = styled(Form.Check)` + margin-top: 10px; + margin-left: 20px; +`; + +export const ProjectsContainer = styled.div` + overflow: auto; +`; + +export const LoadMoreContainer = styled.div` + display: flex; + justify-content: center; + margin: 20px; +`; + +export const LoadMoreButton = styled.button` + border-radius: 5px; + border: 0px; + padding: 5px 10px; + color: white; + background-color: gray; +`; diff --git a/frontend/src/views/projectViews/index.ts b/frontend/src/views/projectViews/index.ts new file mode 100644 index 000000000..2d1a76237 --- /dev/null +++ b/frontend/src/views/projectViews/index.ts @@ -0,0 +1,3 @@ +export { default as ProjectsPage } from "./ProjectsPage"; +export { default as ProjectDetailPage } from "./ProjectDetailPage"; +export { default as CreateProjectPage } from "./CreateProjectPage"; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a273b0cfc..543c9d04d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,26 +1,40 @@ { - "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" ], - "allowJs": true, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "include": [ - "src" - ] + "typedocOptions": { + "hideGenerator": true, + "entryPoints": [ + "src/App.tsx", + "src/Router.tsx", + "src/components", + "src/contexts", + "src/data", + "src/utils", + "src/views" + ], + "name": "OSOC 3 - Frontend Documentation", + "out": "docs" + } } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 4bf18ad1c..b3d4746e3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1024,7 +1024,7 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.16", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.16", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.6", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.17.8" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.8.tgz#3e56e4aff81befa55ac3ac6a0967349fd1c5bca2" integrity sha512-dQpEpK0O9o6lj6oPu0gRDbbnk+4LeHlNcBpspf6Olzt3GIX4P1lWF1gS+pHLDFlaJvbR6q7jCfQ08zA4QJBnmA== @@ -1454,7 +1454,7 @@ schema-utils "^3.0.0" source-map "^0.7.3" -"@popperjs/core@^2.10.1": +"@popperjs/core@^2.10.1", "@popperjs/core@^2.10.2", "@popperjs/core@^2.8.6": version "2.11.4" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503" integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg== @@ -1466,6 +1466,13 @@ dependencies: "@babel/runtime" "^7.6.2" +"@restart/hooks@^0.3.26": + version "0.3.27" + resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505" + integrity sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw== + dependencies: + dequal "^2.0.2" + "@restart/hooks@^0.4.0", "@restart/hooks@^0.4.5": version "0.4.5" resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.5.tgz#e7acbea237bfc9e479970500cf87538b41a1ed02" @@ -1773,6 +1780,13 @@ dependencies: "@types/node" "*" +"@types/cheerio@*": + version "0.22.31" + resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.31.tgz#b8538100653d6bb1b08a1e46dec75b4f2a5d5eb6" + integrity sha512-Kt7Cdjjdi2XWSfrZ53v4Of0wG3ZcmaegFXjMmz9tfNrZSkzzo36G0AL1YqSdcIA78Etjt6E609pt5h1xnQkPUw== + dependencies: + "@types/node" "*" + "@types/connect-history-api-fallback@^1.3.5": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz#d1f7a8a09d0ed5a57aee5ae9c18ab9b803205dae" @@ -1788,6 +1802,21 @@ dependencies: "@types/node" "*" +"@types/enzyme-adapter-react-16@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.0.6.tgz#8aca7ae2fd6c7137d869b6616e696d21bb8b0cec" + integrity sha512-VonDkZ15jzqDWL8mPFIQnnLtjwebuL9YnDkqeCDYnB4IVgwUm0mwKkqhrxLL6mb05xm7qqa3IE95m8CZE9imCg== + dependencies: + "@types/enzyme" "*" + +"@types/enzyme@*", "@types/enzyme@^3.10.11": + version "3.10.11" + resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.10.11.tgz#8924bd92cc63ac1843e215225dfa8f71555fe814" + integrity sha512-LEtC7zXsQlbGXWGcnnmOI7rTyP+i1QzQv4Va91RKXDEukLDaNyxu0rXlfMiGEhJwfgTPCTb0R+Pnlj//oM9e/w== + dependencies: + "@types/cheerio" "*" + "@types/react" "*" + "@types/eslint-scope@^3.7.3": version "3.7.3" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224" @@ -1967,6 +1996,13 @@ dependencies: "@types/react" "*" +"@types/react-infinite-scroller@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/react-infinite-scroller/-/react-infinite-scroller-1.2.3.tgz#b8dcb0e5762c3f79cc92e574d2c77402524cab71" + integrity sha512-l60JckVoO+dxmKW2eEG7jbliEpITsTJvRPTe97GazjF5+ylagAuyYdXl8YY9DQsTP9QjhqGKZROknzgscGJy0A== + dependencies: + "@types/react" "*" + "@types/react-router-bootstrap@^0.24.5": version "0.24.5" resolved "https://registry.yarnpkg.com/@types/react-router-bootstrap/-/react-router-bootstrap-0.24.5.tgz#9257ba3dfb01cda201aac9fa05cde3eb09ea5b27" @@ -2398,6 +2434,21 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +airbnb-prop-types@^2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2" + integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg== + dependencies: + array.prototype.find "^2.1.1" + function.prototype.name "^1.1.2" + is-regex "^1.1.0" + object-is "^1.1.2" + object.assign "^4.1.0" + object.entries "^1.1.2" + prop-types "^15.7.2" + prop-types-exact "^1.2.0" + react-is "^16.13.1" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -2542,7 +2593,27 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -array.prototype.flat@^1.2.5: +array.prototype.filter@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array.prototype.filter/-/array.prototype.filter-1.0.1.tgz#20688792acdb97a09488eaaee9eebbf3966aae21" + integrity sha512-Dk3Ty7N42Odk7PjU/Ci3zT4pLj20YvuVnneG/58ICM6bt4Ij5kZaJTVQ9TSaWaIECX2sFyz4KItkVZqHNnciqw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + es-array-method-boxes-properly "^1.0.0" + is-string "^1.0.7" + +array.prototype.find@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/array.prototype.find/-/array.prototype.find-2.1.2.tgz#6abbd0c2573925d8094f7d23112306af8c16d534" + integrity sha512-00S1O4ewO95OmmJW7EesWfQlrCrLEL8kZ40w3+GkLX2yTt0m2ggcePPa2uHPJ9KUmJvwRq+lCV9bD8Yim23x/Q== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + +array.prototype.flat@^1.2.3, array.prototype.flat@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz#07e0975d84bbc7c48cd1879d609e682598d33e13" integrity sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg== @@ -2570,18 +2641,18 @@ ast-types-flow@^0.0.7: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= -async@0.9.x: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= - async@^2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" - integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + version "2.6.4" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" + integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA== dependencies: lodash "^4.17.14" +async@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" + integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -2790,6 +2861,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -2866,6 +2942,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.1, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -2906,6 +2989,14 @@ buffer-indexof@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + builtin-modules@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" @@ -3027,6 +3118,30 @@ check-types@^11.1.1: resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f" integrity sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== +cheerio-select@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.5.0.tgz#faf3daeb31b17c5e1a9dabcee288aaf8aafa5823" + integrity sha512-qocaHPv5ypefh6YNxvnbABM07KMxExbtbfuJoIie3iZXX1ERwYmJcIiRrr9H05ucQP1k28dav8rpdDgjQd8drg== + dependencies: + css-select "^4.1.3" + css-what "^5.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils "^2.7.0" + +cheerio@^1.0.0-rc.3: + version "1.0.0-rc.10" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.10.tgz#2ba3dcdfcc26e7956fc1f440e61d51c643379f3e" + integrity sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw== + dependencies: + cheerio-select "^1.5.0" + dom-serializer "^1.3.2" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" + chokidar@^3.4.2, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -3057,7 +3172,7 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA== -classnames@^2.3.1: +classnames@^2.2.0, classnames@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== @@ -3143,7 +3258,7 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^2.20.0: +commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -3193,6 +3308,11 @@ compression@^1.7.4: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.17: + version "1.0.17" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab" + integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -3409,7 +3529,7 @@ css-what@^3.2.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== -css-what@^5.1.0: +css-what@^5.0.1, css-what@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.1.0.tgz#3f7b707aadf633baf62c2ceb8579b545bb40f7fe" integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw== @@ -3690,6 +3810,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +discontinuous-range@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" + integrity sha1-44Mx8IRLukm5qctxx3FYWqsbxlo= + dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" @@ -3757,7 +3882,7 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" -dom-serializer@^1.0.1: +dom-serializer@^1.0.1, dom-serializer@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig== @@ -3798,7 +3923,7 @@ domutils@^1.7.0: dom-serializer "0" domelementtype "1" -domutils@^2.5.2, domutils@^2.8.0: +domutils@^2.5.2, domutils@^2.7.0, domutils@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== @@ -3836,11 +3961,11 @@ ee-first@1.1.1: integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= ejs@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a" - integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw== + version "3.1.7" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.7.tgz#c544d9c7f715783dd92f0bddcf73a59e6962d006" + integrity sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw== dependencies: - jake "^10.6.1" + jake "^10.8.5" electron-to-chromium@^1.4.84: version "1.4.88" @@ -3885,6 +4010,70 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +enzyme-adapter-react-16@^1.15.6: + version "1.15.6" + resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz#fd677a658d62661ac5afd7f7f541f141f8085901" + integrity sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g== + dependencies: + enzyme-adapter-utils "^1.14.0" + enzyme-shallow-equal "^1.0.4" + has "^1.0.3" + object.assign "^4.1.2" + object.values "^1.1.2" + prop-types "^15.7.2" + react-is "^16.13.1" + react-test-renderer "^16.0.0-0" + semver "^5.7.0" + +enzyme-adapter-utils@^1.14.0: + version "1.14.0" + resolved "https://registry.yarnpkg.com/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.0.tgz#afbb0485e8033aa50c744efb5f5711e64fbf1ad0" + integrity sha512-F/z/7SeLt+reKFcb7597IThpDp0bmzcH1E9Oabqv+o01cID2/YInlqHbFl7HzWBl4h3OdZYedtwNDOmSKkk0bg== + dependencies: + airbnb-prop-types "^2.16.0" + function.prototype.name "^1.1.3" + has "^1.0.3" + object.assign "^4.1.2" + object.fromentries "^2.0.3" + prop-types "^15.7.2" + semver "^5.7.1" + +enzyme-shallow-equal@^1.0.1, enzyme-shallow-equal@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.4.tgz#b9256cb25a5f430f9bfe073a84808c1d74fced2e" + integrity sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q== + dependencies: + has "^1.0.3" + object-is "^1.1.2" + +enzyme@^3.11.0: + version "3.11.0" + resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.11.0.tgz#71d680c580fe9349f6f5ac6c775bc3e6b7a79c28" + integrity sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw== + dependencies: + array.prototype.flat "^1.2.3" + cheerio "^1.0.0-rc.3" + enzyme-shallow-equal "^1.0.1" + function.prototype.name "^1.1.2" + has "^1.0.3" + html-element-map "^1.2.0" + is-boolean-object "^1.0.1" + is-callable "^1.1.5" + is-number-object "^1.0.4" + is-regex "^1.0.5" + is-string "^1.0.5" + is-subset "^0.1.1" + lodash.escape "^4.0.1" + lodash.isequal "^4.5.0" + object-inspect "^1.7.0" + object-is "^1.0.2" + object.assign "^4.1.0" + object.entries "^1.1.1" + object.values "^1.1.1" + raf "^3.4.1" + rst-selector-parser "^2.2.3" + string.prototype.trim "^1.2.1" + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -3925,6 +4114,11 @@ es-abstract@^1.17.2, es-abstract@^1.19.0, es-abstract@^1.19.1: string.prototype.trimstart "^1.0.4" unbox-primitive "^1.0.1" +es-array-method-boxes-properly@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" + integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== + es-module-lexer@^0.9.0: version "0.9.3" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" @@ -4437,11 +4631,11 @@ file-loader@^6.2.0: schema-utils "^3.0.0" filelist@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b" - integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ== + version "1.0.3" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.3.tgz#448607750376484932f67ef1b9ff07386b036c83" + integrity sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q== dependencies: - minimatch "^3.0.4" + minimatch "^5.0.1" filesize@^8.0.6: version "8.0.7" @@ -4607,11 +4801,26 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function.prototype.name@^1.1.2, function.prototype.name@^1.1.3: + version "1.1.5" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" + integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.0" + functions-have-names "^1.2.2" + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +functions-have-names@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21" + integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -4673,7 +4882,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -4815,6 +5024,14 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +html-element-map@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/html-element-map/-/html-element-map-1.3.1.tgz#44b2cbcfa7be7aa4ff59779e47e51012e1c73c08" + integrity sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg== + dependencies: + array.prototype.filter "^1.0.0" + call-bind "^1.0.2" + html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -4970,6 +5187,11 @@ identity-obj-proxy@^3.0.0: dependencies: harmony-reflect "^1.4.6" +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" @@ -5038,7 +5260,7 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" -invariant@^2.2.4: +invariant@^2.2.1, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -5087,7 +5309,7 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-boolean-object@^1.1.0: +is-boolean-object@^1.0.1, is-boolean-object@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== @@ -5095,7 +5317,7 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-callable@^1.1.4, is-callable@^1.2.4: +is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -5188,7 +5410,7 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== -is-regex@^1.0.4, is-regex@^1.1.4: +is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -5223,6 +5445,11 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" +is-subset@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" + integrity sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY= + is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" @@ -5301,12 +5528,12 @@ istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jake@^10.6.1: - version "10.8.4" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.4.tgz#f6a8b7bf90c6306f768aa82bb7b98bf4ca15e84a" - integrity sha512-MtWeTkl1qGsWUtbl/Jsca/8xSoK3x0UmS82sNbjqxxG/de/M/3b1DntdjHgPMC50enlTNwXOCRqPXLLt5cCfZA== +jake@^10.8.5: + version "10.8.5" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" + integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw== dependencies: - async "0.9.x" + async "^3.2.3" chalk "^4.0.2" filelist "^1.0.1" minimatch "^3.0.4" @@ -5845,6 +6072,11 @@ json5@^2.1.2, json5@^2.2.0: dependencies: minimist "^1.2.5" +jsonc-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.0.0.tgz#abdd785701c7e7eaca8a9ec8cf070ca51a745a22" + integrity sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA== + jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -5988,6 +6220,21 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= +lodash.escape@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98" + integrity sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg= + +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -6034,6 +6281,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lunr@^2.3.9: + version "2.3.9" + resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1" + integrity sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -6060,6 +6312,11 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +marked@^4.0.12: + version "4.0.12" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.0.12.tgz#2262a4e6fd1afd2f13557726238b69a48b982f7d" + integrity sha512-hgibXWrEDNBWgGiK18j/4lkS6ihTe9sxtV4Q1OQppb/0zzyPSzoFANBa5MfsG/zgsWklmNnhm0XACZOH/0HBiQ== + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -6163,6 +6420,13 @@ minimatch@^3.0.4, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" + integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" @@ -6175,6 +6439,11 @@ mkdirp@^0.5.5, mkdirp@~0.5.1: dependencies: minimist "^1.2.5" +moo@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4" + integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -6213,6 +6482,16 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +nearley@^2.7.10: + version "2.20.1" + resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" + integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ== + dependencies: + commander "^2.19.0" + moo "^0.5.0" + railroad-diagrams "^1.0.0" + randexp "0.4.6" + negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" @@ -6297,12 +6576,12 @@ object-hash@^2.2.0: resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== -object-inspect@^1.11.0, object-inspect@^1.9.0: +object-inspect@^1.11.0, object-inspect@^1.7.0, object-inspect@^1.9.0: version "1.12.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== -object-is@^1.0.1: +object-is@^1.0.1, object-is@^1.0.2, object-is@^1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== @@ -6325,7 +6604,7 @@ object.assign@^4.1.0, object.assign@^4.1.2: has-symbols "^1.0.1" object-keys "^1.1.1" -object.entries@^1.1.5: +object.entries@^1.1.1, object.entries@^1.1.2, object.entries@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.5.tgz#e1acdd17c4de2cd96d5a08487cfb9db84d881861" integrity sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g== @@ -6334,7 +6613,7 @@ object.entries@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" -object.fromentries@^2.0.5: +object.fromentries@^2.0.3, object.fromentries@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.5.tgz#7b37b205109c21e741e605727fe8b0ad5fa08251" integrity sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw== @@ -6360,7 +6639,7 @@ object.hasown@^1.1.0: define-properties "^1.1.3" es-abstract "^1.19.1" -object.values@^1.1.0, object.values@^1.1.5: +object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.2, object.values@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac" integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg== @@ -6532,7 +6811,14 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5@6.0.1: +parse5-htmlparser2-tree-adapter@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" + +parse5@6.0.1, parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== @@ -7228,6 +7514,15 @@ prompts@^2.0.1, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types-exact@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869" + integrity sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA== + dependencies: + has "^1.0.3" + object.assign "^4.1.0" + reflect.ownkeys "^0.2.0" + prop-types-extra@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.1.1.tgz#58c3b74cbfbb95d304625975aa2f0848329a010b" @@ -7236,7 +7531,7 @@ prop-types-extra@^1.1.0: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -7290,6 +7585,19 @@ raf@^3.4.1: dependencies: performance-now "^2.1.0" +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha1-635iZ1SN3t+4mcG5Dlc3RVnN234= + +randexp@0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" + integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== + dependencies: + discontinuous-range "1.0.0" + ret "~0.1.10" + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -7324,6 +7632,24 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" whatwg-fetch "^3.6.2" +react-bootstrap-typeahead@^6.0.0-alpha.11: + version "6.0.0-alpha.11" + resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-6.0.0-alpha.11.tgz#6476df85256ad6dfe612913db753b52f3c70fef7" + integrity sha512-yHBPsdkAdvvLpkq6wWei55qt4REdbRnC+1wVxkBSBeTG4Z6lkKKSGx6w4kY9YmkyyVcbmmfwUSXhCWY/M+TzCg== + dependencies: + "@babel/runtime" "^7.14.6" + "@popperjs/core" "^2.10.2" + "@restart/hooks" "^0.4.0" + classnames "^2.2.0" + fast-deep-equal "^3.1.1" + invariant "^2.2.1" + lodash.debounce "^4.0.8" + prop-types "^15.5.8" + react-overlays "^5.1.0" + react-popper "^2.2.5" + scroll-into-view-if-needed "^2.2.20" + warning "^4.0.1" + react-bootstrap@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-2.2.1.tgz#2a6ad0931e9367882ec3fc88a70ed0b8ace90b26" @@ -7346,6 +7672,11 @@ react-bootstrap@^2.2.1: uncontrollable "^7.2.1" warning "^4.0.3" +react-collapsible@^2.8.4: + version "2.8.4" + resolved "https://registry.yarnpkg.com/react-collapsible/-/react-collapsible-2.8.4.tgz#319ff7471138c4381ce0afa3ac308ccde7f4e09f" + integrity sha512-oG4yOk6AGKswe0OD/8t3/nf4Rgj4UhlZUUvqL5jop0/ez02B3dBDmNvs3sQz0PcTpJvt0ai8zF7Atd1SzN/UNw== + react-dev-utils@^12.0.0: version "12.0.0" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.0.tgz#4eab12cdb95692a077616770b5988f0adf806526" @@ -7390,12 +7721,24 @@ react-error-overlay@^6.0.10: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6" integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA== +react-fast-compare@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + react-icons@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca" integrity sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ== -react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0: +react-infinite-scroller@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/react-infinite-scroller/-/react-infinite-scroller-1.2.6.tgz#8b80233226dc753a597a0eb52621247f49b15f18" + integrity sha512-mGdMyOD00YArJ1S1F3TVU9y4fGSfVVl6p5gh/Vt4u99CJOptfVu/q5V/Wlle72TMgYlBwIhbxK5wF0C/R33PXQ== + dependencies: + prop-types "^15.5.8" + +react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.6: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -7410,6 +7753,28 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-overlays@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.1.1.tgz#2e7cf49744b56537c7828ccb94cfc63dd778ae4f" + integrity sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q== + dependencies: + "@babel/runtime" "^7.13.8" + "@popperjs/core" "^2.8.6" + "@restart/hooks" "^0.3.26" + "@types/warning" "^3.0.0" + dom-helpers "^5.2.0" + prop-types "^15.7.2" + uncontrollable "^7.2.1" + warning "^4.0.3" + +react-popper@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96" + integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -7497,6 +7862,16 @@ react-social-login-buttons@^3.6.0: resolved "https://registry.yarnpkg.com/react-social-login-buttons/-/react-social-login-buttons-3.6.0.tgz#2be1cb114d8c0200581ba1c8ec5ea74e89cf7701" integrity sha512-m5E72jHWgC4VBxRziZYQC5kQIzooGRF+dDE97K5JgSlcDPXkNxCjCzP+Qp9fNhNujG7APvPx2Qhzi1BO2xi17Q== +react-test-renderer@^16.0.0-0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" + integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== + dependencies: + object-assign "^4.1.1" + prop-types "^15.6.2" + react-is "^16.8.6" + scheduler "^0.19.1" + react-transition-group@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" @@ -7515,6 +7890,11 @@ react@^17.0.2: loose-envify "^1.1.0" object-assign "^4.1.1" +reactjs-popup@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/reactjs-popup/-/reactjs-popup-2.0.5.tgz#588a74966bb126699429d739948e3448d7771eac" + integrity sha512-b5hv9a6aGsHEHXFAgPO5s1Jw1eSkopueyUVxQewGdLgqk2eW0IVXZrPRpHR629YcgIpC2oxtX8OOZ8a7bQJbxA== + readable-stream@^2.0.1: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -7559,6 +7939,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +reflect.ownkeys@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460" + integrity sha1-dJrO7H8/34tj+SegSAnpDFwLNGA= + regenerate-unicode-properties@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" @@ -7706,6 +8091,11 @@ resolve@^2.0.0-next.3: is-core-module "^2.2.0" path-parse "^1.0.6" +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -7740,6 +8130,14 @@ rollup@^2.43.1: optionalDependencies: fsevents "~2.3.2" +rst-selector-parser@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" + integrity sha1-gbIw6i/MYGbInjRy3nlChdmwPZE= + dependencies: + lodash.flattendeep "^4.4.0" + nearley "^2.7.10" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -7787,6 +8185,14 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" @@ -7832,6 +8238,13 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.0.0" +scroll-into-view-if-needed@^2.2.20: + version "2.2.29" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz#551791a84b7e2287706511f8c68161e4990ab885" + integrity sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg== + dependencies: + compute-scroll-into-view "^1.0.17" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -7849,6 +8262,11 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== +semver@^5.7.0, semver@^5.7.1: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -7949,6 +8367,15 @@ shell-quote@^1.7.3: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== +shiki@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/shiki/-/shiki-0.10.1.tgz#6f9a16205a823b56c072d0f1a0bcd0f2646bef14" + integrity sha512-VsY7QJVzU51j5o1+DguUd+6vmCmZ5v/6gYu4vyYAhzjuNQU6P/vmSy4uQaOhvje031qQMiW0d2BwgMH52vqMng== + dependencies: + jsonc-parser "^3.0.0" + vscode-oniguruma "^1.6.1" + vscode-textmate "5.2.0" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -8143,6 +8570,15 @@ string.prototype.matchall@^4.0.6: regexp.prototype.flags "^1.4.1" side-channel "^1.0.4" +string.prototype.trim@^1.2.1: + version "1.2.5" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.5.tgz#a587bcc8bfad8cb9829a577f5de30dd170c1682c" + integrity sha512-Lnh17webJVsD6ECeovpVN17RlAKjmz4rF9S+8Y45CkMc/ufVpTkU3vZIyIC7sllQ1FCvObZnnCdNs/HXTUOTlg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.19.1" + string.prototype.trimend@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" @@ -8508,7 +8944,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3: +tslib@^2.0.3, tslib@^2.2.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== @@ -8569,6 +9005,17 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" +typedoc@^0.22.13: + version "0.22.13" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.22.13.tgz#d061f8f0fb7c9d686e48814f245bddeea4564e66" + integrity sha512-NHNI7Dr6JHa/I3+c62gdRNXBIyX7P33O9TafGLd07ur3MqzcKgwTvpg18EtvCLHJyfeSthAtCLpM7WkStUmDuQ== + dependencies: + glob "^7.2.0" + lunr "^2.3.9" + marked "^4.0.12" + minimatch "^5.0.1" + shiki "^0.10.1" + typescript@^4.4.2: version "4.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" @@ -8705,6 +9152,16 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= +vscode-oniguruma@^1.6.1: + version "1.6.2" + resolved "https://registry.yarnpkg.com/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz#aeb9771a2f1dbfc9083c8a7fdd9cccaa3f386607" + integrity sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA== + +vscode-textmate@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-5.2.0.tgz#01f01760a391e8222fe4f33fbccbd1ad71aed74e" + integrity sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ== + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -8726,7 +9183,7 @@ walker@^1.0.7: dependencies: makeerror "1.0.12" -warning@^4.0.0, warning@^4.0.3: +warning@^4.0.0, warning@^4.0.1, warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==