aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Numerlor <[email protected]>2021-03-14 23:49:00 +0100
committerGravatar Numerlor <[email protected]>2021-03-15 00:39:58 +0100
commitf9a5c68a4f5400f6963f0795071110ebdc4eebbc (patch)
treee8534db8528370145a42f3e6340daf6f3de6a8cb
parentCreate migration for doc package name validator. (diff)
parentDockerfile optimisations (diff)
Merge branch 'main' into doc-validator
-rw-r--r--.coveragerc3
-rw-r--r--.dockerignore5
-rw-r--r--.flake82
-rw-r--r--.github/CODEOWNERS22
-rw-r--r--.github/review-policy.yml3
-rw-r--r--.github/workflows/build.yaml58
-rw-r--r--.github/workflows/codeql-analysis.yml32
-rw-r--r--.github/workflows/deploy.yaml45
-rw-r--r--.github/workflows/lint-test.yaml142
-rw-r--r--.github/workflows/sentry-release.yml23
-rw-r--r--.github/workflows/status_embed.yaml78
-rw-r--r--.pre-commit-config.yaml2
-rw-r--r--CONTRIBUTING.md6
-rw-r--r--Dockerfile (renamed from docker/Dockerfile)16
-rw-r--r--LICENSE2
-rw-r--r--Pipfile9
-rw-r--r--Pipfile.lock670
-rw-r--r--README.md29
-rw-r--r--azure-pipelines.yml102
-rw-r--r--docker-compose.yml6
-rw-r--r--docker/uwsgi.ini38
-rw-r--r--docs/deployment.md146
-rwxr-xr-xmanage.py36
-rw-r--r--postgres/init.sql39
-rw-r--r--pydis_site/apps/api/admin.py475
-rw-r--r--pydis_site/apps/api/dblogger.py22
-rw-r--r--pydis_site/apps/api/migrations/0008_tag_embed_validator.py7
-rw-r--r--pydis_site/apps/api/migrations/0019_deletedmessage.py2
-rw-r--r--pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py4
-rw-r--r--pydis_site/apps/api/migrations/0051_delete_tag.py16
-rw-r--r--pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py18
-rw-r--r--pydis_site/apps/api/migrations/0061_merge_20200830_0526.py14
-rw-r--r--pydis_site/apps/api/migrations/0062_merge_20200901_1459.py14
-rw-r--r--pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py18
-rw-r--r--pydis_site/apps/api/migrations/0064_auto_20200919_1900.py76
-rw-r--r--pydis_site/apps/api/migrations/0064_delete_logentry.py16
-rw-r--r--pydis_site/apps/api/migrations/0065_auto_20200919_2033.py17
-rw-r--r--pydis_site/apps/api/migrations/0066_merge_20201003_0730.py14
-rw-r--r--pydis_site/apps/api/migrations/0067_add_voice_ban_infraction_type.py18
-rw-r--r--pydis_site/apps/api/migrations/0068_split_nomination_tables.py75
-rw-r--r--pydis_site/apps/api/migrations/0069_documentationlink_packagename_validator.py (renamed from pydis_site/apps/api/migrations/0061_documentationlink_packagename_validator.py)4
-rw-r--r--pydis_site/apps/api/models/__init__.py3
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py3
-rw-r--r--pydis_site/apps/api/models/bot/deleted_message.py4
-rw-r--r--pydis_site/apps/api/models/bot/documentation_link.py5
-rw-r--r--pydis_site/apps/api/models/bot/infraction.py3
-rw-r--r--pydis_site/apps/api/models/bot/message.py10
-rw-r--r--pydis_site/apps/api/models/bot/message_deletion_context.py11
-rw-r--r--pydis_site/apps/api/models/bot/metricity.py91
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py58
-rw-r--r--pydis_site/apps/api/models/bot/off_topic_channel_name.py5
-rw-r--r--pydis_site/apps/api/models/bot/offensive_message.py9
-rw-r--r--pydis_site/apps/api/models/bot/role.py8
-rw-r--r--pydis_site/apps/api/models/bot/user.py17
-rw-r--r--pydis_site/apps/api/models/log_entry.py55
-rw-r--r--pydis_site/apps/api/models/utils.py (renamed from pydis_site/apps/api/models/bot/tag.py)43
-rw-r--r--pydis_site/apps/api/serializers.py142
-rw-r--r--pydis_site/apps/api/tests/test_dblogger.py27
-rw-r--r--pydis_site/apps/api/tests/test_deleted_messages.py21
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py30
-rw-r--r--pydis_site/apps/api/tests/test_models.py21
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py125
-rw-r--r--pydis_site/apps/api/tests/test_off_topic_channel_names.py27
-rw-r--r--pydis_site/apps/api/tests/test_reminders.py28
-rw-r--r--pydis_site/apps/api/tests/test_users.py310
-rw-r--r--pydis_site/apps/api/tests/test_validators.py56
-rw-r--r--pydis_site/apps/api/urls.py7
-rw-r--r--pydis_site/apps/api/views.py8
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py1
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py16
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py142
-rw-r--r--pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py27
-rw-r--r--pydis_site/apps/api/viewsets/bot/reminder.py32
-rw-r--r--pydis_site/apps/api/viewsets/bot/tag.py105
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py161
-rw-r--r--pydis_site/apps/api/viewsets/log_entry.py36
-rw-r--r--pydis_site/apps/home/migrations/0002_auto_now_on_repository_metadata.py18
-rw-r--r--pydis_site/apps/home/models/repository_metadata.py15
-rw-r--r--pydis_site/apps/home/resources/books/effective_python.yaml4
-rw-r--r--pydis_site/apps/home/tests/mock_github_api_response.json4
-rw-r--r--pydis_site/apps/home/tests/test_repodata_helpers.py42
-rw-r--r--pydis_site/apps/home/tests/test_views.py8
-rw-r--r--pydis_site/apps/home/urls.py3
-rw-r--r--pydis_site/apps/home/views/__init__.py4
-rw-r--r--pydis_site/apps/home/views/home.py162
-rw-r--r--pydis_site/apps/staff/tests/test_logs_view.py2
-rw-r--r--pydis_site/constants.py3
-rw-r--r--pydis_site/context_processors.py8
-rw-r--r--pydis_site/hosts.py3
-rw-r--r--pydis_site/settings.py48
-rw-r--r--pydis_site/static/css/base/base.css69
-rw-r--r--pydis_site/static/css/error_pages.css66
-rw-r--r--pydis_site/static/css/home/index.css231
-rw-r--r--pydis_site/static/css/home/timeline.css3823
-rw-r--r--pydis_site/static/images/events/100k.pngbin0 -> 210477 bytes
-rw-r--r--pydis_site/static/images/frontpage/welcome.jpgbin0 -> 51725 bytes
-rw-r--r--pydis_site/static/images/navbar/discord.svg165
-rw-r--r--pydis_site/static/images/navbar/navbar_discordjoin.svg81
-rw-r--r--pydis_site/static/images/sponsors/adafruit.pngbin11705 -> 0 bytes
-rw-r--r--pydis_site/static/images/sponsors/notion.pngbin0 -> 38207 bytes
-rwxr-xr-xpydis_site/static/images/timeline/cd-icon-location.svg4
-rwxr-xr-xpydis_site/static/images/timeline/cd-icon-movie.svg4
-rwxr-xr-xpydis_site/static/images/timeline/cd-icon-picture.svg72
-rw-r--r--pydis_site/static/images/waves/wave_dark.svg73
-rw-r--r--pydis_site/static/images/waves/wave_white.svg77
-rw-r--r--pydis_site/static/js/timeline/main.js104
-rw-r--r--pydis_site/templates/404.html34
-rw-r--r--pydis_site/templates/500.html29
-rw-r--r--pydis_site/templates/base/base.html1
-rw-r--r--pydis_site/templates/base/footer.html2
-rw-r--r--pydis_site/templates/base/navbar.html16
-rw-r--r--pydis_site/templates/home/index.html195
-rw-r--r--pydis_site/templates/home/timeline.html520
114 files changed, 8387 insertions, 1476 deletions
diff --git a/.coveragerc b/.coveragerc
index a49af74e..4906c86a 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -12,8 +12,11 @@ omit =
*/admin.py
*/apps.py
*/urls.py
+ pydis_site/apps/api/models/bot/metricity.py
pydis_site/wsgi.py
pydis_site/settings.py
+ pydis_site/utils/resources.py
+ pydis_site/apps/home/views/home.py
[report]
fail_under = 100
diff --git a/.dockerignore b/.dockerignore
index 236295ca..2a1f68e7 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -16,11 +16,8 @@ pydis_site/apps/home/tests
pydis_site/apps/staff/tests
CHANGELOG.md
CONTRIBUTING.md
-docker
-!docker/uwsgi.ini
-!docker/wheels
docker-compose.yml
-Dockerfile.local
+Dockerfile
docs
htmlcov
LICENSE
diff --git a/.flake8 b/.flake8
index bcd26d9e..6690af3e 100644
--- a/.flake8
+++ b/.flake8
@@ -3,7 +3,7 @@ max-line-length=100
docstring-convention=all
import-order-style=pycharm
application_import_names=pydis_site
-exclude=__pycache__, venv, .venv, **/migrations/**
+exclude=__pycache__, venv, .venv, **/migrations/**, .cache/
ignore=
B311,W503,E226,S311,T000
# Missing Docstrings
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index cf5f1590..009b21c4 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1,21 @@
-* @python-discord/core-developers
+# Infractions API
+pydis_site/apps/api/models/bot/infraction.py @MarkKoz
+pydis_site/apps/api/viewsets/bot/infraction.py @MarkKoz
+
+# Home app
+pydis_site/apps/home/** @ks129
+
+# Django ORM
+**/migrations/** @Akarys42
+**/models/** @Akarys42 @Den4200
+
+# CI & Docker
+.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @ks129
+Dockerfile @MarkKoz @Akarys42 @Den4200
+docker-compose.yml @MarkKoz @Akarys42 @Den4200
+
+# Tools
+Pipfile* @Akarys42
+
+# Metricity
+pydis_site/apps/api/models/bot/metricity.py @jb3
diff --git a/.github/review-policy.yml b/.github/review-policy.yml
new file mode 100644
index 00000000..421b30f8
--- /dev/null
+++ b/.github/review-policy.yml
@@ -0,0 +1,3 @@
+remote: python-discord/.github
+path: review-policies/core-developers.yml
+ref: main
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 00000000..873bcda4
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,58 @@
+name: Build
+
+on:
+ workflow_run:
+ workflows: ["Lint & Test"]
+ branches:
+ - main
+ types:
+ - completed
+
+jobs:
+ build:
+ name: Build Docker Image
+ if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
+ runs-on: ubuntu-latest
+
+ steps:
+ # Create a commit SHA-based tag for the container repositories
+ - name: Create SHA Container Tag
+ id: sha_tag
+ run: |
+ tag=$(cut -c 1-7 <<< $GITHUB_SHA)
+ echo "::set-output name=tag::$tag"
+
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ # The current version (v2) of Docker's build-push action uses
+ # buildx, which comes with BuildKit features that help us speed
+ # up our builds using additional cache features. Buildx also
+ # has a lot of other features that are not as relevant to us.
+ #
+ # See https://github.com/docker/build-push-action
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v1
+
+ - name: Login to Github Container Registry
+ uses: docker/login-action@v1
+ with:
+ registry: ghcr.io
+ username: ${{ secrets.GHCR_USER }}
+ password: ${{ secrets.GHCR_TOKEN }}
+
+ # Build the container, including an inline cache manifest to
+ # allow us to use the registry as a cache source.
+ - name: Build and push
+ uses: docker/build-push-action@v2
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ cache-from: type=registry,ref=ghcr.io/python-discord/site:latest
+ cache-to: type=inline
+ tags: |
+ ghcr.io/python-discord/site:latest
+ ghcr.io/python-discord/site:${{ steps.sha_tag.outputs.tag }}
+ build-args: |
+ git_sha=${{ github.sha }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index 8760b35e..00000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: "Code scanning - action"
-
-on:
- push:
- pull_request:
- schedule:
- - cron: '0 12 * * *'
-
-jobs:
- CodeQL-Build:
-
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v2
- with:
- fetch-depth: 2
-
- - run: git checkout HEAD^2
- if: ${{ github.event_name == 'pull_request' }}
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v1
- with:
- languages: python
-
- - name: Autobuild
- uses: github/codeql-action/autobuild@v1
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
new file mode 100644
index 00000000..8abf2bfb
--- /dev/null
+++ b/.github/workflows/deploy.yaml
@@ -0,0 +1,45 @@
+name: Deploy
+
+on:
+ workflow_run:
+ workflows: ["Build"]
+ branches:
+ - main
+ types:
+ - completed
+
+jobs:
+ deploy:
+ if: github.event.workflow_run.conclusion == 'success'
+ name: Deploy to Kubernetes Cluster
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Create SHA Container Tag
+ id: sha_tag
+ run: |
+ tag=$(cut -c 1-7 <<< $GITHUB_SHA)
+ echo "::set-output name=tag::$tag"
+
+ # Check out the private Kubernetes repository for the
+ # deployment.yaml file using a GitHub Personal Access
+ # Token to get access.
+ - name: Checkout code
+ uses: actions/checkout@v2
+ with:
+ repository: python-discord/kubernetes
+ token: ${{ secrets.REPO_TOKEN }}
+
+ - name: Authenticate with Kubernetes
+ uses: azure/k8s-set-context@v1
+ with:
+ method: kubeconfig
+ kubeconfig: ${{ secrets.KUBECONFIG }}
+
+ - name: Deploy to Kubernetes
+ uses: Azure/k8s-deploy@v1
+ with:
+ manifests: |
+ site/deployment.yaml
+ images: 'ghcr.io/python-discord/site:${{ steps.sha_tag.outputs.tag }}'
+ kubectl-version: 'latest'
diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml
new file mode 100644
index 00000000..9e3d331d
--- /dev/null
+++ b/.github/workflows/lint-test.yaml
@@ -0,0 +1,142 @@
+name: Lint & Test
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+
+jobs:
+ lint-test:
+ runs-on: ubuntu-latest
+ env:
+ # Configure pip to cache dependencies and do a user install
+ PIP_NO_CACHE_DIR: false
+ PIP_USER: 1
+
+ # Hide the graphical elements from pipenv's output
+ PIPENV_HIDE_EMOJIS: 1
+ PIPENV_NOSPIN: 1
+
+ # Make sure pipenv does not try reuse an environment it's running in
+ PIPENV_IGNORE_VIRTUALENVS: 1
+
+ # Specify explicit paths for python dependencies and the pre-commit
+ # environment so we know which directories to cache
+ PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base
+ PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache
+
+ steps:
+ - name: Add custom PYTHONUSERBASE to PATH
+ run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH
+
+ - name: Checkout repository
+ uses: actions/checkout@v2
+
+ - name: Setup python
+ id: python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.8'
+
+ # Start the database early to give it a chance to get ready before
+ # we start running tests.
+ - name: Run database using docker-compose
+ run: docker-compose run -d -p 7777:5432 --name pydis_web postgres
+
+ # This step caches our Python dependencies. To make sure we
+ # only restore a cache when the dependencies, the python version,
+ # the runner operating system, and the dependency location haven't
+ # changed, we create a cache key that is a composite of those states.
+ #
+ # Only when the context is exactly the same, we will restore the cache.
+ - name: Python Dependency Caching
+ uses: actions/cache@v2
+ id: python_cache
+ with:
+ path: ${{ env.PYTHONUSERBASE }}
+ key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\
+ ${{ steps.python.outputs.python-version }}-\
+ ${{ hashFiles('./Pipfile', './Pipfile.lock') }}"
+
+ # Install our dependencies if we did not restore a dependency cache
+ - name: Install dependencies using pipenv
+ if: steps.python_cache.outputs.cache-hit != 'true'
+ run: |
+ pip install pipenv
+ pipenv install --dev --deploy --system
+
+ # This step caches our pre-commit environment. To make sure we
+ # do create a new environment when our pre-commit setup changes,
+ # we create a cache key based on relevant factors.
+ - name: Pre-commit Environment Caching
+ uses: actions/cache@v2
+ with:
+ path: ${{ env.PRE_COMMIT_HOME }}
+ key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\
+ ${{ steps.python.outputs.python-version }}-\
+ ${{ hashFiles('./.pre-commit-config.yaml') }}"
+
+ # We will not run `flake8` here, as we will use a separate flake8
+ # action. As pre-commit does not support user installs, we set
+ # PIP_USER=0 to not do a user install.
+ - name: Run pre-commit hooks
+ run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files
+
+ # Run flake8 and have it format the linting errors in the format of
+ # the GitHub Workflow command to register error annotations. This
+ # means that our flake8 output is automatically added as an error
+ # annotation to both the run result and in the "Files" tab of a
+ # pull request.
+ #
+ # Format used:
+ # ::error file={filename},line={line},col={col}::{message}
+ - name: Run flake8
+ run: "flake8 \
+ --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\
+ [flake8] %(code)s: %(text)s'"
+
+ - name: Migrations and run tests with coverage.py
+ run: |
+ python manage.py makemigrations --check
+ python manage.py migrate
+ coverage run manage.py test --no-input
+ coverage report -m
+ env:
+ CI: True
+ DATABASE_URL: postgres://pysite:pysite@localhost:7777/pysite
+ METRICITY_DB_URL: postgres://pysite:pysite@localhost:7777/metricity
+
+ # This step will publish the coverage reports coveralls.io and
+ # print a "job" link in the output of the GitHub Action
+ - name: Publish coverage report to coveralls.io
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: coveralls
+
+ - name: Tear down docker-compose containers
+ run: docker-compose stop
+ if: ${{ always() }}
+
+ # Prepare the Pull Request Payload artifact. If this fails, we
+ # we fail silently using the `continue-on-error` option. It's
+ # nice if this succeeds, but if it fails for any reason, it
+ # does not mean that our lint-test checks failed.
+ - name: Prepare Pull Request Payload artifact
+ id: prepare-artifact
+ if: always() && github.event_name == 'pull_request'
+ continue-on-error: true
+ run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json
+
+ # This only makes sense if the previous step succeeded. To
+ # get the original outcome of the previous step before the
+ # `continue-on-error` conclusion is applied, we use the
+ # `.outcome` value. This step also fails silently.
+ - name: Upload a Build Artifact
+ if: always() && steps.prepare-artifact.outcome == 'success'
+ continue-on-error: true
+ uses: actions/upload-artifact@v2
+ with:
+ name: pull-request-payload
+ path: pull_request_payload.json
diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml
new file mode 100644
index 00000000..a3df5b1a
--- /dev/null
+++ b/.github/workflows/sentry-release.yml
@@ -0,0 +1,23 @@
+name: Create Sentry release
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ createSentryRelease:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@main
+ - name: Create a Sentry.io release
+ uses: tclindner/[email protected]
+ env:
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+ SENTRY_ORG: python-discord
+ SENTRY_PROJECT: site
+ with:
+ tagName: ${{ github.sha }}
+ environment: production
+ releaseNamePrefix: site@
diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml
new file mode 100644
index 00000000..b6a71b88
--- /dev/null
+++ b/.github/workflows/status_embed.yaml
@@ -0,0 +1,78 @@
+name: Status Embed
+
+on:
+ workflow_run:
+ workflows:
+ - Lint & Test
+ - Build
+ - Deploy
+ types:
+ - completed
+
+jobs:
+ status_embed:
+ # We need to send a status embed whenever the workflow
+ # sequence we're running terminates. There are a number
+ # of situations in which that happens:
+ #
+ # 1. We reach the end of the Deploy workflow, without
+ # it being skipped.
+ #
+ # 2. A `pull_request` triggered a Lint & Test workflow,
+ # as the sequence always terminates with one run.
+ #
+ # 3. If any workflow ends in failure or was cancelled.
+ if: >-
+ (github.event.workflow_run.name == 'Deploy' && github.event.workflow_run.conclusion != 'skipped') ||
+ github.event.workflow_run.event == 'pull_request' ||
+ github.event.workflow_run.conclusion == 'failure' ||
+ github.event.workflow_run.conclusion == 'cancelled'
+ name: Send Status Embed to Discord
+ runs-on: ubuntu-latest
+
+ steps:
+ # A workflow_run event does not contain all the information
+ # we need for a PR embed. That's why we upload an artifact
+ # with that information in the Lint workflow.
+ - name: Get Pull Request Information
+ id: pr_info
+ if: github.event.workflow_run.event == 'pull_request'
+ run: |
+ curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json
+ DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url')
+ [ -z "$DOWNLOAD_URL" ] && exit 1
+ wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2
+ unzip -p pull_request_payload.zip > pull_request_payload.json
+ [ -s pull_request_payload.json ] || exit 3
+ echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)"
+ echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)"
+ echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)"
+ echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Send an informational status embed to Discord instead of the
+ # standard embeds that Discord sends. This embed will contain
+ # more information and we can fine tune when we actually want
+ # to send an embed.
+ - name: GitHub Actions Status Embed for Discord
+ uses: SebastiaanZ/[email protected]
+ with:
+ # Our GitHub Actions webhook
+ webhook_id: '784184528997842985'
+ webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }}
+
+ # Workflow information
+ workflow_name: ${{ github.event.workflow_run.name }}
+ run_id: ${{ github.event.workflow_run.id }}
+ run_number: ${{ github.event.workflow_run.run_number }}
+ status: ${{ github.event.workflow_run.conclusion }}
+ actor: ${{ github.actor }}
+ repository: ${{ github.repository }}
+ ref: ${{ github.ref }}
+ sha: ${{ github.event.workflow_run.head_sha }}
+
+ pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }}
+ pr_number: ${{ steps.pr_info.outputs.pr_number }}
+ pr_title: ${{ steps.pr_info.outputs.pr_title }}
+ pr_source: ${{ steps.pr_info.outputs.pr_source }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index be57904e..a66bf97c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,6 +20,6 @@ repos:
name: Flake8
description: This hook runs flake8 within our project's pipenv environment.
entry: pipenv run flake8
- language: python
+ language: system
types: [python]
require_serial: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index de682a31..84a59d54 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to one of Our Projects
-Our projects are open-source and are automatically deployed whenever commits are pushed to the `master` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order.
+Our projects are open-source and are automatically deployed whenever commits are pushed to the `main` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order.
Note that contributions may be rejected on the basis of a contributor failing to follow these guidelines.
@@ -8,7 +8,7 @@ Note that contributions may be rejected on the basis of a contributor failing to
1. **No force-pushes** or modifying the Git history in any way.
2. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there.
- * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit!
+ * It's common practice for a repository to reject direct pushes to `main`, so make branching a habit!
* If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
3. **Adhere to the prevailing code style**, which we enforce using [`flake8`](http://flake8.pycqa.org/en/latest/index.html) and [`pre-commit`](https://pre-commit.com/).
* Run `flake8` and `pre-commit` against your code [**before** you push it](https://soundcloud.com/lemonsaurusrex/lint-before-you-push). Your commit will be rejected by the build server if it fails to lint.
@@ -18,7 +18,7 @@ Note that contributions may be rejected on the basis of a contributor failing to
* Avoid making minor commits for fixing typos or linting errors. Since you've already set up a `pre-commit` hook to run the linting pipeline before a commit, you shouldn't be committing linting issues anyway.
* A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/)
5. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed.
- * This includes merging master into your branch. Try to leave merging from master for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing.
+ * This includes merging main into your branch. Try to leave merging from main for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to main for your branch, or something was pushed to main that could potentionally affect the functionality of what you're writing.
6. **Don't fight the framework**. Every framework has its flaws, but the frameworks we've picked out have been carefully chosen for their particular merits. If you can avoid it, please resist reimplementing swathes of framework logic - the work has already been done for you!
7. If someone is working on an issue or pull request, **do not open your own pull request for the same task**. Instead, collaborate with the author(s) of the existing pull request. Duplicate PRs opened without communicating with the other author(s) and/or PyDis staff will be closed. Communication is key, and there's no point in two separate implementations of the same thing.
* One option is to fork the other contributor's repository and submit your changes to their branch with your own pull request. We suggest following these guidelines when interacting with their repository as well.
diff --git a/docker/Dockerfile b/Dockerfile
index aa427947..5d8ba5da 100644
--- a/docker/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.7-slim
+FROM python:3.8-slim-buster
# Allow service to handle stops gracefully
STOPSIGNAL SIGQUIT
@@ -8,19 +8,25 @@ ENV PIP_NO_CACHE_DIR=false \
PIPENV_HIDE_EMOJIS=1 \
PIPENV_NOSPIN=1
-# Create non-root user.
-RUN useradd --system --shell /bin/false --uid 1500 pysite
-
# Install pipenv
RUN pip install -U pipenv
# Copy the project files into working directory
WORKDIR /app
-COPY . .
+
+# Copy dependency files
+COPY Pipfile Pipfile.lock ./
# Install project dependencies
RUN pipenv install --system --deploy
+# Copy project code
+COPY . .
+
+# Set Git SHA environment variable
+ARG git_sha="development"
+ENV GIT_SHA=$git_sha
+
# Run web server through custom manager
ENTRYPOINT ["python", "manage.py"]
CMD ["run"]
diff --git a/LICENSE b/LICENSE
index 15f00946..dd39e18d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2018 Python Discord
+Copyright (c) 2018-2020 Python Discord
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Pipfile b/Pipfile
index 0f794078..dfcfa48e 100644
--- a/Pipfile
+++ b/Pipfile
@@ -9,7 +9,6 @@ django-environ = "~=0.4.5"
django-filter = "~=2.1.0"
django-hosts = "~=4.0"
djangorestframework = "~=3.11.0"
-djangorestframework-bulk = "~=0.2.1"
psycopg2-binary = "~=2.8"
django-simple-bulma = "~=1.2"
whitenoise = "~=5.0"
@@ -17,9 +16,9 @@ requests = "~=2.21"
pygments = "~=2.3.1"
wiki = "~=0.6.0"
pyyaml = "~=5.1"
-pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"}
+gunicorn = "~=20.0.4"
django-allauth = "~=0.41"
-sentry-sdk = "~=0.14"
+sentry-sdk = "~=0.19"
[dev-packages]
coverage = "~=5.0"
@@ -35,10 +34,10 @@ flake8-todo = "~=0.7"
mccabe = "~=0.6.1"
pep8-naming = "~=0.9"
pre-commit = "~=2.1"
-unittest-xml-reporting = "~=3.0"
+coveralls = "~=2.1"
[requires]
-python_version = "3.7"
+python_version = "3.8"
[scripts]
start = "python manage.py run --debug"
diff --git a/Pipfile.lock b/Pipfile.lock
index 02d81d76..fe97c5dd 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
- "sha256": "edc59f1c711954bd22606d68e00f44c21c68a7b3193b20e44a86438e24c0f54b"
+ "sha256": "557b4fb3ca12be8059f0721877ecdeea5abfd0cb3191b807138a36dab6c17037"
},
"pipfile-spec": 6,
"requires": {
- "python_version": "3.7"
+ "python_version": "3.8"
},
"sources": [
{
@@ -18,11 +18,11 @@
"default": {
"asgiref": {
"hashes": [
- "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a",
- "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"
+ "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
+ "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
],
"markers": "python_version >= '3.5'",
- "version": "==3.2.10"
+ "version": "==3.3.1"
},
"bleach": {
"hashes": [
@@ -34,17 +34,78 @@
},
"certifi": {
"hashes": [
- "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
- "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
- ],
- "version": "==2020.6.20"
+ "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
+ "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
+ ],
+ "version": "==2020.12.5"
+ },
+ "cffi": {
+ "hashes": [
+ "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e",
+ "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d",
+ "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a",
+ "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec",
+ "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362",
+ "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668",
+ "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c",
+ "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b",
+ "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06",
+ "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698",
+ "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2",
+ "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c",
+ "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7",
+ "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
+ "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
+ "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
+ "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
+ "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
+ "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
+ "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26",
+ "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b",
+ "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01",
+ "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb",
+ "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293",
+ "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd",
+ "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d",
+ "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3",
+ "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d",
+ "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e",
+ "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca",
+ "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d",
+ "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775",
+ "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375",
+ "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b",
+ "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b",
+ "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"
+ ],
+ "version": "==1.14.4"
},
"chardet": {
"hashes": [
- "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
- "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
+ "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
- "version": "==3.0.4"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==4.0.0"
+ },
+ "cryptography": {
+ "hashes": [
+ "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d",
+ "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7",
+ "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901",
+ "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c",
+ "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244",
+ "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6",
+ "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5",
+ "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e",
+ "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c",
+ "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0",
+ "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812",
+ "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a",
+ "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030",
+ "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302"
+ ],
+ "version": "==3.3.1"
},
"defusedxml": {
"hashes": [
@@ -56,24 +117,25 @@
},
"django": {
"hashes": [
- "sha256:31a5fbbea5fc71c99e288ec0b2f00302a0a92c44b13ede80b73a6a4d6d205582",
- "sha256:5457fc953ec560c5521b41fad9e6734a4668b7ba205832191bbdff40ec61073c"
+ "sha256:8c334df4160f7c89f6a8a359dd4e95c688ec5ac0db5db75fcc6fec8f590dc8cf",
+ "sha256:96436d3d2f744d26e193bfb5a1cff3e01b349f835bb0ea16f71743accf9c6fa9"
],
"index": "pypi",
- "version": "==3.0.8"
+ "version": "==3.0.11"
},
"django-allauth": {
"hashes": [
- "sha256:f17209410b7f87da0a84639fd79d3771b596a6d3fc1a8e48ce50dabc7f441d30"
+ "sha256:e51af457466022f52154d74c8523ac69375120fad2acce6e239635d85e610b25"
],
"index": "pypi",
- "version": "==0.42.0"
+ "version": "==0.44.0"
},
"django-classy-tags": {
"hashes": [
- "sha256:ad6a25fc2b58a098f00d86bd5e5dad47922f5ca4e744bc3cccb7b4be5bc35eb1"
+ "sha256:25eb4f95afee396148683bfb4811b83b3f5729218d73ad0a3399271a6f9fcc49",
+ "sha256:d59d98bdf96a764dcf7a2929a86439d023b283a9152492811c7e44fc47555bc9"
],
- "version": "==1.0.0"
+ "version": "==2.0.0"
},
"django-environ": {
"hashes": [
@@ -116,39 +178,41 @@
},
"django-nyt": {
"hashes": [
- "sha256:a696a52a0b729465c062b4808d2ad8c43b439561b2f9654328040c646abb3732",
- "sha256:b16bffcfcb468f7b5c70f61de79294a88b7df63859675721d3417507e3440d15"
+ "sha256:235c6132325b9d1fc15e64303a15552857f13f9bf94f0ae6b6fed75581a696f0",
+ "sha256:a0d9b14c06507af17774a47a461c44c9da683fa1fc23425f9d14f2fc842e07fb"
],
- "version": "==1.1.5"
+ "version": "==1.1.6"
},
"django-sekizai": {
"hashes": [
- "sha256:e2f6e666d4dd9d3ecc27284acb85ef709e198014f5d5af8c6d54ed04c2d684d9"
+ "sha256:5c5e16845d37ce822fc655ce79ec02715191b3d03330b550997bcb842cf24fdf",
+ "sha256:e829f09b0d6bf01ee5cde05de1fb3faf2fbc5df66dc4dc280fbaac224ca4336f"
],
- "version": "==1.1.0"
+ "version": "==2.0.0"
},
"django-simple-bulma": {
"hashes": [
- "sha256:a1462088791af5c65d2ea3b5a517a481dd8afb35b324979cdeefa6f3e6c58d3d",
- "sha256:a93daf425353834db96840ca4aa7744c796899243f114e73b8159724ce4573c1"
+ "sha256:79928fa983151947c635acf65fa5177ca775db98c8d53ddf1c785fe48c727466",
+ "sha256:e5cff3fc5f0d45558362ab8d0e11f92887c4fc85616f77daa6174940f94b12c7"
],
"index": "pypi",
- "version": "==1.2.0"
+ "version": "==1.3.2"
},
"djangorestframework": {
"hashes": [
- "sha256:05809fc66e1c997fd9a32ea5730d9f4ba28b109b9da71fccfa5ff241201fd0a4",
- "sha256:e782087823c47a26826ee5b6fa0c542968219263fb3976ec3c31edab23a4001f"
+ "sha256:5cc724dc4b076463497837269107e1995b1fbc917468d1b92d188fd1af9ea789",
+ "sha256:a5967b68a04e0d97d10f4df228e30f5a2d82ba63b9d03e1759f84993b7bf1b53"
],
"index": "pypi",
- "version": "==3.11.0"
+ "version": "==3.11.2"
},
- "djangorestframework-bulk": {
+ "gunicorn": {
"hashes": [
- "sha256:39230d8379acebd86d313df6c9150cafecb636eae1d097c30a26389ab9fee5b1"
+ "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
+ "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
- "version": "==0.2.1"
+ "version": "==20.0.4"
},
"idna": {
"hashes": [
@@ -158,31 +222,23 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
- "importlib-metadata": {
- "hashes": [
- "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83",
- "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"
- ],
- "markers": "python_version < '3.8'",
- "version": "==1.7.0"
- },
"libsass": {
"hashes": [
- "sha256:107c409524c6a4ed14410fa9dafa9ee59c6bd3ecae75d73af749ab2b75685726",
- "sha256:3bc0d68778b30b5fa83199e18795314f64b26ca5871e026343e63934f616f7f7",
- "sha256:5c8ff562b233734fbc72b23bb862cc6a6f70b1e9bf85a58422aa75108b94783b",
- "sha256:74f6fb8da58179b5d86586bc045c16d93d55074bc7bb48b6354a4da7ac9f9dfd",
- "sha256:7555d9b24e79943cfafac44dbb4ca7e62105c038de7c6b999838c9ff7b88645d",
- "sha256:794f4f4661667263e7feafe5cc866e3746c7c8a9192b2aa9afffdadcbc91c687",
- "sha256:8cf72552b39e78a1852132e16b706406bc76029fe3001583284ece8d8752a60a",
- "sha256:98f6dee9850b29e62977a963e3beb3cfeb98b128a267d59d2c3d675e298c8d57",
- "sha256:a43f3830d83ad9a7f5013c05ce239ca71744d0780dad906587302ac5257bce60",
- "sha256:b077261a04ba1c213e932943208471972c5230222acb7fa97373e55a40872cbb",
- "sha256:b7452f1df274b166dc22ee2e9154c4adca619bcbbdf8041a7aa05f372a1dacbc",
- "sha256:e6a547c0aa731dcb4ed71f198e814bee0400ce04d553f3f12a53bc3a17f2a481",
- "sha256:fd19c8f73f70ffc6cbcca8139da08ea9a71fc48e7dfc4bb236ad88ab2d6558f1"
- ],
- "version": "==0.20.0"
+ "sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b",
+ "sha256:1b2d415bbf6fa7da33ef46e549db1418498267b459978eff8357e5e823962d35",
+ "sha256:25ebc2085f5eee574761ccc8d9cd29a9b436fc970546d5ef08c6fa41eb57dff1",
+ "sha256:2ae806427b28bc1bb7cb0258666d854fcf92ba52a04656b0b17ba5e190fb48a9",
+ "sha256:4a246e4b88fd279abef8b669206228c92534d96ddcd0770d7012088c408dff23",
+ "sha256:553e5096414a8d4fb48d0a48f5a038d3411abe254d79deac5e008516c019e63a",
+ "sha256:697f0f9fa8a1367ca9ec6869437cb235b1c537fc8519983d1d890178614a8903",
+ "sha256:a8fd4af9f853e8bf42b1425c5e48dd90b504fa2e70d7dac5ac80b8c0a5a5fe85",
+ "sha256:c9411fec76f480ffbacc97d8188322e02a5abca6fc78e70b86a2a2b421eae8a2",
+ "sha256:daa98a51086d92aa7e9c8871cf1a8258124b90e2abf4697852a3dca619838618",
+ "sha256:e0e60836eccbf2d9e24ec978a805cd6642fa92515fbd95e3493fee276af76f8a",
+ "sha256:e64ae2587f1a683e831409aad03ba547c245ef997e1329fffadf7a866d2510b8",
+ "sha256:f6852828e9e104d2ce0358b73c550d26dd86cc3a69439438c3b618811b9584f5"
+ ],
+ "version": "==0.20.1"
},
"markdown": {
"hashes": [
@@ -202,79 +258,94 @@
},
"packaging": {
"hashes": [
- "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
- "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
+ "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
+ "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.4"
+ "version": "==20.8"
},
"pillow": {
"hashes": [
- "sha256:0295442429645fa16d05bd567ef5cff178482439c9aad0411d3f0ce9b88b3a6f",
- "sha256:06aba4169e78c439d528fdeb34762c3b61a70813527a2c57f0540541e9f433a8",
- "sha256:09d7f9e64289cb40c2c8d7ad674b2ed6105f55dc3b09aa8e4918e20a0311e7ad",
- "sha256:0a80dd307a5d8440b0a08bd7b81617e04d870e40a3e46a32d9c246e54705e86f",
- "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae",
- "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d",
- "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5",
- "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b",
- "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8",
- "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233",
- "sha256:6d7741e65835716ceea0fd13a7d0192961212fd59e741a46bbed7a473c634ed6",
- "sha256:6edb5446f44d901e8683ffb25ebdfc26988ee813da3bf91e12252b57ac163727",
- "sha256:725aa6cfc66ce2857d585f06e9519a1cc0ef6d13f186ff3447ab6dff0a09bc7f",
- "sha256:8dad18b69f710bf3a001d2bf3afab7c432785d94fcf819c16b5207b1cfd17d38",
- "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4",
- "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626",
- "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d",
- "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6",
- "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63",
- "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f",
- "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41",
- "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1",
- "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d",
- "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9",
- "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a",
- "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce"
+ "sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a",
+ "sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae",
+ "sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce",
+ "sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e",
+ "sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140",
+ "sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb",
+ "sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021",
+ "sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6",
+ "sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302",
+ "sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c",
+ "sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271",
+ "sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09",
+ "sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3",
+ "sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015",
+ "sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3",
+ "sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544",
+ "sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8",
+ "sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792",
+ "sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0",
+ "sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3",
+ "sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8",
+ "sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11",
+ "sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7",
+ "sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11",
+ "sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e",
+ "sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039",
+ "sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5",
+ "sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"
],
- "markers": "python_version >= '3.5'",
- "version": "==7.2.0"
+ "markers": "python_version >= '3.6'",
+ "version": "==8.0.1"
},
"psycopg2-binary": {
"hashes": [
- "sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac",
- "sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a",
- "sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5",
- "sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04",
- "sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1",
- "sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5",
- "sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce",
- "sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434",
- "sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9",
- "sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057",
- "sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98",
- "sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522",
- "sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505",
- "sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa",
- "sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3",
- "sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f",
- "sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4",
- "sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4",
- "sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266",
- "sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66",
- "sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38",
- "sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3",
- "sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389",
- "sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab",
- "sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb",
- "sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6",
- "sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d",
- "sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162",
- "sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e",
- "sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd"
+ "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c",
+ "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67",
+ "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0",
+ "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6",
+ "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db",
+ "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94",
+ "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52",
+ "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056",
+ "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b",
+ "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd",
+ "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550",
+ "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679",
+ "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83",
+ "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77",
+ "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2",
+ "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77",
+ "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2",
+ "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd",
+ "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859",
+ "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1",
+ "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25",
+ "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152",
+ "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf",
+ "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f",
+ "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729",
+ "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71",
+ "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66",
+ "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4",
+ "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449",
+ "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da",
+ "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a",
+ "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c",
+ "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb",
+ "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4",
+ "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5"
],
"index": "pypi",
- "version": "==2.8.5"
+ "version": "==2.8.6"
+ },
+ "pycparser": {
+ "hashes": [
+ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
+ "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.20"
},
"pygments": {
"hashes": [
@@ -284,6 +355,16 @@
"index": "pypi",
"version": "==2.3.1"
},
+ "pyjwt": {
+ "extras": [
+ "crypto"
+ ],
+ "hashes": [
+ "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
+ "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"
+ ],
+ "version": "==1.7.1"
+ },
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
@@ -301,40 +382,23 @@
},
"pytz": {
"hashes": [
- "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
- "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"
+ "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
+ "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
],
- "version": "==2020.1"
- },
- "pyuwsgi": {
- "hashes": [
- "sha256:1a4dd8d99b8497f109755e09484b0bd2aeaa533f7621e7c7e2a120a72111219d",
- "sha256:206937deaebbac5c87692657c3151a5a9d40ecbc9b051b94154205c50a48e963",
- "sha256:2cf35d9145208cc7c96464d688caa3de745bfc969e1a1ae23cb046fc10b0ac7e",
- "sha256:3ab84a168633eeb55847d59475d86e9078d913d190c2a1aed804c562a10301a3",
- "sha256:430406d1bcf288a87f14fde51c66877eaf5e98516838a1c6f761af5d814936fc",
- "sha256:72be25ce7aa86c5616c59d12c2961b938e7bde47b7ff6a996ff83b89f7c5cd27",
- "sha256:aa4d615de430e2066a1c76d9cc2a70abf2dfc703a82c21aee625b445866f2c3b",
- "sha256:aadd231256a672cf4342ef9fb976051949e4d5b616195e696bcb7b8a9c07789e",
- "sha256:b15ee6a7759b0465786d856334b8231d882deda5291cf243be6a343a8f3ef910",
- "sha256:bd1d0a8d4cb87eb63417a72e6b1bac47053f9b0be550adc6d2a375f4cbaa22f0",
- "sha256:d5787779ec24b67ac8898be9dc2b2b4e35f17d79f14361f6cf303d6283a848f2",
- "sha256:ecfae85d6504e0ecbba100a795032a88ce8f110b62b93243f2df1bd116eca67f"
- ],
- "index": "pypi",
- "markers": "sys_platform != 'win32'",
- "version": "==2.0.19.1"
+ "version": "==2020.4"
},
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
@@ -344,11 +408,11 @@
},
"requests": {
"hashes": [
- "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
- "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
+ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
+ "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
],
"index": "pypi",
- "version": "==2.24.0"
+ "version": "==2.25.1"
},
"requests-oauthlib": {
"hashes": [
@@ -360,11 +424,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:2f023ff348359ec5f0b73a840e8b08e6a8d3b2613a98c57d11c222ef43879237",
- "sha256:380a280cfc7c4ade5912294e6d9aa71ce776b5fca60a3782e9331b0bcd2866bf"
+ "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0",
+ "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f"
],
"index": "pypi",
- "version": "==0.16.1"
+ "version": "==0.19.5"
},
"six": {
"hashes": [
@@ -376,27 +440,27 @@
},
"sorl-thumbnail": {
"hashes": [
- "sha256:66771521f3c0ed771e1ce8e1aaf1639ebff18f7f5a40cfd3083da8f0fe6c7c99",
- "sha256:7162639057dff222a651bacbdb6bd6f558fc32946531d541fc71e10c0167ebdf"
+ "sha256:c56cd651feab3bdc415d5301600198e2e70c08234dad48b8f6cfa4746cc102c7",
+ "sha256:fbe6dfd66a1aceb7e0203895ff5622775e50266f8d8cfd841fe1500bd3e19018"
],
"markers": "python_version >= '3.4'",
- "version": "==12.6.3"
+ "version": "==12.7.0"
},
"sqlparse": {
"hashes": [
- "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
- "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
+ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
+ "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==0.3.1"
+ "markers": "python_version >= '3.5'",
+ "version": "==0.4.1"
},
"urllib3": {
"hashes": [
- "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
- "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
- "version": "==1.25.9"
+ "version": "==1.26.2"
},
"webencodings": {
"hashes": [
@@ -407,11 +471,11 @@
},
"whitenoise": {
"hashes": [
- "sha256:60154b976a13901414a25b0273a841145f77eb34a141f9ae032a0ace3e4d5b27",
- "sha256:6dd26bfda3af29177d8ab7333a0c7b7642eb615ce83764f4d15a9aecda3201c4"
+ "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7",
+ "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d"
],
"index": "pypi",
- "version": "==5.1.0"
+ "version": "==5.2.0"
},
"wiki": {
"hashes": [
@@ -420,14 +484,6 @@
],
"index": "pypi",
"version": "==0.6"
- },
- "zipp": {
- "hashes": [
- "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
- "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
- ],
- "markers": "python_version >= '3.6'",
- "version": "==3.1.0"
}
},
"develop": {
@@ -440,66 +496,104 @@
},
"attrs": {
"hashes": [
- "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
- "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
+ "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==19.3.0"
+ "version": "==20.3.0"
},
"bandit": {
"hashes": [
- "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952",
- "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"
+ "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07",
+ "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"
],
- "version": "==1.6.2"
+ "version": "==1.7.0"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
+ "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
+ ],
+ "version": "==2020.12.5"
},
"cfgv": {
"hashes": [
- "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53",
- "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513"
+ "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
+ "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
],
"markers": "python_full_version >= '3.6.1'",
- "version": "==3.1.0"
+ "version": "==3.2.0"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
+ "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==4.0.0"
},
"coverage": {
"hashes": [
- "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d",
- "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2",
- "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703",
- "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404",
- "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7",
- "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405",
- "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d",
- "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c",
- "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6",
- "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70",
- "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40",
- "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4",
- "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613",
- "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10",
- "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b",
- "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0",
- "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec",
- "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1",
- "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d",
- "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913",
- "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e",
- "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62",
- "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e",
- "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a",
- "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d",
- "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f",
- "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e",
- "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b",
- "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c",
- "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032",
- "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a",
- "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee",
- "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c",
- "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b"
+ "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297",
+ "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1",
+ "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497",
+ "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606",
+ "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528",
+ "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b",
+ "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4",
+ "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830",
+ "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1",
+ "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f",
+ "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d",
+ "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3",
+ "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8",
+ "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500",
+ "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7",
+ "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb",
+ "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b",
+ "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059",
+ "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b",
+ "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72",
+ "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36",
+ "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277",
+ "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c",
+ "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631",
+ "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff",
+ "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8",
+ "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec",
+ "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b",
+ "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7",
+ "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105",
+ "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b",
+ "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c",
+ "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b",
+ "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98",
+ "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4",
+ "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879",
+ "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f",
+ "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4",
+ "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044",
+ "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e",
+ "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899",
+ "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f",
+ "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448",
+ "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714",
+ "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2",
+ "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d",
+ "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd",
+ "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7",
+ "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"
],
"index": "pypi",
- "version": "==5.2"
+ "version": "==5.3.1"
+ },
+ "coveralls": {
+ "hashes": [
+ "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc",
+ "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"
+ ],
+ "index": "pypi",
+ "version": "==2.2.0"
},
"distlib": {
"hashes": [
@@ -508,6 +602,12 @@
],
"version": "==0.3.1"
},
+ "docopt": {
+ "hashes": [
+ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
+ ],
+ "version": "==0.6.2"
+ },
"filelock": {
"hashes": [
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
@@ -517,19 +617,19 @@
},
"flake8": {
"hashes": [
- "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
- "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
+ "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+ "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
],
"index": "pypi",
- "version": "==3.8.3"
+ "version": "==3.8.4"
},
"flake8-annotations": {
"hashes": [
- "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26",
- "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414"
+ "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1",
+ "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df"
],
"index": "pypi",
- "version": "==2.3.0"
+ "version": "==2.4.1"
},
"flake8-bandit": {
"hashes": [
@@ -540,11 +640,11 @@
},
"flake8-bugbear": {
"hashes": [
- "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63",
- "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"
+ "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538",
+ "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"
],
"index": "pypi",
- "version": "==20.1.4"
+ "version": "==20.11.1"
},
"flake8-docstrings": {
"hashes": [
@@ -579,11 +679,11 @@
},
"flake8-tidy-imports": {
"hashes": [
- "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd",
- "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3"
+ "sha256:52e5f2f987d3d5597538d5941153409ebcab571635835b78f522c7bf03ca23bc",
+ "sha256:76e36fbbfdc8e3c5017f9a216c2855a298be85bc0631e66777f4e6a07a859dc4"
],
"index": "pypi",
- "version": "==4.1.0"
+ "version": "==4.2.1"
},
"flake8-todo": {
"hashes": [
@@ -602,27 +702,27 @@
},
"gitpython": {
"hashes": [
- "sha256:2db287d71a284e22e5c2846042d0602465c7434d910406990d5b74df4afb0858",
- "sha256:fa3b92da728a457dd75d62bb5f3eb2816d99a7fe6c67398e260637a40e3fafb5"
+ "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b",
+ "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8"
],
"markers": "python_version >= '3.4'",
- "version": "==3.1.7"
+ "version": "==3.1.11"
},
"identify": {
"hashes": [
- "sha256:06b4373546ae55eaaefdac54f006951dbd968fe2912846c00e565b09cfaed101",
- "sha256:5519601b70c831011fb425ffd214101df7639ba3980f24dc283f7675b19127b3"
+ "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5",
+ "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.4.24"
+ "version": "==1.5.10"
},
- "importlib-metadata": {
+ "idna": {
"hashes": [
- "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83",
- "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
- "markers": "python_version < '3.8'",
- "version": "==1.7.0"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.10"
},
"mccabe": {
"hashes": [
@@ -634,16 +734,18 @@
},
"nodeenv": {
"hashes": [
- "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"
+ "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
+ "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
],
- "version": "==1.4.0"
+ "version": "==1.5.0"
},
"pbr": {
"hashes": [
- "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c",
- "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8"
+ "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9",
+ "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"
],
- "version": "==5.4.5"
+ "markers": "python_version >= '2.6'",
+ "version": "==5.5.1"
},
"pep8-naming": {
"hashes": [
@@ -655,11 +757,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915",
- "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626"
+ "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0",
+ "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"
],
"index": "pypi",
- "version": "==2.6.0"
+ "version": "==2.9.3"
},
"pycodestyle": {
"hashes": [
@@ -671,11 +773,11 @@
},
"pydocstyle": {
"hashes": [
- "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586",
- "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"
+ "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
+ "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
],
"markers": "python_version >= '3.5'",
- "version": "==5.0.2"
+ "version": "==5.1.1"
},
"pyflakes": {
"hashes": [
@@ -690,11 +792,13 @@
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
@@ -702,6 +806,14 @@
"index": "pypi",
"version": "==5.3.1"
},
+ "requests": {
+ "hashes": [
+ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
+ "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
+ ],
+ "index": "pypi",
+ "version": "==2.25.1"
+ },
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
@@ -727,69 +839,35 @@
},
"stevedore": {
"hashes": [
- "sha256:38791aa5bed922b0a844513c5f9ed37774b68edc609e5ab8ab8d8fe0ce4315e5",
- "sha256:c8f4f0ebbc394e52ddf49de8bcc3cf8ad2b4425ebac494106bbc5e3661ac7633"
+ "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee",
+ "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"
],
"markers": "python_version >= '3.6'",
- "version": "==3.2.0"
+ "version": "==3.3.0"
},
"toml": {
"hashes": [
- "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
- "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
- ],
- "version": "==0.10.1"
- },
- "typed-ast": {
- "hashes": [
- "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
- "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
- "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
- "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
- "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
- "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
- "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
- "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
- "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
- "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
- "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
- "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
- "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
- "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
- "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
- "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
- "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
- "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
- "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
- "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
- "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
- ],
- "markers": "python_version < '3.8'",
- "version": "==1.4.1"
- },
- "unittest-xml-reporting": {
- "hashes": [
- "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a",
- "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d"
+ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
+ "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
- "index": "pypi",
- "version": "==3.0.2"
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==0.10.2"
},
- "virtualenv": {
+ "urllib3": {
"hashes": [
- "sha256:26cdd725a57fef4c7c22060dba4647ebd8ca377e30d1c1cf547b30a0b79c43b4",
- "sha256:c51f1ba727d1614ce8fd62457748b469fbedfdab2c7e5dd480c9ae3fbe1233f1"
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.0.27"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+ "version": "==1.26.2"
},
- "zipp": {
+ "virtualenv": {
"hashes": [
- "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
- "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
+ "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c",
+ "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"
],
- "markers": "python_version >= '3.6'",
- "version": "==3.1.0"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.2.2"
}
}
}
diff --git a/README.md b/README.md
index ec2f0af3..f88c2cf7 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,25 @@
# Python Discord: Site
-[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E30k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
-[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=2&branchName=master)
-[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/2?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master)
-[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/2/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master)
-[![License](https://img.shields.io/github/license/python-discord/site)](LICENSE)
-[![Status](https://img.shields.io/website?url=https%3A%2F%2Fpythondiscord.com)][1]
+[![Discord][10]][11]
+[![Lint & Test][1]][2]
+[![Build & Deploy][3]][4]
+[![Coverage Status][5]][6]
+[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
-This is all of the code that is responsible for maintaining [our website][1] and all of its subdomains.
+This is all of the code that is responsible for maintaining [our website][7] and all of its subdomains.
The website is built on Django and should be simple to set up and get started with.
If you happen to run into issues with setup, please don't hesitate to open an issue!
-If you're looking to contribute or play around with the code, take a look at [the wiki][2] or the [`docs` directory](docs). If you're looking for things to do, check out [our issues][3].
+If you're looking to contribute or play around with the code, take a look at [the wiki][8] or the [`docs` directory](docs). If you're looking for things to do, check out [our issues][9].
-[1]: https://pythondiscord.com
-[2]: https://pythondiscord.com/pages/contributing/site/
-[3]: https://github.com/python-discord/site/issues
+[1]: https://github.com/python-discord/site/workflows/Lint%20&%20Test/badge.svg?branch=main
+[2]: https://github.com/python-discord/site/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amain
+[3]: https://github.com/python-discord/site/workflows/Build%20&%20Deploy/badge.svg?branch=main
+[4]: https://github.com/python-discord/site/actions?query=workflow%3A%22Build+%26+Deploy%22+branch%3Amain
+[5]: https://coveralls.io/repos/github/python-discord/site/badge.svg?branch=main
+[6]: https://coveralls.io/github/python-discord/site?branch=main
+[7]: https://pythondiscord.com
+[8]: https://pythondiscord.com/pages/contributing/site/
+[9]: https://github.com/python-discord/site/issues
+[10]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg
+[11]: https://discord.gg/python
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
deleted file mode 100644
index f273dad3..00000000
--- a/azure-pipelines.yml
+++ /dev/null
@@ -1,102 +0,0 @@
-# https://aka.ms/yaml
-
-jobs:
- - job: test
- displayName: 'Test & Lint'
- pool:
- vmImage: ubuntu-16.04
-
- variables:
- PIP_CACHE_DIR: .cache/pip
- PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache
-
- steps:
- - task: UsePythonVersion@0
- displayName: 'Set Python Version'
- name: PythonVersion
- inputs:
- versionSpec: '3.7.x'
- addToPath: true
-
- - task: DockerCompose@0
- displayName: 'Setup Database'
- inputs:
- action: Run a specific service
- dockerComposeFile: docker-compose.yml
- projectName: pydis_web
- serviceName: postgres
- ports: '7777:5432'
-
- - script: |
- pip install pipenv
- pipenv install --dev --system
- pip install flake8-formatter-junit-xml
- displayName: 'Install Project Environment'
-
- # Create an executable shell script which replaces the original pipenv binary.
- # The shell script ignores the first argument and executes the rest of the args as a command.
- # It makes the `pipenv run flake8` command in the pre-commit hook work by circumventing
- # pipenv entirely, which is too dumb to know it should use the system interpreter rather than
- # creating a new venv.
- - script: |
- printf '%s\n%s' '#!/bin/bash' '"${@:2}"' > $(PythonVersion.pythonLocation)/bin/pipenv \
- && chmod +x $(PythonVersion.pythonLocation)/bin/pipenv
- displayName: 'Mock pipenv binary'
-
- - task: Cache@2
- displayName: 'Restore pre-commit environment'
- inputs:
- key: pre-commit | "$(PythonVersion.pythonLocation)" | .pre-commit-config.yaml
- restoreKeys: |
- pre-commit | "$(PythonVersion.pythonLocation)"
- path: $(PRE_COMMIT_HOME)
-
- # flake8 runs so it can generate the XML output. pre-commit will run it again to show stdout.
- # flake8 standalone runs first to avoid any fixes pre-commit hooks may make.
- - script: flake8 --format junit-xml --output-file TEST-lint.xml; pre-commit run --all-files
- displayName: 'Run pre-commit hooks'
-
- - script: |
- python3 manage.py makemigrations --check
- python3 manage.py migrate
- coverage run \
- manage.py test \
- --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner \
- --no-input
- env:
- CI: azure
- DATABASE_URL: postgres://pysite:pysite@localhost:7777/pysite
- displayName: 'Run Tests'
-
- - script: coverage report -m && coverage xml
- displayName: 'Generate Coverage Reports'
-
- - task: PublishTestResults@2
- condition: succeededOrFailed()
- displayName: 'Publish Test & Linting Results'
- inputs:
- testResultsFiles: '**/TEST-*.xml'
- testRunTitle: 'Site Test Results'
-
- - task: PublishCodeCoverageResults@1
- displayName: 'Publish Coverage Results'
- condition: succeededOrFailed()
- inputs:
- codeCoverageTool: Cobertura
- summaryFileLocation: '**/coverage.xml'
-
- - job: build
- displayName: 'Build & Push Container'
- dependsOn: test
- condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
-
- steps:
- - task: Docker@2
- displayName: 'Build & Push Container'
- inputs:
- containerRegistry: 'DockerHub'
- repository: 'pythondiscord/site'
- command: 'buildAndPush'
- Dockerfile: 'docker/Dockerfile'
- buildContext: '.'
- tags: 'latest'
diff --git a/docker-compose.yml b/docker-compose.yml
index 73d2ff85..1f49f1f3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,11 +18,13 @@ services:
POSTGRES_DB: pysite
POSTGRES_PASSWORD: pysite
POSTGRES_USER: pysite
+ volumes:
+ - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
web:
build:
context: .
- dockerfile: docker/Dockerfile
+ dockerfile: Dockerfile
command: ["run", "--debug"]
networks:
default:
@@ -40,8 +42,10 @@ services:
- staticfiles:/var/www/static
environment:
DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite
+ METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity
SECRET_KEY: suitable-for-development-only
STATIC_ROOT: /var/www/static
+ DEBUG: 1
volumes:
staticfiles:
diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini
deleted file mode 100644
index 3f35258c..00000000
--- a/docker/uwsgi.ini
+++ /dev/null
@@ -1,38 +0,0 @@
-[uwsgi]
-### Exposed ports
-# uWSGI protocol socket
-socket = :4000
-
-### File settings
-# WSGI application
-wsgi = pydis_site.wsgi:application
-# Directory to move into at startup
-chdir = /app
-
-### Concurrency options
-# Run a master to supervise the workers
-master = true
-# Keep a minimum of 1 worker
-cheaper = 1
-# Allow a maximum of 4 workers
-workers = 4
-# Automatically set up meanginful process names
-auto-procname = true
-# Prefix process names with `pydis_site : `
-procname-prefix-spaced = pydis_site :
-
-### Worker options
-# Kill workers if they take more than 30 seconds to respond.
-harakiri = 30
-
-### Startup settings
-# Exit if we can't load the app
-need-app = true
-# `setuid` to an unprivileged user
-uid = 1500
-# Do not use multiple interpreters
-single-interpreter = true
-
-### Hook setup
-# Gracefully kill workers on `SIGQUIT`
-hook-master-start = unix_signal:3 gracefully_kill_them_all
diff --git a/docs/deployment.md b/docs/deployment.md
deleted file mode 100644
index e561b5d0..00000000
--- a/docs/deployment.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# Deployment
-The default Dockerfile should do a good job at running a solid web server that
-automatically adjusts its worker count based on traffic. This is managed by
-uWSGI. You need to configure the `DATABASE_URL` and `SECRET_KEY` variables. If
-you want to deploy to a different host than the default, configure the
-`ALLOWED_HOSTS` variable.
-
-## Static file hosting
-You can either collect the static files in the container and use uWSGI to host
-them, or put them on your host and manage them through a web server running on
-the host like nginx.
-
-## Database migrations
-To bring the schema up-to-date, first stop an existing database container, then
-start a container that just runs the migrations and exits, and then starts the
-main container off the new container again.
-
-## Ansible task
-An example Ansible task to deploy the site is shown below, it should read fairly
-humanly and give you a rough idea of steps needed to deploy the site.
-
-```yml
-- name: ensure the `{{ pysite_pg_username }}` postgres user exists
- become: yes
- become_user: postgres
- postgresql_user:
- name: "{{ pysite_pg_username }}"
- password: "{{ pysite_pg_password }}"
- when: pysite_pg_host == 'localhost'
-
-- name: ensure the `{{ pysite_pg_database }}` postgres database exists
- become: yes
- become_user: postgres
- postgresql_db:
- name: "{{ pysite_pg_database }}"
- owner: "{{ pysite_pg_username }}"
- when: pysite_pg_host == 'localhost'
-
-- name: ensure the `{{ pysite_hub_repository }}` image is up-to-date
- become: yes
- docker_image:
- name: "{{ pysite_hub_repository }}"
- force: yes
-
-- name: ensure the nginx HTTP vhosts are up-to-date
- become: yes
- template:
- src: "nginx/{{ item.key }}.http.conf.j2"
- dest: "/etc/nginx/sites-available/{{ item.value }}.http.conf"
- with_dict: "{{ pysite_domains }}"
- notify: reload nginx
-
-- name: ensure the nginx HTTPS vhosts are up-to-date
- become: yes
- template:
- src: "nginx/{{ item.key }}.https.conf.j2"
- dest: "/etc/nginx/sites-available/{{ item.value }}.https.conf"
- with_dict: "{{ pysite_domains }}"
- notify: reload nginx
-
-- name: ensure the nginx HTTP vhosts are symlinked to `/etc/nginx/sites-enabled`
- become: yes
- file:
- src: /etc/nginx/sites-available/{{ item.value }}.http.conf
- dest: /etc/nginx/sites-enabled/{{ item.value }}.http.conf
- state: link
- with_dict: "{{ pysite_domains }}"
- notify: reload nginx
-
-- name: ensure we have HTTPS certificates
- include_role:
- name: thefinn93.letsencrypt
- vars:
- letsencrypt_cert_domains: "{{ pysite_domains | dict2items | map(attribute='value') | list }}"
- letsencrypt_email: "[email protected]"
- letsencrypt_renewal_command_args: '--renew-hook "systemctl restart nginx"'
- letsencrypt_webroot_path: /var/www/_letsencrypt
-
-- name: ensure the nginx HTTPS vhosts are symlinked to `/etc/nginx/sites-enabled`
- become: yes
- file:
- src: /etc/nginx/sites-available/{{ item.value }}.https.conf
- dest: /etc/nginx/sites-enabled/{{ item.value }}.https.conf
- state: link
- with_dict: "{{ pysite_domains }}"
- notify: reload nginx
-
-- name: ensure the web container is absent
- become: yes
- docker_container:
- name: pysite
- state: absent
-
-- name: ensure the `{{ pysite_static_file_dir }}` directory exists
- become: yes
- file:
- path: "{{ pysite_static_file_dir }}"
- state: directory
- owner: root
- group: root
-
-- name: collect static files
- become: yes
- docker_container:
- image: "{{ pysite_hub_repository }}"
- name: pysite-static-file-writer
- command: python manage.py collectstatic --noinput
- detach: no
- cleanup: yes
- network_mode: host
- env:
- DATABASE_URL: "{{ pysite_pg_database_url }}"
- SECRET_KEY: "me-dont-need-no-secret-key"
- STATIC_ROOT: "/html"
- volumes:
- - "/var/www/pythondiscord.com:/html"
-
-- name: ensure the database schema is up-to-date
- become: yes
- docker_container:
- image: "{{ pysite_hub_repository }}"
- name: pysite-migrator
- detach: no
- cleanup: yes
- command: python manage.py migrate
- network_mode: host
- env:
- DATABASE_URL: "postgres://{{ pysite_pg_username }}:{{ pysite_pg_password }}@{{ pysite_pg_host }}/{{ pysite_pg_database }}"
- SECRET_KEY: "me-dont-need-no-secret-key"
-
-- name: ensure the website container is started
- become: yes
- docker_container:
- image: "{{ pysite_hub_repository }}"
- name: pysite
- network_mode: host
- restart: yes
- restart_policy: unless-stopped
- ports:
- - "127.0.0.1:4000:4000"
- env:
- ALLOWED_HOSTS: "{{ pysite_domains | dict2items | map(attribute='value') | join(',') }}"
- DATABASE_URL: "postgres://{{ pysite_pg_username }}:{{ pysite_pg_password }}@{{ pysite_pg_host }}/{{ pysite_pg_database }}"
- PARENT_HOST: pysite.example.com
- SECRET_KEY: "{{ pysite_secret_key }}"
-```
diff --git a/manage.py b/manage.py
index ee071376..fb5ee40c 100755
--- a/manage.py
+++ b/manage.py
@@ -7,10 +7,10 @@ import time
from typing import List
import django
+import gunicorn.app.wsgiapp
from django.contrib.auth import get_user_model
from django.core.management import call_command, execute_from_command_line
-
DEFAULT_ENVS = {
"DJANGO_SETTINGS_MODULE": "pydis_site.settings",
"SUPER_USERNAME": "admin",
@@ -112,6 +112,19 @@ class SiteManager:
print("Database could not be found, exiting.")
sys.exit(1)
+ @staticmethod
+ def set_dev_site_name() -> None:
+ """Set the development site domain in admin from default example."""
+ # import Site model now after django setup
+ from django.contrib.sites.models import Site
+ query = Site.objects.filter(id=1)
+ site = query.get()
+ if site.domain == "example.com":
+ query.update(
+ domain="pythondiscord.local:8000",
+ name="pythondiscord.local:8000"
+ )
+
def prepare_server(self) -> None:
"""Perform preparation tasks before running the server."""
django.setup()
@@ -125,6 +138,7 @@ class SiteManager:
call_command("collectstatic", interactive=False, clear=True, verbosity=self.verbosity)
if self.debug:
+ self.set_dev_site_name()
self.create_superuser()
def run_server(self) -> None:
@@ -142,10 +156,22 @@ class SiteManager:
call_command("runserver", "0.0.0.0:8000")
return
- import pyuwsgi
-
- # Run uwsgi for production server
- pyuwsgi.run(["--ini", "docker/uwsgi.ini"])
+ # Patch the arguments for gunicorn
+ sys.argv = [
+ "gunicorn",
+ "--preload",
+ "-b", "0.0.0.0:8000",
+ "pydis_site.wsgi:application",
+ "--threads", "8",
+ "-w", "2",
+ "--max-requests", "1000",
+ "--max-requests-jitter", "50",
+ "--statsd-host", "graphite.default.svc.cluster.local:8125",
+ "--statsd-prefix", "site",
+ ]
+
+ # Run gunicorn for the production server.
+ gunicorn.app.wsgiapp.run()
def main() -> None:
diff --git a/postgres/init.sql b/postgres/init.sql
new file mode 100644
index 00000000..740063e7
--- /dev/null
+++ b/postgres/init.sql
@@ -0,0 +1,39 @@
+CREATE DATABASE metricity;
+
+\c metricity;
+
+CREATE TABLE users (
+ id varchar,
+ joined_at timestamp,
+ primary key(id)
+);
+
+INSERT INTO users VALUES (
+ 0,
+ current_timestamp
+);
+
+CREATE TABLE messages (
+ id varchar,
+ author_id varchar references users(id),
+ is_deleted boolean,
+ created_at timestamp,
+ channel_id varchar,
+ primary key(id)
+);
+
+INSERT INTO messages VALUES(
+ 0,
+ 0,
+ false,
+ now(),
+ '267659945086812160'
+);
+
+INSERT INTO messages VALUES(
+ 1,
+ 0,
+ false,
+ now() + INTERVAL '10 minutes,',
+ '1234'
+);
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py
index 0333fefc..449e660e 100644
--- a/pydis_site/apps/api/admin.py
+++ b/pydis_site/apps/api/admin.py
@@ -1,68 +1,467 @@
-from typing import Optional
+from __future__ import annotations
+import json
+from typing import Iterable, Optional, Tuple
+
+from django import urls
from django.contrib import admin
+from django.db.models import QuerySet
from django.http import HttpRequest
+from django.utils.html import SafeString, format_html
from .models import (
BotSetting,
DeletedMessage,
DocumentationLink,
Infraction,
- LogEntry,
MessageDeletionContext,
Nomination,
OffTopicChannelName,
OffensiveMessage,
Role,
- Tag,
User
)
+from .models.bot.nomination import NominationEntry
+
+admin.site.site_header = "Python Discord | Administration"
+admin.site.site_title = "Python Discord"
+
+
[email protected](BotSetting)
+class BotSettingAdmin(admin.ModelAdmin):
+ """Admin formatting for the BotSetting model."""
+
+ fields = ("name", "data")
+ list_display = ("name",)
+ readonly_fields = ("name",)
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+ def has_delete_permission(self, *args) -> bool:
+ """Prevent deleting from django admin."""
+ return False
+
+
[email protected](DocumentationLink)
+class DocumentationLinkAdmin(admin.ModelAdmin):
+ """Admin formatting for the DocumentationLink model."""
+
+ fields = ("package", "base_url", "inventory_url")
+ list_display = ("package", "base_url", "inventory_url")
+ list_editable = ("base_url", "inventory_url")
+ search_fields = ("package",)
+
+
+class InfractionActorFilter(admin.SimpleListFilter):
+ """Actor Filter for Infraction Admin list page."""
+
+ title = "Actor"
+ parameter_name = "actor"
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ """Selectable values for viewer to filter by."""
+ actor_ids = Infraction.objects.order_by().values_list("actor").distinct()
+ actors = User.objects.filter(id__in=actor_ids)
+ return ((a.id, a.username) for a in actors)
-class LogEntryAdmin(admin.ModelAdmin):
- """Allows viewing logs in the Django Admin without allowing edits."""
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ """Query to filter the list of Users against."""
+ if not self.value():
+ return
+ return queryset.filter(actor__id=self.value())
+
+
[email protected](Infraction)
+class InfractionAdmin(admin.ModelAdmin):
+ """Admin formatting for the Infraction model."""
- actions = None
- list_display = ('timestamp', 'application', 'level', 'message')
fieldsets = (
- ('Overview', {'fields': ('timestamp', 'application', 'logger_name')}),
- ('Metadata', {'fields': ('level', 'module', 'line')}),
- ('Contents', {'fields': ('message',)})
+ ("Members", {"fields": ("user", "actor")}),
+ ("Action", {"fields": ("type", "hidden", "active")}),
+ ("Dates", {"fields": ("inserted_at", "expires_at")}),
+ ("Reason", {"fields": ("reason",)}),
)
- list_filter = ('application', 'level', 'timestamp')
- search_fields = ('message',)
readonly_fields = (
- 'application',
- 'logger_name',
- 'timestamp',
- 'level',
- 'module',
- 'line',
- 'message'
+ "user",
+ "actor",
+ "type",
+ "inserted_at",
+ "expires_at",
+ "active",
+ "hidden"
+ )
+ list_display = (
+ "type",
+ "active",
+ "user",
+ "inserted_at",
+ "reason",
+ )
+ search_fields = (
+ "id",
+ "user__name",
+ "user__id",
+ "actor__name",
+ "actor__id",
+ "reason",
+ "type"
+ )
+ list_filter = (
+ "type",
+ "hidden",
+ "active",
+ InfractionActorFilter
+ )
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+
[email protected](DeletedMessage)
+class DeletedMessageAdmin(admin.ModelAdmin):
+ """Admin formatting for the DeletedMessage model."""
+
+ fields = (
+ "id",
+ "author",
+ "channel_id",
+ "content",
+ "embed_data",
+ "context",
+ "view_full_log"
+ )
+
+ exclude = ("embeds", "deletion_context")
+
+ search_fields = (
+ "id",
+ "content",
+ "author__name",
+ "author__id",
+ "deletion_context__actor__name",
+ "deletion_context__actor__id"
+ )
+
+ list_display = ("id", "author", "channel_id")
+
+ def embed_data(self, message: DeletedMessage) -> Optional[str]:
+ """Format embed data in a code block for better readability."""
+ if message.embeds:
+ return format_html(
+ "<pre style='word-wrap: break-word; white-space: pre-wrap; overflow-x: auto;'>"
+ "<code>{0}</code></pre>",
+ json.dumps(message.embeds, indent=4)
+ )
+
+ embed_data.short_description = "Embeds"
+
+ @staticmethod
+ def context(message: DeletedMessage) -> str:
+ """Provide full context info with a link through to context admin view."""
+ link = urls.reverse(
+ "admin:api_messagedeletioncontext_change",
+ args=[message.deletion_context.id]
+ )
+ details = (
+ f"Deleted by {message.deletion_context.actor} at "
+ f"{message.deletion_context.creation}"
+ )
+ return format_html("<a href='{0}'>{1}</a>", link, details)
+
+ @staticmethod
+ def view_full_log(message: DeletedMessage) -> str:
+ """Provide a link to the message logs for the relevant context."""
+ return format_html(
+ "<a href='{0}'>Click to view full context log</a>",
+ message.deletion_context.log_url
+ )
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+ def has_change_permission(self, *args) -> bool:
+ """Prevent editing from django admin."""
+ return False
+
+
+class DeletedMessageInline(admin.TabularInline):
+ """Tabular Inline Admin model for Deleted Message to be viewed within Context."""
+
+ model = DeletedMessage
+
+
[email protected](MessageDeletionContext)
+class MessageDeletionContextAdmin(admin.ModelAdmin):
+ """Admin formatting for the MessageDeletionContext model."""
+
+ fields = ("actor", "creation")
+ list_display = ("id", "creation", "actor")
+ inlines = (DeletedMessageInline,)
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+ def has_change_permission(self, *args) -> bool:
+ """Prevent editing from django admin."""
+ return False
+
+
+class NominationActorFilter(admin.SimpleListFilter):
+ """Actor Filter for Nomination Admin list page."""
+
+ title = "Actor"
+ parameter_name = "actor"
+
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ """Selectable values for viewer to filter by."""
+ actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct()
+ actors = User.objects.filter(id__in=actor_ids)
+ return ((a.id, a.username) for a in actors)
+
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ """Query to filter the list of Users against."""
+ if not self.value():
+ return
+ nomination_ids = NominationEntry.objects.filter(
+ actor__id=self.value()
+ ).values_list("nomination_id").distinct()
+ return queryset.filter(id__in=nomination_ids)
+
+
[email protected](Nomination)
+class NominationAdmin(admin.ModelAdmin):
+ """Admin formatting for the Nomination model."""
+
+ search_fields = (
+ "user__name",
+ "user__id",
+ "end_reason"
+ )
+
+ list_filter = ("active", NominationActorFilter)
+
+ list_display = (
+ "user",
+ "active",
+ "reviewed"
+ )
+
+ fields = (
+ "user",
+ "active",
+ "inserted_at",
+ "ended_at",
+ "end_reason",
+ "reviewed"
+ )
+
+ # only allow end reason field to be edited.
+ readonly_fields = (
+ "user",
+ "active",
+ "inserted_at",
+ "ended_at",
+ "reviewed"
+ )
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+
+class NominationEntryActorFilter(admin.SimpleListFilter):
+ """Actor Filter for NominationEntry Admin list page."""
+
+ title = "Actor"
+ parameter_name = "actor"
+
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ """Selectable values for viewer to filter by."""
+ actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct()
+ actors = User.objects.filter(id__in=actor_ids)
+ return ((a.id, a.username) for a in actors)
+
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ """Query to filter the list of Users against."""
+ if not self.value():
+ return
+ return queryset.filter(actor__id=self.value())
+
+
[email protected](NominationEntry)
+class NominationEntryAdmin(admin.ModelAdmin):
+ """Admin formatting for the NominationEntry model."""
+
+ search_fields = (
+ "actor__name",
+ "actor__id",
+ "reason",
+ )
+
+ list_filter = (NominationEntryActorFilter,)
+
+ list_display = (
+ "nomination",
+ "actor",
+ )
+
+ fields = (
+ "nomination",
+ "actor",
+ "reason",
+ "inserted_at",
+ )
+
+ # only allow reason field to be edited
+ readonly_fields = (
+ "nomination",
+ "actor",
+ "inserted_at",
)
def has_add_permission(self, request: HttpRequest) -> bool:
- """Deny manual LogEntry creation."""
+ """Disable adding new nomination entry from admin."""
return False
- def has_delete_permission(
- self,
- request: HttpRequest,
- obj: Optional[LogEntry] = None
- ) -> bool:
- """Deny LogEntry deletion."""
+
[email protected](OffTopicChannelName)
+class OffTopicChannelNameAdmin(admin.ModelAdmin):
+ """Admin formatting for the OffTopicChannelName model."""
+
+ search_fields = ("name",)
+ list_filter = ("used",)
+
+
[email protected](OffensiveMessage)
+class OffensiveMessageAdmin(admin.ModelAdmin):
+ """Admin formatting for the OffensiveMessage model."""
+
+ def message_jumplink(self, message: OffensiveMessage) -> SafeString:
+ """Message ID hyperlinked to the direct discord jumplink."""
+ return format_html(
+ '<a href="https://canary.discordapp.com/channels/267624335836053506/{0}/{1}">{1}</a>',
+ message.channel_id,
+ message.id
+ )
+
+ message_jumplink.short_description = "Message ID"
+
+ search_fields = ("id", "channel_id")
+ list_display = ("id", "channel_id", "delete_date")
+ fields = ("message_jumplink", "channel_id", "delete_date")
+ readonly_fields = ("message_jumplink", "channel_id")
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+
+class RoleAdmin(admin.ModelAdmin):
+ """Admin formatting for the Role model."""
+
+ def coloured_name(self, role: Role) -> SafeString:
+ """Role name with html style colouring."""
+ return format_html(
+ '<span style="color: {0}!important; font-weight: bold;">{1}</span>',
+ f"#{role.colour:06X}",
+ role.name
+ )
+
+ coloured_name.short_description = "Name"
+
+ def colour_with_preview(self, role: Role) -> SafeString:
+ """Show colour value in both int and hex, in bolded and coloured style."""
+ return format_html(
+ "<span style='color: {0}; font-weight: bold;'>{0} ({1})</span>",
+ f"#{role.colour:06x}",
+ role.colour
+ )
+
+ colour_with_preview.short_description = "Colour"
+
+ def permissions_with_calc_link(self, role: Role) -> SafeString:
+ """Show permissions with link to API permissions calculator page."""
+ return format_html(
+ "<a href='https://discordapi.com/permissions.html#{0}' target='_blank'>{0}</a>",
+ role.permissions
+ )
+
+ permissions_with_calc_link.short_description = "Permissions"
+
+ search_fields = ("name", "id")
+ list_display = ("coloured_name",)
+ fields = ("id", "name", "colour_with_preview", "permissions_with_calc_link", "position")
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+ def has_change_permission(self, *args) -> bool:
+ """Prevent editing from django admin."""
return False
-admin.site.register(BotSetting)
-admin.site.register(DeletedMessage)
-admin.site.register(DocumentationLink)
-admin.site.register(Infraction)
-admin.site.register(LogEntry, LogEntryAdmin)
-admin.site.register(MessageDeletionContext)
-admin.site.register(Nomination)
-admin.site.register(OffensiveMessage)
-admin.site.register(OffTopicChannelName)
-admin.site.register(Role)
-admin.site.register(Tag)
-admin.site.register(User)
+class UserRoleFilter(admin.SimpleListFilter):
+ """List Filter for User list Admin page."""
+
+ title = "Role"
+ parameter_name = "role"
+
+ def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[Tuple[str, str]]:
+ """Selectable values for viewer to filter by."""
+ roles = Role.objects.all()
+ return ((r.name, r.name) for r in roles)
+
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ """Query to filter the list of Users against."""
+ if not self.value():
+ return
+ role = Role.objects.get(name=self.value())
+ return queryset.filter(roles__contains=[role.id])
+
+
+class UserAdmin(admin.ModelAdmin):
+ """Admin formatting for the User model."""
+
+ def top_role_coloured(self, user: User) -> SafeString:
+ """Returns the top role of the user with html style matching role colour."""
+ return format_html(
+ '<span style="color: {0}; font-weight: bold;">{1}</span>',
+ f"#{user.top_role.colour:06X}",
+ user.top_role.name
+ )
+
+ top_role_coloured.short_description = "Top Role"
+
+ def all_roles_coloured(self, user: User) -> SafeString:
+ """Returns all user roles with html style matching role colours."""
+ roles = Role.objects.filter(id__in=user.roles)
+ return format_html(
+ "</br>".join(
+ f'<span style="color: #{r.colour:06X}; font-weight: bold;">{r.name}</span>'
+ for r in roles
+ )
+ )
+
+ all_roles_coloured.short_description = "All Roles"
+
+ search_fields = ("name", "id", "roles")
+ list_filter = (UserRoleFilter, "in_guild")
+ list_display = ("username", "top_role_coloured", "in_guild")
+ fields = ("username", "id", "in_guild", "all_roles_coloured")
+ sortable_by = ("username",)
+
+ def has_add_permission(self, *args) -> bool:
+ """Prevent adding from django admin."""
+ return False
+
+ def has_change_permission(self, *args) -> bool:
+ """Prevent editing from django admin."""
+ return False
diff --git a/pydis_site/apps/api/dblogger.py b/pydis_site/apps/api/dblogger.py
deleted file mode 100644
index 4b4e3a9d..00000000
--- a/pydis_site/apps/api/dblogger.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from logging import LogRecord, StreamHandler
-
-
-class DatabaseLogHandler(StreamHandler):
- """Logs entries into the database."""
-
- def emit(self, record: LogRecord) -> None:
- """Write the given `record` into the database."""
- # This import needs to be deferred due to Django's application
- # registry instantiation logic loading this handler before the
- # application is ready.
- from pydis_site.apps.api.models.log_entry import LogEntry
-
- entry = LogEntry(
- application='site',
- logger_name=record.name,
- level=record.levelname.lower(),
- module=record.module,
- line=record.lineno,
- message=self.format(record)
- )
- entry.save()
diff --git a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
index d53ddb90..d92042d2 100644
--- a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
+++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
@@ -1,7 +1,5 @@
# Generated by Django 2.1.1 on 2018-09-23 10:07
-import pydis_site.apps.api.models.bot.tag
-import django.contrib.postgres.fields.jsonb
from django.db import migrations
@@ -12,9 +10,4 @@ class Migration(migrations.Migration):
]
operations = [
- migrations.AlterField(
- model_name='tag',
- name='embed',
- field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]),
- ),
]
diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py
index 33746253..6b848d64 100644
--- a/pydis_site/apps/api/migrations/0019_deletedmessage.py
+++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])),
('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])),
('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)),
- ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)),
+ ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), help_text='Embeds attached to this message.', size=None)),
('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')),
('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')),
],
diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
index e617e1c9..124c6a57 100644
--- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
+++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
@@ -3,7 +3,7 @@
import django.contrib.postgres.fields
import django.contrib.postgres.fields.jsonb
from django.db import migrations
-import pydis_site.apps.api.models.bot.tag
+import pydis_site.apps.api.models.utils
class Migration(migrations.Migration):
@@ -16,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='deletedmessage',
name='embeds',
- field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), blank=True, help_text='Embeds attached to this message.', size=None),
+ field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None),
),
]
diff --git a/pydis_site/apps/api/migrations/0051_delete_tag.py b/pydis_site/apps/api/migrations/0051_delete_tag.py
new file mode 100644
index 00000000..bada5788
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0051_delete_tag.py
@@ -0,0 +1,16 @@
+# Generated by Django 2.2.11 on 2020-04-01 06:15
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0050_remove_infractions_active_default_value'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='Tag',
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py
new file mode 100644
index 00000000..dfdf3835
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.11 on 2020-03-30 10:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0051_create_news_setting'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='offtopicchannelname',
+ name='used',
+ field=models.BooleanField(default=False, help_text='Whether or not this name has already been used during this rotation'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py
new file mode 100644
index 00000000..f0668696
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.8 on 2020-08-30 05:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0060_populate_filterlists_fix'),
+ ('api', '0052_offtopicchannelname_used'),
+ ]
+
+ operations = [
+ ]
diff --git a/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py
new file mode 100644
index 00000000..d162acf1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.8 on 2020-09-01 14:59
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0051_delete_tag'),
+ ('api', '0061_merge_20200830_0526'),
+ ]
+
+ operations = [
+ ]
diff --git a/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py
new file mode 100644
index 00000000..9eb05eaa
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.9 on 2020-09-11 21:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0062_merge_20200901_1459'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='nomination',
+ name='reason',
+ field=models.TextField(blank=True, help_text='Why this user was nominated.', null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py b/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py
new file mode 100644
index 00000000..0080eb42
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py
@@ -0,0 +1,76 @@
+# Generated by Django 3.0.9 on 2020-09-19 19:00
+
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.bot.offensive_message
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0063_Allow_blank_or_null_for_nomination_reason'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='deletedmessage',
+ options={'ordering': ('-id',)},
+ ),
+ migrations.AlterModelOptions(
+ name='messagedeletioncontext',
+ options={'ordering': ('-creation',)},
+ ),
+ migrations.AlterModelOptions(
+ name='nomination',
+ options={'ordering': ('-inserted_at',)},
+ ),
+ migrations.AlterModelOptions(
+ name='role',
+ options={'ordering': ('-position',)},
+ ),
+ migrations.AlterField(
+ model_name='deletedmessage',
+ name='channel_id',
+ field=models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')], verbose_name='Channel ID'),
+ ),
+ migrations.AlterField(
+ model_name='deletedmessage',
+ name='id',
+ field=models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')], verbose_name='ID'),
+ ),
+ migrations.AlterField(
+ model_name='nomination',
+ name='end_reason',
+ field=models.TextField(blank=True, default='', help_text='Why the nomination was ended.'),
+ ),
+ migrations.AlterField(
+ model_name='offensivemessage',
+ name='channel_id',
+ field=models.BigIntegerField(help_text='The channel ID that the message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')], verbose_name='Channel ID'),
+ ),
+ migrations.AlterField(
+ model_name='offensivemessage',
+ name='delete_date',
+ field=models.DateTimeField(help_text='The date on which the message will be auto-deleted.', validators=[pydis_site.apps.api.models.bot.offensive_message.future_date_validator], verbose_name='To Be Deleted'),
+ ),
+ migrations.AlterField(
+ model_name='offensivemessage',
+ name='id',
+ field=models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')], verbose_name='Message ID'),
+ ),
+ migrations.AlterField(
+ model_name='role',
+ name='id',
+ field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')], verbose_name='ID'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='id',
+ field=models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')], verbose_name='ID'),
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='in_guild',
+ field=models.BooleanField(default=True, help_text='Whether this user is in our server.', verbose_name='In Guild'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0064_delete_logentry.py b/pydis_site/apps/api/migrations/0064_delete_logentry.py
new file mode 100644
index 00000000..a5f344d1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0064_delete_logentry.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.0.9 on 2020-10-03 06:57
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0063_Allow_blank_or_null_for_nomination_reason'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='LogEntry',
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py b/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py
new file mode 100644
index 00000000..89bc4e02
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.0.9 on 2020-09-19 20:33
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0064_auto_20200919_1900'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='documentationlink',
+ options={'ordering': ['package']},
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py b/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py
new file mode 100644
index 00000000..298416db
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.9 on 2020-10-03 07:30
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0064_delete_logentry'),
+ ('api', '0065_auto_20200919_2033'),
+ ]
+
+ operations = [
+ ]
diff --git a/pydis_site/apps/api/migrations/0067_add_voice_ban_infraction_type.py b/pydis_site/apps/api/migrations/0067_add_voice_ban_infraction_type.py
new file mode 100644
index 00000000..9a940ff4
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0067_add_voice_ban_infraction_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.10 on 2020-10-10 16:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0066_merge_20201003_0730'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='type',
+ field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban')], help_text='The type of the infraction.', max_length=9),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0068_split_nomination_tables.py b/pydis_site/apps/api/migrations/0068_split_nomination_tables.py
new file mode 100644
index 00000000..79825ed7
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0068_split_nomination_tables.py
@@ -0,0 +1,75 @@
+# Generated by Django 3.0.11 on 2021-02-21 15:32
+
+from django.apps.registry import Apps
+from django.db import backends, migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+import django.db.models.deletion
+import pydis_site.apps.api.models.mixins
+
+
+def migrate_nominations(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ Nomination = apps.get_model("api", "Nomination")
+ NominationEntry = apps.get_model("api", "NominationEntry")
+
+ for nomination in Nomination.objects.all():
+ nomination_entry = NominationEntry(
+ nomination=nomination,
+ actor=nomination.actor,
+ reason=nomination.reason,
+ inserted_at=nomination.inserted_at
+ )
+ nomination_entry.save()
+
+
+def unmigrate_nominations(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ Nomination = apps.get_model("api", "Nomination")
+ NominationEntry = apps.get_model("api", "NominationEntry")
+
+ for entry in NominationEntry.objects.all():
+ nomination = Nomination.objects.get(pk=entry.nomination.id)
+ nomination.actor = entry.actor
+ nomination.reason = entry.reason
+ nomination.inserted_at = entry.inserted_at
+
+ nomination.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0067_add_voice_ban_infraction_type'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='NominationEntry',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('reason', models.TextField(blank=True, help_text='Why the actor nominated this user.', default="")),
+ ('inserted_at',
+ models.DateTimeField(auto_now_add=True, help_text='The creation date of this nomination entry.')),
+ ('actor', models.ForeignKey(help_text='The staff member that nominated this user.',
+ on_delete=django.db.models.deletion.CASCADE, related_name='nomination_set',
+ to='api.User')),
+ ('nomination', models.ForeignKey(help_text='The nomination this entry belongs to.',
+ on_delete=django.db.models.deletion.CASCADE, to='api.Nomination',
+ related_name='entries')),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ options={'ordering': ('-inserted_at',), 'verbose_name_plural': 'nomination entries'}
+ ),
+ migrations.RunPython(migrate_nominations, unmigrate_nominations),
+ migrations.RemoveField(
+ model_name='nomination',
+ name='actor',
+ ),
+ migrations.RemoveField(
+ model_name='nomination',
+ name='reason',
+ ),
+ migrations.AddField(
+ model_name='nomination',
+ name='reviewed',
+ field=models.BooleanField(default=False, help_text='Whether a review was made.'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0061_documentationlink_packagename_validator.py b/pydis_site/apps/api/migrations/0069_documentationlink_packagename_validator.py
index cc734c8a..4234e633 100644
--- a/pydis_site/apps/api/migrations/0061_documentationlink_packagename_validator.py
+++ b/pydis_site/apps/api/migrations/0069_documentationlink_packagename_validator.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.0.8 on 2020-08-04 12:25
+# Generated by Django 3.0.11 on 2021-03-14 23:22
import django.core.validators
from django.db import migrations, models
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('api', '0060_populate_filterlists_fix'),
+ ('api', '0068_split_nomination_tables'),
]
operations = [
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index 1d0ab7ea..fd5bf220 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -8,11 +8,10 @@ from .bot import (
Message,
MessageDeletionContext,
Nomination,
+ NominationEntry,
OffensiveMessage,
OffTopicChannelName,
Reminder,
Role,
- Tag,
User
)
-from .log_entry import LogEntry
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index efd98184..ac864de3 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -6,10 +6,9 @@ from .documentation_link import DocumentationLink
from .infraction import Infraction
from .message import Message
from .message_deletion_context import MessageDeletionContext
-from .nomination import Nomination
+from .nomination import Nomination, NominationEntry
from .off_topic_channel_name import OffTopicChannelName
from .offensive_message import OffensiveMessage
from .reminder import Reminder
from .role import Role
-from .tag import Tag
from .user import User
diff --git a/pydis_site/apps/api/models/bot/deleted_message.py b/pydis_site/apps/api/models/bot/deleted_message.py
index 1eb4516e..50b70d8c 100644
--- a/pydis_site/apps/api/models/bot/deleted_message.py
+++ b/pydis_site/apps/api/models/bot/deleted_message.py
@@ -14,6 +14,6 @@ class DeletedMessage(Message):
)
class Meta:
- """Sets the default ordering for list views to oldest first."""
+ """Sets the default ordering for list views to newest first."""
- ordering = ["id"]
+ ordering = ("-id",)
diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py
index 4f2bd2ab..529d26d1 100644
--- a/pydis_site/apps/api/models/bot/documentation_link.py
+++ b/pydis_site/apps/api/models/bot/documentation_link.py
@@ -33,3 +33,8 @@ class DocumentationLink(ModelReprMixin, models.Model):
def __str__(self):
"""Returns the package and URL for the current documentation link, for display purposes."""
return f"{self.package} - {self.base_url}"
+
+ class Meta:
+ """Defines the meta options for the documentation link model."""
+
+ ordering = ['package']
diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py
index 7660cbba..60c1e8dd 100644
--- a/pydis_site/apps/api/models/bot/infraction.py
+++ b/pydis_site/apps/api/models/bot/infraction.py
@@ -15,7 +15,8 @@ class Infraction(ModelReprMixin, models.Model):
("mute", "Mute"),
("kick", "Kick"),
("ban", "Ban"),
- ("superstar", "Superstar")
+ ("superstar", "Superstar"),
+ ("voice_ban", "Voice Ban"),
)
inserted_at = models.DateTimeField(
default=timezone.now,
diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py
index 78dcbf1d..ff06de21 100644
--- a/pydis_site/apps/api/models/bot/message.py
+++ b/pydis_site/apps/api/models/bot/message.py
@@ -5,9 +5,9 @@ from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
-from pydis_site.apps.api.models.bot.tag import validate_tag_embed
from pydis_site.apps.api.models.bot.user import User
from pydis_site.apps.api.models.mixins import ModelReprMixin
+from pydis_site.apps.api.models.utils import validate_embed
class Message(ModelReprMixin, models.Model):
@@ -21,7 +21,8 @@ class Message(ModelReprMixin, models.Model):
limit_value=0,
message="Message IDs cannot be negative."
),
- )
+ ),
+ verbose_name="ID"
)
author = models.ForeignKey(
User,
@@ -38,7 +39,8 @@ class Message(ModelReprMixin, models.Model):
limit_value=0,
message="Channel IDs cannot be negative."
),
- )
+ ),
+ verbose_name="Channel ID"
)
content = models.CharField(
max_length=2_000,
@@ -47,7 +49,7 @@ class Message(ModelReprMixin, models.Model):
)
embeds = pgfields.ArrayField(
pgfields.JSONField(
- validators=(validate_tag_embed,)
+ validators=(validate_embed,)
),
blank=True,
help_text="Embeds attached to this message."
diff --git a/pydis_site/apps/api/models/bot/message_deletion_context.py b/pydis_site/apps/api/models/bot/message_deletion_context.py
index 04ae8d34..1410250a 100644
--- a/pydis_site/apps/api/models/bot/message_deletion_context.py
+++ b/pydis_site/apps/api/models/bot/message_deletion_context.py
@@ -1,4 +1,5 @@
from django.db import models
+from django_hosts.resolvers import reverse
from pydis_site.apps.api.models.bot.user import User
from pydis_site.apps.api.models.mixins import ModelReprMixin
@@ -28,3 +29,13 @@ class MessageDeletionContext(ModelReprMixin, models.Model):
# the deletion context does not take place in the future.
help_text="When this deletion took place."
)
+
+ @property
+ def log_url(self) -> str:
+ """Create the url for the deleted message logs."""
+ return reverse('logs', host="staff", args=(self.id,))
+
+ class Meta:
+ """Set the ordering for list views to newest first."""
+
+ ordering = ("-creation",)
diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py
new file mode 100644
index 00000000..cae630f1
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/metricity.py
@@ -0,0 +1,91 @@
+from django.db import connections
+
+BLOCK_INTERVAL = 10 * 60 # 10 minute blocks
+
+EXCLUDE_CHANNELS = [
+ "267659945086812160", # Bot commands
+ "607247579608121354" # SeasonalBot commands
+]
+
+
+class NotFound(Exception):
+ """Raised when an entity cannot be found."""
+
+ pass
+
+
+class Metricity:
+ """Abstraction for a connection to the metricity database."""
+
+ def __init__(self):
+ self.cursor = connections['metricity'].cursor()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ self.cursor.close()
+
+ def user(self, user_id: str) -> dict:
+ """Query a user's data."""
+ # TODO: Swap this back to some sort of verified at date
+ columns = ["joined_at"]
+ query = f"SELECT {','.join(columns)} FROM users WHERE id = '%s'"
+ self.cursor.execute(query, [user_id])
+ values = self.cursor.fetchone()
+
+ if not values:
+ raise NotFound()
+
+ return dict(zip(columns, values))
+
+ def total_messages(self, user_id: str) -> int:
+ """Query total number of messages for a user."""
+ self.cursor.execute(
+ """
+ SELECT
+ COUNT(*)
+ FROM messages
+ WHERE
+ author_id = '%s'
+ AND NOT is_deleted
+ AND NOT %s::varchar[] @> ARRAY[channel_id]
+ """,
+ [user_id, EXCLUDE_CHANNELS]
+ )
+ values = self.cursor.fetchone()
+
+ if not values:
+ raise NotFound()
+
+ return values[0]
+
+ def total_message_blocks(self, user_id: str) -> int:
+ """
+ Query number of 10 minute blocks during which the user has been active.
+
+ This metric prevents users from spamming to achieve the message total threshold.
+ """
+ self.cursor.execute(
+ """
+ SELECT
+ COUNT(*)
+ FROM (
+ SELECT
+ (floor((extract('epoch' from created_at) / %s )) * %s) AS interval
+ FROM messages
+ WHERE
+ author_id='%s'
+ AND NOT is_deleted
+ AND NOT %s::varchar[] @> ARRAY[channel_id]
+ GROUP BY interval
+ ) block_query;
+ """,
+ [BLOCK_INTERVAL, BLOCK_INTERVAL, user_id, EXCLUDE_CHANNELS]
+ )
+ values = self.cursor.fetchone()
+
+ if not values:
+ raise NotFound()
+
+ return values[0]
diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py
index 21e34e87..221d8534 100644
--- a/pydis_site/apps/api/models/bot/nomination.py
+++ b/pydis_site/apps/api/models/bot/nomination.py
@@ -5,21 +5,12 @@ from pydis_site.apps.api.models.mixins import ModelReprMixin
class Nomination(ModelReprMixin, models.Model):
- """A helper nomination created by staff."""
+ """A general helper nomination information created by staff."""
active = models.BooleanField(
default=True,
help_text="Whether this nomination is still relevant."
)
- actor = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- help_text="The staff member that nominated this user.",
- related_name='nomination_set'
- )
- reason = models.TextField(
- help_text="Why this user was nominated."
- )
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
@@ -32,15 +23,60 @@ class Nomination(ModelReprMixin, models.Model):
)
end_reason = models.TextField(
help_text="Why the nomination was ended.",
- default=""
+ default="",
+ blank=True
)
ended_at = models.DateTimeField(
auto_now_add=False,
help_text="When the nomination was ended.",
null=True
)
+ reviewed = models.BooleanField(
+ default=False,
+ help_text="Whether a review was made."
+ )
def __str__(self):
"""Representation that makes the target and state of the nomination immediately evident."""
status = "active" if self.active else "ended"
return f"Nomination of {self.user} ({status})"
+
+ class Meta:
+ """Set the ordering of nominations to most recent first."""
+
+ ordering = ("-inserted_at",)
+
+
+class NominationEntry(ModelReprMixin, models.Model):
+ """A nomination entry created by a single staff member."""
+
+ nomination = models.ForeignKey(
+ Nomination,
+ on_delete=models.CASCADE,
+ help_text="The nomination this entry belongs to.",
+ related_name="entries"
+ )
+ actor = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The staff member that nominated this user.",
+ related_name='nomination_set'
+ )
+ reason = models.TextField(
+ help_text="Why the actor nominated this user.",
+ default="",
+ blank=True
+ )
+ inserted_at = models.DateTimeField(
+ auto_now_add=True,
+ help_text="The creation date of this nomination entry."
+ )
+
+ class Meta:
+ """Meta options for NominationEntry model."""
+
+ verbose_name_plural = "nomination entries"
+
+ # Set default ordering here to latest first
+ # so we don't need to define it everywhere
+ ordering = ("-inserted_at",)
diff --git a/pydis_site/apps/api/models/bot/off_topic_channel_name.py b/pydis_site/apps/api/models/bot/off_topic_channel_name.py
index 20e77b9f..403c7465 100644
--- a/pydis_site/apps/api/models/bot/off_topic_channel_name.py
+++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py
@@ -16,6 +16,11 @@ class OffTopicChannelName(ModelReprMixin, models.Model):
help_text="The actual channel name that will be used on our Discord server."
)
+ used = models.BooleanField(
+ default=False,
+ help_text="Whether or not this name has already been used during this rotation",
+ )
+
def __str__(self):
"""Returns the current off-topic name, for display purposes."""
return self.name
diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py
index 6c0e5ffb..74dab59b 100644
--- a/pydis_site/apps/api/models/bot/offensive_message.py
+++ b/pydis_site/apps/api/models/bot/offensive_message.py
@@ -24,7 +24,8 @@ class OffensiveMessage(ModelReprMixin, models.Model):
limit_value=0,
message="Message IDs cannot be negative."
),
- )
+ ),
+ verbose_name="Message ID"
)
channel_id = models.BigIntegerField(
help_text=(
@@ -36,11 +37,13 @@ class OffensiveMessage(ModelReprMixin, models.Model):
limit_value=0,
message="Channel IDs cannot be negative."
),
- )
+ ),
+ verbose_name="Channel ID"
)
delete_date = models.DateTimeField(
help_text="The date on which the message will be auto-deleted.",
- validators=(future_date_validator,)
+ validators=(future_date_validator,),
+ verbose_name="To Be Deleted"
)
def __str__(self):
diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py
index 721e4815..cfadfec4 100644
--- a/pydis_site/apps/api/models/bot/role.py
+++ b/pydis_site/apps/api/models/bot/role.py
@@ -22,7 +22,8 @@ class Role(ModelReprMixin, models.Model):
message="Role IDs cannot be negative."
),
),
- help_text="The role ID, taken from Discord."
+ help_text="The role ID, taken from Discord.",
+ verbose_name="ID"
)
name = models.CharField(
max_length=100,
@@ -65,3 +66,8 @@ class Role(ModelReprMixin, models.Model):
def __le__(self, other: Role) -> bool:
"""Compares the roles based on their position in the role hierarchy of the guild."""
return self.position <= other.position
+
+ class Meta:
+ """Set role ordering from highest to lowest position."""
+
+ ordering = ("-position",)
diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py
index cd2d58b9..afc5ba1e 100644
--- a/pydis_site/apps/api/models/bot/user.py
+++ b/pydis_site/apps/api/models/bot/user.py
@@ -26,11 +26,12 @@ class User(ModelReprMixin, models.Model):
message="User IDs cannot be negative."
),
),
+ verbose_name="ID",
help_text="The ID of this user, taken from Discord."
)
name = models.CharField(
max_length=32,
- help_text="The username, taken from Discord."
+ help_text="The username, taken from Discord.",
)
discriminator = models.PositiveSmallIntegerField(
validators=(
@@ -57,12 +58,13 @@ class User(ModelReprMixin, models.Model):
)
in_guild = models.BooleanField(
default=True,
- help_text="Whether this user is in our server."
+ help_text="Whether this user is in our server.",
+ verbose_name="In Guild"
)
def __str__(self):
"""Returns the name and discriminator for the current user, for display purposes."""
- return f"{self.name}#{self.discriminator:0>4}"
+ return f"{self.name}#{self.discriminator:04d}"
@property
def top_role(self) -> Role:
@@ -75,3 +77,12 @@ class User(ModelReprMixin, models.Model):
if not roles:
return Role.objects.get(name="Developers")
return max(roles)
+
+ @property
+ def username(self) -> str:
+ """
+ Returns the display version with name and discriminator as a standard attribute.
+
+ For usability in read-only fields such as Django Admin.
+ """
+ return str(self)
diff --git a/pydis_site/apps/api/models/log_entry.py b/pydis_site/apps/api/models/log_entry.py
deleted file mode 100644
index 752cd2ca..00000000
--- a/pydis_site/apps/api/models/log_entry.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from django.db import models
-from django.utils import timezone
-
-from pydis_site.apps.api.models.mixins import ModelReprMixin
-
-
-class LogEntry(ModelReprMixin, models.Model):
- """A log entry generated by one of the PyDis applications."""
-
- application = models.CharField(
- max_length=20,
- help_text="The application that generated this log entry.",
- choices=(
- ('bot', 'Bot'),
- ('seasonalbot', 'Seasonalbot'),
- ('site', 'Website')
- )
- )
- logger_name = models.CharField(
- max_length=100,
- help_text="The name of the logger that generated this log entry."
- )
- timestamp = models.DateTimeField(
- default=timezone.now,
- help_text="The date and time when this entry was created."
- )
- level = models.CharField(
- max_length=8, # 'critical'
- choices=(
- ('debug', 'Debug'),
- ('info', 'Info'),
- ('warning', 'Warning'),
- ('error', 'Error'),
- ('critical', 'Critical')
- ),
- help_text=(
- "The logger level at which this entry was emitted. The levels "
- "correspond to the Python `logging` levels."
- )
- )
- module = models.CharField(
- max_length=100,
- help_text="The fully qualified path of the module generating this log line."
- )
- line = models.PositiveSmallIntegerField(
- help_text="The line at which the log line was emitted."
- )
- message = models.TextField(
- help_text="The textual content of the log line."
- )
-
- class Meta:
- """Customizes the default generated plural name to valid English."""
-
- verbose_name_plural = 'Log entries'
diff --git a/pydis_site/apps/api/models/bot/tag.py b/pydis_site/apps/api/models/utils.py
index 5e53582f..107231ba 100644
--- a/pydis_site/apps/api/models/bot/tag.py
+++ b/pydis_site/apps/api/models/utils.py
@@ -1,12 +1,8 @@
from collections.abc import Mapping
from typing import Any, Dict
-from django.contrib.postgres import fields as pgfields
from django.core.exceptions import ValidationError
from django.core.validators import MaxLengthValidator, MinLengthValidator
-from django.db import models
-
-from pydis_site.apps.api.models.mixins import ModelReprMixin
def is_bool_validator(value: Any) -> None:
@@ -15,7 +11,7 @@ def is_bool_validator(value: Any) -> None:
raise ValidationError(f"This field must be of type bool, not {type(value)}.")
-def validate_tag_embed_fields(fields: dict) -> None:
+def validate_embed_fields(fields: dict) -> None:
"""Raises a ValidationError if any of the given embed fields is invalid."""
field_validators = {
'name': (MaxLengthValidator(limit_value=256),),
@@ -42,7 +38,7 @@ def validate_tag_embed_fields(fields: dict) -> None:
validator(value)
-def validate_tag_embed_footer(footer: Dict[str, str]) -> None:
+def validate_embed_footer(footer: Dict[str, str]) -> None:
"""Raises a ValidationError if the given footer is invalid."""
field_validators = {
'text': (
@@ -67,7 +63,7 @@ def validate_tag_embed_footer(footer: Dict[str, str]) -> None:
validator(value)
-def validate_tag_embed_author(author: Any) -> None:
+def validate_embed_author(author: Any) -> None:
"""Raises a ValidationError if the given author is invalid."""
field_validators = {
'name': (
@@ -93,7 +89,7 @@ def validate_tag_embed_author(author: Any) -> None:
validator(value)
-def validate_tag_embed(embed: Any) -> None:
+def validate_embed(embed: Any) -> None:
"""
Validate a JSON document containing an embed as possible to send on Discord.
@@ -109,11 +105,11 @@ def validate_tag_embed(embed: Any) -> None:
>>> from django.contrib.postgres import fields as pgfields
>>> from django.db import models
- >>> from pydis_site.apps.api.models.bot.tag import validate_tag_embed
+ >>> from pydis_site.apps.api.models.utils import validate_embed
>>> class MyMessage(models.Model):
... embed = pgfields.JSONField(
... validators=(
- ... validate_tag_embed,
+ ... validate_embed,
... )
... )
... # ...
@@ -149,10 +145,10 @@ def validate_tag_embed(embed: Any) -> None:
'description': (MaxLengthValidator(limit_value=2048),),
'fields': (
MaxLengthValidator(limit_value=25),
- validate_tag_embed_fields
+ validate_embed_fields
),
- 'footer': (validate_tag_embed_footer,),
- 'author': (validate_tag_embed_author,)
+ 'footer': (validate_embed_footer,),
+ 'author': (validate_embed_author,)
}
if not embed:
@@ -175,24 +171,3 @@ def validate_tag_embed(embed: Any) -> None:
if field_name in field_validators:
for validator in field_validators[field_name]:
validator(value)
-
-
-class Tag(ModelReprMixin, models.Model):
- """A tag providing (hopefully) useful information."""
-
- title = models.CharField(
- max_length=100,
- help_text=(
- "The title of this tag, shown in searches and providing "
- "a quick overview over what this embed contains."
- ),
- primary_key=True
- )
- embed = pgfields.JSONField(
- help_text="The actual embed shown by this tag.",
- validators=(validate_tag_embed,)
- )
-
- def __str__(self):
- """Returns the title of this tag, for display purposes."""
- return self.title
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 52e0d972..f47bedca 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -1,7 +1,16 @@
"""Converters from Django models to data interchange formats and back."""
-from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError
+from django.db.models.query import QuerySet
+from django.db.utils import IntegrityError
+from rest_framework.exceptions import NotFound
+from rest_framework.serializers import (
+ IntegerField,
+ ListSerializer,
+ ModelSerializer,
+ PrimaryKeyRelatedField,
+ ValidationError
+)
+from rest_framework.settings import api_settings
from rest_framework.validators import UniqueTogetherValidator
-from rest_framework_bulk import BulkSerializerMixin
from .models import (
BotSetting,
@@ -9,14 +18,13 @@ from .models import (
DocumentationLink,
FilterList,
Infraction,
- LogEntry,
MessageDeletionContext,
Nomination,
+ NominationEntry,
OffTopicChannelName,
OffensiveMessage,
Reminder,
Role,
- Tag,
User
)
@@ -160,7 +168,7 @@ class InfractionSerializer(ModelSerializer):
raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']})
hidden = attrs.get('hidden')
- if hidden and infr_type in ('superstar', 'warning'):
+ if hidden and infr_type in ('superstar', 'warning', 'voice_ban'):
raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']})
if not hidden and infr_type in ('note', ):
@@ -192,19 +200,6 @@ class ExpandedInfractionSerializer(InfractionSerializer):
return ret
-class LogEntrySerializer(ModelSerializer):
- """A class providing (de-)serialization of `LogEntry` instances."""
-
- class Meta:
- """Metadata defined for the Django REST Framework."""
-
- model = LogEntry
- fields = (
- 'application', 'logger_name', 'timestamp',
- 'level', 'module', 'line', 'message'
- )
-
-
class OffTopicChannelNameSerializer(ModelSerializer):
"""A class providing (de-)serialization of `OffTopicChannelName` instances."""
@@ -250,37 +245,130 @@ class RoleSerializer(ModelSerializer):
fields = ('id', 'name', 'colour', 'permissions', 'position')
-class TagSerializer(ModelSerializer):
- """A class providing (de-)serialization of `Tag` instances."""
+class UserListSerializer(ListSerializer):
+ """List serializer for User model to handle bulk updates."""
- class Meta:
- """Metadata defined for the Django REST Framework."""
+ def create(self, validated_data: list) -> list:
+ """Override create method to optimize django queries."""
+ new_users = []
+ seen = set()
+
+ for user_dict in validated_data:
+ if user_dict["id"] in seen:
+ raise ValidationError(
+ {"id": [f"User with ID {user_dict['id']} given multiple times."]}
+ )
+ seen.add(user_dict["id"])
+ new_users.append(User(**user_dict))
+
+ User.objects.bulk_create(new_users, ignore_conflicts=True)
+ return []
+
+ def update(self, queryset: QuerySet, validated_data: list) -> list:
+ """
+ Override update method to support bulk updates.
+
+ ref:https://www.django-rest-framework.org/api-guide/serializers/#customizing-multiple-update
+ """
+ object_ids = set()
+
+ for data in validated_data:
+ try:
+ if data["id"] in object_ids:
+ # If request data contains users with same ID.
+ raise ValidationError(
+ {"id": [f"User with ID {data['id']} given multiple times."]}
+ )
+ except KeyError:
+ # If user ID not provided in request body.
+ raise ValidationError(
+ {"id": ["This field is required."]}
+ )
+ object_ids.add(data["id"])
- model = Tag
- fields = ('title', 'embed')
+ # filter queryset
+ filtered_instances = queryset.filter(id__in=object_ids)
+ instance_mapping = {user.id: user for user in filtered_instances}
-class UserSerializer(BulkSerializerMixin, ModelSerializer):
+ updated = []
+ fields_to_update = set()
+ for user_data in validated_data:
+ for key in user_data:
+ fields_to_update.add(key)
+
+ try:
+ user = instance_mapping[user_data["id"]]
+ except KeyError:
+ raise NotFound({"detail": f"User with id {user_data['id']} not found."})
+
+ user.__dict__.update(user_data)
+ updated.append(user)
+
+ fields_to_update.remove("id")
+
+ if not fields_to_update:
+ # Raise ValidationError when only id field is given.
+ raise ValidationError(
+ {api_settings.NON_FIELD_ERRORS_KEY: ["Insufficient data provided."]}
+ )
+
+ User.objects.bulk_update(updated, fields_to_update)
+ return updated
+
+
+class UserSerializer(ModelSerializer):
"""A class providing (de-)serialization of `User` instances."""
+ # ID field must be explicitly set as the default id field is read-only.
+ id = IntegerField(min_value=0)
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = User
fields = ('id', 'name', 'discriminator', 'roles', 'in_guild')
depth = 1
+ list_serializer_class = UserListSerializer
+
+ def create(self, validated_data: dict) -> User:
+ """Override create method to catch IntegrityError."""
+ try:
+ return super().create(validated_data)
+ except IntegrityError:
+ raise ValidationError({"id": ["User with ID already present."]})
+
+
+class NominationEntrySerializer(ModelSerializer):
+ """A class providing (de-)serialization of `NominationEntry` instances."""
+
+ # We need to define it here, because we don't want that nomination ID
+ # return inside nomination response entry, because ID is already available
+ # as top-level field. Queryset is required if field is not read only.
+ nomination = PrimaryKeyRelatedField(
+ queryset=Nomination.objects.all(),
+ write_only=True
+ )
+
+ class Meta:
+ """Metadata defined for the Django REST framework."""
+
+ model = NominationEntry
+ fields = ('nomination', 'actor', 'reason', 'inserted_at')
class NominationSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Nomination` instances."""
+ entries = NominationEntrySerializer(many=True, read_only=True)
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = Nomination
fields = (
- 'id', 'active', 'actor', 'reason', 'user',
- 'inserted_at', 'end_reason', 'ended_at')
+ 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries'
+ )
class OffensiveMessageSerializer(ModelSerializer):
diff --git a/pydis_site/apps/api/tests/test_dblogger.py b/pydis_site/apps/api/tests/test_dblogger.py
deleted file mode 100644
index bb19f297..00000000
--- a/pydis_site/apps/api/tests/test_dblogger.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import logging
-from datetime import datetime
-
-from django.test import TestCase
-
-from ..dblogger import DatabaseLogHandler
-from ..models import LogEntry
-
-
-class DatabaseLogHandlerTests(TestCase):
- def test_logs_to_database(self):
- module_basename = __name__.split('.')[-1]
- logger = logging.getLogger(__name__)
- logger.handlers = [DatabaseLogHandler()]
- logger.warning("I am a test case!")
-
- # Ensure we only have a single record in the database
- # after the logging call above.
- [entry] = LogEntry.objects.all()
-
- self.assertEqual(entry.application, 'site')
- self.assertEqual(entry.logger_name, __name__)
- self.assertIsInstance(entry.timestamp, datetime)
- self.assertEqual(entry.level, 'warning')
- self.assertEqual(entry.module, module_basename)
- self.assertIsInstance(entry.line, int)
- self.assertEqual(entry.message, "I am a test case!")
diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py
index f079a8dd..40450844 100644
--- a/pydis_site/apps/api/tests/test_deleted_messages.py
+++ b/pydis_site/apps/api/tests/test_deleted_messages.py
@@ -1,5 +1,6 @@
from datetime import datetime
+from django.utils import timezone
from django_hosts.resolvers import reverse
from .base import APISubdomainTestCase
@@ -76,3 +77,23 @@ class DeletedMessagesWithActorTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
[context] = MessageDeletionContext.objects.all()
self.assertEqual(context.actor.id, self.actor.id)
+
+
+class DeletedMessagesLogURLTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = cls.actor = User.objects.create(
+ id=324888,
+ name='Black Knight',
+ discriminator=1975,
+ )
+
+ cls.deletion_context = MessageDeletionContext.objects.create(
+ actor=cls.actor,
+ creation=timezone.now()
+ )
+
+ def test_valid_log_url(self):
+ expected_url = reverse('logs', host="staff", args=(1,))
+ [context] = MessageDeletionContext.objects.all()
+ self.assertEqual(context.log_url, expected_url)
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index 93ef8171..82b497aa 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -512,6 +512,36 @@ class CreationTests(APISubdomainTestCase):
)
+class InfractionDeletionTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = User.objects.create(
+ id=9876,
+ name='Unknown user',
+ discriminator=9876,
+ )
+
+ cls.warning = Infraction.objects.create(
+ user_id=cls.user.id,
+ actor_id=cls.user.id,
+ type='warning',
+ active=False
+ )
+
+ def test_delete_unknown_infraction_returns_404(self):
+ url = reverse('bot:infraction-detail', args=('something',), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_delete_known_infraction_returns_204(self):
+ url = reverse('bot:infraction-detail', args=(self.warning.id,), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 204)
+ self.assertRaises(Infraction.DoesNotExist, Infraction.objects.get, id=self.warning.id)
+
+
class ExpandedTests(APISubdomainTestCase):
@classmethod
def setUpTestData(cls):
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index e0e347bb..66052e01 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -10,11 +10,11 @@ from pydis_site.apps.api.models import (
Message,
MessageDeletionContext,
Nomination,
+ NominationEntry,
OffTopicChannelName,
OffensiveMessage,
Reminder,
Role,
- Tag,
User
)
from pydis_site.apps.api.models.mixins import ModelReprMixin
@@ -38,17 +38,11 @@ class StringDunderMethodTests(SimpleTestCase):
def setUp(self):
self.nomination = Nomination(
id=123,
- actor=User(
- id=9876,
- name='Mr. Hemlock',
- discriminator=6666,
- ),
user=User(
id=9876,
name="Hemlock's Cat",
discriminator=7777,
),
- reason="He purrrrs like the best!",
)
self.objects = (
@@ -104,10 +98,6 @@ class StringDunderMethodTests(SimpleTestCase):
),
creation=dt.utcnow()
),
- Tag(
- title='bob',
- embed={'content': "the builder"}
- ),
User(
id=5,
name='bob',
@@ -140,6 +130,15 @@ class StringDunderMethodTests(SimpleTestCase):
),
content="oh no",
expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc)
+ ),
+ NominationEntry(
+ nomination_id=self.nomination.id,
+ actor=User(
+ id=9876,
+ name='Mr. Hemlock',
+ discriminator=6666,
+ ),
+ reason="He purrrrs like the best!",
)
)
diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py
index 92c62c87..9cefbd8f 100644
--- a/pydis_site/apps/api/tests/test_nominations.py
+++ b/pydis_site/apps/api/tests/test_nominations.py
@@ -3,7 +3,7 @@ from datetime import datetime as dt, timedelta, timezone
from django_hosts.resolvers import reverse
from .base import APISubdomainTestCase
-from ..models import Nomination, User
+from ..models import Nomination, NominationEntry, User
class CreationTests(APISubdomainTestCase):
@@ -14,6 +14,11 @@ class CreationTests(APISubdomainTestCase):
name='joe dart',
discriminator=1111,
)
+ cls.user2 = User.objects.create(
+ id=9876,
+ name='Who?',
+ discriminator=1234
+ )
def test_accepts_valid_data(self):
url = reverse('bot:nomination-list', host='api')
@@ -27,17 +32,39 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
nomination = Nomination.objects.get(id=response.json()['id'])
+ nomination_entry = NominationEntry.objects.get(
+ nomination_id=nomination.id,
+ actor_id=self.user.id
+ )
self.assertAlmostEqual(
nomination.inserted_at,
dt.now(timezone.utc),
delta=timedelta(seconds=2)
)
self.assertEqual(nomination.user.id, data['user'])
- self.assertEqual(nomination.actor.id, data['actor'])
- self.assertEqual(nomination.reason, data['reason'])
+ self.assertEqual(nomination_entry.reason, data['reason'])
self.assertEqual(nomination.active, True)
- def test_returns_400_on_second_active_nomination(self):
+ def test_returns_200_on_second_active_nomination_by_different_user(self):
+ url = reverse('bot:nomination-list', host='api')
+ first_data = {
+ 'actor': self.user.id,
+ 'reason': 'Joe Dart on Fender Bass',
+ 'user': self.user.id,
+ }
+ second_data = {
+ 'actor': self.user2.id,
+ 'reason': 'Great user',
+ 'user': self.user.id
+ }
+
+ response1 = self.client.post(url, data=first_data)
+ self.assertEqual(response1.status_code, 201)
+
+ response2 = self.client.post(url, data=second_data)
+ self.assertEqual(response2.status_code, 201)
+
+ def test_returns_400_on_second_active_nomination_by_existing_nominator(self):
url = reverse('bot:nomination-list', host='api')
data = {
'actor': self.user.id,
@@ -51,7 +78,7 @@ class CreationTests(APISubdomainTestCase):
response2 = self.client.post(url, data=data)
self.assertEqual(response2.status_code, 400)
self.assertEqual(response2.json(), {
- 'active': ['There can only be one active nomination.']
+ 'actor': ['This actor has already endorsed this nomination.']
})
def test_returns_400_for_missing_user(self):
@@ -80,7 +107,7 @@ class CreationTests(APISubdomainTestCase):
'actor': ['This field is required.']
})
- def test_returns_400_for_missing_reason(self):
+ def test_returns_201_for_missing_reason(self):
url = reverse('bot:nomination-list', host='api')
data = {
'user': self.user.id,
@@ -88,10 +115,7 @@ class CreationTests(APISubdomainTestCase):
}
response = self.client.post(url, data=data)
- self.assertEqual(response.status_code, 400)
- self.assertEqual(response.json(), {
- 'reason': ['This field is required.']
- })
+ self.assertEqual(response.status_code, 201)
def test_returns_400_for_bad_user(self):
url = reverse('bot:nomination-list', host='api')
@@ -192,30 +216,40 @@ class NominationTests(APISubdomainTestCase):
)
cls.active_nomination = Nomination.objects.create(
- user=cls.user,
+ user=cls.user
+ )
+ cls.active_nomination_entry = NominationEntry.objects.create(
+ nomination=cls.active_nomination,
actor=cls.user,
reason="He's pretty funky"
)
cls.inactive_nomination = Nomination.objects.create(
user=cls.user,
- actor=cls.user,
- reason="He's pretty funky",
active=False,
end_reason="His neck couldn't hold the funk",
ended_at="5018-11-20T15:52:00+00:00"
)
+ cls.inactive_nomination_entry = NominationEntry.objects.create(
+ nomination=cls.inactive_nomination,
+ actor=cls.user,
+ reason="He's pretty funky"
+ )
- def test_returns_200_update_reason_on_active(self):
+ def test_returns_200_update_reason_on_active_with_actor(self):
url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api')
data = {
- 'reason': "He's one funky duck"
+ 'reason': "He's one funky duck",
+ 'actor': self.user.id
}
response = self.client.patch(url, data=data)
self.assertEqual(response.status_code, 200)
- nomination = Nomination.objects.get(id=response.json()['id'])
- self.assertEqual(nomination.reason, data['reason'])
+ nomination_entry = NominationEntry.objects.get(
+ nomination_id=response.json()['id'],
+ actor_id=self.user.id
+ )
+ self.assertEqual(nomination_entry.reason, data['reason'])
def test_returns_400_on_frozen_field_update(self):
url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api')
@@ -244,14 +278,18 @@ class NominationTests(APISubdomainTestCase):
def test_returns_200_update_reason_on_inactive(self):
url = reverse('bot:nomination-detail', args=(self.inactive_nomination.id,), host='api')
data = {
- 'reason': "He's one funky duck"
+ 'reason': "He's one funky duck",
+ 'actor': self.user.id
}
response = self.client.patch(url, data=data)
self.assertEqual(response.status_code, 200)
- nomination = Nomination.objects.get(id=response.json()['id'])
- self.assertEqual(nomination.reason, data['reason'])
+ nomination_entry = NominationEntry.objects.get(
+ nomination_id=response.json()['id'],
+ actor_id=self.user.id
+ )
+ self.assertEqual(nomination_entry.reason, data['reason'])
def test_returns_200_update_end_reason_on_inactive(self):
url = reverse('bot:nomination-detail', args=(self.inactive_nomination.id,), host='api')
@@ -445,3 +483,50 @@ class NominationTests(APISubdomainTestCase):
infractions = response.json()
self.assertEqual(len(infractions), 2)
+
+ def test_patch_nomination_set_reviewed_of_active_nomination(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reviewed': True}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_patch_nomination_set_reviewed_of_inactive_nomination(self):
+ url = reverse('api:nomination-detail', args=(self.inactive_nomination.id,), host='api')
+ data = {'reviewed': True}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'reviewed': ['This field cannot be set if the nomination is inactive.']
+ })
+
+ def test_patch_nomination_set_reviewed_and_end(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reviewed': True, 'active': False, 'end_reason': "What?"}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'reviewed': ['This field cannot be set while you are ending a nomination.']
+ })
+
+ def test_modifying_reason_without_actor(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reason': 'That is my reason!'}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'actor': ['This field is required when editing the reason.']
+ })
+
+ def test_modifying_reason_with_unknown_actor(self):
+ url = reverse('api:nomination-detail', args=(self.active_nomination.id,), host='api')
+ data = {'reason': 'That is my reason!', 'actor': 90909090909090}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'actor': ["The actor doesn't exist or has not nominated the user."]
+ })
diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py
index bd42cd81..3ab8b22d 100644
--- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py
+++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py
@@ -10,12 +10,14 @@ class UnauthenticatedTests(APISubdomainTestCase):
self.client.force_authenticate(user=None)
def test_cannot_read_off_topic_channel_name_list(self):
+ """Return a 401 response when not authenticated."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(url)
self.assertEqual(response.status_code, 401)
def test_cannot_read_off_topic_channel_name_list_with_random_item_param(self):
+ """Return a 401 response when `random_items` provided and not authenticated."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=no')
@@ -24,6 +26,7 @@ class UnauthenticatedTests(APISubdomainTestCase):
class EmptyDatabaseTests(APISubdomainTestCase):
def test_returns_empty_object(self):
+ """Return empty list when no names in database."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(url)
@@ -31,6 +34,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):
self.assertEqual(response.json(), [])
def test_returns_empty_list_with_get_all_param(self):
+ """Return empty list when no names and `random_items` param provided."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=5')
@@ -38,6 +42,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):
self.assertEqual(response.json(), [])
def test_returns_400_for_bad_random_items_param(self):
+ """Return error message when passing not integer as `random_items`."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=totally-a-valid-integer')
@@ -47,6 +52,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):
})
def test_returns_400_for_negative_random_items_param(self):
+ """Return error message when passing negative int as `random_items`."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=-5')
@@ -59,10 +65,11 @@ class EmptyDatabaseTests(APISubdomainTestCase):
class ListTests(APISubdomainTestCase):
@classmethod
def setUpTestData(cls):
- cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand')
- cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk')
+ cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand', used=False)
+ cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk', used=True)
def test_returns_name_in_list(self):
+ """Return all off-topic channel names."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(url)
@@ -76,11 +83,21 @@ class ListTests(APISubdomainTestCase):
)
def test_returns_single_item_with_random_items_param_set_to_1(self):
+ """Return not-used name instead used."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=1')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
+ self.assertEqual(response.json(), [self.test_name.name])
+
+ def test_running_out_of_names_with_random_parameter(self):
+ """Reset names `used` parameter to `False` when running out of names."""
+ url = reverse('bot:offtopicchannelname-list', host='api')
+ response = self.client.get(f'{url}?random_items=2')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [self.test_name.name, self.test_name_2.name])
class CreationTests(APISubdomainTestCase):
@@ -93,6 +110,7 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
def test_returns_201_for_unicode_chars(self):
+ """Accept all valid characters."""
url = reverse('bot:offtopicchannelname-list', host='api')
names = (
'𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹',
@@ -104,6 +122,7 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
def test_returns_400_for_missing_name_param(self):
+ """Return error message when name not provided."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.post(url)
self.assertEqual(response.status_code, 400)
@@ -112,6 +131,7 @@ class CreationTests(APISubdomainTestCase):
})
def test_returns_400_for_bad_name_param(self):
+ """Return error message when invalid characters provided."""
url = reverse('bot:offtopicchannelname-list', host='api')
invalid_names = (
'space between words',
@@ -134,18 +154,21 @@ class DeletionTests(APISubdomainTestCase):
cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk')
def test_deleting_unknown_name_returns_404(self):
+ """Return 404 reponse when trying to delete unknown name."""
url = reverse('bot:offtopicchannelname-detail', args=('unknown-name',), host='api')
response = self.client.delete(url)
self.assertEqual(response.status_code, 404)
def test_deleting_known_name_returns_204(self):
+ """Return 204 response when deleting was successful."""
url = reverse('bot:offtopicchannelname-detail', args=(self.test_name.name,), host='api')
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
def test_name_gets_deleted(self):
+ """Name gets actually deleted."""
url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api')
response = self.client.delete(url)
diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py
index a05d9296..9dffb668 100644
--- a/pydis_site/apps/api/tests/test_reminders.py
+++ b/pydis_site/apps/api/tests/test_reminders.py
@@ -163,6 +163,34 @@ class ReminderListTests(APISubdomainTestCase):
self.assertEqual(response.json(), [self.rem_dict_one])
+class ReminderRetrieveTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = User.objects.create(
+ id=6789,
+ name='Reminder author',
+ discriminator=6789,
+ )
+
+ cls.reminder = Reminder.objects.create(
+ author=cls.author,
+ content="Reminder content",
+ expiration=datetime.utcnow().isoformat(),
+ jump_url="http://example.com/",
+ channel_id=123
+ )
+
+ def test_retrieve_unknown_returns_404(self):
+ url = reverse('bot:reminder-detail', args=("not_an_id",), host='api')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_retrieve_known_returns_200(self):
+ url = reverse('bot:reminder-detail', args=(self.reminder.id,), host='api')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+
class ReminderUpdateTests(APISubdomainTestCase):
@classmethod
def setUpTestData(cls):
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
index 4c0f6e27..69bbfefc 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -1,7 +1,11 @@
+from unittest.mock import patch
+
+from django.core.exceptions import ObjectDoesNotExist
from django_hosts.resolvers import reverse
from .base import APISubdomainTestCase
from ..models import Role, User
+from ..models.bot.metricity import NotFound
class UnauthedUserAPITests(APISubdomainTestCase):
@@ -45,6 +49,13 @@ class CreationTests(APISubdomainTestCase):
position=1
)
+ cls.user = User.objects.create(
+ id=11,
+ name="Name doesn't matter.",
+ discriminator=1122,
+ in_guild=True
+ )
+
def test_accepts_valid_data(self):
url = reverse('bot:user-list', host='api')
data = {
@@ -89,7 +100,7 @@ class CreationTests(APISubdomainTestCase):
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 201)
- self.assertEqual(response.json(), data)
+ self.assertEqual(response.json(), [])
def test_returns_400_for_unknown_role_id(self):
url = reverse('bot:user-list', host='api')
@@ -115,6 +126,176 @@ class CreationTests(APISubdomainTestCase):
response = self.client.post(url, data=data)
self.assertEqual(response.status_code, 400)
+ def test_returns_400_for_user_recreation(self):
+ """Return 201 if User is already present in database as it skips User creation."""
+ url = reverse('bot:user-list', host='api')
+ data = [{
+ 'id': 11,
+ 'name': 'You saw nothing.',
+ 'discriminator': 112,
+ 'in_guild': True
+ }]
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 201)
+
+ def test_returns_400_for_duplicate_request_users(self):
+ """Return 400 if 2 Users with same ID is passed in the request data."""
+ url = reverse('bot:user-list', host='api')
+ data = [
+ {
+ 'id': 11,
+ 'name': 'You saw nothing.',
+ 'discriminator': 112,
+ 'in_guild': True
+ },
+ {
+ 'id': 11,
+ 'name': 'You saw nothing part 2.',
+ 'discriminator': 1122,
+ 'in_guild': False
+ }
+ ]
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_returns_400_for_existing_user(self):
+ """Returns 400 if user is already present in DB."""
+ url = reverse('bot:user-list', host='api')
+ data = {
+ 'id': 11,
+ 'name': 'You saw nothing part 3.',
+ 'discriminator': 1122,
+ 'in_guild': True
+ }
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
+
+class MultiPatchTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.role_developer = Role.objects.create(
+ id=159,
+ name="Developer",
+ colour=2,
+ permissions=0b01010010101,
+ position=10,
+ )
+ cls.user_1 = User.objects.create(
+ id=1,
+ name="Patch test user 1.",
+ discriminator=1111,
+ in_guild=True
+ )
+ cls.user_2 = User.objects.create(
+ id=2,
+ name="Patch test user 2.",
+ discriminator=2222,
+ in_guild=True
+ )
+
+ def test_multiple_users_patch(self):
+ url = reverse("bot:user-bulk-patch", host="api")
+ data = [
+ {
+ "id": 1,
+ "name": "User 1 patched!",
+ "discriminator": 1010,
+ "roles": [self.role_developer.id],
+ "in_guild": False
+ },
+ {
+ "id": 2,
+ "name": "User 2 patched!"
+ }
+ ]
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()[0], data[0])
+
+ user_2 = User.objects.get(id=2)
+ self.assertEqual(user_2.name, data[1]["name"])
+
+ def test_returns_400_for_missing_user_id(self):
+ url = reverse("bot:user-bulk-patch", host="api")
+ data = [
+ {
+ "name": "I am ghost user!",
+ "discriminator": 1010,
+ "roles": [self.role_developer.id],
+ "in_guild": False
+ },
+ {
+ "name": "patch me? whats my id?"
+ }
+ ]
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_returns_404_for_not_found_user(self):
+ url = reverse("bot:user-bulk-patch", host="api")
+ data = [
+ {
+ "id": 1,
+ "name": "User 1 patched again!!!",
+ "discriminator": 1010,
+ "roles": [self.role_developer.id],
+ "in_guild": False
+ },
+ {
+ "id": 22503405,
+ "name": "User unknown not patched!"
+ }
+ ]
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 404)
+
+ def test_returns_400_for_bad_data(self):
+ url = reverse("bot:user-bulk-patch", host="api")
+ data = [
+ {
+ "id": 1,
+ "in_guild": "Catch me!"
+ },
+ {
+ "id": 2,
+ "discriminator": "find me!"
+ }
+ ]
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_returns_400_for_insufficient_data(self):
+ url = reverse("bot:user-bulk-patch", host="api")
+ data = [
+ {
+ "id": 1,
+ },
+ {
+ "id": 2,
+ }
+ ]
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_returns_400_for_duplicate_request_users(self):
+ """Return 400 if 2 Users with same ID is passed in the request data."""
+ url = reverse("bot:user-bulk-patch", host="api")
+ data = [
+ {
+ 'id': 1,
+ 'name': 'You saw nothing.',
+ },
+ {
+ 'id': 1,
+ 'name': 'You saw nothing part 2.',
+ }
+ ]
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
class UserModelTests(APISubdomainTestCase):
@classmethod
@@ -143,7 +324,7 @@ class UserModelTests(APISubdomainTestCase):
cls.user_with_roles = User.objects.create(
id=1,
name="Test User with two roles",
- discriminator=1111,
+ discriminator=1,
in_guild=True,
)
cls.user_with_roles.roles.extend([cls.role_bottom.id, cls.role_top.id])
@@ -166,3 +347,128 @@ class UserModelTests(APISubdomainTestCase):
top_role = self.user_without_roles.top_role
self.assertIsInstance(top_role, Role)
self.assertEqual(top_role.id, self.developers_role.id)
+
+ def test_correct_username_formatting(self):
+ """Tests the username property with both name and discriminator formatted together."""
+ self.assertEqual(self.user_with_roles.username, "Test User with two roles#0001")
+
+
+class UserPaginatorTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ users = []
+ for i in range(1, 10_001):
+ users.append(User(
+ id=i,
+ name=f"user{i}",
+ discriminator=1111,
+ in_guild=True
+ ))
+ cls.users = User.objects.bulk_create(users)
+
+ def test_returns_single_page_response(self):
+ url = reverse("bot:user-list", host="api")
+ response = self.client.get(url).json()
+ self.assertIsNone(response["next_page_no"])
+ self.assertIsNone(response["previous_page_no"])
+
+ def test_returns_next_page_number(self):
+ User.objects.create(
+ id=10_001,
+ name="user10001",
+ discriminator=1111,
+ in_guild=True
+ )
+ url = reverse("bot:user-list", host="api")
+ response = self.client.get(url).json()
+ self.assertEqual(2, response["next_page_no"])
+
+ def test_returns_previous_page_number(self):
+ User.objects.create(
+ id=10_001,
+ name="user10001",
+ discriminator=1111,
+ in_guild=True
+ )
+ url = reverse("bot:user-list", host="api")
+ response = self.client.get(url, {"page": 2}).json()
+ self.assertEqual(1, response["previous_page_no"])
+
+
+class UserMetricityTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ User.objects.create(
+ id=0,
+ name="Test user",
+ discriminator=1,
+ in_guild=True,
+ )
+
+ def test_get_metricity_data(self):
+ # Given
+ joined_at = "foo"
+ total_messages = 1
+ total_blocks = 1
+ self.mock_metricity_user(joined_at, total_messages, total_blocks)
+
+ # When
+ url = reverse('bot:user-metricity-data', args=[0], host='api')
+ response = self.client.get(url)
+
+ # Then
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), {
+ "joined_at": joined_at,
+ "total_messages": total_messages,
+ "voice_banned": False,
+ "activity_blocks": total_blocks
+ })
+
+ def test_no_metricity_user(self):
+ # Given
+ self.mock_no_metricity_user()
+
+ # When
+ url = reverse('bot:user-metricity-data', args=[0], host='api')
+ response = self.client.get(url)
+
+ # Then
+ self.assertEqual(response.status_code, 404)
+
+ def test_metricity_voice_banned(self):
+ cases = [
+ {'exception': None, 'voice_banned': True},
+ {'exception': ObjectDoesNotExist, 'voice_banned': False},
+ ]
+
+ self.mock_metricity_user("foo", 1, 1)
+
+ for case in cases:
+ with self.subTest(exception=case['exception'], voice_banned=case['voice_banned']):
+ with patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.get") as p:
+ p.side_effect = case['exception']
+
+ url = reverse('bot:user-metricity-data', args=[0], host='api')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["voice_banned"], case["voice_banned"])
+
+ def mock_metricity_user(self, joined_at, total_messages, total_blocks):
+ patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
+ self.metricity = patcher.start()
+ self.addCleanup(patcher.stop)
+ self.metricity = self.metricity.return_value.__enter__.return_value
+ self.metricity.user.return_value = dict(joined_at=joined_at)
+ self.metricity.total_messages.return_value = total_messages
+ self.metricity.total_message_blocks.return_value = total_blocks
+
+ def mock_no_metricity_user(self):
+ patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
+ self.metricity = patcher.start()
+ self.addCleanup(patcher.stop)
+ self.metricity = self.metricity.return_value.__enter__.return_value
+ self.metricity.user.side_effect = NotFound()
+ self.metricity.total_messages.side_effect = NotFound()
+ self.metricity.total_message_blocks.side_effect = NotFound()
diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py
index 241af08c..8bb7b917 100644
--- a/pydis_site/apps/api/tests/test_validators.py
+++ b/pydis_site/apps/api/tests/test_validators.py
@@ -5,7 +5,7 @@ from django.test import TestCase
from ..models.bot.bot_setting import validate_bot_setting_name
from ..models.bot.offensive_message import future_date_validator
-from ..models.bot.tag import validate_tag_embed
+from ..models.utils import validate_embed
REQUIRED_KEYS = (
@@ -25,77 +25,77 @@ class BotSettingValidatorTests(TestCase):
class TagEmbedValidatorTests(TestCase):
def test_rejects_non_mapping(self):
with self.assertRaises(ValidationError):
- validate_tag_embed('non-empty non-mapping')
+ validate_embed('non-empty non-mapping')
def test_rejects_missing_required_keys(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'unknown': "key"
})
def test_rejects_one_correct_one_incorrect(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'provider': "??",
'title': ""
})
def test_rejects_empty_required_key(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': ''
})
def test_rejects_list_as_embed(self):
with self.assertRaises(ValidationError):
- validate_tag_embed([])
+ validate_embed([])
def test_rejects_required_keys_and_unknown_keys(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "the duck walked up to the lemonade stand",
'and': "he said to the man running the stand"
})
def test_rejects_too_long_title(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': 'a' * 257
})
def test_rejects_too_many_fields(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [{} for _ in range(26)]
})
def test_rejects_too_long_description(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'description': 'd' * 2049
})
def test_allows_valid_embed(self):
- validate_tag_embed({
+ validate_embed({
'title': "My embed",
'description': "look at my embed, my embed is amazing"
})
def test_allows_unvalidated_fields(self):
- validate_tag_embed({
+ validate_embed({
'title': "My embed",
'provider': "what am I??"
})
def test_rejects_fields_as_list_of_non_mappings(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': ['abc']
})
def test_rejects_fields_with_unknown_fields(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'what': "is this field"
@@ -105,7 +105,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_fields_with_too_long_name(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "a" * 257
@@ -115,7 +115,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_one_correct_one_incorrect_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "Totally valid",
@@ -131,7 +131,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_missing_required_field_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "Totally valid",
@@ -142,7 +142,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_invalid_inline_field_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "Totally valid",
@@ -153,7 +153,7 @@ class TagEmbedValidatorTests(TestCase):
})
def test_allows_valid_fields(self):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "valid",
@@ -174,14 +174,14 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_footer_as_non_mapping(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': []
})
def test_rejects_footer_with_unknown_fields(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': {
'duck': "quack"
@@ -190,7 +190,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_footer_with_empty_text(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': {
'text': ""
@@ -198,7 +198,7 @@ class TagEmbedValidatorTests(TestCase):
})
def test_allows_footer_with_proper_values(self):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': {
'text': "django good"
@@ -207,14 +207,14 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_author_as_non_mapping(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': []
})
def test_rejects_author_with_unknown_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
'field': "that is unknown"
@@ -223,7 +223,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_author_with_empty_name(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
'name': ""
@@ -232,7 +232,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_author_with_one_correct_one_incorrect(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
# Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour
@@ -242,7 +242,7 @@ class TagEmbedValidatorTests(TestCase):
})
def test_allows_author_with_proper_values(self):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
'name': "Bob"
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index a4fd5b2e..2e1ef0b4 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -8,13 +8,11 @@ from .viewsets import (
DocumentationLinkViewSet,
FilterListViewSet,
InfractionViewSet,
- LogEntryViewSet,
NominationViewSet,
OffTopicChannelNameViewSet,
OffensiveMessageViewSet,
ReminderViewSet,
RoleViewSet,
- TagViewSet,
UserViewSet
)
@@ -62,10 +60,6 @@ bot_router.register(
RoleViewSet
)
bot_router.register(
- 'tags',
- TagViewSet
-)
-bot_router.register(
'users',
UserViewSet
)
@@ -76,7 +70,6 @@ urlpatterns = (
#
# from django_hosts.resolvers import reverse
path('bot/', include((bot_router.urls, 'api'), namespace='bot')),
- path('logs', LogEntryViewSet.as_view({'post': 'create'}), name='logs'),
path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),
path('rules', RulesView.as_view(), name='rules')
)
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index a73d4718..0d126051 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -135,12 +135,14 @@ class RulesView(APIView):
),
(
"Do not provide or request help on projects that may break laws, "
- "breach terms of services, be considered malicious/inappropriate "
- "or be for graded coursework/exams."
+ "breach terms of services, be considered malicious or inappropriate. "
+ "Do not help with ongoing exams. Do not provide or request solutions "
+ "for graded assignments, although general guidance is okay."
),
(
"No spamming or unapproved advertising, including requests for paid work. "
- "Open-source projects can be showcased in #show-your-projects."
+ "Open-source projects can be shared with others in #python-general and "
+ "code reviews can be asked for in a help channel."
),
(
"Keep discussions relevant to channel topics and guidelines."
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index 8699517e..f133e77f 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -10,7 +10,5 @@ from .bot import (
OffTopicChannelNameViewSet,
ReminderViewSet,
RoleViewSet,
- TagViewSet,
UserViewSet
)
-from .log_entry import LogEntryViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index e64e3988..84b87eab 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -9,5 +9,4 @@ from .off_topic_channel_name import OffTopicChannelNameViewSet
from .offensive_message import OffensiveMessageViewSet
from .reminder import ReminderViewSet
from .role import RoleViewSet
-from .tag import TagViewSet
from .user import UserViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
index edec0a1e..423e806e 100644
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -5,6 +5,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.mixins import (
CreateModelMixin,
+ DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin
)
@@ -18,7 +19,13 @@ from pydis_site.apps.api.serializers import (
)
-class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
+class InfractionViewSet(
+ CreateModelMixin,
+ RetrieveModelMixin,
+ ListModelMixin,
+ GenericViewSet,
+ DestroyModelMixin
+):
"""
View providing CRUD operations on infractions for Discord users.
@@ -108,6 +115,13 @@ class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
- 400: if a field in the request body is invalid or disallowed
- 404: if an infraction with the given `id` could not be found
+ ### DELETE /bot/infractions/<id:int>
+ Delete the infraction with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a infraction with the given `id` does not exist
+
### Expanded routes
All routes support expansion of `user` and `actor` in responses. To use an expanded route,
append `/expanded` to the end of the route e.g. `GET /bot/infractions/expanded`.
diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py
index cf6e262f..144daab0 100644
--- a/pydis_site/apps/api/viewsets/bot/nomination.py
+++ b/pydis_site/apps/api/viewsets/bot/nomination.py
@@ -14,8 +14,8 @@ from rest_framework.mixins import (
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
-from pydis_site.apps.api.models.bot import Nomination
-from pydis_site.apps.api.serializers import NominationSerializer
+from pydis_site.apps.api.models.bot import Nomination, NominationEntry
+from pydis_site.apps.api.serializers import NominationEntrySerializer, NominationSerializer
class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
@@ -29,7 +29,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
#### Query parameters
- **active** `bool`: whether the nomination is still active
- - **actor__id** `int`: snowflake of the user who nominated the user
- **user__id** `int`: snowflake of the user who received the nomination
- **ordering** `str`: comma-separated sequence of fields to order the returned results
@@ -40,12 +39,18 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
... {
... 'id': 1,
... 'active': false,
- ... 'actor': 336843820513755157,
- ... 'reason': 'They know how to explain difficult concepts',
... 'user': 336843820513755157,
... 'inserted_at': '2019-04-25T14:02:37.775587Z',
... 'end_reason': 'They were helpered after a staff-vote',
- ... 'ended_at': '2019-04-26T15:12:22.123587Z'
+ ... 'ended_at': '2019-04-26T15:12:22.123587Z',
+ ... 'entries': [
+ ... {
+ ... 'actor': 336843820513755157,
+ ... 'reason': 'They know how to explain difficult concepts',
+ ... 'inserted_at': '2019-04-25T14:02:37.775587Z'
+ ... }
+ ... ],
+ ... 'reviewed': true
... }
... ]
@@ -59,12 +64,18 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
>>> {
... 'id': 1,
... 'active': true,
- ... 'actor': 336843820513755157,
- ... 'reason': 'They know how to explain difficult concepts',
... 'user': 336843820513755157,
... 'inserted_at': '2019-04-25T14:02:37.775587Z',
... 'end_reason': 'They were helpered after a staff-vote',
- ... 'ended_at': '2019-04-26T15:12:22.123587Z'
+ ... 'ended_at': '2019-04-26T15:12:22.123587Z',
+ ... 'entries': [
+ ... {
+ ... 'actor': 336843820513755157,
+ ... 'reason': 'They know how to explain difficult concepts',
+ ... 'inserted_at': '2019-04-25T14:02:37.775587Z'
+ ... }
+ ... ],
+ ... 'reviewed': false
... }
### Status codes
@@ -75,8 +86,9 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
Create a new, active nomination returns the created nominations.
The `user`, `reason` and `actor` fields are required and the `user`
and `actor` need to know by the site. Providing other valid fields
- is not allowed and invalid fields are ignored. A `user` is only
- allowed one active nomination at a time.
+ is not allowed and invalid fields are ignored. If `user` already has an
+ active nomination, a new nomination entry will be created and assigned to the
+ active nomination.
#### Request body
>>> {
@@ -91,7 +103,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
#### Status codes
- 201: returned on success
- 400: returned on failure for one of the following reasons:
- - A user already has an active nomination;
- The `user` or `actor` are unknown to the site;
- The request contained a field that cannot be set at creation.
@@ -102,16 +113,18 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
1. Updating the `reason` of `active` nomination;
2. Ending an `active` nomination;
3. Updating the `end_reason` or `reason` field of an `inactive` nomination.
+ 4. Updating `reviewed` field of `active` nomination.
While the response format and status codes are the same for all three operations (see
below), the request bodies vary depending on the operation. For all operations it holds
that providing other valid fields is not allowed and invalid fields are ignored.
- ### 1. Updating the `reason` of `active` nomination
+ ### 1. Updating the `reason` of `active` nomination. The `actor` field is required.
#### Request body
>>> {
... 'reason': 'He would make a great helper',
+ ... 'actor': 409107086526644234
... }
#### Response format
@@ -133,24 +146,35 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
See operation 1 for the response format and status codes.
### 3. Updating the `end_reason` or `reason` field of an `inactive` nomination.
+ Actor field is required when updating reason.
#### Request body
>>> {
... 'reason': 'Updated reason for this nomination',
+ ... 'actor': 409107086526644234,
... 'end_reason': 'Updated end_reason for this nomination',
... }
Note: The request body may contain either or both fields.
See operation 1 for the response format and status codes.
+
+ ### 4. Setting nomination `reviewed`
+
+ #### Request body
+ >>> {
+ ... 'reviewed': True
+ ... }
+
+ See operation 1 for the response format and status codes.
"""
serializer_class = NominationSerializer
queryset = Nomination.objects.all()
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
- filter_fields = ('user__id', 'actor__id', 'active')
- frozen_fields = ('id', 'actor', 'inserted_at', 'user', 'ended_at')
- frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at')
+ filter_fields = ('user__id', 'active')
+ frozen_fields = ('id', 'inserted_at', 'user', 'ended_at')
+ frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at', 'reviewed')
def create(self, request: HttpRequest, *args, **kwargs) -> Response:
"""
@@ -163,19 +187,50 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
raise ValidationError({field: ['This field cannot be set at creation.']})
user_id = request.data.get("user")
- if Nomination.objects.filter(active=True, user__id=user_id).exists():
- raise ValidationError({'active': ['There can only be one active nomination.']})
+ nomination_filter = Nomination.objects.filter(active=True, user__id=user_id)
+
+ if not nomination_filter.exists():
+ serializer = NominationSerializer(
+ data=ChainMap(
+ request.data,
+ {"active": True}
+ )
+ )
+ serializer.is_valid(raise_exception=True)
+ nomination = Nomination.objects.create(**serializer.validated_data)
- serializer = self.get_serializer(
- data=ChainMap(
- request.data,
- {"active": True}
+ # The serializer will truncate and get rid of excessive data
+ entry_serializer = NominationEntrySerializer(
+ data=ChainMap(request.data, {"nomination": nomination.id})
)
+ entry_serializer.is_valid(raise_exception=True)
+ NominationEntry.objects.create(**entry_serializer.validated_data)
+
+ data = NominationSerializer(nomination).data
+
+ headers = self.get_success_headers(data)
+ return Response(data, status=status.HTTP_201_CREATED, headers=headers)
+
+ entry_serializer = NominationEntrySerializer(
+ data=ChainMap(request.data, {"nomination": nomination_filter[0].id})
)
- serializer.is_valid(raise_exception=True)
- self.perform_create(serializer)
- headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+ entry_serializer.is_valid(raise_exception=True)
+
+ # Don't allow a user to create many nomination entries in a single nomination
+ if NominationEntry.objects.filter(
+ nomination_id=nomination_filter[0].id,
+ actor__id=entry_serializer.validated_data["actor"].id
+ ).exists():
+ raise ValidationError(
+ {'actor': ['This actor has already endorsed this nomination.']}
+ )
+
+ NominationEntry.objects.create(**entry_serializer.validated_data)
+
+ data = NominationSerializer(nomination_filter[0]).data
+
+ headers = self.get_success_headers(data)
+ return Response(data, status=status.HTTP_201_CREATED, headers=headers)
def partial_update(self, request: HttpRequest, *args, **kwargs) -> Response:
"""
@@ -203,7 +258,7 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
elif instance.active and not data['active']:
# 2. We're ending an active nomination.
- if 'reason' in data:
+ if 'reason' in request.data:
raise ValidationError(
{'reason': ['This field cannot be set when ending a nomination.']}
)
@@ -213,6 +268,11 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'end_reason': ['This field is required when ending a nomination.']}
)
+ if 'reviewed' in request.data:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set while you are ending a nomination.']}
+ )
+
instance.ended_at = timezone.now()
elif 'active' in data:
@@ -221,6 +281,34 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'active': ['This field can only be used to end a nomination']}
)
+ # This is actually covered, but for some reason coverage don't think so.
+ elif 'reviewed' in request.data: # pragma: no cover
+ # 4. We are altering the reviewed state of the nomination.
+ if not instance.active:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set if the nomination is inactive.']}
+ )
+
+ if 'reason' in request.data:
+ if 'actor' not in request.data:
+ raise ValidationError(
+ {'actor': ['This field is required when editing the reason.']}
+ )
+
+ entry_filter = NominationEntry.objects.filter(
+ nomination_id=instance.id,
+ actor__id=request.data['actor']
+ )
+
+ if not entry_filter.exists():
+ raise ValidationError(
+ {'actor': ["The actor doesn't exist or has not nominated the user."]}
+ )
+
+ entry = entry_filter[0]
+ entry.reason = request.data['reason']
+ entry.save()
+
serializer.save()
return Response(serializer.data)
diff --git a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
index d6da2399..826ad25e 100644
--- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
+++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
@@ -1,3 +1,4 @@
+from django.db.models import Case, Value, When
from django.db.models.query import QuerySet
from django.http.request import HttpRequest
from django.shortcuts import get_object_or_404
@@ -20,7 +21,9 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
Return all known off-topic channel names from the database.
If the `random_items` query parameter is given, for example using...
$ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?random_items=5
- ... then the API will return `5` random items from the database.
+ ... then the API will return `5` random items from the database
+ that is not used in current rotation.
+ When running out of names, API will mark all names to not used and start new rotation.
#### Response format
Return a list of off-topic-channel names:
@@ -106,7 +109,27 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
'random_items': ["Must be a positive integer."]
})
- queryset = self.get_queryset().order_by('?')[:random_count]
+ queryset = self.get_queryset().order_by('used', '?')[:random_count]
+
+ # When any name is used in our listing then this means we reached end of round
+ # and we need to reset all other names `used` to False
+ if any(offtopic_name.used for offtopic_name in queryset):
+ # These names that we just got have to be excluded from updating used to False
+ self.get_queryset().update(
+ used=Case(
+ When(
+ name__in=(offtopic_name.name for offtopic_name in queryset),
+ then=Value(True)
+ ),
+ default=Value(False)
+ )
+ )
+ else:
+ # Otherwise mark selected names `used` to True
+ self.get_queryset().filter(
+ name__in=(offtopic_name.name for offtopic_name in queryset)
+ ).update(used=True)
+
serialized = self.serializer_class(queryset, many=True)
return Response(serialized.data)
diff --git a/pydis_site/apps/api/viewsets/bot/reminder.py b/pydis_site/apps/api/viewsets/bot/reminder.py
index 6f8a28f2..111660d9 100644
--- a/pydis_site/apps/api/viewsets/bot/reminder.py
+++ b/pydis_site/apps/api/viewsets/bot/reminder.py
@@ -4,6 +4,7 @@ from rest_framework.mixins import (
CreateModelMixin,
DestroyModelMixin,
ListModelMixin,
+ RetrieveModelMixin,
UpdateModelMixin
)
from rest_framework.viewsets import GenericViewSet
@@ -13,7 +14,12 @@ from pydis_site.apps.api.serializers import ReminderSerializer
class ReminderViewSet(
- CreateModelMixin, ListModelMixin, DestroyModelMixin, UpdateModelMixin, GenericViewSet
+ CreateModelMixin,
+ RetrieveModelMixin,
+ ListModelMixin,
+ DestroyModelMixin,
+ UpdateModelMixin,
+ GenericViewSet,
):
"""
View providing CRUD access to reminders.
@@ -44,6 +50,30 @@ class ReminderViewSet(
#### Status codes
- 200: returned on success
+ ### GET /bot/reminders/<id:int>
+ Fetches the reminder with the given id.
+
+ #### Response format
+ >>>
+ ... {
+ ... 'active': True,
+ ... 'author': 1020103901030,
+ ... 'mentions': [
+ ... 336843820513755157,
+ ... 165023948638126080,
+ ... 267628507062992896
+ ... ],
+ ... 'content': "Make dinner",
+ ... 'expiration': '5018-11-20T15:52:00Z',
+ ... 'id': 11,
+ ... 'channel_id': 634547009956872193,
+ ... 'jump_url': "https://discord.com/channels/<guild_id>/<channel_id>/<message_id>"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned when the reminder doesn't exist
+
### POST /bot/reminders
Create a new reminder.
diff --git a/pydis_site/apps/api/viewsets/bot/tag.py b/pydis_site/apps/api/viewsets/bot/tag.py
deleted file mode 100644
index 7e9ba117..00000000
--- a/pydis_site/apps/api/viewsets/bot/tag.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from rest_framework.viewsets import ModelViewSet
-
-from pydis_site.apps.api.models.bot.tag import Tag
-from pydis_site.apps.api.serializers import TagSerializer
-
-
-class TagViewSet(ModelViewSet):
- """
- View providing CRUD operations on tags shown by our bot.
-
- ## Routes
- ### GET /bot/tags
- Returns all tags in the database.
-
- #### Response format
- >>> [
- ... {
- ... 'title': "resources",
- ... 'embed': {
- ... 'content': "Did you really think I'd put something useful here?"
- ... }
- ... }
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### GET /bot/tags/<title:str>
- Gets a single tag by its title.
-
- #### Response format
- >>> {
- ... 'title': "My awesome tag",
- ... 'embed': {
- ... 'content': "totally not filler words"
- ... }
- ... }
-
- #### Status codes
- - 200: returned on success
- - 404: if a tag with the given `title` could not be found
-
- ### POST /bot/tags
- Adds a single tag to the database.
-
- #### Request body
- >>> {
- ... 'title': str,
- ... 'embed': dict
- ... }
-
- The embed structure is the same as the embed structure that the Discord API
- expects. You can view the documentation for it here:
- https://discordapp.com/developers/docs/resources/channel#embed-object
-
- #### Status codes
- - 201: returned on success
- - 400: if one of the given fields is invalid
-
- ### PUT /bot/tags/<title:str>
- Update the tag with the given `title`.
-
- #### Request body
- >>> {
- ... 'title': str,
- ... 'embed': dict
- ... }
-
- The embed structure is the same as the embed structure that the Discord API
- expects. You can view the documentation for it here:
- https://discordapp.com/developers/docs/resources/channel#embed-object
-
- #### Status codes
- - 200: returned on success
- - 400: if the request body was invalid, see response body for details
- - 404: if the tag with the given `title` could not be found
-
- ### PATCH /bot/tags/<title:str>
- Update the tag with the given `title`.
-
- #### Request body
- >>> {
- ... 'title': str,
- ... 'embed': dict
- ... }
-
- The embed structure is the same as the embed structure that the Discord API
- expects. You can view the documentation for it here:
- https://discordapp.com/developers/docs/resources/channel#embed-object
-
- #### Status codes
- - 200: returned on success
- - 400: if the request body was invalid, see response body for details
- - 404: if the tag with the given `title` could not be found
-
- ### DELETE /bot/tags/<title:str>
- Deletes the tag with the given `title`.
-
- #### Status codes
- - 204: returned on success
- - 404: if a tag with the given `title` does not exist
- """
-
- serializer_class = TagSerializer
- queryset = Tag.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
index 9571b3d7..829e2694 100644
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -1,21 +1,67 @@
+import typing
+from collections import OrderedDict
+
+from django.core.exceptions import ObjectDoesNotExist
+from rest_framework import status
+from rest_framework.decorators import action
+from rest_framework.pagination import PageNumberPagination
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
-from rest_framework_bulk import BulkCreateModelMixin
+from pydis_site.apps.api.models.bot.infraction import Infraction
+from pydis_site.apps.api.models.bot.metricity import Metricity, NotFound
from pydis_site.apps.api.models.bot.user import User
from pydis_site.apps.api.serializers import UserSerializer
-class UserViewSet(BulkCreateModelMixin, ModelViewSet):
+class UserListPagination(PageNumberPagination):
+ """Custom pagination class for the User Model."""
+
+ page_size = 10000
+ page_size_query_param = "page_size"
+
+ def get_next_page_number(self) -> typing.Optional[int]:
+ """Get the next page number."""
+ if not self.page.has_next():
+ return None
+ page_number = self.page.next_page_number()
+ return page_number
+
+ def get_previous_page_number(self) -> typing.Optional[int]:
+ """Get the previous page number."""
+ if not self.page.has_previous():
+ return None
+
+ page_number = self.page.previous_page_number()
+ return page_number
+
+ def get_paginated_response(self, data: list) -> Response:
+ """Override method to send modified response."""
+ return Response(OrderedDict([
+ ('count', self.page.paginator.count),
+ ('next_page_no', self.get_next_page_number()),
+ ('previous_page_no', self.get_previous_page_number()),
+ ('results', data)
+ ]))
+
+
+class UserViewSet(ModelViewSet):
"""
View providing CRUD operations on Discord users through the bot.
## Routes
### GET /bot/users
- Returns all users currently known.
+ Returns all users currently known with pagination.
#### Response format
- >>> [
- ... {
+ >>> {
+ ... 'count': 95000,
+ ... 'next_page_no': "2",
+ ... 'previous_page_no': None,
+ ... 'results': [
+ ... {
... 'id': 409107086526644234,
... 'name': "Python",
... 'discriminator': 4329,
@@ -26,8 +72,13 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
... 458226699344019457
... ],
... 'in_guild': True
- ... }
- ... ]
+ ... },
+ ... ]
+ ... }
+
+ #### Optional Query Parameters
+ - page_size: number of Users in one page, defaults to 10,000
+ - page: page number
#### Status codes
- 200: returned on success
@@ -53,9 +104,25 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
- 200: returned on success
- 404: if a user with the given `snowflake` could not be found
+ ### GET /bot/users/<snowflake:int>/metricity_data
+ Gets metricity data for a single user by ID.
+
+ #### Response format
+ >>> {
+ ... "joined_at": "2020-10-06T21:54:23.540766",
+ ... "total_messages": 2,
+ ... "voice_banned": False,
+ ... "activity_blocks": 1
+ ...}
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if a user with the given `snowflake` could not be found
+
### POST /bot/users
Adds a single or multiple new users.
The roles attached to the user(s) must be roles known by the site.
+ Users that already exist in the database will be skipped.
#### Request body
>>> {
@@ -67,11 +134,13 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
... }
Alternatively, request users can be POSTed as a list of above objects,
- in which case multiple users will be created at once.
+ in which case multiple users will be created at once. In this case,
+ the response is an empty list.
#### Status codes
- 201: returned on success
- 400: if one of the given roles does not exist, or one of the given fields is invalid
+ - 400: if multiple user objects with the same id are given
### PUT /bot/users/<snowflake:int>
Update the user with the given `snowflake`.
@@ -109,6 +178,34 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
- 400: if the request body was invalid, see response body for details
- 404: if the user with the given `snowflake` could not be found
+ ### BULK PATCH /bot/users/bulk_patch
+ Update users with the given `ids` and `details`.
+ `id` field and at least one other field is mandatory.
+
+ #### Request body
+ >>> [
+ ... {
+ ... 'id': int,
+ ... 'name': str,
+ ... 'discriminator': int,
+ ... 'roles': List[int],
+ ... 'in_guild': bool
+ ... },
+ ... {
+ ... 'id': int,
+ ... 'name': str,
+ ... 'discriminator': int,
+ ... 'roles': List[int],
+ ... 'in_guild': bool
+ ... },
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if the request body was invalid, see response body for details
+ - 400: if multiple user objects with the same id are given
+ - 404: if the user with the given id does not exist
+
### DELETE /bot/users/<snowflake:int>
Deletes the user with the given `snowflake`.
@@ -118,4 +215,50 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
"""
serializer_class = UserSerializer
- queryset = User.objects
+ queryset = User.objects.all().order_by("id")
+ pagination_class = UserListPagination
+
+ def get_serializer(self, *args, **kwargs) -> ModelSerializer:
+ """Set Serializer many attribute to True if request body contains a list."""
+ if isinstance(kwargs.get('data', {}), list):
+ kwargs['many'] = True
+
+ return super().get_serializer(*args, **kwargs)
+
+ @action(detail=False, methods=["PATCH"], name='user-bulk-patch')
+ def bulk_patch(self, request: Request) -> Response:
+ """Update multiple User objects in a single request."""
+ serializer = self.get_serializer(
+ instance=self.get_queryset(),
+ data=request.data,
+ many=True,
+ partial=True
+ )
+
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @action(detail=True)
+ def metricity_data(self, request: Request, pk: str = None) -> Response:
+ """Request handler for metricity_data endpoint."""
+ user = self.get_object()
+
+ try:
+ Infraction.objects.get(user__id=user.id, active=True, type="voice_ban")
+ except ObjectDoesNotExist:
+ voice_banned = False
+ else:
+ voice_banned = True
+
+ with Metricity() as metricity:
+ try:
+ data = metricity.user(user.id)
+ data["total_messages"] = metricity.total_messages(user.id)
+ data["voice_banned"] = voice_banned
+ data["activity_blocks"] = metricity.total_message_blocks(user.id)
+ return Response(data, status=status.HTTP_200_OK)
+ except NotFound:
+ return Response(dict(detail="User not found in metricity"),
+ status=status.HTTP_404_NOT_FOUND)
diff --git a/pydis_site/apps/api/viewsets/log_entry.py b/pydis_site/apps/api/viewsets/log_entry.py
deleted file mode 100644
index 9108a4fa..00000000
--- a/pydis_site/apps/api/viewsets/log_entry.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from rest_framework.mixins import CreateModelMixin
-from rest_framework.viewsets import GenericViewSet
-
-from pydis_site.apps.api.models.log_entry import LogEntry
-from pydis_site.apps.api.serializers import LogEntrySerializer
-
-
-class LogEntryViewSet(CreateModelMixin, GenericViewSet):
- """
- View supporting the creation of log entries in the database for viewing via the log browser.
-
- ## Routes
- ### POST /logs
- Create a new log entry.
-
- #### Request body
- >>> {
- ... 'application': str, # 'bot' | 'seasonalbot' | 'site'
- ... 'logger_name': str, # such as 'bot.cogs.moderation'
- ... 'timestamp': Optional[str], # from `datetime.utcnow().isoformat()`
- ... 'level': str, # 'debug' | 'info' | 'warning' | 'error' | 'critical'
- ... 'module': str, # such as 'pydis_site.apps.api.serializers'
- ... 'line': int, # > 0
- ... 'message': str, # textual formatted content of the logline
- ... }
-
- #### Status codes
- - 201: returned on success
- - 400: if the request body has invalid fields, see the response for details
-
- ## Authentication
- Requires a API token.
- """
-
- queryset = LogEntry.objects.all()
- serializer_class = LogEntrySerializer
diff --git a/pydis_site/apps/home/migrations/0002_auto_now_on_repository_metadata.py b/pydis_site/apps/home/migrations/0002_auto_now_on_repository_metadata.py
new file mode 100644
index 00000000..7e78045b
--- /dev/null
+++ b/pydis_site/apps/home/migrations/0002_auto_now_on_repository_metadata.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.11 on 2020-12-21 22:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('home', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='repositorymetadata',
+ name='last_updated',
+ field=models.DateTimeField(auto_now=True, help_text='The date and time this data was last fetched.'),
+ ),
+ ]
diff --git a/pydis_site/apps/home/models/repository_metadata.py b/pydis_site/apps/home/models/repository_metadata.py
index 92d2404d..00a83cd7 100644
--- a/pydis_site/apps/home/models/repository_metadata.py
+++ b/pydis_site/apps/home/models/repository_metadata.py
@@ -1,32 +1,31 @@
from django.db import models
-from django.utils import timezone
class RepositoryMetadata(models.Model):
"""Information about one of our repos fetched from the GitHub API."""
last_updated = models.DateTimeField(
- default=timezone.now,
- help_text="The date and time this data was last fetched."
+ help_text="The date and time this data was last fetched.",
+ auto_now=True,
)
repo_name = models.CharField(
primary_key=True,
max_length=40,
- help_text="The full name of the repo, e.g. python-discord/site"
+ help_text="The full name of the repo, e.g. python-discord/site",
)
description = models.CharField(
max_length=400,
- help_text="The description of the repo."
+ help_text="The description of the repo.",
)
forks = models.IntegerField(
- help_text="The number of forks of this repo"
+ help_text="The number of forks of this repo",
)
stargazers = models.IntegerField(
- help_text="The number of stargazers for this repo"
+ help_text="The number of stargazers for this repo",
)
language = models.CharField(
max_length=20,
- help_text="The primary programming language used for this repo."
+ help_text="The primary programming language used for this repo.",
)
def __str__(self):
diff --git a/pydis_site/apps/home/resources/books/effective_python.yaml b/pydis_site/apps/home/resources/books/effective_python.yaml
index ab782704..7f9d0dea 100644
--- a/pydis_site/apps/home/resources/books/effective_python.yaml
+++ b/pydis_site/apps/home/resources/books/effective_python.yaml
@@ -1,4 +1,4 @@
-description: A book that gives 59 best practices for writing excellent Python. Great
+description: A book that gives 90 best practices for writing excellent Python. Great
for intermediates.
name: Effective Python
payment: paid
@@ -8,7 +8,7 @@ urls:
url: https://effectivepython.com/
- icon: branding/amazon
title: Amazon
- url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134034287
+ url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134853989
- icon: branding/github
title: GitHub
url: https://github.com/bslatkin/effectivepython
diff --git a/pydis_site/apps/home/tests/mock_github_api_response.json b/pydis_site/apps/home/tests/mock_github_api_response.json
index 35604a85..ddbffed8 100644
--- a/pydis_site/apps/home/tests/mock_github_api_response.json
+++ b/pydis_site/apps/home/tests/mock_github_api_response.json
@@ -28,14 +28,14 @@
"forks_count": 31
},
{
- "full_name": "python-discord/flake8-annotations",
+ "full_name": "python-discord/metricity",
"description": "test",
"stargazers_count": 97,
"language": "Python",
"forks_count": 31
},
{
- "full_name": "python-discord/seasonalbot",
+ "full_name": "python-discord/sir-lancebot",
"description": "test",
"stargazers_count": 97,
"language": "Python",
diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py
index 77b1a68d..5634bc9b 100644
--- a/pydis_site/apps/home/tests/test_repodata_helpers.py
+++ b/pydis_site/apps/home/tests/test_repodata_helpers.py
@@ -123,10 +123,38 @@ class TestRepositoryMetadataHelpers(TestCase):
mock_get.return_value.json.return_value = ['garbage']
metadata = self.home_view._get_repo_data()
- self.assertEquals(len(metadata), len(self.home_view.repos))
- for item in metadata:
- with self.subTest(item=item):
- self.assertEqual(item.description, "Not available.")
- self.assertEqual(item.forks, 999)
- self.assertEqual(item.stargazers, 999)
- self.assertEqual(item.language, "Python")
+ self.assertEquals(len(metadata), 0)
+
+ def test_cleans_up_stale_metadata(self):
+ """Tests that we clean up stale metadata when we start the HomeView."""
+ repo_data = RepositoryMetadata(
+ repo_name="python-discord/INVALID",
+ description="testrepo",
+ forks=42,
+ stargazers=42,
+ language="English",
+ last_updated=timezone.now() - timedelta(seconds=HomeView.repository_cache_ttl + 1),
+ )
+ repo_data.save()
+ self.home_view.__init__()
+ cached_repos = RepositoryMetadata.objects.all()
+ cached_names = [repo.repo_name for repo in cached_repos]
+
+ self.assertNotIn("python-discord/INVALID", cached_names)
+
+ def test_dont_clean_up_unstale_metadata(self):
+ """Tests that we don't clean up good metadata when we start the HomeView."""
+ repo_data = RepositoryMetadata(
+ repo_name="python-discord/site",
+ description="testrepo",
+ forks=42,
+ stargazers=42,
+ language="English",
+ last_updated=timezone.now() - timedelta(seconds=HomeView.repository_cache_ttl + 1),
+ )
+ repo_data.save()
+ self.home_view.__init__()
+ cached_repos = RepositoryMetadata.objects.all()
+ cached_names = [repo.repo_name for repo in cached_repos]
+
+ self.assertIn("python-discord/site", cached_names)
diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py
index 572317a7..40c80205 100644
--- a/pydis_site/apps/home/tests/test_views.py
+++ b/pydis_site/apps/home/tests/test_views.py
@@ -203,6 +203,14 @@ class TestIndexReturns200(TestCase):
self.assertEqual(resp.status_code, 200)
+class TestTimelineReturns200(TestCase):
+ def test_timeline_returns_200(self):
+ """Check that the timeline page returns a HTTP 200 response."""
+ url = reverse('timeline')
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+
+
class TestLoginCancelledReturns302(TestCase):
def test_login_cancelled_returns_302(self):
"""Check that the login cancelled redirect returns a HTTP 302 response."""
diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py
index 61e87a39..14d118f8 100644
--- a/pydis_site/apps/home/urls.py
+++ b/pydis_site/apps/home/urls.py
@@ -6,7 +6,7 @@ from django.contrib.messages import ERROR
from django.urls import include, path
from pydis_site.utils.views import MessageRedirectView
-from .views import AccountDeleteView, AccountSettingsView, HomeView
+from .views import AccountDeleteView, AccountSettingsView, HomeView, timeline
app_name = 'home'
urlpatterns = [
@@ -38,4 +38,5 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('notifications/', include('django_nyt.urls')),
+ path('timeline/', timeline, name="timeline"),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py
index 801fd398..36b88b1b 100644
--- a/pydis_site/apps/home/views/__init__.py
+++ b/pydis_site/apps/home/views/__init__.py
@@ -1,4 +1,4 @@
from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView
-from .home import HomeView
+from .home import HomeView, timeline
-__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"]
+__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView", "timeline"]
diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py
index 20e38ab0..e77772fb 100644
--- a/pydis_site/apps/home/views/home.py
+++ b/pydis_site/apps/home/views/home.py
@@ -1,4 +1,4 @@
-import datetime
+import logging
from typing import Dict, List
import requests
@@ -10,11 +10,13 @@ from django.views import View
from pydis_site.apps.home.models import RepositoryMetadata
+log = logging.getLogger(__name__)
+
class HomeView(View):
"""The main landing page for the website."""
- github_api = "https://api.github.com/users/python-discord/repos"
+ github_api = "https://api.github.com/users/python-discord/repos?per_page=100"
repository_cache_ttl = 3600
# Which of our GitHub repos should be displayed on the front page, and in which order?
@@ -22,82 +24,98 @@ class HomeView(View):
"python-discord/site",
"python-discord/bot",
"python-discord/snekbox",
- "python-discord/seasonalbot",
- "python-discord/flake8-annotations",
+ "python-discord/sir-lancebot",
+ "python-discord/metricity",
"python-discord/django-simple-bulma",
]
+ def __init__(self):
+ """Clean up stale RepositoryMetadata."""
+ RepositoryMetadata.objects.exclude(repo_name__in=self.repos).delete()
+
def _get_api_data(self) -> Dict[str, Dict[str, str]]:
- """Call the GitHub API and get information about our repos."""
- repo_dict: Dict[str, dict] = {repo_name: {} for repo_name in self.repos}
+ """
+ Call the GitHub API and get information about our repos.
+
+ If we're unable to get that info for any reason, return an empty dict.
+ """
+ repo_dict = {}
# Fetch the data from the GitHub API
api_data: List[dict] = requests.get(self.github_api).json()
# Process the API data into our dict
for repo in api_data:
- full_name = repo["full_name"]
-
- if full_name in self.repos:
- repo_dict[full_name] = {
- "full_name": repo["full_name"],
- "description": repo["description"],
- "language": repo["language"],
- "forks_count": repo["forks_count"],
- "stargazers_count": repo["stargazers_count"],
- }
+ try:
+ full_name = repo["full_name"]
+
+ if full_name in self.repos:
+ repo_dict[full_name] = {
+ "full_name": repo["full_name"],
+ "description": repo["description"],
+ "language": repo["language"],
+ "forks_count": repo["forks_count"],
+ "stargazers_count": repo["stargazers_count"],
+ }
+ # Something is not right about the API data we got back from GitHub.
+ except (TypeError, ConnectionError, KeyError) as e:
+ log.error(
+ "Unable to parse the GitHub repository metadata from response!",
+ extra={
+ 'api_data': api_data,
+ 'error': e
+ }
+ )
+ continue
return repo_dict
def _get_repo_data(self) -> List[RepositoryMetadata]:
"""Build a list of RepositoryMetadata objects that we can use to populate the front page."""
- # Try to get site data from the cache
- try:
- repo_data = RepositoryMetadata.objects.get(repo_name="python-discord/site")
+ database_repositories = []
- # If the data is stale, we should refresh it.
- if (timezone.now() - repo_data.last_updated).seconds > self.repository_cache_ttl:
+ # First, let's see if we have any metadata cached.
+ cached_data = RepositoryMetadata.objects.all()
- # Try to get new data from the API. If it fails, return the cached data.
- try:
- api_repositories = self._get_api_data()
- except (TypeError, ConnectionError):
- return RepositoryMetadata.objects.all()
- database_repositories = []
-
- # Update or create all RepoData objects in self.repos
- for repo_name, api_data in api_repositories.items():
- try:
- repo_data = RepositoryMetadata.objects.get(repo_name=repo_name)
- repo_data.description = api_data["description"]
- repo_data.language = api_data["language"]
- repo_data.forks = api_data["forks_count"]
- repo_data.stargazers = api_data["stargazers_count"]
- except RepositoryMetadata.DoesNotExist:
- repo_data = RepositoryMetadata(
- repo_name=api_data["full_name"],
- description=api_data["description"],
- forks=api_data["forks_count"],
- stargazers=api_data["stargazers_count"],
- language=api_data["language"],
- )
- repo_data.save()
- database_repositories.append(repo_data)
- return database_repositories
-
- # Otherwise, if the data is fresher than 2 minutes old, we should just return it.
- else:
- return RepositoryMetadata.objects.all()
+ # If we don't, we have to create some!
+ if not cached_data:
- # If this is raised, the database has no repodata at all, we will create them all.
- except RepositoryMetadata.DoesNotExist:
- database_repositories = []
- try:
- # Get new data from API
- api_repositories = self._get_api_data()
+ # Try to get new data from the API. If it fails, we'll return an empty list.
+ # In this case, we simply don't display our projects on the site.
+ api_repositories = self._get_api_data()
+
+ # Create all the repodata records in the database.
+ for api_data in api_repositories.values():
+ repo_data = RepositoryMetadata(
+ repo_name=api_data["full_name"],
+ description=api_data["description"],
+ forks=api_data["forks_count"],
+ stargazers=api_data["stargazers_count"],
+ language=api_data["language"],
+ )
+
+ repo_data.save()
+ database_repositories.append(repo_data)
+
+ return database_repositories
+
+ # If the data is stale, we should refresh it.
+ if (timezone.now() - cached_data[0].last_updated).seconds > self.repository_cache_ttl:
+ # Try to get new data from the API. If it fails, return the cached data.
+ api_repositories = self._get_api_data()
+
+ if not api_repositories:
+ return RepositoryMetadata.objects.all()
- # Create all the repodata records in the database.
- for api_data in api_repositories.values():
+ # Update or create all RepoData objects in self.repos
+ for repo_name, api_data in api_repositories.items():
+ try:
+ repo_data = RepositoryMetadata.objects.get(repo_name=repo_name)
+ repo_data.description = api_data["description"]
+ repo_data.language = api_data["language"]
+ repo_data.forks = api_data["forks_count"]
+ repo_data.stargazers = api_data["stargazers_count"]
+ except RepositoryMetadata.DoesNotExist:
repo_data = RepositoryMetadata(
repo_name=api_data["full_name"],
description=api_data["description"],
@@ -105,24 +123,20 @@ class HomeView(View):
stargazers=api_data["stargazers_count"],
language=api_data["language"],
)
- repo_data.save()
- database_repositories.append(repo_data)
- except TypeError:
- for repo_name in self.repos:
- repo_data = RepositoryMetadata(
- last_updated=timezone.now() - datetime.timedelta(minutes=50),
- repo_name=repo_name,
- description="Not available.",
- forks=999,
- stargazers=999,
- language="Python",
- )
- repo_data.save()
- database_repositories.append(repo_data)
-
+ repo_data.save()
+ database_repositories.append(repo_data)
return database_repositories
+ # Otherwise, if the data is fresher than 2 minutes old, we should just return it.
+ else:
+ return RepositoryMetadata.objects.all()
+
def get(self, request: WSGIRequest) -> HttpResponse:
"""Collect repo data and render the homepage view."""
repo_data = self._get_repo_data()
return render(request, "home/index.html", {"repo_data": repo_data})
+
+
+def timeline(request: WSGIRequest) -> HttpResponse:
+ """Render timeline view."""
+ return render(request, 'home/timeline.html')
diff --git a/pydis_site/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py
index 17910bb6..00e0ab2f 100644
--- a/pydis_site/apps/staff/tests/test_logs_view.py
+++ b/pydis_site/apps/staff/tests/test_logs_view.py
@@ -133,7 +133,7 @@ class TestLogsView(TestCase):
response = self.client.get(url)
self.assertIn("messages", response.context)
self.assertListEqual(
- [self.deleted_message_one, self.deleted_message_two],
+ [self.deleted_message_two, self.deleted_message_one],
list(response.context["deletion_context"].deletedmessage_set.all())
)
diff --git a/pydis_site/constants.py b/pydis_site/constants.py
new file mode 100644
index 00000000..c7ab5db0
--- /dev/null
+++ b/pydis_site/constants.py
@@ -0,0 +1,3 @@
+import os
+
+GIT_SHA = os.environ.get("GIT_SHA", "development")
diff --git a/pydis_site/context_processors.py b/pydis_site/context_processors.py
new file mode 100644
index 00000000..6937a3db
--- /dev/null
+++ b/pydis_site/context_processors.py
@@ -0,0 +1,8 @@
+from django.template import RequestContext
+
+from pydis_site.constants import GIT_SHA
+
+
+def git_sha_processor(_: RequestContext) -> dict:
+ """Expose the git SHA for this repo to all views."""
+ return {'git_sha': GIT_SHA}
diff --git a/pydis_site/hosts.py b/pydis_site/hosts.py
index 898e8cdc..5a837a8b 100644
--- a/pydis_site/hosts.py
+++ b/pydis_site/hosts.py
@@ -4,7 +4,10 @@ from django_hosts import host, patterns
host_patterns = patterns(
'',
host(r'admin', 'pydis_site.apps.admin.urls', name="admin"),
+ # External API ingress (over the net)
host(r'api', 'pydis_site.apps.api.urls', name='api'),
+ # Internal API ingress (cluster local)
+ host(r'pydis-api', 'pydis_site.apps.api.urls', name='internal_api'),
host(r'staff', 'pydis_site.apps.staff.urls', name='staff'),
host(r'.*', 'pydis_site.apps.home.urls', name=settings.DEFAULT_HOST)
)
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 2c87007c..300452fa 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -20,6 +20,7 @@ import sentry_sdk
from django.contrib.messages import constants as messages
from sentry_sdk.integrations.django import DjangoIntegration
+from pydis_site.constants import GIT_SHA
if typing.TYPE_CHECKING:
from django.contrib.auth.models import User
@@ -27,13 +28,14 @@ if typing.TYPE_CHECKING:
env = environ.Env(
DEBUG=(bool, False),
- SITE_SENTRY_DSN=(str, "")
+ SITE_DSN=(str, "")
)
sentry_sdk.init(
- dsn=env('SITE_SENTRY_DSN'),
+ dsn=env('SITE_DSN'),
integrations=[DjangoIntegration()],
- send_default_pii=True
+ send_default_pii=True,
+ release=f"site@{GIT_SHA}"
)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -45,21 +47,7 @@ DEBUG = env('DEBUG')
# SECURITY WARNING: keep the secret key used in production secret!
if DEBUG:
- ALLOWED_HOSTS = env.list(
- 'ALLOWED_HOSTS',
- default=[
- 'pythondiscord.local',
- 'api.pythondiscord.local',
- 'admin.pythondiscord.local',
- 'staff.pythondiscord.local',
- '0.0.0.0', # noqa: S104
- 'localhost',
- 'web',
- 'api.web',
- 'admin.web',
- 'staff.web'
- ]
- )
+ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*'])
SECRET_KEY = "yellow polkadot bikini" # noqa: S105
elif 'CI' in os.environ:
@@ -74,10 +62,7 @@ else:
'admin.pythondiscord.com',
'api.pythondiscord.com',
'staff.pythondiscord.com',
- 'pydis.com',
- 'api.pydis.com',
- 'admin.pydis.com',
- 'staff.pydis.com',
+ 'pydis-api.default.svc.cluster.local',
]
)
SECRET_KEY = env('SECRET_KEY')
@@ -157,8 +142,8 @@ TEMPLATES = [
'django.template.context_processors.static',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
-
"sekizai.context_processors.sekizai",
+ "pydis_site.context_processors.git_sha_processor"
],
},
},
@@ -170,7 +155,8 @@ WSGI_APPLICATION = 'pydis_site.wsgi.application'
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
DATABASES = {
- 'default': env.db()
+ 'default': env.db(),
+ 'metricity': env.db('METRICITY_DB_URL'),
}
# Password validation
@@ -258,14 +244,11 @@ LOGGING = {
'handlers': {
'console': {
'class': 'logging.StreamHandler'
- },
- 'database': {
- 'class': 'pydis_site.apps.api.dblogger.DatabaseLogHandler'
}
},
'loggers': {
'django': {
- 'handlers': ['console', 'database'],
+ 'handlers': ['console'],
'propagate': True,
'level': env(
'LOG_LEVEL',
@@ -392,6 +375,7 @@ AUTHENTICATION_BACKENDS = (
ACCOUNT_ADAPTER = "pydis_site.utils.account.AccountAdapter"
ACCOUNT_EMAIL_REQUIRED = False # Undocumented allauth setting; don't require emails
ACCOUNT_EMAIL_VERIFICATION = "none" # No verification required; we don't use emails for anything
+ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
# We use this validator because Allauth won't let us actually supply a list with no validators
# in it, and we can't just give it a lambda - that'd be too easy, I suppose.
@@ -399,3 +383,11 @@ ACCOUNT_USERNAME_VALIDATORS = "pydis_site.VALIDATORS"
LOGIN_REDIRECT_URL = "home"
SOCIALACCOUNT_ADAPTER = "pydis_site.utils.account.SocialAccountAdapter"
+SOCIALACCOUNT_PROVIDERS = {
+ "discord": {
+ "SCOPE": [
+ "identify",
+ ],
+ "AUTH_PARAMS": {"prompt": "none"}
+ }
+}
diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css
index dc7c504d..a1d325f9 100644
--- a/pydis_site/static/css/base/base.css
+++ b/pydis_site/static/css/base/base.css
@@ -12,42 +12,69 @@ main.site-content {
flex: 1;
}
-div.card.has-equal-height {
+.card.has-equal-height {
height: 100%;
display: flex;
flex-direction: column;
}
-.navbar-item.is-fullsize {
- padding: 0;
+.navbar {
+ padding-right: 0.8em;
}
-.navbar-item.is-fullsize img {
- max-height: 4.75rem;
+.navbar-item .navbar-link {
+ padding-left: 1.5em;
+ padding-right: 2.5em;
+}
+
+.navbar-link:not(.is-arrowless)::after {
+ right: 1.125em;
+ margin-top: -0.455em;
}
.navbar-item.has-no-highlight:hover {
background-color: transparent;
}
-.navbar-item.has-left-margin-1 {
- margin-left: 1rem;
+#navbar-banner {
+ background-color: transparent;
}
-.navbar-item.has-left-margin-2 {
- margin-left: 2rem;
+#navbar-banner img {
+ max-height: 3rem;
}
-.navbar-item.has-left-margin-3 {
- margin-left: 3rem;
+#discord-btn a {
+ color: transparent;
+ background-image: url(../../images/navbar/discord.svg);
+ background-size: 200%;
+ background-position: 100% 50%;
+ background-repeat: no-repeat;
+ padding-left: 2.5rem;
+ padding-right: 2.5rem;
+ background-color: #697ec4ff;
+ margin-left: 0.5rem;
+ transition: all 0.2s cubic-bezier(.25,.8,.25,1);
+ overflow: hidden;
}
-#navbar-banner {
+#discord-btn:hover a {
+ box-shadow: 0 1px 4px rgba(0,0,0,0.16), 0 1px 6px rgba(0,0,0,0.23);
+ /*transform: scale(1.03) translate3d(0,0,0);*/
+ background-size: 200%;
+ background-position: 1% 50%;
+}
+
+#discord-btn:hover {
background-color: transparent;
}
-#navbar-banner img {
- max-height: 3rem;
+#linode-logo {
+ padding-left: 15px;
+ background: url(https://www.linode.com/wp-content/uploads/2021/01/Linode-Logo-Black.svg) no-repeat center;
+ filter: invert(1) grayscale(1);
+ background-size: 60px;
+ color: #00000000;
}
#django-logo {
@@ -111,3 +138,17 @@ button.is-size-navbar-menu, a.is-size-navbar-menu {
.codehilite-wrap {
margin-bottom: 1em;
}
+
+/* 16:9 aspect ratio fixing */
+.force-aspect-container {
+ position: relative;
+ padding-bottom: 56.25%;
+}
+
+.force-aspect-content {
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+}
diff --git a/pydis_site/static/css/error_pages.css b/pydis_site/static/css/error_pages.css
new file mode 100644
index 00000000..ee41fa5c
--- /dev/null
+++ b/pydis_site/static/css/error_pages.css
@@ -0,0 +1,66 @@
+html {
+ height: 100%;
+}
+
+body {
+ background-color: #7289DA;
+ background-image: url("https://raw.githubusercontent.com/python-discord/branding/main/logos/banner_pattern/banner_pattern.svg");
+ background-size: 128px;
+ font-family: "Hind", "Helvetica", "Arial", sans-serif;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+h1,
+p {
+ color: black;
+ padding: 0;
+ margin: 0;
+ margin-bottom: 10px;
+}
+
+h1 {
+ margin-bottom: 15px;
+ font-size: 26px;
+}
+
+p,
+li {
+ line-height: 125%;
+}
+
+a {
+ color: #7289DA;
+}
+
+ul {
+ margin-bottom: 0;
+}
+
+li {
+ margin-top: 10px;
+}
+
+.error-box {
+ display: flex;
+ flex-direction: column;
+ max-width: 512px;
+ background-color: white;
+ border-radius: 20px;
+ overflow: hidden;
+ box-shadow: 5px 7px 40px rgba(0, 0, 0, 0.432);
+}
+
+.logo-box {
+ display: flex;
+ justify-content: center;
+ height: 80px;
+ padding: 15px;
+ background-color: #758ad4;
+}
+
+.content-box {
+ padding: 25px;
+}
diff --git a/pydis_site/static/css/home/index.css b/pydis_site/static/css/home/index.css
index ba856a8e..58ca8888 100644
--- a/pydis_site/static/css/home/index.css
+++ b/pydis_site/static/css/home/index.css
@@ -1,87 +1,214 @@
-.discord-banner {
- border-radius: 0.5rem;
+h1 {
+ padding-bottom: 0.5em;
}
-.hero-image {
- width: 20rem;
- margin: auto;
+/* Mobile-only notice banner */
+
+#mobile-notice {
+ margin: 5px;
+ margin-bottom: -10px!important;
}
-.hero-body {
- padding-top: 1rem;
- padding-bottom: 1rem;
+/* Wave hero */
+
+#wave-hero {
+ position: relative;
+ background-color: #7289DA;
+ color: #fff;
+ height: 32vw;
+ min-height: 270px;
+ max-height: 500px;
+ overflow-x: hidden;
+ width: 100%;
+ padding: 0;
}
-.section-sp img {
- height: 5rem;
- margin-right: 2rem;
+#wave-hero .container {
+ z-index: 4; /* keep hero contents above wave animations */
}
-.video-container iframe,
-.video-container object,
-.video-container embed {
- width: 100%;
- height: calc(92vw * 0.5625);
- margin: 8px auto auto auto;
+@media screen and (min-width: 769px) and (max-width: 1023px) {
+ #wave-hero .columns {
+ margin: 0 1em 0 1em; /* Stop cards touching canvas edges in table-view */
+ }
+}
+
+#wave-hero iframe {
+ box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
+ transition: all 0.3s cubic-bezier(.25,.8,.25,1);
+ border-radius: 10px;
+ margin-top: 1em;
+ border: none;
+}
+
+#wave-hero iframe:hover {
+ box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);
+}
+
+#wave-hero-right img{
+ border-radius: 10px;
+ box-shadow: 0 1px 6px rgba(0,0,0,0.16), 0 1px 6px rgba(0,0,0,0.23);
+ margin-top: 1em;
+ text-align: right;
+}
+
+#wave-hero .wave {
+ background: url(../../images/waves/wave_dark.svg) repeat-x;
+ position: absolute;
+ bottom: 0;
+ width: 6400px;
+ animation-name: wave;
+ animation-timing-function: cubic-bezier(.36,.45,.63,.53);
+ animation-iteration-count: infinite;
+ transform: translate3d(0,0,0); /* Trigger 3D acceleration for smoother animation */
}
-div.card.github-card {
+#front-wave {
+ animation-duration: 60s;
+ animation-delay: -50s;
+ opacity: 0.5;
+ height: 178px;
+}
+
+#back-wave {
+ animation-duration: 65s;
+ height: 198px;
+}
+
+#bottom-wave {
+ animation-duration: 50s;
+ animation-delay: -10s;
+ background: url(../../images/waves/wave_white.svg) repeat-x !important;
+ height: 26px;
+ z-index: 3;
+}
+
+@keyframes wave {
+ 0% {
+ margin-left: 0;
+ }
+ 100% {
+ margin-left: -1600px;
+ }
+}
+
+/* Showcase */
+
+#showcase {
+ margin: 0 1em;
+}
+
+#showcase .mini-timeline {
+ height: 3px;
+ position: relative;
+ margin: 50px 0 50px 0;
+ background: linear-gradient(to right, #ffffff00, #666666ff, #ffffff00);
+ text-align: center;
+}
+
+#showcase .mini-timeline i {
+ display: inline-block;
+ vertical-align: middle;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ position: relative;
+ top: -14px;
+ margin: 0 4% 0 4%;
+ background-color: #3EB2EF;
+ color: white;
+ font-size: 15px;
+ line-height: 33px;
+ border:none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
+ transition: all 0.3s cubic-bezier(.25,.8,.25,1);
+}
+
+#showcase .mini-timeline i:hover {
+ box-shadow: 0 2px 5px rgba(0,0,0,0.16), 0 2px 5px rgba(0,0,0,0.23);
+ transform: scale(1.5);
+}
+
+/* Projects */
+
+#projects {
+ padding-top: 0;
+}
+
+#projects .card {
box-shadow: none;
border: #d1d5da 1px solid;
border-radius: 3px;
+ transition: all 0.2s cubic-bezier(.25,.8,.25,1);
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+#projects .card:hover {
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
}
-div.repo-headline {
+#projects .card-header {
+ box-shadow: none;
font-size: 1.25rem;
- margin-bottom: 8px;
+ padding: 1.5rem 1.5rem 0 1.5rem;
}
-span.repo-language-dot {
- border-radius: 50%;
- height: 12px;
- width: 12px;
- top: 1px;
- display: inline-block;
- position: relative;
+#projects .card-header-icon {
+ font-size: 1.5rem;
+ padding: 0 1rem 0 0;
}
-span.repo-language-dot.python {
- background-color: #3572A5;
+#projects .card-header-title {
+ padding: 0;
+ color: #7289DA;
}
-span.repo-language-dot.html {
- background-color: #e34c26;
+#projects .card:hover .card-header-title {
+ color: #363636;
}
-span.repo-language-dot.css {
- background-color: #563d7c;
+#projects .card-content {
+ padding-top: 8px;
+ padding-bottom: 1rem;
}
-span.repo-language-dot.javascript {
- background-color: #f1e05a;
+#projects .card-footer {
+ margin-top: auto;
+ border: none;
}
-#repo-footer-item {
- margin-left: 1.2rem;
+#projects .card-footer-item {
+ border: none;
}
-#sponsors-hero {
- padding-top: 2rem;
- padding-bottom: 3rem;
+#projects .card-footer-item i {
+ margin-right: 0.5rem;
}
-@media screen and (min-width: 1088px) {
- .video-container iframe {
- height: calc(42vw * 0.5625);
- max-height: 371px;
- max-width: 660px;
- }
+#projects .repo-language-dot {
+ border-radius: 50%;
+ height: 12px;
+ width: 12px;
+ top: -1px;
+ display: inline-block;
+ position: relative;
}
-@media screen and (max-width: 1087px) {
- .video-container iframe {
- height: calc(92vw * 0.5625);
- max-height: none;
- max-width: none;
- }
+#projects .repo-language-dot.python { background-color: #3572A5; }
+#projects .repo-language-dot.html { background-color: #e34c26; }
+#projects .repo-language-dot.css { background-color: #563d7c; }
+#projects .repo-language-dot.javascript { background-color: #f1e05a; }
+
+/* Sponsors */
+
+#sponsors .hero-body {
+ padding-top: 2rem;
+ padding-bottom: 3rem;
+}
+
+#sponsors img {
+ height: 5rem;
+ margin-right: 2rem;
}
diff --git a/pydis_site/static/css/home/timeline.css b/pydis_site/static/css/home/timeline.css
new file mode 100644
index 00000000..0a4dfbb6
--- /dev/null
+++ b/pydis_site/static/css/home/timeline.css
@@ -0,0 +1,3823 @@
+body {
+ background-color: hsl(0, 0%, 100%);
+ background-color: var(--color-bg, white)
+}
+
+h2 {
+ font-size: 2em;
+}
+
+@media (max-width: 500px) {
+ h2 {
+ font-size: 1em;
+ }
+}
+
+article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section, main, form legend {
+ display: block
+}
+
+ol, ul {
+ list-style: none
+}
+
+blockquote, q {
+ quotes: none
+}
+
+button, input, textarea, select {
+ margin: 0
+}
+
+.pastel-red {
+ background-color: #FF7878 !important;
+}
+
+.pastel-orange {
+ background-color: #FFBF76 !important;
+}
+
+.pastel-green {
+ background-color: #8bd6a7 !important;
+}
+
+.pastel-blue {
+ background-color: #8edbec !important;
+}
+
+.pastel-purple {
+ background-color: #CBB1FF !important;
+}
+
+.pastel-pink {
+ background-color: #F6ACFF !important;
+}
+
+.pastel-lime {
+ background-color: #b6df3a !important;
+}
+
+.pastel-dark-blue {
+ background-color: #576297 !important;
+}
+
+.pydis-logo-banner {
+ background-color: #7289DA !important;
+ border-radius: 10px;
+}
+
+.pydis-logo-banner img {
+ padding-right: 20px;
+}
+
+.btn, .form-control, .link, .reset {
+ background-color: transparent;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ line-height: inherit;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none
+}
+
+select.form-control::-ms-expand {
+ display: none
+}
+
+textarea {
+ resize: vertical;
+ overflow: auto;
+ vertical-align: top
+}
+
+input::-ms-clear {
+ display: none
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0
+}
+
+img, video, svg {
+ max-width: 100%
+}
+
+[data-theme] {
+ background-color: hsl(0, 0%, 100%);
+ background-color: var(--color-bg, #fff);
+ color: hsl(240, 4%, 20%);
+ color: var(--color-contrast-high, #313135)
+}
+
+:root {
+ --space-unit: 1em;
+ --space-xxxxs: calc(0.125*var(--space-unit));
+ --space-xxxs: calc(0.25*var(--space-unit));
+ --space-xxs: calc(0.375*var(--space-unit));
+ --space-xs: calc(0.5*var(--space-unit));
+ --space-sm: calc(0.75*var(--space-unit));
+ --space-md: calc(1.25*var(--space-unit));
+ --space-lg: calc(2*var(--space-unit));
+ --space-xl: calc(3.25*var(--space-unit));
+ --space-xxl: calc(5.25*var(--space-unit));
+ --space-xxxl: calc(8.5*var(--space-unit));
+ --space-xxxxl: calc(13.75*var(--space-unit));
+ --component-padding: var(--space-md)
+}
+
+:root {
+ --max-width-xxs: 32rem;
+ --max-width-xs: 38rem;
+ --max-width-sm: 48rem;
+ --max-width-md: 64rem;
+ --max-width-lg: 80rem;
+ --max-width-xl: 90rem;
+ --max-width-xxl: 120rem
+}
+
+.container {
+ width: calc(100% - 1.25em);
+ width: calc(100% - 2*var(--component-padding));
+ margin-left: auto;
+ margin-right: auto
+}
+
+.max-width-xxs {
+ max-width: 32rem;
+ max-width: var(--max-width-xxs)
+}
+
+.max-width-xs {
+ max-width: 38rem;
+ max-width: var(--max-width-xs)
+}
+
+.max-width-sm {
+ max-width: 48rem;
+ max-width: var(--max-width-sm)
+}
+
+.max-width-md {
+ max-width: 64rem;
+ max-width: var(--max-width-md)
+}
+
+.max-width-lg {
+ max-width: 80rem;
+ max-width: var(--max-width-lg)
+}
+
+.max-width-xl {
+ max-width: 90rem;
+ max-width: var(--max-width-xl)
+}
+
+.max-width-xxl {
+ max-width: 120rem;
+ max-width: var(--max-width-xxl)
+}
+
+.max-width-adaptive-sm {
+ max-width: 38rem;
+ max-width: var(--max-width-xs)
+}
+
+@media (min-width: 64rem) {
+ .max-width-adaptive-sm {
+ max-width: 48rem;
+ max-width: var(--max-width-sm)
+ }
+}
+
+.max-width-adaptive-md {
+ max-width: 38rem;
+ max-width: var(--max-width-xs)
+}
+
+@media (min-width: 64rem) {
+ .max-width-adaptive-md {
+ max-width: 64rem;
+ max-width: var(--max-width-md)
+ }
+}
+
+.max-width-adaptive, .max-width-adaptive-lg {
+ max-width: 38rem;
+ max-width: var(--max-width-xs)
+}
+
+@media (min-width: 64rem) {
+ .max-width-adaptive, .max-width-adaptive-lg {
+ max-width: 64rem;
+ max-width: var(--max-width-md)
+ }
+}
+
+@media (min-width: 90rem) {
+ .max-width-adaptive, .max-width-adaptive-lg {
+ max-width: 80rem;
+ max-width: var(--max-width-lg)
+ }
+}
+
+.max-width-adaptive-xl {
+ max-width: 38rem;
+ max-width: var(--max-width-xs)
+}
+
+@media (min-width: 64rem) {
+ .max-width-adaptive-xl {
+ max-width: 64rem;
+ max-width: var(--max-width-md)
+ }
+}
+
+@media (min-width: 90rem) {
+ .max-width-adaptive-xl {
+ max-width: 90rem;
+ max-width: var(--max-width-xl)
+ }
+}
+
+.grid {
+ --grid-gap: 0px;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+}
+
+.grid>* {
+ -ms-flex-preferred-size: 100%;
+ flex-basis: 100%
+}
+
+[class*="grid-gap"] {
+ margin-bottom: 1em * -1;
+ margin-bottom: calc(var(--grid-gap, 1em)*-1);
+ margin-right: 1em * -1;
+ margin-right: calc(var(--grid-gap, 1em)*-1)
+}
+
+[class*="grid-gap"]>* {
+ margin-bottom: 1em;
+ margin-bottom: var(--grid-gap, 1em);
+ margin-right: 1em;
+ margin-right: var(--grid-gap, 1em)
+}
+
+.grid-gap-xxxxs {
+ --grid-gap: var(--space-xxxxs)
+}
+
+.grid-gap-xxxs {
+ --grid-gap: var(--space-xxxs)
+}
+
+.grid-gap-xxs {
+ --grid-gap: var(--space-xxs)
+}
+
+.grid-gap-xs {
+ --grid-gap: var(--space-xs)
+}
+
+.grid-gap-sm {
+ --grid-gap: var(--space-sm)
+}
+
+.grid-gap-md {
+ --grid-gap: var(--space-md)
+}
+
+.grid-gap-lg {
+ --grid-gap: var(--space-lg)
+}
+
+.grid-gap-xl {
+ --grid-gap: var(--space-xl)
+}
+
+.grid-gap-xxl {
+ --grid-gap: var(--space-xxl)
+}
+
+.grid-gap-xxxl {
+ --grid-gap: var(--space-xxxl)
+}
+
+.grid-gap-xxxxl {
+ --grid-gap: var(--space-xxxxl)
+}
+
+.col {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ max-width: 100%
+}
+
+.col-1 {
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(8.33% - 0.01px - 1em);
+ flex-basis: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(8.33% - 0.01px - 1em);
+ max-width: calc(8.33% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-2 {
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(16.66% - 0.01px - 1em);
+ flex-basis: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(16.66% - 0.01px - 1em);
+ max-width: calc(16.66% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-3 {
+ -ms-flex-preferred-size: calc(25% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(25% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(25% - 0.01px - 1em);
+ flex-basis: calc(25% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(25% - 0.01px - 1em);
+ max-width: calc(25% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-4 {
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(33.33% - 0.01px - 1em);
+ flex-basis: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(33.33% - 0.01px - 1em);
+ max-width: calc(33.33% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-5 {
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(41.66% - 0.01px - 1em);
+ flex-basis: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(41.66% - 0.01px - 1em);
+ max-width: calc(41.66% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-6 {
+ -ms-flex-preferred-size: calc(50% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(50% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(50% - 0.01px - 1em);
+ flex-basis: calc(50% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(50% - 0.01px - 1em);
+ max-width: calc(50% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-7 {
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(58.33% - 0.01px - 1em);
+ flex-basis: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(58.33% - 0.01px - 1em);
+ max-width: calc(58.33% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-8 {
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(66.66% - 0.01px - 1em);
+ flex-basis: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(66.66% - 0.01px - 1em);
+ max-width: calc(66.66% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-9 {
+ -ms-flex-preferred-size: calc(75% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(75% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(75% - 0.01px - 1em);
+ flex-basis: calc(75% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(75% - 0.01px - 1em);
+ max-width: calc(75% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-10 {
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(83.33% - 0.01px - 1em);
+ flex-basis: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(83.33% - 0.01px - 1em);
+ max-width: calc(83.33% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-11 {
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(91.66% - 0.01px - 1em);
+ flex-basis: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(91.66% - 0.01px - 1em);
+ max-width: calc(91.66% - 0.01px - var(--grid-gap, 1em))
+}
+
+.col-12 {
+ -ms-flex-preferred-size: calc(100% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(100% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(100% - 0.01px - 1em);
+ flex-basis: calc(100% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(100% - 0.01px - 1em);
+ max-width: calc(100% - 0.01px - var(--grid-gap, 1em))
+}
+
+@media (min-width: 32rem) {
+ .col\@xs {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ max-width: 100%
+ }
+ .col-1\@xs {
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(8.33% - 0.01px - 1em);
+ flex-basis: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(8.33% - 0.01px - 1em);
+ max-width: calc(8.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-2\@xs {
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(16.66% - 0.01px - 1em);
+ flex-basis: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(16.66% - 0.01px - 1em);
+ max-width: calc(16.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-3\@xs {
+ -ms-flex-preferred-size: calc(25% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(25% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(25% - 0.01px - 1em);
+ flex-basis: calc(25% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(25% - 0.01px - 1em);
+ max-width: calc(25% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-4\@xs {
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(33.33% - 0.01px - 1em);
+ flex-basis: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(33.33% - 0.01px - 1em);
+ max-width: calc(33.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-5\@xs {
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(41.66% - 0.01px - 1em);
+ flex-basis: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(41.66% - 0.01px - 1em);
+ max-width: calc(41.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-6\@xs {
+ -ms-flex-preferred-size: calc(50% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(50% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(50% - 0.01px - 1em);
+ flex-basis: calc(50% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(50% - 0.01px - 1em);
+ max-width: calc(50% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-7\@xs {
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(58.33% - 0.01px - 1em);
+ flex-basis: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(58.33% - 0.01px - 1em);
+ max-width: calc(58.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-8\@xs {
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(66.66% - 0.01px - 1em);
+ flex-basis: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(66.66% - 0.01px - 1em);
+ max-width: calc(66.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-9\@xs {
+ -ms-flex-preferred-size: calc(75% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(75% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(75% - 0.01px - 1em);
+ flex-basis: calc(75% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(75% - 0.01px - 1em);
+ max-width: calc(75% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-10\@xs {
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(83.33% - 0.01px - 1em);
+ flex-basis: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(83.33% - 0.01px - 1em);
+ max-width: calc(83.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-11\@xs {
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(91.66% - 0.01px - 1em);
+ flex-basis: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(91.66% - 0.01px - 1em);
+ max-width: calc(91.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-12\@xs {
+ -ms-flex-preferred-size: calc(100% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(100% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(100% - 0.01px - 1em);
+ flex-basis: calc(100% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(100% - 0.01px - 1em);
+ max-width: calc(100% - 0.01px - var(--grid-gap, 1em))
+ }
+}
+
+@media (min-width: 48rem) {
+ .col\@sm {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ max-width: 100%
+ }
+ .col-1\@sm {
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(8.33% - 0.01px - 1em);
+ flex-basis: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(8.33% - 0.01px - 1em);
+ max-width: calc(8.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-2\@sm {
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(16.66% - 0.01px - 1em);
+ flex-basis: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(16.66% - 0.01px - 1em);
+ max-width: calc(16.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-3\@sm {
+ -ms-flex-preferred-size: calc(25% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(25% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(25% - 0.01px - 1em);
+ flex-basis: calc(25% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(25% - 0.01px - 1em);
+ max-width: calc(25% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-4\@sm {
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(33.33% - 0.01px - 1em);
+ flex-basis: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(33.33% - 0.01px - 1em);
+ max-width: calc(33.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-5\@sm {
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(41.66% - 0.01px - 1em);
+ flex-basis: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(41.66% - 0.01px - 1em);
+ max-width: calc(41.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-6\@sm {
+ -ms-flex-preferred-size: calc(50% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(50% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(50% - 0.01px - 1em);
+ flex-basis: calc(50% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(50% - 0.01px - 1em);
+ max-width: calc(50% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-7\@sm {
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(58.33% - 0.01px - 1em);
+ flex-basis: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(58.33% - 0.01px - 1em);
+ max-width: calc(58.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-8\@sm {
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(66.66% - 0.01px - 1em);
+ flex-basis: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(66.66% - 0.01px - 1em);
+ max-width: calc(66.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-9\@sm {
+ -ms-flex-preferred-size: calc(75% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(75% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(75% - 0.01px - 1em);
+ flex-basis: calc(75% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(75% - 0.01px - 1em);
+ max-width: calc(75% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-10\@sm {
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(83.33% - 0.01px - 1em);
+ flex-basis: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(83.33% - 0.01px - 1em);
+ max-width: calc(83.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-11\@sm {
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(91.66% - 0.01px - 1em);
+ flex-basis: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(91.66% - 0.01px - 1em);
+ max-width: calc(91.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-12\@sm {
+ -ms-flex-preferred-size: calc(100% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(100% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(100% - 0.01px - 1em);
+ flex-basis: calc(100% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(100% - 0.01px - 1em);
+ max-width: calc(100% - 0.01px - var(--grid-gap, 1em))
+ }
+}
+
+@media (min-width: 64rem) {
+ .col\@md {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ max-width: 100%
+ }
+ .col-1\@md {
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(8.33% - 0.01px - 1em);
+ flex-basis: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(8.33% - 0.01px - 1em);
+ max-width: calc(8.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-2\@md {
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(16.66% - 0.01px - 1em);
+ flex-basis: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(16.66% - 0.01px - 1em);
+ max-width: calc(16.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-3\@md {
+ -ms-flex-preferred-size: calc(25% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(25% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(25% - 0.01px - 1em);
+ flex-basis: calc(25% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(25% - 0.01px - 1em);
+ max-width: calc(25% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-4\@md {
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(33.33% - 0.01px - 1em);
+ flex-basis: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(33.33% - 0.01px - 1em);
+ max-width: calc(33.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-5\@md {
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(41.66% - 0.01px - 1em);
+ flex-basis: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(41.66% - 0.01px - 1em);
+ max-width: calc(41.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-6\@md {
+ -ms-flex-preferred-size: calc(50% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(50% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(50% - 0.01px - 1em);
+ flex-basis: calc(50% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(50% - 0.01px - 1em);
+ max-width: calc(50% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-7\@md {
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(58.33% - 0.01px - 1em);
+ flex-basis: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(58.33% - 0.01px - 1em);
+ max-width: calc(58.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-8\@md {
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(66.66% - 0.01px - 1em);
+ flex-basis: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(66.66% - 0.01px - 1em);
+ max-width: calc(66.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-9\@md {
+ -ms-flex-preferred-size: calc(75% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(75% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(75% - 0.01px - 1em);
+ flex-basis: calc(75% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(75% - 0.01px - 1em);
+ max-width: calc(75% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-10\@md {
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(83.33% - 0.01px - 1em);
+ flex-basis: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(83.33% - 0.01px - 1em);
+ max-width: calc(83.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-11\@md {
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(91.66% - 0.01px - 1em);
+ flex-basis: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(91.66% - 0.01px - 1em);
+ max-width: calc(91.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-12\@md {
+ -ms-flex-preferred-size: calc(100% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(100% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(100% - 0.01px - 1em);
+ flex-basis: calc(100% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(100% - 0.01px - 1em);
+ max-width: calc(100% - 0.01px - var(--grid-gap, 1em))
+ }
+}
+
+@media (min-width: 80rem) {
+ .col\@lg {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ max-width: 100%
+ }
+ .col-1\@lg {
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(8.33% - 0.01px - 1em);
+ flex-basis: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(8.33% - 0.01px - 1em);
+ max-width: calc(8.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-2\@lg {
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(16.66% - 0.01px - 1em);
+ flex-basis: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(16.66% - 0.01px - 1em);
+ max-width: calc(16.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-3\@lg {
+ -ms-flex-preferred-size: calc(25% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(25% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(25% - 0.01px - 1em);
+ flex-basis: calc(25% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(25% - 0.01px - 1em);
+ max-width: calc(25% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-4\@lg {
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(33.33% - 0.01px - 1em);
+ flex-basis: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(33.33% - 0.01px - 1em);
+ max-width: calc(33.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-5\@lg {
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(41.66% - 0.01px - 1em);
+ flex-basis: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(41.66% - 0.01px - 1em);
+ max-width: calc(41.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-6\@lg {
+ -ms-flex-preferred-size: calc(50% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(50% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(50% - 0.01px - 1em);
+ flex-basis: calc(50% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(50% - 0.01px - 1em);
+ max-width: calc(50% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-7\@lg {
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(58.33% - 0.01px - 1em);
+ flex-basis: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(58.33% - 0.01px - 1em);
+ max-width: calc(58.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-8\@lg {
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(66.66% - 0.01px - 1em);
+ flex-basis: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(66.66% - 0.01px - 1em);
+ max-width: calc(66.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-9\@lg {
+ -ms-flex-preferred-size: calc(75% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(75% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(75% - 0.01px - 1em);
+ flex-basis: calc(75% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(75% - 0.01px - 1em);
+ max-width: calc(75% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-10\@lg {
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(83.33% - 0.01px - 1em);
+ flex-basis: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(83.33% - 0.01px - 1em);
+ max-width: calc(83.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-11\@lg {
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(91.66% - 0.01px - 1em);
+ flex-basis: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(91.66% - 0.01px - 1em);
+ max-width: calc(91.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-12\@lg {
+ -ms-flex-preferred-size: calc(100% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(100% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(100% - 0.01px - 1em);
+ flex-basis: calc(100% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(100% - 0.01px - 1em);
+ max-width: calc(100% - 0.01px - var(--grid-gap, 1em))
+ }
+}
+
+@media (min-width: 90rem) {
+ .col\@xl {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ -ms-flex-preferred-size: 0;
+ flex-basis: 0;
+ max-width: 100%
+ }
+ .col-1\@xl {
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(8.33% - 0.01px - 1em);
+ flex-basis: calc(8.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(8.33% - 0.01px - 1em);
+ max-width: calc(8.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-2\@xl {
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(16.66% - 0.01px - 1em);
+ flex-basis: calc(16.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(16.66% - 0.01px - 1em);
+ max-width: calc(16.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-3\@xl {
+ -ms-flex-preferred-size: calc(25% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(25% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(25% - 0.01px - 1em);
+ flex-basis: calc(25% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(25% - 0.01px - 1em);
+ max-width: calc(25% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-4\@xl {
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(33.33% - 0.01px - 1em);
+ flex-basis: calc(33.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(33.33% - 0.01px - 1em);
+ max-width: calc(33.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-5\@xl {
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(41.66% - 0.01px - 1em);
+ flex-basis: calc(41.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(41.66% - 0.01px - 1em);
+ max-width: calc(41.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-6\@xl {
+ -ms-flex-preferred-size: calc(50% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(50% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(50% - 0.01px - 1em);
+ flex-basis: calc(50% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(50% - 0.01px - 1em);
+ max-width: calc(50% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-7\@xl {
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(58.33% - 0.01px - 1em);
+ flex-basis: calc(58.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(58.33% - 0.01px - 1em);
+ max-width: calc(58.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-8\@xl {
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(66.66% - 0.01px - 1em);
+ flex-basis: calc(66.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(66.66% - 0.01px - 1em);
+ max-width: calc(66.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-9\@xl {
+ -ms-flex-preferred-size: calc(75% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(75% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(75% - 0.01px - 1em);
+ flex-basis: calc(75% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(75% - 0.01px - 1em);
+ max-width: calc(75% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-10\@xl {
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(83.33% - 0.01px - 1em);
+ flex-basis: calc(83.33% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(83.33% - 0.01px - 1em);
+ max-width: calc(83.33% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-11\@xl {
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(91.66% - 0.01px - 1em);
+ flex-basis: calc(91.66% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(91.66% - 0.01px - 1em);
+ max-width: calc(91.66% - 0.01px - var(--grid-gap, 1em))
+ }
+ .col-12\@xl {
+ -ms-flex-preferred-size: calc(100% - 0.01px - 1em);
+ -ms-flex-preferred-size: calc(100% - 0.01px - var(--grid-gap, 1em));
+ flex-basis: calc(100% - 0.01px - 1em);
+ flex-basis: calc(100% - 0.01px - var(--grid-gap, 1em));
+ max-width: calc(100% - 0.01px - 1em);
+ max-width: calc(100% - 0.01px - var(--grid-gap, 1em))
+ }
+}
+
+:root {
+ --radius-sm: calc(var(--radius, 0.25em)/2);
+ --radius-md: var(--radius, 0.25em);
+ --radius-lg: calc(var(--radius, 0.25em)*2);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, .085), 0 1px 8px rgba(0, 0, 0, .1);
+ --shadow-md: 0 1px 8px rgba(0, 0, 0, .1), 0 8px 24px rgba(0, 0, 0, .15);
+ --shadow-lg: 0 1px 8px rgba(0, 0, 0, .1), 0 16px 48px rgba(0, 0, 0, .1), 0 24px 60px rgba(0, 0, 0, .1);
+ --bounce: cubic-bezier(0.175, 0.885, 0.32, 1.275);
+ --ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
+ --ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19);
+ --ease-out: cubic-bezier(0.215, 0.61, 0.355, 1)
+}
+
+:root {
+ --body-line-height: 1.4;
+ --heading-line-height: 1.2
+}
+
+body {
+ color: hsl(240, 4%, 20%);
+ color: var(--color-contrast-high, #313135)
+}
+
+h1, h2, h3, h4 {
+ color: hsl(240, 8%, 12%);
+ color: var(--color-contrast-higher, #1c1c21);
+ line-height: 1.2;
+ line-height: var(--heading-line-height, 1.2)
+}
+
+.text-xxxl {
+ font-size: 2.48832em;
+ font-size: var(--text-xxxl, 2.488em)
+}
+
+small, .text-sm {
+ font-size: 0.83333em;
+ font-size: var(--text-sm, 0.833em)
+}
+
+.text-xs {
+ font-size: 0.69444em;
+ font-size: var(--text-xs, 0.694em)
+}
+
+strong, .text-bold {
+ font-weight: bold
+}
+
+s {
+ text-decoration: line-through
+}
+
+u, .text-underline {
+ text-decoration: underline
+}
+
+
+.text-component h1, .text-component h2, .text-component h3, .text-component h4 {
+ line-height: 1.2;
+ line-height: var(--component-heading-line-height, 1.2);
+ margin-bottom: 0.25em;
+ margin-bottom: calc(var(--space-xxxs)*var(--text-vspace-multiplier, 1))
+}
+
+.text-component h2, .text-component h3, .text-component h4 {
+ margin-top: 0.75em;
+ margin-top: calc(var(--space-sm)*var(--text-vspace-multiplier, 1))
+}
+
+.text-component p, .text-component blockquote, .text-component ul li, .text-component ol li {
+ line-height: 1.4;
+ line-height: var(--component-body-line-height)
+}
+
+.text-component ul, .text-component ol, .text-component p, .text-component blockquote, .text-component .text-component__block {
+ margin-bottom: 0.75em;
+ margin-bottom: calc(var(--space-sm)*var(--text-vspace-multiplier, 1))
+}
+
+.text-component ul, .text-component ol {
+ padding-left: 1em
+}
+
+.text-component ul {
+ list-style-type: disc
+}
+
+.text-component ol {
+ list-style-type: decimal
+}
+
+.text-component img {
+ display: block;
+ margin: 0 auto
+}
+
+.text-component figcaption {
+ text-align: center;
+ margin-top: 0.5em;
+ margin-top: var(--space-xs)
+}
+
+.text-component em {
+ font-style: italic
+}
+
+.text-component hr {
+ margin-top: 2em;
+ margin-top: calc(var(--space-lg)*var(--text-vspace-multiplier, 1));
+ margin-bottom: 2em;
+ margin-bottom: calc(var(--space-lg)*var(--text-vspace-multiplier, 1));
+ margin-left: auto;
+ margin-right: auto
+}
+
+.text-component>*:first-child {
+ margin-top: 0
+}
+
+.text-component>*:last-child {
+ margin-bottom: 0
+}
+
+.text-component__block--full-width {
+ width: 100vw;
+ margin-left: calc(50% - 50vw)
+}
+
+@media (min-width: 48rem) {
+ .text-component__block--left, .text-component__block--right {
+ width: 45%
+ }
+ .text-component__block--left img, .text-component__block--right img {
+ width: 100%
+ }
+ .text-component__block--left {
+ float: left;
+ margin-right: 0.75em;
+ margin-right: calc(var(--space-sm)*var(--text-vspace-multiplier, 1))
+ }
+ .text-component__block--right {
+ float: right;
+ margin-left: 0.75em;
+ margin-left: calc(var(--space-sm)*var(--text-vspace-multiplier, 1))
+ }
+}
+
+@media (min-width: 90rem) {
+ .text-component__block--outset {
+ width: calc(100% + 10.5em);
+ width: calc(100% + 2*var(--space-xxl))
+ }
+ .text-component__block--outset img {
+ width: 100%
+ }
+ .text-component__block--outset:not(.text-component__block--right) {
+ margin-left: -5.25em;
+ margin-left: calc(-1*var(--space-xxl))
+ }
+ .text-component__block--left, .text-component__block--right {
+ width: 50%
+ }
+ .text-component__block--right.text-component__block--outset {
+ margin-right: -5.25em;
+ margin-right: calc(-1*var(--space-xxl))
+ }
+}
+
+:root {
+ --icon-xxs: 12px;
+ --icon-xs: 16px;
+ --icon-sm: 24px;
+ --icon-md: 32px;
+ --icon-lg: 48px;
+ --icon-xl: 64px;
+ --icon-xxl: 128px
+}
+
+.icon--xxs {
+ font-size: 12px;
+ font-size: var(--icon-xxs)
+}
+
+.icon--xs {
+ font-size: 16px;
+ font-size: var(--icon-xs)
+}
+
+.icon--sm {
+ font-size: 24px;
+ font-size: var(--icon-sm)
+}
+
+.icon--md {
+ font-size: 32px;
+ font-size: var(--icon-md)
+}
+
+.icon--lg {
+ font-size: 48px;
+ font-size: var(--icon-lg)
+}
+
+.icon--xl {
+ font-size: 64px;
+ font-size: var(--icon-xl)
+}
+
+.icon--xxl {
+ font-size: 128px;
+ font-size: var(--icon-xxl)
+}
+
+.icon--is-spinning {
+ -webkit-animation: icon-spin 1s infinite linear;
+ animation: icon-spin 1s infinite linear
+}
+
+@-webkit-keyframes icon-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg)
+ }
+ 100% {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg)
+ }
+}
+
+@keyframes icon-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg)
+ }
+ 100% {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg)
+ }
+}
+
+.icon use {
+ color: inherit;
+ fill: currentColor
+}
+
+.btn {
+ position: relative;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center;
+ white-space: nowrap;
+ text-decoration: none;
+ line-height: 1;
+ font-size: 1em;
+ font-size: var(--btn-font-size, 1em);
+ padding-top: 0.5em;
+ padding-top: var(--btn-padding-y, 0.5em);
+ padding-bottom: 0.5em;
+ padding-bottom: var(--btn-padding-y, 0.5em);
+ padding-left: 0.75em;
+ padding-left: var(--btn-padding-x, 0.75em);
+ padding-right: 0.75em;
+ padding-right: var(--btn-padding-x, 0.75em);
+ border-radius: 0.25em;
+ border-radius: var(--btn-radius, 0.25em)
+}
+
+.btn--primary {
+ background-color: hsl(220, 90%, 56%);
+ background-color: var(--color-primary, #2a6df4);
+ color: hsl(0, 0%, 100%);
+ color: var(--color-white, #fff)
+}
+
+.btn--subtle {
+ background-color: hsl(240, 1%, 83%);
+ background-color: var(--color-contrast-low, #d3d3d4);
+ color: hsl(240, 8%, 12%);
+ color: var(--color-contrast-higher, #1c1c21)
+}
+
+.btn--accent {
+ background-color: hsl(355, 90%, 61%);
+ background-color: var(--color-accent, #f54251);
+ color: hsl(0, 0%, 100%);
+ color: var(--color-white, #fff)
+}
+
+.btn--disabled {
+ cursor: not-allowed
+}
+
+.btn--sm {
+ font-size: 0.8em;
+ font-size: var(--btn-font-size-sm, 0.8em)
+}
+
+.btn--md {
+ font-size: 1.2em;
+ font-size: var(--btn-font-size-md, 1.2em)
+}
+
+.btn--lg {
+ font-size: 1.4em;
+ font-size: var(--btn-font-size-lg, 1.4em)
+}
+
+.btn--icon {
+ padding: 0.5em;
+ padding: var(--btn-padding-y, 0.5em)
+}
+
+.form-control {
+ background-color: hsl(0, 0%, 100%);
+ background-color: var(--color-bg, #f2f2f2);
+ padding-top: 0.5em;
+ padding-top: var(--form-control-padding-y, 0.5em);
+ padding-bottom: 0.5em;
+ padding-bottom: var(--form-control-padding-y, 0.5em);
+ padding-left: 0.75em;
+ padding-left: var(--form-control-padding-x, 0.75em);
+ padding-right: 0.75em;
+ padding-right: var(--form-control-padding-x, 0.75em);
+ border-radius: 0.25em;
+ border-radius: var(--form-control-radius, 0.25em)
+}
+
+.form-control::-webkit-input-placeholder {
+ color: hsl(240, 1%, 48%);
+ color: var(--color-contrast-medium, #79797c)
+}
+
+.form-control::-moz-placeholder {
+ opacity: 1;
+ color: hsl(240, 1%, 48%);
+ color: var(--color-contrast-medium, #79797c)
+}
+
+.form-control:-ms-input-placeholder {
+ color: hsl(240, 1%, 48%);
+ color: var(--color-contrast-medium, #79797c)
+}
+
+.form-control:-moz-placeholder {
+ color: hsl(240, 1%, 48%);
+ color: var(--color-contrast-medium, #79797c)
+}
+
+.form-control[disabled], .form-control[readonly] {
+ cursor: not-allowed
+}
+
+.form-legend {
+ color: hsl(240, 8%, 12%);
+ color: var(--color-contrast-higher, #1c1c21);
+ line-height: 1.2;
+ font-size: 1.2em;
+ font-size: var(--text-md, 1.2em);
+ margin-bottom: 0.375em;
+ margin-bottom: var(--space-xxs)
+}
+
+.form-label {
+ display: inline-block
+}
+
+.form__msg-error {
+ background-color: hsl(355, 90%, 61%);
+ background-color: var(--color-error, #f54251);
+ color: hsl(0, 0%, 100%);
+ color: var(--color-white, #fff);
+ font-size: 0.83333em;
+ font-size: var(--text-sm, 0.833em);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ padding: 0.5em;
+ padding: var(--space-xs);
+ margin-top: 0.75em;
+ margin-top: var(--space-sm);
+ border-radius: 0.25em;
+ border-radius: var(--radius-md, 0.25em);
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px)
+}
+
+.form__msg-error::before {
+ content: '';
+ position: absolute;
+ left: 0.75em;
+ left: var(--space-sm);
+ top: 0;
+ -webkit-transform: translateY(-100%);
+ -ms-transform: translateY(-100%);
+ transform: translateY(-100%);
+ width: 0;
+ height: 0;
+ border: 8px solid transparent;
+ border-bottom-color: hsl(355, 90%, 61%);
+ border-bottom-color: var(--color-error)
+}
+
+.form__msg-error--is-visible {
+ position: relative;
+ clip: auto
+}
+
+.radio-list>*, .checkbox-list>* {
+ position: relative;
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-align: baseline;
+ align-items: baseline;
+ margin-bottom: 0.375em;
+ margin-bottom: var(--space-xxs)
+}
+
+.radio-list>*:last-of-type, .checkbox-list>*:last-of-type {
+ margin-bottom: 0
+}
+
+.radio-list label, .checkbox-list label {
+ line-height: 1.4;
+ line-height: var(--body-line-height);
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none
+}
+
+.radio-list input, .checkbox-list input {
+ vertical-align: top;
+ margin-right: 0.25em;
+ margin-right: var(--space-xxxs);
+ -ms-flex-negative: 0;
+ flex-shrink: 0
+}
+
+:root {
+ --zindex-header: 2;
+ --zindex-popover: 5;
+ --zindex-fixed-element: 10;
+ --zindex-overlay: 15
+}
+
+@media not all and (min-width: 32rem) {
+ .display\@xs {
+ display: none !important
+ }
+}
+
+@media (min-width: 32rem) {
+ .hide\@xs {
+ display: none !important
+ }
+}
+
+@media not all and (min-width: 48rem) {
+ .display\@sm {
+ display: none !important
+ }
+}
+
+@media (min-width: 48rem) {
+ .hide\@sm {
+ display: none !important
+ }
+}
+
+@media not all and (min-width: 64rem) {
+ .display\@md {
+ display: none !important
+ }
+}
+
+@media (min-width: 64rem) {
+ .hide\@md {
+ display: none !important
+ }
+}
+
+@media not all and (min-width: 80rem) {
+ .display\@lg {
+ display: none !important
+ }
+}
+
+@media (min-width: 80rem) {
+ .hide\@lg {
+ display: none !important
+ }
+}
+
+@media not all and (min-width: 90rem) {
+ .display\@xl {
+ display: none !important
+ }
+}
+
+@media (min-width: 90rem) {
+ .hide\@xl {
+ display: none !important
+ }
+}
+
+:root {
+ --display: block
+}
+
+.is-visible {
+ display: block !important;
+ display: var(--display) !important
+}
+
+.is-hidden {
+ display: none !important
+}
+
+.sr-only {
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px);
+ -webkit-clip-path: inset(50%);
+ clip-path: inset(50%);
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ padding: 0;
+ border: 0;
+ white-space: nowrap
+}
+
+.flex {
+ display: -ms-flexbox;
+ display: flex
+}
+
+.inline-flex {
+ display: -ms-inline-flexbox;
+ display: inline-flex
+}
+
+.flex-wrap {
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+}
+
+.flex-column {
+ -ms-flex-direction: column;
+ flex-direction: column
+}
+
+.flex-row {
+ -ms-flex-direction: row;
+ flex-direction: row
+}
+
+.flex-center {
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.justify-start {
+ -ms-flex-pack: start;
+ justify-content: flex-start
+}
+
+.justify-end {
+ -ms-flex-pack: end;
+ justify-content: flex-end
+}
+
+.justify-center {
+ -ms-flex-pack: center;
+ justify-content: center
+}
+
+.justify-between {
+ -ms-flex-pack: justify;
+ justify-content: space-between
+}
+
+.items-center {
+ -ms-flex-align: center;
+ align-items: center
+}
+
+.items-start {
+ -ms-flex-align: start;
+ align-items: flex-start
+}
+
+.items-end {
+ -ms-flex-align: end;
+ align-items: flex-end
+}
+
+@media (min-width: 32rem) {
+ .flex-wrap\@xs {
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+ }
+ .flex-column\@xs {
+ -ms-flex-direction: column;
+ flex-direction: column
+ }
+ .flex-row\@xs {
+ -ms-flex-direction: row;
+ flex-direction: row
+ }
+ .flex-center\@xs {
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .justify-start\@xs {
+ -ms-flex-pack: start;
+ justify-content: flex-start
+ }
+ .justify-end\@xs {
+ -ms-flex-pack: end;
+ justify-content: flex-end
+ }
+ .justify-center\@xs {
+ -ms-flex-pack: center;
+ justify-content: center
+ }
+ .justify-between\@xs {
+ -ms-flex-pack: justify;
+ justify-content: space-between
+ }
+ .items-center\@xs {
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .items-start\@xs {
+ -ms-flex-align: start;
+ align-items: flex-start
+ }
+ .items-end\@xs {
+ -ms-flex-align: end;
+ align-items: flex-end
+ }
+}
+
+@media (min-width: 48rem) {
+ .flex-wrap\@sm {
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+ }
+ .flex-column\@sm {
+ -ms-flex-direction: column;
+ flex-direction: column
+ }
+ .flex-row\@sm {
+ -ms-flex-direction: row;
+ flex-direction: row
+ }
+ .flex-center\@sm {
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .justify-start\@sm {
+ -ms-flex-pack: start;
+ justify-content: flex-start
+ }
+ .justify-end\@sm {
+ -ms-flex-pack: end;
+ justify-content: flex-end
+ }
+ .justify-center\@sm {
+ -ms-flex-pack: center;
+ justify-content: center
+ }
+ .justify-between\@sm {
+ -ms-flex-pack: justify;
+ justify-content: space-between
+ }
+ .items-center\@sm {
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .items-start\@sm {
+ -ms-flex-align: start;
+ align-items: flex-start
+ }
+ .items-end\@sm {
+ -ms-flex-align: end;
+ align-items: flex-end
+ }
+}
+
+@media (min-width: 64rem) {
+ .flex-wrap\@md {
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+ }
+ .flex-column\@md {
+ -ms-flex-direction: column;
+ flex-direction: column
+ }
+ .flex-row\@md {
+ -ms-flex-direction: row;
+ flex-direction: row
+ }
+ .flex-center\@md {
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .justify-start\@md {
+ -ms-flex-pack: start;
+ justify-content: flex-start
+ }
+ .justify-end\@md {
+ -ms-flex-pack: end;
+ justify-content: flex-end
+ }
+ .justify-center\@md {
+ -ms-flex-pack: center;
+ justify-content: center
+ }
+ .justify-between\@md {
+ -ms-flex-pack: justify;
+ justify-content: space-between
+ }
+ .items-center\@md {
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .items-start\@md {
+ -ms-flex-align: start;
+ align-items: flex-start
+ }
+ .items-end\@md {
+ -ms-flex-align: end;
+ align-items: flex-end
+ }
+}
+
+@media (min-width: 80rem) {
+ .flex-wrap\@lg {
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+ }
+ .flex-column\@lg {
+ -ms-flex-direction: column;
+ flex-direction: column
+ }
+ .flex-row\@lg {
+ -ms-flex-direction: row;
+ flex-direction: row
+ }
+ .flex-center\@lg {
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .justify-start\@lg {
+ -ms-flex-pack: start;
+ justify-content: flex-start
+ }
+ .justify-end\@lg {
+ -ms-flex-pack: end;
+ justify-content: flex-end
+ }
+ .justify-center\@lg {
+ -ms-flex-pack: center;
+ justify-content: center
+ }
+ .justify-between\@lg {
+ -ms-flex-pack: justify;
+ justify-content: space-between
+ }
+ .items-center\@lg {
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .items-start\@lg {
+ -ms-flex-align: start;
+ align-items: flex-start
+ }
+ .items-end\@lg {
+ -ms-flex-align: end;
+ align-items: flex-end
+ }
+}
+
+@media (min-width: 90rem) {
+ .flex-wrap\@xl {
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap
+ }
+ .flex-column\@xl {
+ -ms-flex-direction: column;
+ flex-direction: column
+ }
+ .flex-row\@xl {
+ -ms-flex-direction: row;
+ flex-direction: row
+ }
+ .flex-center\@xl {
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .justify-start\@xl {
+ -ms-flex-pack: start;
+ justify-content: flex-start
+ }
+ .justify-end\@xl {
+ -ms-flex-pack: end;
+ justify-content: flex-end
+ }
+ .justify-center\@xl {
+ -ms-flex-pack: center;
+ justify-content: center
+ }
+ .justify-between\@xl {
+ -ms-flex-pack: justify;
+ justify-content: space-between
+ }
+ .items-center\@xl {
+ -ms-flex-align: center;
+ align-items: center
+ }
+ .items-start\@xl {
+ -ms-flex-align: start;
+ align-items: flex-start
+ }
+ .items-end\@xl {
+ -ms-flex-align: end;
+ align-items: flex-end
+ }
+}
+
+.flex-grow {
+ -ms-flex-positive: 1;
+ flex-grow: 1
+}
+
+.flex-shrink-0 {
+ -ms-flex-negative: 0;
+ flex-shrink: 0
+}
+
+.flex-gap-xxxs {
+ margin-bottom: -0.25em;
+ margin-bottom: calc(-1*var(--space-xxxs));
+ margin-right: -0.25em;
+ margin-right: calc(-1*var(--space-xxxs))
+}
+
+.flex-gap-xxxs>* {
+ margin-bottom: 0.25em;
+ margin-bottom: var(--space-xxxs);
+ margin-right: 0.25em;
+ margin-right: var(--space-xxxs)
+}
+
+.flex-gap-xxs {
+ margin-bottom: -0.375em;
+ margin-bottom: calc(-1*var(--space-xxs));
+ margin-right: -0.375em;
+ margin-right: calc(-1*var(--space-xxs))
+}
+
+.flex-gap-xxs>* {
+ margin-bottom: 0.375em;
+ margin-bottom: var(--space-xxs);
+ margin-right: 0.375em;
+ margin-right: var(--space-xxs)
+}
+
+.flex-gap-xs {
+ margin-bottom: -0.5em;
+ margin-bottom: calc(-1*var(--space-xs));
+ margin-right: -0.5em;
+ margin-right: calc(-1*var(--space-xs))
+}
+
+.flex-gap-xs>* {
+ margin-bottom: 0.5em;
+ margin-bottom: var(--space-xs);
+ margin-right: 0.5em;
+ margin-right: var(--space-xs)
+}
+
+.flex-gap-sm {
+ margin-bottom: -0.75em;
+ margin-bottom: calc(-1*var(--space-sm));
+ margin-right: -0.75em;
+ margin-right: calc(-1*var(--space-sm))
+}
+
+.flex-gap-sm>* {
+ margin-bottom: 0.75em;
+ margin-bottom: var(--space-sm);
+ margin-right: 0.75em;
+ margin-right: var(--space-sm)
+}
+
+.flex-gap-md {
+ margin-bottom: -1.25em;
+ margin-bottom: calc(-1*var(--space-md));
+ margin-right: -1.25em;
+ margin-right: calc(-1*var(--space-md))
+}
+
+.flex-gap-md>* {
+ margin-bottom: 1.25em;
+ margin-bottom: var(--space-md);
+ margin-right: 1.25em;
+ margin-right: var(--space-md)
+}
+
+.flex-gap-lg {
+ margin-bottom: -2em;
+ margin-bottom: calc(-1*var(--space-lg));
+ margin-right: -2em;
+ margin-right: calc(-1*var(--space-lg))
+}
+
+.flex-gap-lg>* {
+ margin-bottom: 2em;
+ margin-bottom: var(--space-lg);
+ margin-right: 2em;
+ margin-right: var(--space-lg)
+}
+
+.flex-gap-xl {
+ margin-bottom: -3.25em;
+ margin-bottom: calc(-1*var(--space-xl));
+ margin-right: -3.25em;
+ margin-right: calc(-1*var(--space-xl))
+}
+
+.flex-gap-xl>* {
+ margin-bottom: 3.25em;
+ margin-bottom: var(--space-xl);
+ margin-right: 3.25em;
+ margin-right: var(--space-xl)
+}
+
+.flex-gap-xxl {
+ margin-bottom: -5.25em;
+ margin-bottom: calc(-1*var(--space-xxl));
+ margin-right: -5.25em;
+ margin-right: calc(-1*var(--space-xxl))
+}
+
+.flex-gap-xxl>* {
+ margin-bottom: 5.25em;
+ margin-bottom: var(--space-xxl);
+ margin-right: 5.25em;
+ margin-right: var(--space-xxl)
+}
+
+.margin-xxxxs {
+ margin: 0.125em;
+ margin: var(--space-xxxxs)
+}
+
+.margin-xxxs {
+ margin: 0.25em;
+ margin: var(--space-xxxs)
+}
+
+.margin-xxs {
+ margin: 0.375em;
+ margin: var(--space-xxs)
+}
+
+.margin-xs {
+ margin: 0.5em;
+ margin: var(--space-xs)
+}
+
+.margin-sm {
+ margin: 0.75em;
+ margin: var(--space-sm)
+}
+
+.margin-md {
+ margin: 1.25em;
+ margin: var(--space-md)
+}
+
+.margin-lg {
+ margin: 2em;
+ margin: var(--space-lg)
+}
+
+.margin-xl {
+ margin: 3.25em;
+ margin: var(--space-xl)
+}
+
+.margin-xxl {
+ margin: 5.25em;
+ margin: var(--space-xxl)
+}
+
+.margin-xxxl {
+ margin: 8.5em;
+ margin: var(--space-xxxl)
+}
+
+.margin-xxxxl {
+ margin: 13.75em;
+ margin: var(--space-xxxxl)
+}
+
+.margin-auto {
+ margin: auto
+}
+
+.margin-top-xxxxs {
+ margin-top: 0.125em;
+ margin-top: var(--space-xxxxs)
+}
+
+.margin-top-xxxs {
+ margin-top: 0.25em;
+ margin-top: var(--space-xxxs)
+}
+
+.margin-top-xxs {
+ margin-top: 0.375em;
+ margin-top: var(--space-xxs)
+}
+
+.margin-top-xs {
+ margin-top: 0.5em;
+ margin-top: var(--space-xs)
+}
+
+.margin-top-sm {
+ margin-top: 0.75em;
+ margin-top: var(--space-sm)
+}
+
+.margin-top-md {
+ margin-top: 1.25em;
+ margin-top: var(--space-md)
+}
+
+.margin-top-lg {
+ margin-top: 2em;
+ margin-top: var(--space-lg)
+}
+
+.margin-top-xl {
+ margin-top: 3.25em;
+ margin-top: var(--space-xl)
+}
+
+.margin-top-xxl {
+ margin-top: 5.25em;
+ margin-top: var(--space-xxl)
+}
+
+.margin-top-xxxl {
+ margin-top: 8.5em;
+ margin-top: var(--space-xxxl)
+}
+
+.margin-top-xxxxl {
+ margin-top: 13.75em;
+ margin-top: var(--space-xxxxl)
+}
+
+.margin-top-auto {
+ margin-top: auto
+}
+
+.margin-bottom-xxxxs {
+ margin-bottom: 0.125em;
+ margin-bottom: var(--space-xxxxs)
+}
+
+.margin-bottom-xxxs {
+ margin-bottom: 0.25em;
+ margin-bottom: var(--space-xxxs)
+}
+
+.margin-bottom-xxs {
+ margin-bottom: 0.375em;
+ margin-bottom: var(--space-xxs)
+}
+
+.margin-bottom-xs {
+ margin-bottom: 0.5em;
+ margin-bottom: var(--space-xs)
+}
+
+.margin-bottom-sm {
+ margin-bottom: 0.75em;
+ margin-bottom: var(--space-sm)
+}
+
+.margin-bottom-md {
+ margin-bottom: 1.25em;
+ margin-bottom: var(--space-md)
+}
+
+.margin-bottom-lg {
+ margin-bottom: 2em;
+ margin-bottom: var(--space-lg)
+}
+
+.margin-bottom-xl {
+ margin-bottom: 3.25em;
+ margin-bottom: var(--space-xl)
+}
+
+.margin-bottom-xxl {
+ margin-bottom: 5.25em;
+ margin-bottom: var(--space-xxl)
+}
+
+.margin-bottom-xxxl {
+ margin-bottom: 8.5em;
+ margin-bottom: var(--space-xxxl)
+}
+
+.margin-bottom-xxxxl {
+ margin-bottom: 13.75em;
+ margin-bottom: var(--space-xxxxl)
+}
+
+.margin-bottom-auto {
+ margin-bottom: auto
+}
+
+.margin-right-xxxxs {
+ margin-right: 0.125em;
+ margin-right: var(--space-xxxxs)
+}
+
+.margin-right-xxxs {
+ margin-right: 0.25em;
+ margin-right: var(--space-xxxs)
+}
+
+.margin-right-xxs {
+ margin-right: 0.375em;
+ margin-right: var(--space-xxs)
+}
+
+.margin-right-xs {
+ margin-right: 0.5em;
+ margin-right: var(--space-xs)
+}
+
+.margin-right-sm {
+ margin-right: 0.75em;
+ margin-right: var(--space-sm)
+}
+
+.margin-right-md {
+ margin-right: 1.25em;
+ margin-right: var(--space-md)
+}
+
+.margin-right-lg {
+ margin-right: 2em;
+ margin-right: var(--space-lg)
+}
+
+.margin-right-xl {
+ margin-right: 3.25em;
+ margin-right: var(--space-xl)
+}
+
+.margin-right-xxl {
+ margin-right: 5.25em;
+ margin-right: var(--space-xxl)
+}
+
+.margin-right-xxxl {
+ margin-right: 8.5em;
+ margin-right: var(--space-xxxl)
+}
+
+.margin-right-xxxxl {
+ margin-right: 13.75em;
+ margin-right: var(--space-xxxxl)
+}
+
+.margin-right-auto {
+ margin-right: auto
+}
+
+.margin-left-xxxxs {
+ margin-left: 0.125em;
+ margin-left: var(--space-xxxxs)
+}
+
+.margin-left-xxxs {
+ margin-left: 0.25em;
+ margin-left: var(--space-xxxs)
+}
+
+.margin-left-xxs {
+ margin-left: 0.375em;
+ margin-left: var(--space-xxs)
+}
+
+.margin-left-xs {
+ margin-left: 0.5em;
+ margin-left: var(--space-xs)
+}
+
+.margin-left-sm {
+ margin-left: 0.75em;
+ margin-left: var(--space-sm)
+}
+
+.margin-left-md {
+ margin-left: 1.25em;
+ margin-left: var(--space-md)
+}
+
+.margin-left-lg {
+ margin-left: 2em;
+ margin-left: var(--space-lg)
+}
+
+.margin-left-xl {
+ margin-left: 3.25em;
+ margin-left: var(--space-xl)
+}
+
+.margin-left-xxl {
+ margin-left: 5.25em;
+ margin-left: var(--space-xxl)
+}
+
+.margin-left-xxxl {
+ margin-left: 8.5em;
+ margin-left: var(--space-xxxl)
+}
+
+.margin-left-xxxxl {
+ margin-left: 13.75em;
+ margin-left: var(--space-xxxxl)
+}
+
+.margin-left-auto {
+ margin-left: auto
+}
+
+.margin-x-xxxxs {
+ margin-left: 0.125em;
+ margin-left: var(--space-xxxxs);
+ margin-right: 0.125em;
+ margin-right: var(--space-xxxxs)
+}
+
+.margin-x-xxxs {
+ margin-left: 0.25em;
+ margin-left: var(--space-xxxs);
+ margin-right: 0.25em;
+ margin-right: var(--space-xxxs)
+}
+
+.margin-x-xxs {
+ margin-left: 0.375em;
+ margin-left: var(--space-xxs);
+ margin-right: 0.375em;
+ margin-right: var(--space-xxs)
+}
+
+.margin-x-xs {
+ margin-left: 0.5em;
+ margin-left: var(--space-xs);
+ margin-right: 0.5em;
+ margin-right: var(--space-xs)
+}
+
+.margin-x-sm {
+ margin-left: 0.75em;
+ margin-left: var(--space-sm);
+ margin-right: 0.75em;
+ margin-right: var(--space-sm)
+}
+
+.margin-x-md {
+ margin-left: 1.25em;
+ margin-left: var(--space-md);
+ margin-right: 1.25em;
+ margin-right: var(--space-md)
+}
+
+.margin-x-lg {
+ margin-left: 2em;
+ margin-left: var(--space-lg);
+ margin-right: 2em;
+ margin-right: var(--space-lg)
+}
+
+.margin-x-xl {
+ margin-left: 3.25em;
+ margin-left: var(--space-xl);
+ margin-right: 3.25em;
+ margin-right: var(--space-xl)
+}
+
+.margin-x-xxl {
+ margin-left: 5.25em;
+ margin-left: var(--space-xxl);
+ margin-right: 5.25em;
+ margin-right: var(--space-xxl)
+}
+
+.margin-x-xxxl {
+ margin-left: 8.5em;
+ margin-left: var(--space-xxxl);
+ margin-right: 8.5em;
+ margin-right: var(--space-xxxl)
+}
+
+.margin-x-xxxxl {
+ margin-left: 13.75em;
+ margin-left: var(--space-xxxxl);
+ margin-right: 13.75em;
+ margin-right: var(--space-xxxxl)
+}
+
+.margin-x-auto {
+ margin-left: auto;
+ margin-right: auto
+}
+
+.margin-y-xxxxs {
+ margin-top: 0.125em;
+ margin-top: var(--space-xxxxs);
+ margin-bottom: 0.125em;
+ margin-bottom: var(--space-xxxxs)
+}
+
+.margin-y-xxxs {
+ margin-top: 0.25em;
+ margin-top: var(--space-xxxs);
+ margin-bottom: 0.25em;
+ margin-bottom: var(--space-xxxs)
+}
+
+.margin-y-xxs {
+ margin-top: 0.375em;
+ margin-top: var(--space-xxs);
+ margin-bottom: 0.375em;
+ margin-bottom: var(--space-xxs)
+}
+
+.margin-y-xs {
+ margin-top: 0.5em;
+ margin-top: var(--space-xs);
+ margin-bottom: 0.5em;
+ margin-bottom: var(--space-xs)
+}
+
+.margin-y-sm {
+ margin-top: 0.75em;
+ margin-top: var(--space-sm);
+ margin-bottom: 0.75em;
+ margin-bottom: var(--space-sm)
+}
+
+.margin-y-md {
+ margin-top: 1.25em;
+ margin-top: var(--space-md);
+ margin-bottom: 1.25em;
+ margin-bottom: var(--space-md)
+}
+
+.margin-y-lg {
+ margin-top: 2em;
+ margin-top: var(--space-lg);
+ margin-bottom: 2em;
+ margin-bottom: var(--space-lg)
+}
+
+.margin-y-xl {
+ margin-top: 3.25em;
+ margin-top: var(--space-xl);
+ margin-bottom: 3.25em;
+ margin-bottom: var(--space-xl)
+}
+
+.margin-y-xxl {
+ margin-top: 5.25em;
+ margin-top: var(--space-xxl);
+ margin-bottom: 5.25em;
+ margin-bottom: var(--space-xxl)
+}
+
+.margin-y-xxxl {
+ margin-top: 8.5em;
+ margin-top: var(--space-xxxl);
+ margin-bottom: 8.5em;
+ margin-bottom: var(--space-xxxl)
+}
+
+.margin-y-xxxxl {
+ margin-top: 13.75em;
+ margin-top: var(--space-xxxxl);
+ margin-bottom: 13.75em;
+ margin-bottom: var(--space-xxxxl)
+}
+
+.margin-y-auto {
+ margin-top: auto;
+ margin-bottom: auto
+}
+
+@media not all and (min-width: 32rem) {
+ .has-margin\@xs {
+ margin: 0 !important
+ }
+}
+
+@media not all and (min-width: 48rem) {
+ .has-margin\@sm {
+ margin: 0 !important
+ }
+}
+
+@media not all and (min-width: 64rem) {
+ .has-margin\@md {
+ margin: 0 !important
+ }
+}
+
+@media not all and (min-width: 80rem) {
+ .has-margin\@lg {
+ margin: 0 !important
+ }
+}
+
+@media not all and (min-width: 90rem) {
+ .has-margin\@xl {
+ margin: 0 !important
+ }
+}
+
+.padding-md {
+ padding: 1.25em;
+ padding: var(--space-md)
+}
+
+.padding-xxxxs {
+ padding: 0.125em;
+ padding: var(--space-xxxxs)
+}
+
+.padding-xxxs {
+ padding: 0.25em;
+ padding: var(--space-xxxs)
+}
+
+.padding-xxs {
+ padding: 0.375em;
+ padding: var(--space-xxs)
+}
+
+.padding-xs {
+ padding: 0.5em;
+ padding: var(--space-xs)
+}
+
+.padding-sm {
+ padding: 0.75em;
+ padding: var(--space-sm)
+}
+
+.padding-lg {
+ padding: 2em;
+ padding: var(--space-lg)
+}
+
+.padding-xl {
+ padding: 3.25em;
+ padding: var(--space-xl)
+}
+
+.padding-xxl {
+ padding: 5.25em;
+ padding: var(--space-xxl)
+}
+
+.padding-xxxl {
+ padding: 8.5em;
+ padding: var(--space-xxxl)
+}
+
+.padding-xxxxl {
+ padding: 13.75em;
+ padding: var(--space-xxxxl)
+}
+
+.padding-component {
+ padding: 1.25em;
+ padding: var(--component-padding)
+}
+
+.padding-top-md {
+ padding-top: 1.25em;
+ padding-top: var(--space-md)
+}
+
+.padding-top-xxxxs {
+ padding-top: 0.125em;
+ padding-top: var(--space-xxxxs)
+}
+
+.padding-top-xxxs {
+ padding-top: 0.25em;
+ padding-top: var(--space-xxxs)
+}
+
+.padding-top-xxs {
+ padding-top: 0.375em;
+ padding-top: var(--space-xxs)
+}
+
+.padding-top-xs {
+ padding-top: 0.5em;
+ padding-top: var(--space-xs)
+}
+
+.padding-top-sm {
+ padding-top: 0.75em;
+ padding-top: var(--space-sm)
+}
+
+.padding-top-lg {
+ padding-top: 2em;
+ padding-top: var(--space-lg)
+}
+
+.padding-top-xl {
+ padding-top: 3.25em;
+ padding-top: var(--space-xl)
+}
+
+.padding-top-xxl {
+ padding-top: 5.25em;
+ padding-top: var(--space-xxl)
+}
+
+.padding-top-xxxl {
+ padding-top: 8.5em;
+ padding-top: var(--space-xxxl)
+}
+
+.padding-top-xxxxl {
+ padding-top: 13.75em;
+ padding-top: var(--space-xxxxl)
+}
+
+.padding-top-component {
+ padding-top: 1.25em;
+ padding-top: var(--component-padding)
+}
+
+.padding-bottom-md {
+ padding-bottom: 1.25em;
+ padding-bottom: var(--space-md)
+}
+
+.padding-bottom-xxxxs {
+ padding-bottom: 0.125em;
+ padding-bottom: var(--space-xxxxs)
+}
+
+.padding-bottom-xxxs {
+ padding-bottom: 0.25em;
+ padding-bottom: var(--space-xxxs)
+}
+
+.padding-bottom-xxs {
+ padding-bottom: 0.375em;
+ padding-bottom: var(--space-xxs)
+}
+
+.padding-bottom-xs {
+ padding-bottom: 0.5em;
+ padding-bottom: var(--space-xs)
+}
+
+.padding-bottom-sm {
+ padding-bottom: 0.75em;
+ padding-bottom: var(--space-sm)
+}
+
+.padding-bottom-lg {
+ padding-bottom: 2em;
+ padding-bottom: var(--space-lg)
+}
+
+.padding-bottom-xl {
+ padding-bottom: 3.25em;
+ padding-bottom: var(--space-xl)
+}
+
+.padding-bottom-xxl {
+ padding-bottom: 5.25em;
+ padding-bottom: var(--space-xxl)
+}
+
+.padding-bottom-xxxl {
+ padding-bottom: 8.5em;
+ padding-bottom: var(--space-xxxl)
+}
+
+.padding-bottom-xxxxl {
+ padding-bottom: 13.75em;
+ padding-bottom: var(--space-xxxxl)
+}
+
+.padding-bottom-component {
+ padding-bottom: 1.25em;
+ padding-bottom: var(--component-padding)
+}
+
+.padding-right-md {
+ padding-right: 1.25em;
+ padding-right: var(--space-md)
+}
+
+.padding-right-xxxxs {
+ padding-right: 0.125em;
+ padding-right: var(--space-xxxxs)
+}
+
+.padding-right-xxxs {
+ padding-right: 0.25em;
+ padding-right: var(--space-xxxs)
+}
+
+.padding-right-xxs {
+ padding-right: 0.375em;
+ padding-right: var(--space-xxs)
+}
+
+.padding-right-xs {
+ padding-right: 0.5em;
+ padding-right: var(--space-xs)
+}
+
+.padding-right-sm {
+ padding-right: 0.75em;
+ padding-right: var(--space-sm)
+}
+
+.padding-right-lg {
+ padding-right: 2em;
+ padding-right: var(--space-lg)
+}
+
+.padding-right-xl {
+ padding-right: 3.25em;
+ padding-right: var(--space-xl)
+}
+
+.padding-right-xxl {
+ padding-right: 5.25em;
+ padding-right: var(--space-xxl)
+}
+
+.padding-right-xxxl {
+ padding-right: 8.5em;
+ padding-right: var(--space-xxxl)
+}
+
+.padding-right-xxxxl {
+ padding-right: 13.75em;
+ padding-right: var(--space-xxxxl)
+}
+
+.padding-right-component {
+ padding-right: 1.25em;
+ padding-right: var(--component-padding)
+}
+
+.padding-left-md {
+ padding-left: 1.25em;
+ padding-left: var(--space-md)
+}
+
+.padding-left-xxxxs {
+ padding-left: 0.125em;
+ padding-left: var(--space-xxxxs)
+}
+
+.padding-left-xxxs {
+ padding-left: 0.25em;
+ padding-left: var(--space-xxxs)
+}
+
+.padding-left-xxs {
+ padding-left: 0.375em;
+ padding-left: var(--space-xxs)
+}
+
+.padding-left-xs {
+ padding-left: 0.5em;
+ padding-left: var(--space-xs)
+}
+
+.padding-left-sm {
+ padding-left: 0.75em;
+ padding-left: var(--space-sm)
+}
+
+.padding-left-lg {
+ padding-left: 2em;
+ padding-left: var(--space-lg)
+}
+
+.padding-left-xl {
+ padding-left: 3.25em;
+ padding-left: var(--space-xl)
+}
+
+.padding-left-xxl {
+ padding-left: 5.25em;
+ padding-left: var(--space-xxl)
+}
+
+.padding-left-xxxl {
+ padding-left: 8.5em;
+ padding-left: var(--space-xxxl)
+}
+
+.padding-left-xxxxl {
+ padding-left: 13.75em;
+ padding-left: var(--space-xxxxl)
+}
+
+.padding-left-component {
+ padding-left: 1.25em;
+ padding-left: var(--component-padding)
+}
+
+.padding-x-md {
+ padding-left: 1.25em;
+ padding-left: var(--space-md);
+ padding-right: 1.25em;
+ padding-right: var(--space-md)
+}
+
+.padding-x-xxxxs {
+ padding-left: 0.125em;
+ padding-left: var(--space-xxxxs);
+ padding-right: 0.125em;
+ padding-right: var(--space-xxxxs)
+}
+
+.padding-x-xxxs {
+ padding-left: 0.25em;
+ padding-left: var(--space-xxxs);
+ padding-right: 0.25em;
+ padding-right: var(--space-xxxs)
+}
+
+.padding-x-xxs {
+ padding-left: 0.375em;
+ padding-left: var(--space-xxs);
+ padding-right: 0.375em;
+ padding-right: var(--space-xxs)
+}
+
+.padding-x-xs {
+ padding-left: 0.5em;
+ padding-left: var(--space-xs);
+ padding-right: 0.5em;
+ padding-right: var(--space-xs)
+}
+
+.padding-x-sm {
+ padding-left: 0.75em;
+ padding-left: var(--space-sm);
+ padding-right: 0.75em;
+ padding-right: var(--space-sm)
+}
+
+.padding-x-lg {
+ padding-left: 2em;
+ padding-left: var(--space-lg);
+ padding-right: 2em;
+ padding-right: var(--space-lg)
+}
+
+.padding-x-xl {
+ padding-left: 3.25em;
+ padding-left: var(--space-xl);
+ padding-right: 3.25em;
+ padding-right: var(--space-xl)
+}
+
+.padding-x-xxl {
+ padding-left: 5.25em;
+ padding-left: var(--space-xxl);
+ padding-right: 5.25em;
+ padding-right: var(--space-xxl)
+}
+
+.padding-x-xxxl {
+ padding-left: 8.5em;
+ padding-left: var(--space-xxxl);
+ padding-right: 8.5em;
+ padding-right: var(--space-xxxl)
+}
+
+.padding-x-xxxxl {
+ padding-left: 13.75em;
+ padding-left: var(--space-xxxxl);
+ padding-right: 13.75em;
+ padding-right: var(--space-xxxxl)
+}
+
+.padding-x-component {
+ padding-left: 1.25em;
+ padding-left: var(--component-padding);
+ padding-right: 1.25em;
+ padding-right: var(--component-padding)
+}
+
+.padding-y-md {
+ padding-top: 1.25em;
+ padding-top: var(--space-md);
+ padding-bottom: 1.25em;
+ padding-bottom: var(--space-md)
+}
+
+.padding-y-xxxxs {
+ padding-top: 0.125em;
+ padding-top: var(--space-xxxxs);
+ padding-bottom: 0.125em;
+ padding-bottom: var(--space-xxxxs)
+}
+
+.padding-y-xxxs {
+ padding-top: 0.25em;
+ padding-top: var(--space-xxxs);
+ padding-bottom: 0.25em;
+ padding-bottom: var(--space-xxxs)
+}
+
+.padding-y-xxs {
+ padding-top: 0.375em;
+ padding-top: var(--space-xxs);
+ padding-bottom: 0.375em;
+ padding-bottom: var(--space-xxs)
+}
+
+.padding-y-xs {
+ padding-top: 0.5em;
+ padding-top: var(--space-xs);
+ padding-bottom: 0.5em;
+ padding-bottom: var(--space-xs)
+}
+
+.padding-y-sm {
+ padding-top: 0.75em;
+ padding-top: var(--space-sm);
+ padding-bottom: 0.75em;
+ padding-bottom: var(--space-sm)
+}
+
+.padding-y-lg {
+ padding-top: 2em;
+ padding-top: var(--space-lg);
+ padding-bottom: 2em;
+ padding-bottom: var(--space-lg)
+}
+
+.padding-y-xl {
+ padding-top: 3.25em;
+ padding-top: var(--space-xl);
+ padding-bottom: 3.25em;
+ padding-bottom: var(--space-xl)
+}
+
+.padding-y-xxl {
+ padding-top: 5.25em;
+ padding-top: var(--space-xxl);
+ padding-bottom: 5.25em;
+ padding-bottom: var(--space-xxl)
+}
+
+.padding-y-xxxl {
+ padding-top: 8.5em;
+ padding-top: var(--space-xxxl);
+ padding-bottom: 8.5em;
+ padding-bottom: var(--space-xxxl)
+}
+
+.padding-y-xxxxl {
+ padding-top: 13.75em;
+ padding-top: var(--space-xxxxl);
+ padding-bottom: 13.75em;
+ padding-bottom: var(--space-xxxxl)
+}
+
+.padding-y-component {
+ padding-top: 1.25em;
+ padding-top: var(--component-padding);
+ padding-bottom: 1.25em;
+ padding-bottom: var(--component-padding)
+}
+
+@media not all and (min-width: 32rem) {
+ .has-padding\@xs {
+ padding: 0 !important
+ }
+}
+
+@media not all and (min-width: 48rem) {
+ .has-padding\@sm {
+ padding: 0 !important
+ }
+}
+
+@media not all and (min-width: 64rem) {
+ .has-padding\@md {
+ padding: 0 !important
+ }
+}
+
+@media not all and (min-width: 80rem) {
+ .has-padding\@lg {
+ padding: 0 !important
+ }
+}
+
+@media not all and (min-width: 90rem) {
+ .has-padding\@xl {
+ padding: 0 !important
+ }
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap
+}
+
+.text-replace {
+ overflow: hidden;
+ color: transparent;
+ text-indent: 100%;
+ white-space: nowrap
+}
+
+.text-center {
+ text-align: center
+}
+
+.text-left {
+ text-align: left
+}
+
+.text-right {
+ text-align: right
+}
+
+@media (min-width: 32rem) {
+ .text-center\@xs {
+ text-align: center
+ }
+ .text-left\@xs {
+ text-align: left
+ }
+ .text-right\@xs {
+ text-align: right
+ }
+}
+
+@media (min-width: 48rem) {
+ .text-center\@sm {
+ text-align: center
+ }
+ .text-left\@sm {
+ text-align: left
+ }
+ .text-right\@sm {
+ text-align: right
+ }
+}
+
+@media (min-width: 64rem) {
+ .text-center\@md {
+ text-align: center
+ }
+ .text-left\@md {
+ text-align: left
+ }
+ .text-right\@md {
+ text-align: right
+ }
+}
+
+@media (min-width: 80rem) {
+ .text-center\@lg {
+ text-align: center
+ }
+ .text-left\@lg {
+ text-align: left
+ }
+ .text-right\@lg {
+ text-align: right
+ }
+}
+
+@media (min-width: 90rem) {
+ .text-center\@xl {
+ text-align: center
+ }
+ .text-left\@xl {
+ text-align: left
+ }
+ .text-right\@xl {
+ text-align: right
+ }
+}
+
+.color-inherit {
+ color: inherit
+}
+
+.color-contrast-medium {
+ color: hsl(240, 1%, 48%);
+ color: var(--color-contrast-medium, #79797c)
+}
+
+.color-contrast-high {
+ color: hsl(240, 4%, 20%);
+ color: var(--color-contrast-high, #313135)
+}
+
+.color-contrast-higher {
+ color: hsl(240, 8%, 12%);
+ color: var(--color-contrast-higher, #1c1c21)
+}
+
+.color-primary {
+ color: hsl(220, 90%, 56%);
+ color: var(--color-primary, #2a6df4)
+}
+
+.color-accent {
+ color: hsl(355, 90%, 61%);
+ color: var(--color-accent, #f54251)
+}
+
+.color-success {
+ color: hsl(94, 48%, 56%);
+ color: var(--color-success, #88c559)
+}
+
+.color-warning {
+ color: hsl(46, 100%, 61%);
+ color: var(--color-warning, #ffd138)
+}
+
+.color-error {
+ color: hsl(355, 90%, 61%);
+ color: var(--color-error, #f54251)
+}
+
+.width-100\% {
+ width: 100%
+}
+
+.height-100\% {
+ height: 100%
+}
+
+.media-wrapper {
+ position: relative;
+ height: 0;
+ padding-bottom: 56.25%
+}
+
+.media-wrapper iframe, .media-wrapper video, .media-wrapper img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%
+}
+
+.media-wrapper video, .media-wrapper img {
+ -o-object-fit: cover;
+ object-fit: cover
+}
+
+.media-wrapper--4\:3 {
+ padding-bottom: 75%
+}
+
+:root, [data-theme="default"] {
+ --color-primary-darker: hsl(220, 90%, 36%);
+ --color-primary-darker-h: 220;
+ --color-primary-darker-s: 90%;
+ --color-primary-darker-l: 36%;
+ --color-primary-dark: hsl(220, 90%, 46%);
+ --color-primary-dark-h: 220;
+ --color-primary-dark-s: 90%;
+ --color-primary-dark-l: 46%;
+ --color-primary: hsl(220, 90%, 56%);
+ --color-primary-h: 220;
+ --color-primary-s: 90%;
+ --color-primary-l: 56%;
+ --color-primary-light: hsl(220, 90%, 66%);
+ --color-primary-light-h: 220;
+ --color-primary-light-s: 90%;
+ --color-primary-light-l: 66%;
+ --color-primary-lighter: hsl(220, 90%, 76%);
+ --color-primary-lighter-h: 220;
+ --color-primary-lighter-s: 90%;
+ --color-primary-lighter-l: 76%;
+ --color-accent-darker: hsl(355, 90%, 41%);
+ --color-accent-darker-h: 355;
+ --color-accent-darker-s: 90%;
+ --color-accent-darker-l: 41%;
+ --color-accent-dark: hsl(355, 90%, 51%);
+ --color-accent-dark-h: 355;
+ --color-accent-dark-s: 90%;
+ --color-accent-dark-l: 51%;
+ --color-accent: hsl(355, 90%, 61%);
+ --color-accent-h: 355;
+ --color-accent-s: 90%;
+ --color-accent-l: 61%;
+ --color-accent-light: hsl(355, 90%, 71%);
+ --color-accent-light-h: 355;
+ --color-accent-light-s: 90%;
+ --color-accent-light-l: 71%;
+ --color-accent-lighter: hsl(355, 90%, 81%);
+ --color-accent-lighter-h: 355;
+ --color-accent-lighter-s: 90%;
+ --color-accent-lighter-l: 81%;
+ --color-black: hsl(240, 8%, 12%);
+ --color-black-h: 240;
+ --color-black-s: 8%;
+ --color-black-l: 12%;
+ --color-white: hsl(0, 0%, 100%);
+ --color-white-h: 0;
+ --color-white-s: 0%;
+ --color-white-l: 100%;
+ --color-success-darker: hsl(94, 48%, 36%);
+ --color-success-darker-h: 94;
+ --color-success-darker-s: 48%;
+ --color-success-darker-l: 36%;
+ --color-success-dark: hsl(94, 48%, 46%);
+ --color-success-dark-h: 94;
+ --color-success-dark-s: 48%;
+ --color-success-dark-l: 46%;
+ --color-success: hsl(94, 48%, 56%);
+ --color-success-h: 94;
+ --color-success-s: 48%;
+ --color-success-l: 56%;
+ --color-success-light: hsl(94, 48%, 66%);
+ --color-success-light-h: 94;
+ --color-success-light-s: 48%;
+ --color-success-light-l: 66%;
+ --color-success-lighter: hsl(94, 48%, 76%);
+ --color-success-lighter-h: 94;
+ --color-success-lighter-s: 48%;
+ --color-success-lighter-l: 76%;
+ --color-error-darker: hsl(355, 90%, 41%);
+ --color-error-darker-h: 355;
+ --color-error-darker-s: 90%;
+ --color-error-darker-l: 41%;
+ --color-error-dark: hsl(355, 90%, 51%);
+ --color-error-dark-h: 355;
+ --color-error-dark-s: 90%;
+ --color-error-dark-l: 51%;
+ --color-error: hsl(355, 90%, 61%);
+ --color-error-h: 355;
+ --color-error-s: 90%;
+ --color-error-l: 61%;
+ --color-error-light: hsl(355, 90%, 71%);
+ --color-error-light-h: 355;
+ --color-error-light-s: 90%;
+ --color-error-light-l: 71%;
+ --color-error-lighter: hsl(355, 90%, 81%);
+ --color-error-lighter-h: 355;
+ --color-error-lighter-s: 90%;
+ --color-error-lighter-l: 81%;
+ --color-warning-darker: hsl(46, 100%, 41%);
+ --color-warning-darker-h: 46;
+ --color-warning-darker-s: 100%;
+ --color-warning-darker-l: 41%;
+ --color-warning-dark: hsl(46, 100%, 51%);
+ --color-warning-dark-h: 46;
+ --color-warning-dark-s: 100%;
+ --color-warning-dark-l: 51%;
+ --color-warning: hsl(46, 100%, 61%);
+ --color-warning-h: 46;
+ --color-warning-s: 100%;
+ --color-warning-l: 61%;
+ --color-warning-light: hsl(46, 100%, 71%);
+ --color-warning-light-h: 46;
+ --color-warning-light-s: 100%;
+ --color-warning-light-l: 71%;
+ --color-warning-lighter: hsl(46, 100%, 81%);
+ --color-warning-lighter-h: 46;
+ --color-warning-lighter-s: 100%;
+ --color-warning-lighter-l: 81%;
+ --color-bg: hsl(0, 0%, 100%);
+ --color-bg-h: 0;
+ --color-bg-s: 0%;
+ --color-bg-l: 100%;
+ --color-contrast-lower: hsl(0, 0%, 95%);
+ --color-contrast-lower-h: 0;
+ --color-contrast-lower-s: 0%;
+ --color-contrast-lower-l: 95%;
+ --color-contrast-low: hsl(240, 1%, 83%);
+ --color-contrast-low-h: 240;
+ --color-contrast-low-s: 1%;
+ --color-contrast-low-l: 83%;
+ --color-contrast-medium: hsl(240, 1%, 48%);
+ --color-contrast-medium-h: 240;
+ --color-contrast-medium-s: 1%;
+ --color-contrast-medium-l: 48%;
+ --color-contrast-high: hsl(240, 4%, 20%);
+ --color-contrast-high-h: 240;
+ --color-contrast-high-s: 4%;
+ --color-contrast-high-l: 20%;
+ --color-contrast-higher: hsl(240, 8%, 12%);
+ --color-contrast-higher-h: 240;
+ --color-contrast-higher-s: 8%;
+ --color-contrast-higher-l: 12%
+}
+
+@supports (--css: variables) {
+ @media (min-width: 64rem) {
+ :root {
+ --space-unit: 1.25em
+ }
+ }
+}
+
+:root {
+ --radius: 0.25em
+}
+
+:root {
+ --font-primary: sans-serif;
+ --text-base-size: 1em;
+ --text-scale-ratio: 1.2;
+ --text-xs: calc(1em/var(--text-scale-ratio)/var(--text-scale-ratio));
+ --text-sm: calc(var(--text-xs)*var(--text-scale-ratio));
+ --text-md: calc(var(--text-sm)*var(--text-scale-ratio)*var(--text-scale-ratio));
+ --text-lg: calc(var(--text-md)*var(--text-scale-ratio));
+ --text-xl: calc(var(--text-lg)*var(--text-scale-ratio));
+ --text-xxl: calc(var(--text-xl)*var(--text-scale-ratio));
+ --text-xxxl: calc(var(--text-xxl)*var(--text-scale-ratio));
+ --body-line-height: 1.4;
+ --heading-line-height: 1.2;
+ --font-primary-capital-letter: 1
+}
+
+@supports (--css: variables) {
+ @media (min-width: 64rem) {
+ :root {
+ --text-base-size: 1.25em;
+ --text-scale-ratio: 1.25
+ }
+ }
+}
+
+mark {
+ background-color: hsla(355, 90%, 61%, 0.2);
+ background-color: hsla(var(--color-accent-h), var(--color-accent-s), var(--color-accent-l), 0.2);
+ color: inherit
+}
+
+.text-component {
+ --line-height-multiplier: 1;
+ --text-vspace-multiplier: 1
+}
+
+.text-component blockquote {
+ padding-left: 1em;
+ border-left: 4px solid hsl(240, 1%, 83%);
+ border-left: 4px solid var(--color-contrast-low)
+}
+
+.text-component hr {
+ background: hsl(240, 1%, 83%);
+ background: var(--color-contrast-low);
+ height: 1px
+}
+
+.text-component figcaption {
+ font-size: 0.83333em;
+ font-size: var(--text-sm);
+ color: hsl(240, 1%, 48%);
+ color: var(--color-contrast-medium)
+}
+
+.article.text-component {
+ --line-height-multiplier: 1.13;
+ --text-vspace-multiplier: 1.2
+}
+
+:root {
+ --btn-font-size: 1em;
+ --btn-font-size-sm: calc(var(--btn-font-size) - 0.2em);
+ --btn-font-size-md: calc(var(--btn-font-size) + 0.2em);
+ --btn-font-size-lg: calc(var(--btn-font-size) + 0.4em);
+ --btn-radius: 0.25em;
+ --btn-padding-x: var(--space-sm);
+ --btn-padding-y: var(--space-xs)
+}
+
+.btn {
+ --color-shadow: hsla(240, 8%, 12%, 0.15);
+ --color-shadow: hsla(var(--color-black-h), var(--color-black-s), var(--color-black-l), 0.15);
+ box-shadow: 0 4px 16px hsla(240, 8%, 12%, 0.15);
+ box-shadow: 0 4px 16px hsla(var(--color-black-h), var(--color-black-s), var(--color-black-l), 0.15);
+ cursor: pointer
+}
+
+.btn--primary {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale
+}
+
+.btn--accent {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale
+}
+
+.btn--disabled {
+ opacity: 0.6
+}
+
+:root {
+ --form-control-padding-x: var(--space-sm);
+ --form-control-padding-y: var(--space-xs);
+ --form-control-radius: 0.25em
+}
+
+.form-control {
+ border: 2px solid hsl(240, 1%, 83%);
+ border: 2px solid var(--color-contrast-low)
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: hsl(220, 90%, 56%);
+ border-color: var(--color-primary);
+ --color-shadow: hsla(220, 90%, 56%, 0.2);
+ --color-shadow: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.2);
+ box-shadow: undefined;
+ box-shadow: 0 0 0 3px var(--color-shadow)
+}
+
+.form-control:focus:focus {
+ box-shadow: 0 0 0 3px hsla(220, 90%, 56%, 0.2);
+ box-shadow: 0 0 0 3px var(--color-shadow)
+}
+
+.form-control[aria-invalid="true"] {
+ border-color: hsl(355, 90%, 61%);
+ border-color: var(--color-error)
+}
+
+.form-control[aria-invalid="true"]:focus {
+ --color-shadow: hsla(355, 90%, 61%, 0.2);
+ --color-shadow: hsla(var(--color-error-h), var(--color-error-s), var(--color-error-l), 0.2);
+ box-shadow: undefined;
+ box-shadow: 0 0 0 3px var(--color-shadow)
+}
+
+.form-control[aria-invalid="true"]:focus:focus {
+ box-shadow: 0 0 0 3px hsla(355, 90%, 61%, 0.2);
+ box-shadow: 0 0 0 3px var(--color-shadow)
+}
+
+.form-label {
+ font-size: 0.83333em;
+ font-size: var(--text-sm)
+}
+
+:root {
+ --cd-color-1: hsl(206, 21%, 24%);
+ --cd-color-1-h: 206;
+ --cd-color-1-s: 21%;
+ --cd-color-1-l: 24%;
+ --cd-color-2: hsl(205, 38%, 89%);
+ --cd-color-2-h: 205;
+ --cd-color-2-s: 38%;
+ --cd-color-2-l: 89%;
+ --cd-color-3: hsl(207, 10%, 55%);
+ --cd-color-3-h: 207;
+ --cd-color-3-s: 10%;
+ --cd-color-3-l: 55%;
+ --cd-color-4: hsl(111, 51%, 60%);
+ --cd-color-4-h: 111;
+ --cd-color-4-s: 51%;
+ --cd-color-4-l: 60%;
+ --cd-color-5: hsl(356, 53%, 49%);
+ --cd-color-5-h: 356;
+ --cd-color-5-s: 53%;
+ --cd-color-5-l: 49%;
+ --cd-color-6: hsl(47, 85%, 61%);
+ --cd-color-6-h: 47;
+ --cd-color-6-s: 85%;
+ --cd-color-6-l: 61%;
+ --cd-header-height: 200px;
+ --font-primary: 'Droid Serif', serif;
+ --font-secondary: 'Open Sans', sans-serif
+}
+
+@supports (--css: variables) {
+ @media (min-width: 64rem) {
+ :root {
+ --cd-header-height: 300px
+ }
+ }
+}
+
+.cd-main-header {
+ height: 200px;
+ height: var(--cd-header-height);
+ background: hsl(206, 21%, 24%);
+ background: var(--cd-color-1);
+ color: hsl(0, 0%, 100%);
+ color: var(--color-white);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale
+}
+
+.cd-main-header h1 {
+ color: inherit
+}
+
+.cd-timeline {
+ overflow: hidden;
+ padding: 2em 0;
+ padding: var(--space-lg) 0;
+ color: hsl(207, 10%, 55%);
+ color: var(--cd-color-3);
+ background-color: hsl(205, 38%, 93.45%);
+ background-color: hsl(var(--cd-color-2-h), var(--cd-color-2-s), calc(var(--cd-color-2-l)*1.05));
+}
+
+.cd-timeline h2 {
+ font-weight: 700
+}
+
+.cd-timeline__container {
+ position: relative;
+ padding: 1.25em 0;
+ padding: var(--space-md) 0
+}
+
+.cd-timeline__container::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 18px;
+ height: 100%;
+ width: 4px;
+ background: hsl(205, 38%, 89%);
+ background: var(--cd-color-2)
+}
+
+@media (min-width: 64rem) {
+ .cd-timeline__container::before {
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ -ms-transform: translateX(-50%);
+ transform: translateX(-50%)
+ }
+}
+
+.cd-timeline__block {
+ display: -ms-flexbox;
+ display: flex;
+ position: relative;
+ z-index: 1;
+ margin-bottom: 2em;
+ margin-bottom: var(--space-lg)
+}
+
+.cd-timeline__block:last-child {
+ margin-bottom: 0
+}
+
+@media (min-width: 64rem) {
+ .cd-timeline__block:nth-child(even) {
+ -ms-flex-direction: row-reverse;
+ flex-direction: row-reverse
+ }
+}
+
+.cd-timeline__img {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-pack: center;
+ justify-content: center;
+ -ms-flex-align: center;
+ align-items: center;
+ -ms-flex-negative: 0;
+ flex-shrink: 0;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ box-shadow: 0 0 0 4px hsl(0, 0%, 100%), inset 0 2px 0 rgba(0, 0, 0, 0.08), 0 3px 0 4px rgba(0, 0, 0, 0.05);
+ box-shadow: 0 0 0 4px var(--color-white), inset 0 2px 0 rgba(0, 0, 0, 0.08), 0 3px 0 4px rgba(0, 0, 0, 0.05)
+}
+
+.cd-timeline__img i {
+ font-size: 1.5em;
+ color: white;
+}
+
+@media (max-width: 64rem) {
+ .cd-timeline__img i {
+ font-size: 0.9em;
+ }
+}
+
+.cd-timeline__img img {
+ width: 40px;
+ height: 40px;
+ margin-left: 2px;
+ margin-top: 2px;
+}
+
+@media (max-width: 64rem) {
+ .cd-timeline__img img {
+ width: 20px;
+ height: 20px;
+ margin-left: 2px;
+ margin-top: 2px;
+ }
+}
+
+@media (min-width: 64rem) {
+ .cd-timeline__img {
+ width: 60px;
+ height: 60px;
+ -ms-flex-order: 1;
+ order: 1;
+ margin-left: calc(5% - 30px);
+ will-change: transform;
+ }
+
+ .cd-timeline__block:nth-child(even) .cd-timeline__img {
+ margin-right: calc(5% - 30px)
+ }
+}
+
+.cd-timeline__img--picture {
+ background-color: #7289DA;
+}
+
+.cd-timeline__img--movie {
+ background-color: hsl(356, 53%, 49%);
+ background-color: var(--cd-color-5)
+}
+
+.cd-timeline__img--location {
+ background-color: hsl(47, 85%, 61%);
+ background-color: var(--cd-color-6)
+}
+
+.cd-timeline__content {
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ position: relative;
+ margin-left: 1.25em;
+ margin-left: var(--space-md);
+ background: hsl(0, 0%, 100%);
+ background: var(--color-white);
+ border-radius: 0.25em;
+ border-radius: var(--radius-md);
+ padding: 1.25em;
+ padding: var(--space-md);
+ box-shadow: 0 3px 0 hsl(205, 38%, 89%);
+ box-shadow: 0 3px 0 var(--cd-color-2)
+}
+
+.cd-timeline__content::before {
+ content: '';
+ position: absolute;
+ top: 16px;
+ right: 100%;
+ width: 0;
+ height: 0;
+ border: 7px solid transparent;
+ border-right-color: hsl(0, 0%, 100%);
+ border-right-color: var(--color-white)
+}
+
+.cd-timeline__content h2 {
+ color: hsl(206, 21%, 24%);
+ color: var(--cd-color-1)
+}
+
+@media (min-width: 64rem) {
+ .cd-timeline__content {
+ width: 45%;
+ -ms-flex-positive: 0;
+ flex-grow: 0;
+ will-change: transform;
+ margin: 0;
+ font-size: 0.9em;
+ --line-height-multiplier: 1.2
+ }
+ .cd-timeline__content::before {
+ top: 24px
+ }
+ .cd-timeline__block:nth-child(odd) .cd-timeline__content::before {
+ right: auto;
+ left: 100%;
+ width: 0;
+ height: 0;
+ border: 7px solid transparent;
+ border-left-color: hsl(0, 0%, 100%);
+ border-left-color: var(--color-white)
+ }
+}
+
+.cd-timeline__date {
+ color: hsla(207, 10%, 55%, 0.7);
+ color: hsla(var(--cd-color-3-h), var(--cd-color-3-s), var(--cd-color-3-l), 0.7)
+}
+
+@media (min-width: 64rem) {
+ .cd-timeline__date {
+ position: absolute;
+ width: 100%;
+ left: 120%;
+ top: 20px
+ }
+ .cd-timeline__block:nth-child(even) .cd-timeline__date {
+ left: auto;
+ right: 120%;
+ text-align: right
+ }
+}
+
+@media (min-width: 64rem) {
+ .cd-timeline__img--hidden, .cd-timeline__content--hidden {
+ visibility: hidden
+ }
+ .cd-timeline__img--bounce-in {
+ -webkit-animation: cd-bounce-1 0.6s;
+ animation: cd-bounce-1 0.6s
+ }
+ .cd-timeline__content--bounce-in {
+ -webkit-animation: cd-bounce-2 0.6s;
+ animation: cd-bounce-2 0.6s
+ }
+ .cd-timeline__block:nth-child(even) .cd-timeline__content--bounce-in {
+ -webkit-animation-name: cd-bounce-2-inverse;
+ animation-name: cd-bounce-2-inverse
+ }
+ .cd-timeline__img--bounce-out {
+ -webkit-animation: cd-bounce-out-1 0.6s;
+ animation: cd-bounce-out-1 0.6s;
+ }
+ .cd-timeline__content--bounce-out {
+ -webkit-animation: cd-bounce-out-2 0.6s;
+ animation: cd-bounce-out-2 0.6s;
+ }
+}
+
+@-webkit-keyframes cd-bounce-1 {
+ 0% {
+ opacity: 0;
+ -webkit-transform: scale(0.5);
+ transform: scale(0.5)
+ }
+ 60% {
+ opacity: 1;
+ -webkit-transform: scale(1.2);
+ transform: scale(1.2)
+ }
+ 100% {
+ -webkit-transform: scale(1);
+ transform: scale(1)
+ }
+}
+
+@keyframes cd-bounce-1 {
+ 0% {
+ opacity: 0;
+ -webkit-transform: scale(0.5);
+ transform: scale(0.5)
+ }
+ 60% {
+ opacity: 1;
+ -webkit-transform: scale(1.2);
+ transform: scale(1.2)
+ }
+ 100% {
+ -webkit-transform: scale(1);
+ transform: scale(1)
+ }
+}
+
+@-webkit-keyframes cd-bounce-2 {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateX(-100px);
+ transform: translateX(-100px)
+ }
+ 60% {
+ opacity: 1;
+ -webkit-transform: translateX(20px);
+ transform: translateX(20px)
+ }
+ 100% {
+ -webkit-transform: translateX(0);
+ transform: translateX(0)
+ }
+}
+
+@keyframes cd-bounce-2 {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateX(-100px);
+ transform: translateX(-100px)
+ }
+ 60% {
+ opacity: 1;
+ -webkit-transform: translateX(20px);
+ transform: translateX(20px)
+ }
+ 100% {
+ -webkit-transform: translateX(0);
+ transform: translateX(0)
+ }
+}
+
+@-webkit-keyframes cd-bounce-2-inverse {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateX(100px);
+ transform: translateX(100px)
+ }
+ 60% {
+ opacity: 1;
+ -webkit-transform: translateX(-20px);
+ transform: translateX(-20px)
+ }
+ 100% {
+ -webkit-transform: translateX(0);
+ transform: translateX(0)
+ }
+}
+
+@keyframes cd-bounce-2-inverse {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateX(100px);
+ transform: translateX(100px)
+ }
+ 60% {
+ opacity: 1;
+ -webkit-transform: translateX(-20px);
+ transform: translateX(-20px)
+ }
+ 100% {
+ -webkit-transform: translateX(0);
+ transform: translateX(0)
+ }
+}
+
+@-webkit-keyframes cd-bounce-out-1 {
+ 0% {
+ opacity: 1;
+ -webkit-transform: scale(1);
+ transform: scale(1)
+ }
+
+ 60% {
+ -webkit-transform: scale(1.2);
+ transform: scale(1.2)
+ }
+
+ 100% {
+ opacity: 0;
+ -webkit-transform: scale(0.5);
+ transform: scale(0.5)
+ }
+}
+
+@keyframes cd-bounce-out-1 {
+ 0% {
+ opacity: 1;
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+
+ 60% {
+ -webkit-transform: scale(1.2);
+ transform: scale(1.2);
+ }
+
+ 100% {
+ opacity: 0;
+ -webkit-transform: scale(0.5);
+ transform: scale(0.5);
+ }
+}
+
+@-webkit-keyframes cd-bounce-out-2 {
+ 0% {
+ opacity: 1;
+ -webkit-transform: translateX(0);
+ transform: translateX(0)
+ }
+ 60% {
+ -webkit-transform: translateX(20px);
+ transform: translateX(20px)
+ }
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateX(-100px);
+ transform: translateX(-100px)
+ }
+}
+
+@keyframes cd-bounce-out-2 {
+ 0% {
+ opacity: 1;
+ -webkit-transform: translateX(0);
+ transform: translateX(0)
+ }
+ 60% {
+ -webkit-transform: translateX(20px);
+ transform: translateX(20px)
+ }
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateX(-100px);
+ transform: translateX(-100px)
+ }
+}
diff --git a/pydis_site/static/images/events/100k.png b/pydis_site/static/images/events/100k.png
new file mode 100644
index 00000000..ae024d77
--- /dev/null
+++ b/pydis_site/static/images/events/100k.png
Binary files differ
diff --git a/pydis_site/static/images/frontpage/welcome.jpg b/pydis_site/static/images/frontpage/welcome.jpg
new file mode 100644
index 00000000..0eb8f672
--- /dev/null
+++ b/pydis_site/static/images/frontpage/welcome.jpg
Binary files differ
diff --git a/pydis_site/static/images/navbar/discord.svg b/pydis_site/static/images/navbar/discord.svg
new file mode 100644
index 00000000..406e3836
--- /dev/null
+++ b/pydis_site/static/images/navbar/discord.svg
@@ -0,0 +1,165 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="120mm"
+ height="30mm"
+ viewBox="0 0 120 30"
+ version="1.1"
+ id="svg8"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
+ sodipodi:docname="discord.svg">
+ <defs
+ id="defs2">
+ <rect
+ x="75.819944"
+ y="98.265513"
+ width="25.123336"
+ height="7.8844509"
+ id="rect953" />
+ <rect
+ x="75.819946"
+ y="98.265511"
+ width="25.123337"
+ height="7.8844509"
+ id="rect953-0" />
+ <rect
+ x="75.819946"
+ y="98.265511"
+ width="25.123337"
+ height="7.8844509"
+ id="rect968" />
+ </defs>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="2.8"
+ inkscape:cx="194.44623"
+ inkscape:cy="53.152927"
+ inkscape:document-units="mm"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ inkscape:window-width="2560"
+ inkscape:window-height="1413"
+ inkscape:window-x="4880"
+ inkscape:window-y="677"
+ inkscape:window-maximized="1"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ inkscape:document-rotation="0" />
+ <metadata
+ id="metadata5">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-52.233408,-75.88169)">
+ <rect
+ style="fill:#ffffff;fill-opacity:1;stroke-width:0.137677;paint-order:stroke fill markers;stop-color:#000000"
+ id="rect832"
+ width="61.511906"
+ height="30"
+ x="52.23341"
+ y="75.881691" />
+ <g
+ id="g910"
+ transform="matrix(0.90000009,0,0,0.90000009,17.445516,9.7980333)">
+ <g
+ id="g850"
+ transform="matrix(0.06491223,0,0,0.06491223,109.76284,82.07218)">
+ <path
+ class="st0"
+ d="m 142.8,120.1 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0,-6.1 -4.6,-11 -10.2,-11 z m -36.5,0 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0.1,-6.1 -4.5,-11 -10.2,-11 z"
+ id="path836" />
+ <path
+ class="st0"
+ d="m 191.4,36.9 h -134 c -11.3,0 -20.5,9.2 -20.5,20.5 v 134 c 0,11.3 9.2,20.5 20.5,20.5 h 113.4 l -5.3,-18.3 12.8,11.8 12.1,11.1 21.6,18.7 V 57.4 C 211.9,46.1 202.7,36.9 191.4,36.9 Z m -38.6,129.5 c 0,0 -3.6,-4.3 -6.6,-8 13.1,-3.7 18.1,-11.8 18.1,-11.8 -4.1,2.7 -8,4.6 -11.5,5.9 -5,2.1 -9.8,3.4 -14.5,4.3 -9.6,1.8 -18.4,1.3 -25.9,-0.1 -5.7,-1.1 -10.6,-2.6 -14.7,-4.3 -2.3,-0.9 -4.8,-2 -7.3,-3.4 -0.3,-0.2 -0.6,-0.3 -0.9,-0.5 -0.2,-0.1 -0.3,-0.2 -0.4,-0.2 -1.8,-1 -2.8,-1.7 -2.8,-1.7 0,0 4.8,7.9 17.5,11.7 -3,3.8 -6.7,8.2 -6.7,8.2 C 75,165.8 66.6,151.4 66.6,151.4 66.6,119.5 81,93.6 81,93.6 95.4,82.9 109,83.2 109,83.2 l 1,1.2 c -18,5.1 -26.2,13 -26.2,13 0,0 2.2,-1.2 5.9,-2.8 10.7,-4.7 19.2,-5.9 22.7,-6.3 0.6,-0.1 1.1,-0.2 1.7,-0.2 6.1,-0.8 13,-1 20.2,-0.2 9.5,1.1 19.7,3.9 30.1,9.5 0,0 -7.9,-7.5 -24.9,-12.6 l 1.4,-1.6 c 0,0 13.7,-0.3 28,10.4 0,0 14.4,25.9 14.4,57.8 0,-0.1 -8.4,14.3 -30.5,15 z m 151,-86.7 H 270.6 V 117 l 22.1,19.9 v -36.2 h 11.8 c 7.5,0 11.2,3.6 11.2,9.4 v 27.7 c 0,5.8 -3.5,9.7 -11.2,9.7 h -34 v 21.1 h 33.2 c 17.8,0.1 34.5,-8.8 34.5,-29.2 V 109.6 C 338.3,88.8 321.6,79.7 303.8,79.7 Z m 174,59.7 v -30.6 c 0,-11 19.8,-13.5 25.8,-2.5 l 18.3,-7.4 c -7.2,-15.8 -20.3,-20.4 -31.2,-20.4 -17.8,0 -35.4,10.3 -35.4,30.3 v 30.6 c 0,20.2 17.6,30.3 35,30.3 11.2,0 24.6,-5.5 32,-19.9 l -19.6,-9 c -4.8,12.3 -24.9,9.3 -24.9,-1.4 z M 417.3,113 c -6.9,-1.5 -11.5,-4 -11.8,-8.3 0.4,-10.3 16.3,-10.7 25.6,-0.8 l 14.7,-11.3 c -9.2,-11.2 -19.6,-14.2 -30.3,-14.2 -16.3,0 -32.1,9.2 -32.1,26.6 0,16.9 13,26 27.3,28.2 7.3,1 15.4,3.9 15.2,8.9 -0.6,9.5 -20.2,9 -29.1,-1.8 l -14.2,13.3 c 8.3,10.7 19.6,16.1 30.2,16.1 16.3,0 34.4,-9.4 35.1,-26.6 1,-21.7 -14.8,-27.2 -30.6,-30.1 z m -67,55.5 h 22.4 V 79.7 H 350.3 Z M 728,79.7 H 694.8 V 117 l 22.1,19.9 v -36.2 h 11.8 c 7.5,0 11.2,3.6 11.2,9.4 v 27.7 c 0,5.8 -3.5,9.7 -11.2,9.7 h -34 v 21.1 H 728 c 17.8,0.1 34.5,-8.8 34.5,-29.2 V 109.6 C 762.5,88.8 745.8,79.7 728,79.7 Z M 565.1,78.5 c -18.4,0 -36.7,10 -36.7,30.5 v 30.3 c 0,20.3 18.4,30.5 36.9,30.5 18.4,0 36.7,-10.2 36.7,-30.5 V 109 C 602,88.6 583.5,78.5 565.1,78.5 Z m 14.4,60.8 c 0,6.4 -7.2,9.7 -14.3,9.7 -7.2,0 -14.4,-3.1 -14.4,-9.7 V 109 c 0,-6.5 7,-10 14,-10 7.3,0 14.7,3.1 14.7,10 z M 682.4,109 c -0.5,-20.8 -14.7,-29.2 -33,-29.2 h -35.5 v 88.8 h 22.7 v -28.2 h 4 l 20.6,28.2 h 28 L 665,138.1 c 10.7,-3.4 17.4,-12.7 17.4,-29.1 z m -32.6,12 h -13.2 v -20.3 h 13.2 c 14.1,0 14.1,20.3 0,20.3 z"
+ id="path838" />
+ </g>
+ <path
+ id="path4789-6"
+ class=""
+ d="m 167.72059,90.383029 -3.19204,3.19205 c -0.15408,0.15408 -0.40352,0.15408 -0.55746,0 l -0.37229,-0.37231 c -0.15368,-0.15369 -0.15408,-0.40277 -4.9e-4,-0.55681 l 2.52975,-2.54167 -2.52975,-2.54164 c -0.15329,-0.15408 -0.15309,-0.40312 4.9e-4,-0.55681 l 0.37229,-0.37228 c 0.15408,-0.15408 0.40353,-0.15408 0.55746,0 l 3.19204,3.19201 c 0.15408,0.15407 0.15408,0.40354 0,0.55746 z"
+ inkscape:connector-curvature="0"
+ style="fill:#ffffff;fill-opacity:1;stroke-width:0.0164247" />
+ </g>
+ <g
+ id="g904"
+ transform="matrix(0.90000009,0,0,0.90000009,10.464254,9.7980333)">
+ <g
+ id="g850-3"
+ transform="matrix(0.06491223,0,0,0.06491223,52.083661,82.07218)">
+ <path
+ class="st0"
+ d="m 142.8,120.1 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0,-6.1 -4.6,-11 -10.2,-11 z m -36.5,0 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0.1,-6.1 -4.5,-11 -10.2,-11 z"
+ id="path836-5"
+ style="fill:#7289da;fill-opacity:1" />
+ <path
+ class="st0"
+ d="m 191.4,36.9 h -134 c -11.3,0 -20.5,9.2 -20.5,20.5 v 134 c 0,11.3 9.2,20.5 20.5,20.5 h 113.4 l -5.3,-18.3 12.8,11.8 12.1,11.1 21.6,18.7 V 57.4 C 211.9,46.1 202.7,36.9 191.4,36.9 Z m -38.6,129.5 c 0,0 -3.6,-4.3 -6.6,-8 13.1,-3.7 18.1,-11.8 18.1,-11.8 -4.1,2.7 -8,4.6 -11.5,5.9 -5,2.1 -9.8,3.4 -14.5,4.3 -9.6,1.8 -18.4,1.3 -25.9,-0.1 -5.7,-1.1 -10.6,-2.6 -14.7,-4.3 -2.3,-0.9 -4.8,-2 -7.3,-3.4 -0.3,-0.2 -0.6,-0.3 -0.9,-0.5 -0.2,-0.1 -0.3,-0.2 -0.4,-0.2 -1.8,-1 -2.8,-1.7 -2.8,-1.7 0,0 4.8,7.9 17.5,11.7 -3,3.8 -6.7,8.2 -6.7,8.2 C 75,165.8 66.6,151.4 66.6,151.4 66.6,119.5 81,93.6 81,93.6 95.4,82.9 109,83.2 109,83.2 l 1,1.2 c -18,5.1 -26.2,13 -26.2,13 0,0 2.2,-1.2 5.9,-2.8 10.7,-4.7 19.2,-5.9 22.7,-6.3 0.6,-0.1 1.1,-0.2 1.7,-0.2 6.1,-0.8 13,-1 20.2,-0.2 9.5,1.1 19.7,3.9 30.1,9.5 0,0 -7.9,-7.5 -24.9,-12.6 l 1.4,-1.6 c 0,0 13.7,-0.3 28,10.4 0,0 14.4,25.9 14.4,57.8 0,-0.1 -8.4,14.3 -30.5,15 z"
+ id="path838-6"
+ style="fill:#7289da;fill-opacity:1"
+ sodipodi:nodetypes="sssssccccccscccccccccccccccccccccccccccc" />
+ </g>
+ <path
+ id="path4789-6-2"
+ class=""
+ d="m 107.16039,90.382629 -3.19204,3.19205 c -0.15408,0.15408 -0.40352,0.15408 -0.55746,0 l -0.37229,-0.37231 c -0.15368,-0.15369 -0.15408,-0.40277 -5.3e-4,-0.55681 l 2.52975,-2.54167 -2.52975,-2.54164 c -0.15329,-0.15408 -0.15309,-0.40312 5.3e-4,-0.55681 l 0.37229,-0.37228 c 0.15408,-0.15408 0.40353,-0.15408 0.55746,0 l 3.19204,3.19201 c 0.15408,0.15407 0.15408,0.40354 0,0.55746 z"
+ inkscape:connector-curvature="0"
+ style="fill:#7289da;fill-opacity:1;stroke-width:0.0164247" />
+ <g
+ aria-label="JOIN US"
+ transform="matrix(1.2501707,0,0,1.2501707,-25.160061,-36.966352)"
+ id="text951"
+ style="font-style:normal;font-weight:normal;font-size:6.35px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect953-0);fill:#7289da;fill-opacity:1;stroke:none">
+ <path
+ d="m 75.839362,102.56309 c 0.127,0.9525 0.89535,1.3843 1.67005,1.3843 0.85725,0 1.7145,-0.55245 1.7145,-1.53035 v -3.028953 h -2.1463 v 1.028703 h 1.02235 v 2.00025 c 0,0.26035 -0.2667,0.4318 -0.5461,0.4318 -0.2794,0 -0.57785,-0.14605 -0.64135,-0.508 z"
+ style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1"
+ id="path850" />
+ <path
+ d="m 79.795412,102.40434 c 0,1.0287 0.93345,1.54305 1.8669,1.54305 0.93345,0 1.86055,-0.51435 1.86055,-1.54305 v -1.5367 c 0,-1.028703 -0.93345,-1.543053 -1.8669,-1.543053 -0.93345,0 -1.86055,0.508 -1.86055,1.543053 z m 1.13665,-1.5367 c 0,-0.3302 0.3556,-0.508 0.7112,-0.508 0.3683,0 0.74295,0.15875 0.74295,0.508 v 1.5367 c 0,0.32385 -0.36195,0.48895 -0.7239,0.48895 -0.36195,0 -0.73025,-0.15875 -0.73025,-0.48895 z"
+ style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1"
+ id="path852" />
+ <path
+ d="m 85.262755,99.388087 h -1.13665 v 4.495803 h 1.13665 z"
+ style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1"
+ id="path854" />
+ <path
+ d="m 85.973945,103.88389 h 1.13665 v -1.79705 l -0.14605,-0.86995 0.03175,-0.006 0.3937,0.9017 1.016,1.77165 h 1.14935 v -4.495803 h -1.1303 v 2.038353 c 0.0063,0 0.12065,0.7747 0.127,0.7747 l -0.03175,0.006 -0.381,-0.9017 -1.08585,-1.917703 h -1.0795 z"
+ style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1"
+ id="path856" />
+ <path
+ d="m 92.546182,99.388087 h -1.14935 v 2.990853 c -0.0063,2.1082 3.5814,2.1082 3.58775,0 v -2.990853 h -1.14935 v 2.990853 c -0.0064,0.7239 -1.28905,0.7239 -1.28905,0 z"
+ style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1"
+ id="path858" />
+ <path
+ d="m 95.44178,103.13459 c 0.4191,0.53975 0.9906,0.8128 1.53035,0.8128 0.8255,0 1.7399,-0.47625 1.778,-1.3462 0.0508,-1.1049 -0.7493,-1.3843 -1.5494,-1.53035 -0.34925,-0.0762 -0.5842,-0.2032 -0.5969,-0.4191 0.01905,-0.5207 0.8255,-0.53975 1.2954,-0.0381 l 0.74295,-0.5715 c -0.46355,-0.565153 -0.9906,-0.717553 -1.5367,-0.717553 -0.8255,0 -1.6256,0.46355 -1.6256,1.346203 0,0.85725 0.6604,1.31445 1.3843,1.42875 0.3683,0.0508 0.78105,0.19685 0.76835,0.45085 -0.03175,0.4826 -1.02235,0.4572 -1.4732,-0.0889 z"
+ style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-family:'Uni Sans';-inkscape-font-specification:'Uni Sans Heavy';fill:#7289da;fill-opacity:1"
+ id="path860" />
+ </g>
+ </g>
+ </g>
+ <style
+ id="style834">.st0{fill:#FFFFFF;}</style>
+</svg>
diff --git a/pydis_site/static/images/navbar/navbar_discordjoin.svg b/pydis_site/static/images/navbar/navbar_discordjoin.svg
deleted file mode 100644
index 75e6b102..00000000
--- a/pydis_site/static/images/navbar/navbar_discordjoin.svg
+++ /dev/null
@@ -1,81 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
- xmlns:dc="http://purl.org/dc/elements/1.1/"
- xmlns:cc="http://creativecommons.org/ns#"
- xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
- xmlns:svg="http://www.w3.org/2000/svg"
- xmlns="http://www.w3.org/2000/svg"
- xmlns:xlink="http://www.w3.org/1999/xlink"
- xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
- xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
- width="114.70044mm"
- height="61.897388mm"
- viewBox="0 0 114.70044 61.897388"
- version="1.1"
- id="svg8"
- inkscape:version="0.92.4 5da689c313, 2019-01-14"
- sodipodi:docname="discordjoin.svg">
- <defs
- id="defs2" />
- <sodipodi:namedview
- id="base"
- pagecolor="#ffffff"
- bordercolor="#666666"
- borderopacity="1.0"
- inkscape:pageopacity="0.0"
- inkscape:pageshadow="2"
- inkscape:zoom="0.98994949"
- inkscape:cx="-404.01729"
- inkscape:cy="34.494854"
- inkscape:document-units="mm"
- inkscape:current-layer="layer1"
- showgrid="false"
- inkscape:window-width="3440"
- inkscape:window-height="1409"
- inkscape:window-x="2560"
- inkscape:window-y="31"
- inkscape:window-maximized="1"
- fit-margin-top="0"
- fit-margin-left="0"
- fit-margin-right="0"
- fit-margin-bottom="0" />
- <metadata
- id="metadata5">
- <rdf:RDF>
- <cc:Work
- rdf:about="">
- <dc:format>image/svg+xml</dc:format>
- <dc:type
- rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
- <dc:title></dc:title>
- </cc:Work>
- </rdf:RDF>
- </metadata>
- <g
- inkscape:label="Layer 1"
- inkscape:groupmode="layer"
- id="layer1"
- transform="translate(-52.233408,-75.88169)">
- <path
- style="opacity:1;vector-effect:none;fill:#697ec4;fill-opacity:1;stroke:none;stroke-width:0.81460673;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
- d="m 60.360377,75.88169 -8.126969,61.89739 H 166.93385 V 75.88169 Z"
- id="rect4758-3"
- inkscape:connector-curvature="0" />
- <path
- id="path4789-6"
- class=""
- d="m 157.65213,107.11299 -4.99428,4.9943 c -0.24107,0.24107 -0.63135,0.24107 -0.8722,0 l -0.58249,-0.58252 c -0.24045,-0.24046 -0.24107,-0.63017 -8.3e-4,-0.87119 l 3.95805,-3.9767 -3.95805,-3.97665 c -0.23984,-0.24107 -0.23953,-0.63072 8.3e-4,-0.87118 l 0.58249,-0.58248 c 0.24107,-0.24108 0.63137,-0.24108 0.8722,0 l 4.99428,4.99422 c 0.24107,0.24107 0.24107,0.63138 0,0.8722 z"
- inkscape:connector-curvature="0"
- style="fill:#ffffff;fill-opacity:1;stroke-width:0.02569815" />
- <image
- y="94.290833"
- x="67.190086"
- id="image4856"
- xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAABmCAYAAABxyazLAAAABHNCSVQICAgIfAhkiAAAEexJREFU eJztnXnUnHV1xz83BCGAgIAKkUUCJRxtAUE2EYIIDYgWEAgHMYJQaQkuUJBDOTaVRYIFXFpBLWhs EEVDFcqmKIc1CoHUsCcsTQiQsAbCFiDLt3/c54VhMvPss7x57+ec95xk5vf87p2Z33Of33IXCIIg CIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIg6AOs7g4lrQeMBlaru+8e IOAJYI6ZLe+1MkEw1KnFYEn6K+AcYDfg/XX02We8APwamGRmj/VamSAYqlQ2WJIOwG/md1VXp+95 AzgXOMPMlvRamSAYalQyWJJ2BW4DhtWjzqBhipkd2WslgmCoUdpgSVoDeATYqD51BhUHm9lveq1E EAwlqsyMTmDoGivwpWEQBF2kisE6tDYtBiejJO3YayWCYChRymBJGg5sV7Mug5GP9lqBIBhKlJ1h rYyuC2UY2WsFgmAoUdZgDbVTwXas0msFgmAoEYYnCIJBQxisIAgGDWGwgiAYNITBCoJg0BAGKwiC QUMYrCAIBg3De61AsHIjaTXcwfaDwCbA6sBrwP3AA8BcM1ON8tYEtgc2Bj4ArAUsBeYA083s4bpk JfLWx52oR+Khamskb70O3JvIfLZOmUFBJG2iQJLO7tD3e0YFnV6TNE3SeZL2lJR7Fi1pSkbft+Xs 532Svpbo8WZGn09L+q6k0nGpknaSdK6keyQtz5B3r6TjJK1eUtYISQdJ+oWkeRmyBpgm6TB5hEhe Offk7Lsdj0u6XNJX5Ek188qdWEHmYkl/ko+9vVRg7HUUddZgPSbpe5IOl99w20p6ocD1N0j6hKQT 5TfgnJr1a6QfDVYz8yX9g6TMzByqaLAkrSfpfElvlNDzdUmnqZiBHS3pjhKyJGmupN0LyBom6UuS FpaUJ0mPSvpkTnlVDVYjSyRNlbRFDrlVDFYzCyRNUI6x11HUGYP1WyU/pqTVJH0+eW1xQ5tX5E+1 uyXdKmmm3MAt0jufrHMknSVpVNLfdvKb4c6adR4MBmuAmyW9L0NuaYMlaTP5b1GHnpmhX5K+KL8R q7Bc0sk5ZK0lH291camk1ISXqtdgDbBE0vEZcus0WANMU4UZdGVUr8G6XtLfJP2uLulbkl6SG6aL JI2TtHkOnVaXtIOkYyT9SG/fPH+QtG9Du0/JlwV1MJgMluSG/IMpcksZLEkbSXqiRj2fkbRlip6H KHvpV4TTUmStJWl6jbIGmK6UpZo6Y7AGODZFbicMluRjr+1v2lFUj8F6UtIhSX8m6Qj50/UMSdvX pOdWkk6WNEPS7ZK2S14fLunr8v2eKgw2gyW5sW4ZA6nyBut3HdDz/ySt2kLWh1R9ZtXMMkkfb/PZ rqhZViNXpIyBThqsNyVt1kZupwyWJM1SgX28VvRyU2xfM7s8+eLGAw+b2Rgzm2hm/1uHADN7yMzO M7MdgKOAz8mfLsvM7Fw8CeFQ46+Bk+rqTNL+wNi6+ktYCHyxOW++3ND+gvpPt4cBk9VkyCUdARxQ s6xGDkhkdJtV6U0CytHAN6p00CuDdbWZ3SvfjHvSzKaY2fROCjSzWWZ2CnApsEHy8s+A+Z2U26ec 2nxzVuDvc7SZDZwK7A8cC/wEN0qtuBPYxsxubvHegcC2ZZTMwZbAhIH/JGOzGzf1xC7IaMVnVeD0 sEZOUskTWuidH9b5AIn/zdJuCjazV4FXk3+/Kenf8RJlg4mXgL80vbYmMArIMwjfA3wKuKqKEonR 2z+j2TRgj6a6jhdJOg74AnA67i8FcAFwgpm1GxNH5VBrOXAxcAvwJF63YBfgCODDba6Zm1zzq4bX 9iFfCvDpwPeA683s+cTQbQP8LfB14L0Z128laRczuz2HrEaeB+5rem0Y7uu2Gdn1GlYBDgYuKij3 ZaB5BbQGPvbWz3H9WviD57KCcsujantY93ZN0RzIj+LL7mX1ag/rTynXjpH0cA7dL2hxbaE9LEmj csgZk/FZN5TvLx6S0W64sveuXpG0U5vrTX6yOOAXtkTSbySNVYtjd0kX5vhsP1CKG4akdSVdm6Of U1pcm7WHdWWK3NGSHskh9+IW12btYd2ZIneMpNk55P5Xuz6y6MUM66c9kNkWM1soaSr+tB/0mNnN cveQ+/GnWTs+kPJeXtbN0abd0g8AM3sKnwFlsTHZ4/X0dlsLyWx+sqSl+CzkYjN7JqWvURmyHgW+ mlYR3MxelHQwsABYJ6WvTTJkFcLMZkvaC3iI9Arsdcu9WdIngFnAu1OablxWRrf3sJbhe0j9RtFp cV9jZvOAOzKa1ZHe+akcbc6VlGY485Ln5mo78xzAzC4xs7MzjBXAphnvX5dmrBrkLQbuzmhWq+FI 5M4Dfp7RrLThSJE7n+zfofTDstsG68YcA6XrmNlt+BNzZWJuxvtr1iDjGeDNjDZjgdmSjpLH+ZVl RI42L1Tov6i8Vwr09VrG+6U3oTN4IOP9ZR2SOzfj/dLjoNsG67ddlleE/+m1AjWT+fSvSrI5/rsc TUcCk4EX5Y68/6jenFANNbJmiUWMbhE6NvbCYL1NPxmsxb1WoABFTnuGA3sDPwSelYdWfT/ZrO1t vNlKhqQRwGEZzR5v8Vpfj71uGqy7zWxBF+UV5VZgUa+VSDgPaHsK1Gf8ihWP1/MwDPep+ipwE/CI cgYGB+lI2gS4Atgwo2mrJeN3gKm1K1UT3TRYf8jbUNJIebjHi/KwmuOVw9FR0t7JU/sleUhFy3CL VpjZMnJs2naDxMP7IHwm0tckG8/jcd+wKowC/ijp29W1GhLsJummpr9bJM0BHsP9wLK4qfkFM1tm ZuPo07HXTbeGVp7LKyBpbfyEa+AEY/vk71Bgz5Tr9gWu4W0jfAAe+vAlM1vB36QN04D9crbtKMkx /ARJjwMd8feqCzObKfe3+j2QmhEiB6dIereZTchuOqRZH0j1ccvgOdy5tiVmNiExfv9WQUbtdGuG JVpY8zacRevj1jGS/i7luv+k9ef5tqQ1Wrzeir6YYTViZpOAz9OFTfQqmNlM4CPAn2vo7jhJK4Vf XB/zjaxMr0m87Tg6d5pYmG4ZrAfNLO+JxG4p77Wc/UjamPa+LOuRzzERsn2XeoKZXQp8Gk+727ck Pji7AUcD8yp29x1J76muVdCCG/EHfCZmNhXYlz4Ze90yWA8VaJvmPb1Bm9ezBnZmQjgAM3sNeCJP 225jZtcBu9M/BwMtMTOZ2WRgczzOcDLljNf6dDZTwlBlJnBokTz6ZvZHfOylRi10g24ZrCKJ/+8q 8d79pB/Hto1/asHsAm27ipndBeyMh3r0NWa23MyuNbOjzWwz3Lv5cDyqIK8B+3THFByaXA3sbmbP F70wGXs70eMHej8arHYpPebj0fwrkJxUfb/NdT8zs0cKyO9bgwUeJ4ZnHshs2mldimBm883sMjM7 NjFgxwNLMi77UBdUGwrcCYwzs88U2JpZATN7FPhcnqZlZWTRLYM1J2/DxJIfR5ICJmEuMCbjyz4b uKTptalJX0XIrWuvMLM8+wlZaaXfqEOXZiTtrBz5jszsQtznJ43GgOE8nzkzvYky8to3kOVAWSQ+ Mqttmf2h5/GT9zzLtPcD15eQsQJJbGQWHRt73TJYhWK8zOxH+Gb5R/AshVtkzZLM7GUz+wK+B7Yz MNLMxuW8uRt5uWD7vkNeK69lmpUGak9cKGk//Ca6UvlS4WbNfBtv9DzL4APT3kyCsB+QF0XIyuOV 9f3sL6+5mIq8oEbWb/FkVj8tmGZme+L7fFn7UZsCl3cjmkDSumQfcpUee90yWIWNgJm9aWYzkzTH uY/0zWyRmU2v4FXfqfiqriBpND7TTEvvATUbLEmH4uFNq+FOizMkfSyl/QjgmIxuG2e7efQ9QVJL h0lJ6+Az7vWBjwFXJ07Gh6l1TqusvZrNgRvVJjd6InNtPKVzaoWcHLLakgTuT8rRdG/gX8vKyUMy 9n5OeiodqDD2uuU42tYLOhnU2wI/MbOsyP9KJE+YccAsM2uX8qPnMyx5Ns6sOLBmimR9BHeSrQVJ R+MZOxuf4NsA0yTdhwdIP4B7YC8HtgJOBLbO6HrGwD/M7FVJd+FVpNsxDPi9pLuBX+JuKsOAj+Op nJtdX7bFYyHPTDzspzTkkb8BODJDv12BWZIuwyM5Gm/Ew3D/uTxLxxtztEnjX3CDlDWTmyjptuTU ryXymgd59qkaWQM34O1O8ZupbezlQsUzjqYmepOXbfqLpJ07pK9JOlDSXZLGZbQdW+BzdSrj6HkF v9+iLFaLHFUqUTVH0gkd1HPHJllf7qAsSfpxg6zV5RlMO03LQx4VzDgqaWPlKzj8olIKqko6p+Ln yWKp8u8jrkC3loQnpr1pZpfja/Gz5D/U6fLy45XW3PJ6ef+Euz38B3CSmf06pf0I+jwMpiYmVzkt GkAerPzdGvRpxa1m1uyOcgnwdIfkLcJnKsBbBxvnd0hWI9+qoxMze4J8p8frANcof/RH3VxWJSde twzWREmnpjUws3lmtg8+aPbDp/PPSvpv+ZN158QAtcq//S5JmyZtxstnKDfhewNn4vsIW7epxPJW H/jyoJaaiH3MU8AKOcTLYGY34BVw6mYRvpxqlrcI+HIH5C0DDmlxI50J3NMBeQNcZ2ZT6urMzK4F LszRdGCfs9u8CHyt61JVvgjFFElZm8EDMo5u08cSeVXoP8uLgj7Xpt0yebn7zCeJvMz6tBKfZ7At CV+RtEeK3DJLwmGSrqxRx8Vqs3Heoe9nqaTxKbK21NtVxOvkQUltK+qoZBEK+cP7/pw6rLDyUeeW hK/LT5Er0e0EfuPxdLlHKGW5Jz9taLfUGI5vnu6CFwVtt8k8DH9Kt3VrkDRCPvN7ED85WplZgHs5 t43QL0NygjsOd9ytGqC9ENcx1WfIzE4G/pnqQbkvAvuYWdvZRuJOswMpmQ1KMAsvffZsjX0CfrqO u3dkpWUGz7efN862Cs8Bn0zCyyrRi0KqG+FHn/dJOk5NTxn58fNVwNo1yBqLZ39o7H9ded6sb+IO qZPIly98sPIMXh9vCzNrrmVYC2b2hpmdgN/YM0t08Roe4TA6cRzOI/Mc3E8vV/sWXJ7IyzyhM7Pn 8NRGx1Atnm4pXsNwx04YqwHM7GE8kiCLVXCfuToKkrRiIV7peXMz6+7JYCOqVpewmeWS7pA0VdIF 8sR9dfNjSVdJmltzv/24JFwiX8LcIulY5XBubJBbeEnYog+TtJ+ka+S/bTsWSbpa0pHKuU2QIu8z km7O8d08IU/JPLqCvDXlD9qHcsgbYLakSZKy3Dga5ZSuS9jQxy9z6jdD0qrJNVWWhEskPS7pNklf kR9i1UqpUzh5Ctaq6UNWBiaZ2Wl1dyo/di5S+kn4RvV84NkikfhNcrcmPa3uoiKzNEmb4/5Y78UT +72MH4TMxlMOldIzRd6GuC/SNrzTx/BVvDJzVrmtovI2w/3CtsELbYzEl6kLcO/1mcAMMyvsyS7p o6T7cD1nZqmpqeVVinZMa9PAveaVq0eRXbziHWJwP8sFwNN1/6bNhMGqRkcMVhAErenFHlYQBEEp wmAFQTBoCIMVBMGgIQxWEASDhjBYQRAMGsJgBUEwaChrsDrqaxEEQdCKsgarUyk+uslTvVYgCIJi lDJYSVbGx2rWpdtMAm7vtRJBEOSnyh7W1bVp0Rt2A/bCU+EGQTAIqGKwzqZDpaK6xFg89cx+QGYg aRAEvae0wTKz+cDJNerSbdYBdk2Wt58FLu2xPkEQZFDJrcHMfgCcRnYF335lX3grCd144Ie9VScI gjQq+2GZ2STgw/iyqmoGyG7zVspWM5OZTcDzeAdB0IfUUpcwyXB4oKQNgD3wenNZxSP7geWSrDGH j5lNlLSQzlWDCYIgqBd5EYy0jJlShzKOBkHQmgjNaYOZ/RQ4iJQiFkEQdJcwWCmY2ZV4mfPneq1L EARhsDIxsxl4NZiHeq1LEAx1wmDlwMzm4cUNMqvGBEHQOcJg5SQpk74XXs4+CIIeEAarAGa2xMwO B07vtS5BMBSpxQ9rqGFm35Q0C/c3C4Ig6H86Udk2CIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIgCIIg CIIgCIIgCIIgCIIgCIIgCIIgCII+5/8B+E0haQri5CUAAAAASUVORK5CYII= "
- preserveAspectRatio="none"
- height="26.987499"
- width="79.375" />
- </g>
-</svg>
diff --git a/pydis_site/static/images/sponsors/adafruit.png b/pydis_site/static/images/sponsors/adafruit.png
deleted file mode 100644
index eb14cf5d..00000000
--- a/pydis_site/static/images/sponsors/adafruit.png
+++ /dev/null
Binary files differ
diff --git a/pydis_site/static/images/sponsors/notion.png b/pydis_site/static/images/sponsors/notion.png
new file mode 100644
index 00000000..44ae9244
--- /dev/null
+++ b/pydis_site/static/images/sponsors/notion.png
Binary files differ
diff --git a/pydis_site/static/images/timeline/cd-icon-location.svg b/pydis_site/static/images/timeline/cd-icon-location.svg
new file mode 100755
index 00000000..6128fecd
--- /dev/null
+++ b/pydis_site/static/images/timeline/cd-icon-location.svg
@@ -0,0 +1,4 @@
+<svg class="nc-icon glyph" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="24px" height="24px" viewBox="0 0 24 24">
+<path fill="#ffffff" d="M12,0C7.6,0,3,3.4,3,9c0,5.3,8,13.4,8.3,13.7c0.2,0.2,0.4,0.3,0.7,0.3s0.5-0.1,0.7-0.3C13,22.4,21,14.3,21,9
+ C21,3.4,16.4,0,12,0z M12,12c-1.7,0-3-1.3-3-3s1.3-3,3-3s3,1.3,3,3S13.7,12,12,12z"></path>
+</svg>
diff --git a/pydis_site/static/images/timeline/cd-icon-movie.svg b/pydis_site/static/images/timeline/cd-icon-movie.svg
new file mode 100755
index 00000000..498a93fa
--- /dev/null
+++ b/pydis_site/static/images/timeline/cd-icon-movie.svg
@@ -0,0 +1,4 @@
+<svg class="nc-icon glyph" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="24px" height="24px" viewBox="0 0 24 24"><g>
+<path fill="#ffffff" d="M23.6,6.2c-0.3-0.2-0.6-0.2-0.9-0.1L17,8.5V5c0-0.6-0.4-1-1-1H1C0.4,4,0,4.4,0,5v14c0,0.6,0.4,1,1,1h15
+ c0.6,0,1-0.4,1-1v-3.5l5.6,2.4C22.7,18,22.9,18,23,18c0.2,0,0.4-0.1,0.6-0.2c0.3-0.2,0.4-0.5,0.4-0.8V7C24,6.7,23.8,6.4,23.6,6.2z"></path>
+</g></svg>
diff --git a/pydis_site/static/images/timeline/cd-icon-picture.svg b/pydis_site/static/images/timeline/cd-icon-picture.svg
new file mode 100755
index 00000000..015718a8
--- /dev/null
+++ b/pydis_site/static/images/timeline/cd-icon-picture.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ enable-background="new 0 0 438 438"
+ version="1.1"
+ viewBox="0 0 346.16486 345.72064"
+ xml:space="preserve"
+ id="svg6016"
+ sodipodi:docname="logo_solo.svg"
+ inkscape:version="0.91 r13725"
+ inkscape:export-filename="/home/scragly/Github/PyDisBranding/logos/logo_solo/logo_full_512.png"
+ inkscape:export-xdpi="112.21918"
+ inkscape:export-ydpi="112.21918"
+ width="346.16486"
+ height="345.72064"><defs
+ id="defs6020" /><sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1859"
+ inkscape:window-height="1056"
+ id="namedview6018"
+ showgrid="false"
+ inkscape:zoom="2.1552511"
+ inkscape:cx="150.03331"
+ inkscape:cy="193.10048"
+ inkscape:window-x="61"
+ inkscape:window-y="24"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg6016"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0" /><metadata
+ id="metadata6004"><rdf:RDF><cc:Work
+ rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><style
+ type="text/css"
+ id="style6006">
+ .st0{fill:#7289DA;}
+ .st1{fill:#5B6DAE;}
+ .st2{fill:#CAD6FF;}
+ .st3{fill:#FFFFFF;}
+</style><path
+ d="m 228.38807,14.3068 -11.506,3.3086 c -12.223,-1.8051 -24.757,-2.5971 -36.895,-2.5078 -13.5,0.1 -26.498,1.1992 -37.898,3.1992 -3.6998,0.6516 -7.0474,1.386 -10.107,2.1992 l -35.893004,0 0,18.801 4.500004,0 0,7.6992 2.7832,0 c -0.63936,3.7142 -0.88476,7.7997 -0.88476,12.301 l 0,4 -15.398004,0 -2.2012,11 17.600004,15.014 0,0.0859 79.201,0 0,10 -108.900004,0 c -23,0 -43.2,13.8 -49.5,40 -7.3,30 -7.6,48.801 0,80.201 0.49734,2.0782 1.0605,4.0985 1.6836,6.0625 l -1.1836,10.438 13.346,11.549 c 7.032,7.5103 16.371,11.951 28.254,11.951 l 27.201,0 0,-36 c 0,-26 22.600004,-49 49.500004,-49 l 79.199,0 c 22,0 39.6,-18.102 39.6,-40.102 l 0,-75.199 c 0,-12.9 -6.5819,-23.831 -16.516,-31.273 z m 76.801,77.6 -14.301,7.4004 -20.1,0 0,35.1 c 0,27.2 -23.1,50 -49.5,50 l -79.1,0 c -21.7,0 -39.6,18.5 -39.6,40.1 l 0,75.102 c 0,21.4 18.7,34 39.6,40.1 25.1,7.3 49.1,8.7 79.1,0 19.9,-5.7 39.6,-17.3 39.6,-40.1 l 0,-30.102 -0.11914,0 -11.721,-10 51.439,0 c 23,0 31.602,-16 39.602,-40 8.3,-24.7 7.9,-48.499 0,-80.199 -3.6226,-14.491 -9.3525,-26.71 -18.947,-33.699 z m -123.4,167.6 57.5,0 0,10 -57.5,0 z"
+ id="path6010"
+ inkscape:label="shadow"
+ style="fill:#5b6dae;fill-opacity:1"
+ inkscape:connector-curvature="0" /><path
+ class="st2"
+ d="m 162.28807,0.00679951 c -13.5,0.1 -26.5,1.19999999 -37.9,3.19999999 C 90.888066,9.1067995 84.788066,21.4068 84.788066,44.2068 l 0,30.1 79.200004,0 0,10 -108.900004,0 c -23,0 -43.2,13.8 -49.4999998,40 -7.3,30 -7.6,48.8 0,80.2 5.5999998,23.4 19.0999998,40 42.0999998,40 l 27.2,0 0,-36 c 0,-26 22.6,-49 49.500004,-49 l 79.1,0 c 22,0 39.6,-18.1 39.6,-40.1 l 0,-75.2 c 0,-21.4 -18.1,-37.4000005 -39.6,-41.0000005 -13.5,-2.29999999 -27.6,-3.29999999 -41.2,-3.19999999 z m -42.8,24.20000049 c 8.2,0 14.9,6.8 14.9,15.1 0,8.3 -6.7,15 -14.9,15 -8.2,0 -14.9,-6.7 -14.9,-15 0,-8.4 6.7,-15.1 14.9,-15.1 z"
+ id="path6012"
+ inkscape:label="upper_snake"
+ inkscape:connector-curvature="0"
+ style="fill:#cad6ff" /><path
+ class="st3"
+ d="m 253.08807,84.3068 0,35 c 0,27.2 -23.1,50 -49.5,50 l -79.1,0 c -21.7,0 -39.600004,18.5 -39.600004,40.1 l 0,75.1 c 0,21.4 18.700004,34 39.600004,40.1 25.1,7.3 49.1,8.7 79.1,0 19.9,-5.7 39.6,-17.3 39.6,-40.1 l 0,-30.1 -79.1,0 0,-10 118.7,0 c 23,0 31.6,-16 39.6,-40 8.3,-24.7 7.9,-48.5 0,-80.2 -5.7,-22.8 -16.6,-40 -39.6,-40 l -29.7,0 z m -44.5,190.2 c 8.2,0 14.9,6.7 14.9,15 0,8.3 -6.7,15.1 -14.9,15.1 -8.2,0 -14.9,-6.8 -14.9,-15.1 0,-8.3 6.7,-15 14.9,-15 z"
+ id="path6014"
+ inkscape:label="lower_snake"
+ inkscape:connector-curvature="0"
+ style="fill:#ffffff" /></svg>
diff --git a/pydis_site/static/images/waves/wave_dark.svg b/pydis_site/static/images/waves/wave_dark.svg
new file mode 100644
index 00000000..35174c47
--- /dev/null
+++ b/pydis_site/static/images/waves/wave_dark.svg
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="1600"
+ height="198"
+ version="1.1"
+ id="svg11"
+ sodipodi:docname="wave.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata15">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="2560"
+ inkscape:window-height="1409"
+ id="namedview13"
+ showgrid="false"
+ inkscape:zoom="1.44625"
+ inkscape:cx="757.49384"
+ inkscape:cy="107.38903"
+ inkscape:window-x="4880"
+ inkscape:window-y="677"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg11" />
+ <defs
+ id="defs7">
+ <linearGradient
+ id="a"
+ x1="50%"
+ x2="50%"
+ y1="-10.959%"
+ y2="100%">
+ <stop
+ stop-color="#57BBC1"
+ stop-opacity=".25"
+ offset="0%"
+ id="stop2" />
+ <stop
+ stop-color="#015871"
+ offset="100%"
+ id="stop4" />
+ </linearGradient>
+ </defs>
+ <path
+ fill="url(#a)"
+ fill-rule="evenodd"
+ d="M.005 121C311 121 409.898-.25 811 0c400 0 500 121 789 121v77H0s.005-48 .005-77z"
+ transform="matrix(-1 0 0 1 1600 0)"
+ id="path9"
+ style="fill:#5b6daf;fill-opacity:1" />
+</svg>
diff --git a/pydis_site/static/images/waves/wave_white.svg b/pydis_site/static/images/waves/wave_white.svg
new file mode 100644
index 00000000..441dacff
--- /dev/null
+++ b/pydis_site/static/images/waves/wave_white.svg
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="1600"
+ height="28.745832"
+ version="1.1"
+ id="svg11"
+ sodipodi:docname="wavew.svg"
+ inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+ <metadata
+ id="metadata15">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="2560"
+ inkscape:window-height="1409"
+ id="namedview13"
+ showgrid="false"
+ inkscape:zoom="1.44625"
+ inkscape:cx="884.40031"
+ inkscape:cy="-61.865141"
+ inkscape:window-x="4880"
+ inkscape:window-y="677"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg11"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0" />
+ <defs
+ id="defs7">
+ <linearGradient
+ id="a"
+ x1="0.5"
+ x2="0.5"
+ y1="-0.10958999"
+ y2="1">
+ <stop
+ stop-color="#57BBC1"
+ stop-opacity=".25"
+ offset="0%"
+ id="stop2" />
+ <stop
+ stop-color="#015871"
+ offset="100%"
+ id="stop4" />
+ </linearGradient>
+ </defs>
+ <path
+ fill="url(#a)"
+ fill-rule="evenodd"
+ d="M 1599.995,17.566918 C 1289,17.566918 1190.102,-0.03623696 789,5.6042811e-5 389,5.6042811e-5 289,17.566918 0,17.566918 v 11.178914 h 1600 c 0,0 -0.01,-6.968673 -0.01,-11.178914 z"
+ id="path9"
+ style="fill:#ffffff;fill-opacity:1;stroke-width:0.381026" />
+</svg>
diff --git a/pydis_site/static/js/timeline/main.js b/pydis_site/static/js/timeline/main.js
new file mode 100644
index 00000000..2ff7df57
--- /dev/null
+++ b/pydis_site/static/js/timeline/main.js
@@ -0,0 +1,104 @@
+(function(){
+ // Vertical Timeline - by CodyHouse.co (modified)
+ function VerticalTimeline( element ) {
+ this.element = element;
+ this.blocks = this.element.getElementsByClassName("cd-timeline__block");
+ this.images = this.element.getElementsByClassName("cd-timeline__img");
+ this.contents = this.element.getElementsByClassName("cd-timeline__content");
+ this.offset = 0.8;
+ this.hideBlocks();
+ };
+
+ VerticalTimeline.prototype.hideBlocks = function() {
+ if ( !"classList" in document.documentElement ) {
+ return; // no animation on older browsers
+ }
+ //hide timeline blocks which are outside the viewport
+ var self = this;
+ for( var i = 0; i < this.blocks.length; i++) {
+ (function(i){
+ if( self.blocks[i].getBoundingClientRect().top > window.innerHeight*self.offset ) {
+ self.images[i].classList.add("cd-timeline__img--hidden");
+ self.contents[i].classList.add("cd-timeline__content--hidden");
+ }
+ })(i);
+ }
+ };
+
+ VerticalTimeline.prototype.showBlocks = function() {
+ if ( ! "classList" in document.documentElement ) {
+ return;
+ }
+ var self = this;
+ for( var i = 0; i < this.blocks.length; i++) {
+ (function(i){
+ if((self.contents[i].classList.contains("cd-timeline__content--hidden") || self.contents[i].classList.contains("cd-timeline__content--bounce-out")) && self.blocks[i].getBoundingClientRect().top <= window.innerHeight*self.offset ) {
+ // add bounce-in animation
+ self.images[i].classList.add("cd-timeline__img--bounce-in");
+ self.contents[i].classList.add("cd-timeline__content--bounce-in");
+ self.images[i].classList.remove("cd-timeline__img--hidden");
+ self.contents[i].classList.remove("cd-timeline__content--hidden");
+ self.images[i].classList.remove("cd-timeline__img--bounce-out");
+ self.contents[i].classList.remove("cd-timeline__content--bounce-out");
+ }
+ })(i);
+ }
+ };
+
+ VerticalTimeline.prototype.hideBlocksScroll = function () {
+ if ( ! "classList" in document.documentElement ) {
+ return;
+ }
+ var self = this;
+ for( var i = 0; i < this.blocks.length; i++) {
+ (function(i){
+ if(self.contents[i].classList.contains("cd-timeline__content--bounce-in") && self.blocks[i].getBoundingClientRect().top > window.innerHeight*self.offset ) {
+ self.images[i].classList.remove("cd-timeline__img--bounce-in");
+ self.contents[i].classList.remove("cd-timeline__content--bounce-in");
+ self.images[i].classList.add("cd-timeline__img--bounce-out");
+ self.contents[i].classList.add("cd-timeline__content--bounce-out");
+ }
+ })(i);
+ }
+ }
+
+ var verticalTimelines = document.getElementsByClassName("js-cd-timeline"),
+ verticalTimelinesArray = [],
+ scrolling = false;
+ if( verticalTimelines.length > 0 ) {
+ for( var i = 0; i < verticalTimelines.length; i++) {
+ (function(i){
+ verticalTimelinesArray.push(new VerticalTimeline(verticalTimelines[i]));
+ })(i);
+ }
+
+ //show timeline blocks on scrolling
+ window.addEventListener("scroll", function(event) {
+ if( !scrolling ) {
+ scrolling = true;
+ (!window.requestAnimationFrame) ? setTimeout(checkTimelineScroll, 250) : window.requestAnimationFrame(checkTimelineScroll);
+ }
+ });
+
+ function animationEnd(event) {
+ if (event.target.classList.contains("cd-timeline__img--bounce-out")) {
+ event.target.classList.add("cd-timeline__img--hidden");
+ event.target.classList.remove("cd-timeline__img--bounce-out");
+ } else if (event.target.classList.contains("cd-timeline__content--bounce-out")) {
+ event.target.classList.add("cd-timeline__content--hidden");
+ event.target.classList.remove("cd-timeline__content--bounce-out");
+ }
+ }
+
+ window.addEventListener("animationend", animationEnd);
+ window.addEventListener("webkitAnimationEnd", animationEnd);
+ }
+
+ function checkTimelineScroll() {
+ verticalTimelinesArray.forEach(function(timeline){
+ timeline.showBlocks();
+ timeline.hideBlocksScroll();
+ });
+ scrolling = false;
+ };
+})();
diff --git a/pydis_site/templates/404.html b/pydis_site/templates/404.html
new file mode 100644
index 00000000..42e317d2
--- /dev/null
+++ b/pydis_site/templates/404.html
@@ -0,0 +1,34 @@
+{% load static %}
+
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <title>Python Discord | 404</title>
+
+ <meta charset="UTF-8">
+
+ <link rel="preconnect" href="https://fonts.gstatic.com">
+ <link href="https://fonts.googleapis.com/css2?family=Hind:wght@400;600&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="{% static "css/error_pages.css" %}">
+</head>
+
+<body>
+ <div class="error-box">
+ <div class="logo-box">
+ <img src="https://raw.githubusercontent.com/python-discord/branding/b67897df93e572c1576a9026eb78c785a794d226/logos/logo_banner/logo_site_banner.svg"
+ alt="Python Discord banner" />
+ </div>
+ <div class="content-box">
+ <h1>404 — Not Found</h1>
+ <p>We couldn't find the page you're looking for. Here are a few things to try out:</p>
+ <ul>
+ <li>Double check the URL. Are you sure you typed it out correctly?
+ <li>Come join <a href="https://discord.gg/python">our Discord Server</a>. Maybe we can help you out over
+ there
+ </ul>
+ </div>
+ </div>
+</body>
+
+</html>
diff --git a/pydis_site/templates/500.html b/pydis_site/templates/500.html
new file mode 100644
index 00000000..869892ec
--- /dev/null
+++ b/pydis_site/templates/500.html
@@ -0,0 +1,29 @@
+{% load static %}
+
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <title>Python Discord | 500</title>
+
+ <meta charset="UTF-8">
+
+ <link rel="preconnect" href="https://fonts.gstatic.com">
+ <link href="https://fonts.googleapis.com/css2?family=Hind:wght@400;600&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="{% static "css/error_pages.css" %}">
+</head>
+
+<body>
+ <div class="error-box">
+ <div class="logo-box">
+ <img src="https://raw.githubusercontent.com/python-discord/branding/b67897df93e572c1576a9026eb78c785a794d226/logos/logo_banner/logo_site_banner.svg"
+ alt="Python Discord banner" />
+ </div>
+ <div class="content-box">
+ <h1>500 — Internal Server Error</h1>
+ <p>Something went wrong at our end. Please try again shortly, or if the problem persists, please let us know <a href="https://discord.gg/python">on Discord</a>.</p>
+ </div>
+ </div>
+</body>
+
+</html>
diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html
index 4c70d778..70426dc1 100644
--- a/pydis_site/templates/base/base.html
+++ b/pydis_site/templates/base/base.html
@@ -37,6 +37,7 @@
{% render_block "css" %}
</head>
<body class="site">
+ <!-- Git hash for this release: {{ git_sha }} -->
<main class="site-content">
{% if messages %}
diff --git a/pydis_site/templates/base/footer.html b/pydis_site/templates/base/footer.html
index 90f06f3c..bca43b5d 100644
--- a/pydis_site/templates/base/footer.html
+++ b/pydis_site/templates/base/footer.html
@@ -1,7 +1,7 @@
<footer class="footer has-background-dark has-text-light">
<div class="content has-text-centered">
<p>
- Built with <a href="https://www.djangoproject.com/"><span id="django-logo">django</span></a> and <a href="https://bulma.io"><span id="bulma-logo">Bulma</span></a> <br/> &copy; {% now "Y" %} <span id="pydis-text">Python Discord</span>
+ Powered by <a href="https://www.linode.com/?r=3bc18ce876ff43ea31f201b91e8e119c9753f085"><span id="linode-logo">Linode</span></a><br>Built with <a href="https://www.djangoproject.com/"><span id="django-logo">django</span></a> and <a href="https://bulma.io"><span id="bulma-logo">Bulma</span></a> <br/> &copy; {% now "Y" %} <span id="pydis-text">Python Discord</span>
</p>
</div>
</footer>
diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html
index f07662ae..d8abf36d 100644
--- a/pydis_site/templates/base/navbar.html
+++ b/pydis_site/templates/base/navbar.html
@@ -20,7 +20,7 @@
<div class="navbar-menu is-paddingless" id="navbar_menu">
<div class="navbar-end">
- {# Discord invite - only visible in the hamburger on mobile sizes. #}
+ {# Burger-menu Discord #}
<a class="navbar-item is-hidden-desktop" href="https://discord.gg/python">
<span class="icon is-size-4 is-medium"><i class="fab fa-discord"></i></span>
<span>&nbsp;Discord</span>
@@ -57,7 +57,7 @@
</a>
{# More #}
- <div class="navbar-item has-dropdown is-hoverable has-left-margin-1">
+ <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
More
</a>
@@ -88,7 +88,7 @@
<strong>Events</strong>
</div>
<a class="navbar-item" href="{% url 'wiki:get' path="code-jams/code-jam-7/" %}">
- Upcoming: Code Jam 7
+ Most Recent: Code Jam 7
</a>
<a class="navbar-item" href="{% url 'wiki:get' path="events/" %}">
All events
@@ -122,12 +122,14 @@
</div>
</div>
+
+ {# Desktop Nav Discord #}
+ <div id="discord-btn" class="buttons is-hidden-touch">
+ <a href="https://discord.gg/python" class="button is-large is-primary">Discord</a>
+ </div>
+
</div>
- {# Join us on Discord! #}
- <a class="navbar-item is-fullsize has-no-highlight has-left-margin-1" href="https://discord.gg/python">
- <img class="is-hidden-touch" src="{% static "images/navbar/navbar_discordjoin.svg" %}" alt="Join us on Discord!"/>
- </a>
</div>
</nav>
diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html
index 3e96cc91..04815b7f 100644
--- a/pydis_site/templates/home/index.html
+++ b/pydis_site/templates/home/index.html
@@ -9,12 +9,69 @@
{% block content %}
{% include "base/navbar.html" %}
- <section class="section">
+ <!-- Mobile-only Notice -->
+ <section id="mobile-notice" class="message is-primary is-hidden-tablet">
+ <div class="message-header">
+ <p>100K Member Milestone!</p>
+ </div>
+ <div class="message-body">
+ Thanks to all our members for helping us create this friendly and helpful community!
+ <br><br>
+ As a nice treat, we've created a <a href="{% url 'timeline' %}">Timeline page</a> for people
+ to discover the events that made our community what it is today. Be sure to check it out!
+ </div>
+ </section>
+
+ <!-- Wave Hero -->
+ <section id="wave-hero" class="section is-hidden-mobile">
+
+ <div class="container">
+ <div class="columns is-variable is-8">
- {# Who are we? #}
- <div class="container is-spaced">
+ {# Embedded Welcome video #}
+ <div id="wave-hero-left" class="column is-half">
+ <div class="force-aspect-container">
+ <iframe
+ class="force-aspect-content"
+ src="https://www.youtube.com/embed/ZH26PuX3re0"
+ srcdoc="
+ <style>
+ *{padding:0;margin:0;overflow:hidden}
+ html,body{height:100%}
+ img,span{position:absolute;width:100%;top:0;bottom:0;margin:auto}
+ span{height:1.5em;text-align:center;font:68px/1.5 sans-serif;color:#FFFFFFEE;text-shadow:0 0 0.1em #00000020}
+ </style>
+ <a href=https://www.youtube.com/embed/ZH26PuX3re0?autoplay=1>
+ <img src='{% static "images/frontpage/welcome.jpg" %}' alt='Welcome to Python Discord'>
+ <span>▶</span>
+ </a>"
+ allow="autoplay; accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen
+ ></iframe>
+ </div>
+ </div>
+
+ {# Right side content #}
+ <div id="wave-hero-right" class="column is-half">
+ <img src="{% static "images/events/100k.png" %}" alt="100K members!">
+ </div>
+
+ </div>
+ </div>
+
+ {# Animated wave elements #}
+ <span id="front-wave" class="wave"></span>
+ <span id="back-wave" class="wave"></span>
+ <span id="bottom-wave" class="wave"></span>
+
+ </section>
+
+ <!-- Main Body -->
+ <section id="body" class="section">
+
+ <div class="container">
<h1 class="is-size-1">Who are we?</h1>
- <br>
+
<div class="columns is-desktop">
<div class="column is-half-desktop content">
<p>
@@ -31,70 +88,122 @@
</p>
<p>
You can find help with most Python-related problems in one of our help channels.
- Our staff of over 50 dedicated expert Helpers are available around the clock
+ Our staff of over 90 dedicated expert Helpers are available around the clock
in every timezone. Whether you're looking to learn the language or working on a
complex project, we've got someone who can help you if you get stuck.
</p>
</div>
- {# Right column container #}
- <div class="column is-half-desktop">
- <a href="https://pythondiscord.com/pages/code-jams/code-jam-7/">
- <img src="{% static "images/events/summer_code_jam_2020.png" %}">
- </a>
- </div>
- </div>
+ {# Showcase box #}
+ <section id="showcase" class="column is-half-desktop has-text-centered">
+ <article class="box">
+
+ <header class="title">New Timeline!</header>
- {# Projects #}
- <h1 class="is-size-1">Projects</h1>
- <br>
- <div class="columns is-multiline is-tablet">
-
- {# Display projects from HomeView.repos #}
- {% for repo in repo_data %}
- <div class="column is-one-third-desktop is-half-tablet">
- <div class="card has-equal-height github-card">
- <div class="card-content">
- <div class="repo-headline">
- <i class="fab fa-github"></i>
- <a href="https://github.com/{{ repo.repo_name }}"> <strong>{{ repo.repo_name }}</strong></a>
- </div>
- <div>
- {{ repo.description }}
- <br><br>
- </span><span class="repo-language-dot {{ repo.language | lower }}"></span> {{ repo.language }}
- <span id="repo-footer-item"><i class="fas fa-star"></i> {{ repo.stargazers }}</span>
- <span id="repo-footer-item"><i class="fas fa-code-branch"></i> {{ repo.forks }}</span>
- </div>
- </div>
+ <div class="mini-timeline">
+ <i class="fa fa-asterisk"></i>
+ <i class="fa fa-code"></i>
+ <i class="fab fa-python"></i>
+ <i class="fa fa-alien-monster"></i>
+ <i class="fa fa-duck"></i>
+ <i class="fa fa-bug"></i>
</div>
- </div>
- {% endfor %}
+
+ <p class="subtitle">
+ Start from our humble beginnings to discover the events that made our community what it is today.
+ </p>
+
+ <div class="buttons are-large is-centered">
+ <a href="{% url 'timeline' %}" class="button is-primary">
+ <span>Check it out!</span>
+ <span class="icon">
+ <i class="fas fa-arrow-right"></i>
+ </span>
+ </a>
+ </div>
+
+ </article>
+ </section>
</div>
</div>
</section>
- {# Sponsors #}
- <section class="section-sp hero is-light">
- <div id="sponsors-hero" class="hero-body">
+ <!-- Projects -->
+ {% if repo_data %}
+ <section id="projects" class="section">
+ <div class="container">
+ <h1 class="is-size-1">Projects</h1>
+
+ <div class="columns is-multiline is-tablet">
+
+ {# Generate project data from HomeView.repos #}
+ {% for repo in repo_data %}
+ <div class="column is-one-third-desktop is-half-tablet">
+
+ <a href="https://github.com/{{ repo.repo_name }}">
+ <article class="card">
+
+ <header class="card-header">
+ <span class="card-header-icon">
+ <span class="icon"><i class="fab fa-github"></i></span>
+ </span>
+ <div class="card-header-title">
+ {{ repo.repo_name|cut:"python-discord/" }}
+ </div>
+ </header>
+
+ <p class="card-content">
+ {{ repo.description }}
+ </p>
+
+ <footer class="card-footer">
+ <div class="card-footer-item">
+ <i class="repo-language-dot {{ repo.language | lower }}"></i>
+ {{ repo.language }}
+ </div>
+ <div class="card-footer-item">
+ <i class="fas fa-star"></i>
+ {{ repo.stargazers }}
+ </div>
+ <div class="card-footer-item">
+ <i class="fas fa-code-branch"></i>
+ {{ repo.forks }}
+ </div>
+ </footer>
+
+ </article>
+ </a>
+
+ </div>
+ {% endfor %}
+
+ </div>
+
+ </div>
+ </section>
+ {% endif %}
+
+ <!-- Sponsors -->
+ <section id="sponsors" class="hero is-light">
+ <div class="hero-body">
<div class="container">
<h1 class="title is-6 has-text-grey">
Sponsors
</h1>
<div class="columns is-mobile is-multiline">
- <a href="https://linode.com" class="column is-narrow">
+ <a href="https://www.linode.com/?r=3bc18ce876ff43ea31f201b91e8e119c9753f085" class="column is-narrow">
<img src="{% static "images/sponsors/linode.png" %}" alt="Linode"/>
</a>
<a href="https://jetbrains.com" class="column is-narrow">
<img src="{% static "images/sponsors/jetbrains.png" %}" alt="JetBrains"/>
</a>
- <a href="https://adafruit.com" class="column is-narrow">
- <img src="{% static "images/sponsors/adafruit.png" %}" alt="Adafruit"/>
- </a>
<a href="https://sentry.io" class="column is-narrow">
<img src="{% static "images/sponsors/sentry.png" %}" alt="Sentry"/>
</a>
+ <a href="https://notion.so" class="column is-narrow">
+ <img src="{% static "images/sponsors/notion.png" %}" alt="Notion"/>
+ </a>
</div>
</div>
</div>
diff --git a/pydis_site/templates/home/timeline.html b/pydis_site/templates/home/timeline.html
new file mode 100644
index 00000000..ece2e4e5
--- /dev/null
+++ b/pydis_site/templates/home/timeline.html
@@ -0,0 +1,520 @@
+{% extends 'base/base.html' %}
+{% load static %}
+
+{% block title %}Timeline{% endblock %}
+{% block head %}
+<link rel="stylesheet" href="{% static "css/home/timeline.css" %}">
+<link rel="stylesheet" href="{% static "css/home/index.css" %}">
+{% endblock %}
+
+{% block content %}
+{% include "base/navbar.html" %}
+
+<section class="cd-timeline js-cd-timeline">
+ <div class="container max-width-lg cd-timeline__container">
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture">
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord is created</h2>
+ <p class="color-contrast-medium"><strong>joe</strong> becomes one of the owners around 3 days after it
+ is created, and <strong>lemon</strong> joins the owner team later in the year, when the community
+ has around 300 members.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jan 8th, 2017</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord hits 1,000 members</h2>
+ <p class="color-contrast-medium">Our main source of new users at this point is a post on Reddit that
+ happens to get very good SEO. We are one of the top 10 search engine hits for the search term
+ "python discord".</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Nov 10th, 2017</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture">
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Our logo is born. Thanks @Aperture!</h2>
+ <p class="pydis-logo-banner"><img
+ src="https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_banner/logo_site_banner.svg">
+ </p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Feb 3rd, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis hits 2,000 members; pythondiscord.com and @Python are live</h2>
+ <p class="color-contrast-medium">The public moderation bot we're using at the time, Rowboat, announces
+ it will be shutting down. We decide that we'll write our own bot to handle moderation, so that we
+ can have more control over its features. We also buy a domain and start making a website in Flask.
+ </p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Mar 4th, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
+ <i class="fa fa-dice"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>First code jam with the theme “snakes”</h2>
+ <p class="color-contrast-medium">Our very first Code Jam attracts a handful of users who work in random
+ teams of 2. We ask our participants to write a snake-themed Discord bot. Most of the code written
+ for this jam still lives on in SeasonalBot, and you can play with it by using the
+ <code>.snakes</code> command. For more information on this event, see <a
+ href="https://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/">the event page</a></p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Mar 23rd, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-lime cd-timeline__img--picture">
+ <i class="fa fa-scroll"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>The privacy policy is created</h2>
+ <p class="color-contrast-medium">Since data privacy is quite important to us, we create a privacy page
+ pretty much as soon as our new bot and site starts collecting some data. To this day, we keep <a
+ href="https://pythondiscord.com/pages/privacy/">our privacy policy</a> up to date with all
+ changes, and since April 2020 we've started doing <a
+ href="https://pythondiscord.com/pages/data-reviews/">monthly data reviews</a>.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">May 21st, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Do You Even Python and PyDis merger</h2>
+ <p class="color-contrast-medium">At this point in time, there are only two serious Python communities on
+ Discord - Ours, and one called Do You Even Python. We approach the owners of DYEP with a bold
+ proposal - let's shut down their community, replace it with links to ours, and in return we will let
+ their staff join our staff. This gives us a big boost in members, and eventually leads to @eivl and
+ @Mr. Hemlock joining our Admin team</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jun 9th, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis hits 5,000 members and partners with r/Python</h2>
+ <p class="color-contrast-medium">As we continue to grow, we approach the r/Python subreddit and ask to
+ become their official Discord community. They agree, and we become listed in their sidebar, giving
+ us yet another source of new members.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jun 20th, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis is now partnered with Discord; the vanity URL discord.gg/python is created</h2>
+ <p class="color-contrast-medium">After being rejected for their Partner program several times, we
+ finally get approved. The recent partnership with the r/Python subreddit plays a significant role in
+ qualifying us for this partnership.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jul 10th, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
+ <i class="fa fa-dice"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>First Hacktoberfest PyDis event; @SeasonalBot is created</h2>
+ <p class="color-contrast-medium">We create a second bot for our community and fill it up with simple,
+ fun and relatively easy issues. The idea is to create an approachable arena for our members to cut
+ their open-source teeth on, and to provide lots of help and hand-holding for those who get stuck.
+ We're training our members to be productive contributors in the open-source ecosystem.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Oct 1st, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis hits 10,000 members</h2>
+ <p class="color-contrast-medium">We partner with RLBot, move from GitLab to GitHub, and start putting
+ together the first Advent of Code event.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Nov 24th, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
+ <i class="fa fa-code"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>django-simple-bulma is released on PyPi</h2>
+ <p class="color-contrast-medium">Our very first package on PyPI, <a
+ href="https://pypi.org/project/django-simple-bulma/">django-simple-bulma</a> is a package that
+ sets up the Bulma CSS framework for your Django application and lets you configure everything in
+ settings.py.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Dec 19th, 2018</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis hits 15,000 members; the “hot ones special” video is released</h2>
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Apr 8th, 2019</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-orange cd-timeline__img--picture">
+ <i class="fa fa-code"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>The Django rewrite of pythondiscord.com is now live!</h2>
+ <p class="color-contrast-medium">The site is getting more and more complex, and it's time for a rewrite.
+ We decide to go for a different stack, and build a website based on Django, DRF, Bulma and
+ PostgreSQL.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Sep 15, 2019</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-lime cd-timeline__img--picture">
+ <i class="fa fa-scroll"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>The code of conduct is created</h2>
+ <p class="color-contrast-medium">Inspired by the Adafruit, Rust and Django communities, an essential
+ community pillar is created; Our <a href="https://pythondiscord.com/pages/code-of-conduct/">Code of
+ Conduct.</a></p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Oct 26th, 2019</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src={% static "images/timeline/cd-icon-picture.svg" %} alt="Picture">
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Ves Zappa becomes an owner</h2>
+ <p class="color-contrast-medium">After being a long time active contributor to our projects and the driving force behind our events, Ves Zappa joined the Owners team alongside joe & lemon.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Sept 22nd, 2019</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis hits 30,000 members</h2>
+ <p class="color-contrast-medium">More than tripling in size since the year before, the community hits
+ 30000 users. At this point, we're probably the largest Python chat community on the planet.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Dec 22nd, 2019</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
+ <i class="fa fa-dice"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis sixth code jam with the theme “Ancient technology” and the technology Kivy</h2>
+ <p class="color-contrast-medium">Our Code Jams are becoming an increasingly big deal, and the Kivy core
+ developers join us to judge the event and help out our members during the event. One of them,
+ @tshirtman, even joins our staff!</p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/8fbZsGrqBzo" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jan 17, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
+ <i class="fa fa-comments"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>The new help channel system is live</h2>
+ <p class="color-contrast-medium">We release our dynamic help-channel system, which allows you to claim
+ your very own help channel instead of fighting over the static help channels. We release a <a
+ href="https://pythondiscord.com/pages/resources/guides/help-channels/">Help Channel Guide</a> to
+ help our members fully understand how the system works.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Apr 5th, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord hits 40,000 members, and is now bigger than Liechtenstein.</h2>
+ <p class="color-contrast-medium"><img
+ src="https://cdn.discordapp.com/attachments/354619224620138496/699666518476324954/unknown.png">
+ </p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Apr 14, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-purple cd-timeline__img--picture">
+ <i class="fa fa-gamepad"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis Game Jam 2020 with the “Three of a Kind” theme and Arcade as the technology</h2>
+ <p class="color-contrast-medium">The creator of Arcade, Paul Vincent Craven, joins us as a judge.
+ Several of the Code Jam participants also end up getting involved contributing to the Arcade
+ repository.</p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/KkLXMvKfEgs" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Apr 17th, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-green cd-timeline__img--picture">
+ <i class="fa fa-comments"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>ModMail is now live</h2>
+ <p class="color-contrast-medium">Having originally planned to write our own ModMail bot from scratch, we
+ come across an exceptionally good <a href="https://github.com/kyb3r/modmail">ModMail bot by
+ kyb3r</a> and decide to just self-host that one instead.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">May 25th, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord is now listed on python.org/community</h2>
+ <p class="color-contrast-medium">After working towards this goal for months, we finally work out an
+ arrangement with the PSF that allows us to be listed on that most holiest of websites:
+ https://python.org/. <a href="https://youtu.be/yciX2meIkXI?t=3">There was much rejoicing.</a></p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">May 28th, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-red cd-timeline__img--picture">
+ <i class="fa fa-chart-bar"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord Public Statistics are now live</h2>
+ <p class="color-contrast-medium">After getting numerous requests to publish beautiful data on member
+ count and channel use, we create <a href="https://stats.pythondiscord.com/">stats.pythondiscord.com</a> for all to enjoy.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jun 4th, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-blue cd-timeline__img--picture">
+ <i class="fa fa-dice"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>PyDis summer code jam 2020 with the theme “Early Internet” and Django as the technology</h2>
+ <p class="color-contrast-medium">Sponsored by the Django Software Foundation and JetBrains, the Summer
+ Code Jam for 2020 attracts hundreds of participants, and sees the creation of some fantastic
+ projects. Check them out in our judge stream below:</p>
+
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/OFtm8f2iu6c" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Jul 31st, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-pink cd-timeline__img--picture">
+ <i class="fa fa-handshake"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord is now the new home of the PyWeek event!</h2>
+ <p class="color-contrast-medium">PyWeek, a game jam that has been running since 2005, joins Python
+ Discord as one of our official events. Find more information about PyWeek on <a
+ href="https://pyweek.org/">their official website</a>.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Aug 16th, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img cd-timeline__img--picture">
+ <img src="{% static "images/timeline/cd-icon-picture.svg" %}" alt="Picture">
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord hosts the 2020 CPython Core Developer Q&A</h2>
+ <div class="force-aspect-container">
+ <iframe class="force-aspect-content" src="https://www.youtube.com/embed/gXMdfBTcOfQ" frameborder="0"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowfullscreen></iframe>
+ </div>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Oct 21st, 2020</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="cd-timeline__block">
+ <div class="cd-timeline__img pastel-dark-blue cd-timeline__img--picture">
+ <i class="fa fa-users"></i>
+ </div>
+
+ <div class="cd-timeline__content text-component">
+ <h2>Python Discord hits 100,000 members.</h2>
+ <p class="color-contrast-medium">After years of hard work, we hit 100,000 users. A monumental milestone,
+ and one we're very proud of. To commemorate it, we create this timeline.</p>
+
+ <div class="flex justify-between items-center">
+ <span class="cd-timeline__date">Oct 22nd, 2020</span>
+ </div>
+ </div>
+ </div>
+ </div>
+</section>
+
+<script src="{% static "js/timeline/main.js" %}"></script>
+{% endblock %}