diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 10ce50a9..b5c315f7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,41 +6,7 @@ jobs: docs: runs-on: ubuntu-latest env: - DJANGO_SETTINGS_MODULE: 'backend.settings.test' - REDIS_PORT: 6379 - REDIS_CONFIG_DB: 1 - REDIS_CACHE_DB: 2 - REDIS_HOST: localhost - SQL_PORT: 5432 - SQL_USER: ractf - SQL_HOST: localhost - SQL_DATABASE: ractf - SQL_PASSWORD: postgres - services: - postgres: - image: postgres:12-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_EXTENSIONS: citext - POSTGRES_HOST_AUTH_METHOD: trust - POSTGRES_DB: ractf - POSTGRES_USER: ractf - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - redis: - image: redis:5 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 + DJANGO_SETTINGS_MODULE: 'core.settings.lint' steps: - name: Checkout repository @@ -89,11 +55,10 @@ jobs: PIP_CACHE_DIR: ~/.pip - name: Run Migrations - run: ./manage.py migrate - working-directory: ./src + run: make migrate - name: Generate OpenAPI schema - run: set -eo pipefail && ./src/manage.py getschema | tee openapi-schema.yml + run: set -eo pipefail && python src/manage.py getschema | tee openapi-schema.yml - name: Publish API documentation to GitHub uses: actions/upload-artifact@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e49e74c0..fe5266af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,8 +5,6 @@ on: push jobs: test: runs-on: ubuntu-latest - env: - DJANGO_SETTINGS_MODULE: 'backend.settings.lint' steps: - name: Checkout repository diff --git a/Makefile b/Makefile index 58fd9450..d2d05373 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .EXPORT_ALL_VARIABLES: BETTER_EXCEPTIONS=1 -DJANGO_SETTINGS_MODULE?=backend.settings.lint +PYTHONPATH=$(shell pwd)/src +DJANGO_SETTINGS_MODULE?=core.settings.lint migrate: python src/manage.py migrate @@ -9,33 +10,43 @@ migrations: python src/manage.py makemigrations test: migrate - pytest --testmon src || \ + pytest --testmon || \ if [ $$? = 5 ]; \ then exit 0; \ else exit $$?; \ fi coverage: migrate - pytest --cov=. --cov-report=xml src && \ + pytest --cov=. --cov-report=xml && \ coverage html && \ - which xdg-open && \ + [ "$$CI" != "true" ] && \ xdg-open htmlcov/index.html || true format: isort src && \ black src -lint: - flake8 && \ +lint: migrate + flakehell lint src && \ isort --check-only src dev-server: docker-compose build && \ docker-compose up -d +dev-server-attach: + docker-compose build && \ + docker-compose up + dev-test: dev-server docker-compose exec backend pytest --cov=src src +dev-server-logs: dev-server + docker-compose logs -f + +dev-server-down: + docker-compose down + fake-data: python -m scripts.fake generate $(ARGS) diff --git a/docker-compose.yml b/docker-compose.yml index 7fa7694e..cfe260ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: - DOMAIN=localhost - FRONTEND_URL=http://localhost:8000/ - SECRET_KEY=CorrectHorseBatteryStaple - - DJANGO_SETTINGS_MODULE=backend.settings.local + - DJANGO_SETTINGS_MODULE=core.settings.local - ANDROMEDA_IP=andromeda - ANDROMEDA_URL=http://andromeda:6000 @@ -58,7 +58,7 @@ services: sockets: <<: *backend entrypoint: /app/entrypoints/sockets.sh - command: gunicorn -w 12 -b 0.0.0.0:8000 -k uvicorn.workers.UvicornWorker backend.asgi:application + command: gunicorn -w 1 -b 0.0.0.0:8000 -k uvicorn.workers.UvicornWorker core.asgi:application --chdir src depends_on: - backend working_dir: /app/src/ diff --git a/poetry.lock b/poetry.lock index 988aeda6..cc4395b1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,81 +1,101 @@ [[package]] -category = "main" -description = "asyncio (PEP 3156) Redis support" -name = "aioredis" +name = "aiohttp" +version = "3.7.4.post0" +description = "Async http client/server framework (asyncio)" +category = "dev" optional = false -python-versions = "*" -version = "1.3.1" +python-versions = ">=3.6" [package.dependencies] -async-timeout = "*" -hiredis = "*" +async-timeout = ">=3.0,<4.0" +attrs = ">=17.3.0" +chardet = ">=2.0,<5.0" +multidict = ">=4.5,<7.0" +typing-extensions = ">=3.6.5" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotlipy", "cchardet"] [[package]] +name = "aiohttp-cors" +version = "0.7.0" +description = "CORS support for aiohttp" category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -name = "appdirs" optional = false python-versions = "*" -version = "1.4.4" + +[package.dependencies] +aiohttp = ">=1.1" [[package]] -category = "dev" -description = "Disable App Nap on macOS >= 10.9" -marker = "sys_platform == \"darwin\"" -name = "appnope" +name = "aioredis" +version = "1.3.1" +description = "asyncio (PEP 3156) Redis support" +category = "main" optional = false python-versions = "*" + +[package.dependencies] +async-timeout = "*" +hiredis = "*" + +[[package]] +name = "appnope" version = "0.1.2" +description = "Disable App Nap on macOS >= 10.9" +category = "dev" +optional = false +python-versions = "*" [[package]] -category = "main" -description = "ASGI specs, helper code, and adapters" name = "asgiref" +version = "3.4.1" +description = "ASGI specs, helper code, and adapters" +category = "main" optional = false python-versions = ">=3.6" -version = "3.4.1" [package.extras] tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] [[package]] -category = "main" -description = "Timeout context manager for asyncio programs" name = "async-timeout" +version = "3.0.1" +description = "Timeout context manager for asyncio programs" +category = "main" optional = false python-versions = ">=3.5.3" -version = "3.0.1" [[package]] -category = "dev" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.0" [[package]] -category = "main" -description = "Classes Without Boilerplate" name = "attrs" +version = "21.2.0" +description = "Classes Without Boilerplate" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "21.2.0" [package.extras] -dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] -category = "main" -description = "WebSocket client & server library, WAMP real-time framework" name = "autobahn" +version = "21.3.1" +description = "WebSocket client & server library, WAMP real-time framework" +category = "main" optional = false python-versions = ">=3.7" -version = "21.3.1" [package.dependencies] cryptography = ">=3.4.6" @@ -95,23 +115,23 @@ twisted = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)", "attrs (>=20.3.0)"] xbr = ["xbr (>=21.2.1)", "cbor2 (>=5.2.0)", "zlmdb (>=21.2.1)", "twisted (>=20.3.0)", "web3 (>=5.16.0)", "jinja2 (>=2.11.3)", "rlp (>=2.0.1)", "py-eth-sig-utils (>=0.4.0)", "py-ecc (>=5.1.0)", "eth-abi (>=2.1.1)", "mnemonic (>=0.19)", "base58 (>=2.1.0)", "ecdsa (>=0.16.1)", "py-multihash (>=2.0.1)"] [[package]] -category = "dev" -description = "Removes unused imports and unused variables" name = "autoflake" +version = "1.4" +description = "Removes unused imports and unused variables" +category = "dev" optional = false python-versions = "*" -version = "1.4" [package.dependencies] pyflakes = ">=1.1.0" [[package]] -category = "main" -description = "Self-service finite-state machines for the programmer on the go." name = "automat" +version = "20.2.0" +description = "Self-service finite-state machines for the programmer on the go." +category = "main" optional = false python-versions = "*" -version = "20.2.0" [package.dependencies] attrs = ">=19.2.0" @@ -121,98 +141,108 @@ six = "*" visualize = ["graphviz (>0.5.1)", "Twisted (>=16.1.1)"] [[package]] -category = "main" -description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" name = "autopep8" +version = "1.5.7" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +category = "main" optional = false python-versions = "*" -version = "1.5.5" [package.dependencies] -pycodestyle = ">=2.6.0" +pycodestyle = ">=2.7.0" toml = "*" [[package]] -category = "dev" -description = "Specifications for callback functions passed in to an API" name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" optional = false python-versions = "*" -version = "0.2.0" [[package]] -category = "dev" -description = "Compatibility shim providing selectable entry points for older implementations" name = "backports.entry-points-selectable" +version = "1.1.0" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" optional = false python-versions = ">=2.7" -version = "1.1.0" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] [[package]] -category = "dev" -description = "Pretty and helpful exceptions, automatically" name = "better-exceptions" +version = "0.3.3" +description = "Pretty and helpful exceptions, automatically" +category = "dev" optional = false python-versions = "*" -version = "0.3.3" [package.dependencies] -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} [[package]] -category = "dev" -description = "The uncompromising code formatter." name = "black" +version = "21.8b0" +description = "The uncompromising code formatter." +category = "dev" optional = false -python-versions = ">=3.6" -version = "20.8b1" +python-versions = ">=3.6.2" [package.dependencies] -appdirs = "*" +aiohttp = {version = ">=3.6.0", optional = true, markers = "extra == \"d\""} +aiohttp-cors = {version = ">=0.4.0", optional = true, markers = "extra == \"d\""} click = ">=7.1.2" mypy-extensions = ">=0.4.3" -pathspec = ">=0.6,<1" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" regex = ">=2020.1.8" -toml = ">=0.10.1" -typed-ast = ">=1.4.0" -typing-extensions = ">=3.7.4" +tomli = ">=0.2.6,<2.0.0" +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.2)"] +uvloop = ["uvloop (>=0.15.2)"] [[package]] -category = "dev" -description = "Fast, simple object-to-object and broadcast signaling" name = "blinker" +version = "1.4" +description = "Fast, simple object-to-object and broadcast signaling" +category = "dev" optional = false python-versions = "*" -version = "1.4" [[package]] -category = "main" -description = "The AWS SDK for Python" name = "boto3" +version = "1.18.36" +description = "The AWS SDK for Python" +category = "main" optional = false python-versions = ">= 3.6" -version = "1.18.6" [package.dependencies] -botocore = ">=1.21.6,<1.22.0" +botocore = ">=1.21.36,<1.22.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + [[package]] -category = "main" -description = "Low-level, data-driven core of boto 3." name = "botocore" +version = "1.21.36" +description = "Low-level, data-driven core of boto 3." +category = "main" optional = false python-versions = ">= 3.6" -version = "1.21.6" [package.dependencies] jmespath = ">=0.7.1,<1.0.0" @@ -220,58 +250,58 @@ python-dateutil = ">=2.1,<3.0.0" urllib3 = ">=1.25.4,<1.27" [package.extras] -crt = ["awscrt (0.11.24)"] +crt = ["awscrt (==0.11.24)"] [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" +version = "2021.5.30" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = "*" -version = "2021.5.30" [[package]] -category = "main" -description = "Foreign Function Interface for Python calling C code." name = "cffi" +version = "1.14.6" +description = "Foreign Function Interface for Python calling C code." +category = "main" optional = false python-versions = "*" -version = "1.14.6" [package.dependencies] pycparser = "*" [[package]] -category = "dev" -description = "Validate configuration and produce human readable error messages." name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" -version = "3.3.0" [[package]] -category = "main" -description = "Brings async, event-driven capabilities to Django. Django 2.2 and up only." name = "channels" +version = "3.0.4" +description = "Brings async, event-driven capabilities to Django. Django 2.2 and up only." +category = "main" optional = false python-versions = ">=3.6" -version = "3.0.4" [package.dependencies] -Django = ">=2.2" asgiref = ">=3.3.1,<4" daphne = ">=3.0,<4" +Django = ">=2.2" [package.extras] tests = ["pytest", "pytest-django", "pytest-asyncio", "async-timeout", "coverage (>=4.5,<5.0)"] [[package]] -category = "main" -description = "Redis-backed ASGI channel layer implementation" name = "channels-redis" +version = "3.3.0" +description = "Redis-backed ASGI channel layer implementation" +category = "main" optional = false python-versions = ">=3.6" -version = "3.3.0" [package.dependencies] aioredis = ">=1.0,<2.0" @@ -281,52 +311,58 @@ msgpack = ">=1.0,<2.0" [package.extras] cryptography = ["cryptography (>=1.3.0)"] -tests = ["cryptography (>=1.3.0)", "pytest", "pytest-asyncio (0.14.0)", "async-generator", "async-timeout"] +tests = ["cryptography (>=1.3.0)", "pytest", "pytest-asyncio (==0.14.0)", "async-generator", "async-timeout"] + +[[package]] +name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] -category = "main" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -marker = "python_version >= \"3\"" name = "charset-normalizer" +version = "2.0.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" optional = false python-versions = ">=3.5.0" -version = "2.0.3" [package.extras] unicode_backport = ["unicodedata2"] [[package]] -category = "main" -description = "Composable command line interface toolkit" name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.2" [[package]] -category = "main" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\" or sys_platform == \"win32\"" name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.4" [[package]] -category = "main" -description = "Symbolic constants in Python" name = "constantly" +version = "15.1.0" +description = "Symbolic constants in Python" +category = "main" optional = false python-versions = "*" -version = "15.1.0" [[package]] -category = "dev" -description = "Python client library for Core API." name = "coreapi" +version = "2.3.3" +description = "Python client library for Core API." +category = "dev" optional = false python-versions = "*" -version = "2.3.3" [package.dependencies] coreschema = "*" @@ -335,88 +371,88 @@ requests = "*" uritemplate = "*" [[package]] -category = "dev" -description = "Core Schema." name = "coreschema" +version = "0.0.4" +description = "Core Schema." +category = "dev" optional = false python-versions = "*" -version = "0.0.4" [package.dependencies] jinja2 = "*" [[package]] -category = "main" -description = "Code coverage measurement for Python" name = "coverage" +version = "5.5" +description = "Code coverage measurement for Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.5" + +[package.dependencies] +toml = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["toml"] [[package]] -category = "main" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." name = "cryptography" +version = "3.4.8" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" optional = false python-versions = ">=3.6" -version = "3.4.7" [package.dependencies] cffi = ">=1.12" [package.extras] -docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0,<3.1.0 || >3.1.0,<3.1.1 || >3.1.1)", "sphinx-rtd-theme"] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"] +test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] [[package]] -category = "main" -description = "Django ASGI (HTTP/WebSocket) server" name = "daphne" +version = "3.0.2" +description = "Django ASGI (HTTP/WebSocket) server" +category = "main" optional = false python-versions = ">=3.6" -version = "3.0.2" [package.dependencies] asgiref = ">=3.2.10,<4" autobahn = ">=0.18" - -[package.dependencies.twisted] -extras = ["tls"] -version = ">=18.7" +twisted = {version = ">=18.7", extras = ["tls"]} [package.extras] -tests = ["hypothesis (4.23)", "pytest (>=3.10,<4.0)", "pytest-asyncio (>=0.8,<1.0)"] +tests = ["hypothesis (==4.23)", "pytest (>=3.10,<4.0)", "pytest-asyncio (>=0.8,<1.0)"] [[package]] -category = "dev" -description = "Decorators for Humans" name = "decorator" +version = "5.0.9" +description = "Decorators for Humans" +category = "dev" optional = false python-versions = ">=3.5" -version = "5.0.9" [[package]] -category = "dev" -description = "Distribution utilities" name = "distlib" +version = "0.3.2" +description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" -version = "0.3.2" [[package]] -category = "main" -description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." name = "django" +version = "3.2.7" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +category = "main" optional = false python-versions = ">=3.6" -version = "3.2.5" [package.dependencies] asgiref = ">=3.3.2,<4" @@ -428,118 +464,118 @@ argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] -category = "main" -description = "Caches your Django ORM queries and automatically invalidates them." name = "django-cachalot" +version = "2.4.3" +description = "Caches your Django ORM queries and automatically invalidates them." +category = "main" optional = false python-versions = "*" -version = "2.4.2" [package.dependencies] Django = ">=2.2,<3.3" [[package]] -category = "main" -description = "The unseen, silent tribute to those we have lost." name = "django-clacks" +version = "0.2.0" +description = "The unseen, silent tribute to those we have lost." +category = "main" optional = false python-versions = ">=3.8,<4.0" -version = "0.2.0" [package.dependencies] Django = ">=3.2.4,<4.0.0" [[package]] -category = "main" -description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." name = "django-cors-headers" +version = "3.2.1" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." +category = "main" optional = false python-versions = ">=3.5" -version = "3.2.1" [package.dependencies] Django = ">=1.11" [[package]] -category = "main" -description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." name = "django-filter" +version = "2.4.0" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +category = "main" optional = false python-versions = ">=3.5" -version = "2.4.0" [package.dependencies] Django = ">=2.2" [[package]] -category = "main" -description = "Django middlewares to monitor your application with Prometheus.io." name = "django-prometheus" +version = "2.1.0" +description = "Django middlewares to monitor your application with Prometheus.io." +category = "main" optional = false python-versions = "*" -version = "2.1.0" [package.dependencies] prometheus-client = ">=0.7" [[package]] -category = "dev" -description = "Middleware that Prints the number of DB queries to the runserver console." name = "django-querycount" +version = "0.7.0" +description = "Middleware that Prints the number of DB queries to the runserver console." +category = "dev" optional = false python-versions = "*" -version = "0.7.0" [[package]] -category = "main" -description = "Full featured redis cache backend for Django." name = "django-redis" +version = "4.11.0" +description = "Full featured redis cache backend for Django." +category = "main" optional = false python-versions = ">=3.5" -version = "4.11.0" [package.dependencies] Django = ">=1.11" redis = ">=2.10.0" [[package]] -category = "main" -description = "Redis Cache Backend for Django" name = "django-redis-cache" +version = "2.1.1" +description = "Redis Cache Backend for Django" +category = "main" optional = false python-versions = "*" -version = "2.1.1" [package.dependencies] redis = "<4.0" six = "*" [[package]] -category = "main" -description = "Silky smooth profiling for the Django Framework" name = "django-silk" +version = "4.1.0" +description = "Silky smooth profiling for the Django Framework" +category = "main" optional = false python-versions = ">=3.5" -version = "4.1.0" [package.dependencies] +autopep8 = "*" Django = ">=2.2" +gprof2dot = ">=2017.09.19" Jinja2 = "*" Pygments = "*" -autopep8 = "*" -gprof2dot = ">=2017.09.19" python-dateutil = "*" pytz = "*" requests = "*" sqlparse = "*" [[package]] -category = "main" -description = "Support for many storage backends in Django" name = "django-storages" +version = "1.9.1" +description = "Support for many storage backends in Django" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.9.1" [package.dependencies] Django = ">=1.11" @@ -553,12 +589,12 @@ libcloud = ["apache-libcloud"] sftp = ["paramiko"] [[package]] -category = "dev" -description = "Mypy stubs for Django" name = "django-stubs" +version = "1.8.0" +description = "Mypy stubs for Django" +category = "dev" optional = false python-versions = ">=3.6" -version = "1.8.0" [package.dependencies] django = "*" @@ -567,46 +603,46 @@ mypy = ">=0.790" typing-extensions = "*" [[package]] -category = "dev" -description = "Monkey-patching and extensions for django-stubs" name = "django-stubs-ext" +version = "0.2.0" +description = "Monkey-patching and extensions for django-stubs" +category = "dev" optional = false python-versions = ">=3.6" -version = "0.2.0" [package.dependencies] django = "*" [[package]] -category = "main" -description = "A translatable password validator for django, based on zxcvbn-python." name = "django-zxcvbn-password-validator" +version = "1.3.2" +description = "A translatable password validator for django, based on zxcvbn-python." +category = "main" optional = false python-versions = "*" -version = "1.3.2" [package.dependencies] Django = ">=2.0" zxcvbn = "*" [[package]] -category = "main" -description = "Web APIs for Django, made easy." name = "djangorestframework" +version = "3.11.1" +description = "Web APIs for Django, made easy." +category = "main" optional = false python-versions = ">=3.5" -version = "3.11.1" [package.dependencies] django = ">=1.11" [[package]] -category = "dev" -description = "PEP-484 stubs for django-rest-framework" name = "djangorestframework-stubs" +version = "1.4.0" +description = "PEP-484 stubs for django-rest-framework" +category = "dev" optional = false python-versions = ">=3.6" -version = "1.4.0" [package.dependencies] coreapi = ">=2.0.0" @@ -616,64 +652,116 @@ requests = ">=2.0.0" typing-extensions = ">=3.7.2" [[package]] -category = "dev" -description = "Pythonic argument parser, that will make you smile" name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "dev" optional = false python-versions = "*" -version = "0.6.2" [[package]] +name = "entrypoints" +version = "0.3" +description = "Discover and load entry points from installed packages." category = "dev" -description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=2.7" + +[[package]] +name = "factory-boy" +version = "3.2.0" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Faker = ">=0.7.0" + +[package.extras] +dev = ["coverage", "django", "flake8", "isort", "pillow", "sqlalchemy", "mongoengine", "wheel (>=0.32.0)", "tox", "zest.releaser"] +doc = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] + +[[package]] name = "faker" +version = "8.12.1" +description = "Faker is a Python package that generates fake data for you." +category = "dev" optional = false python-versions = ">=3.6" -version = "8.10.3" [package.dependencies] python-dateutil = ">=2.4" text-unidecode = "1.3" [[package]] -category = "dev" -description = "A platform independent file lock." name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." +category = "dev" optional = false python-versions = "*" -version = "3.0.12" [[package]] -category = "dev" +name = "flake8" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" -name = "flake9" +category = "dev" optional = false -python-versions = ">=3.4" -version = "3.8.3.post2" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.6.0a1,<2.7.0" -pyflakes = ">=2.2.0,<2.3.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" [[package]] -category = "main" -description = "Generate a dot graph from the output of several profilers." -name = "gprof2dot" +name = "flake8-docstrings" +version = "1.6.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +category = "dev" optional = false python-versions = "*" -version = "2021.2.21" + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" [[package]] -category = "main" -description = "WSGI HTTP Server for UNIX" -name = "gunicorn" +name = "flakehell" +version = "0.9.0" +description = "Flake8 wrapper to make it nice and configurable" +category = "dev" optional = false python-versions = ">=3.5" -version = "20.1.0" [package.dependencies] -setuptools = ">=3.0" +colorama = "*" +entrypoints = "*" +flake8 = ">=3.8.0" +pygments = "*" +toml = "*" +urllib3 = "*" + +[package.extras] +docs = ["alabaster", "pygments-github-lexers", "recommonmark", "sphinx"] +dev = ["dlint", "flake8-2020", "flake8-aaa", "flake8-absolute-import", "flake8-alfred", "flake8-annotations-complexity", "flake8-bandit", "flake8-black", "flake8-broken-line", "flake8-bugbear", "flake8-builtins", "flake8-coding", "flake8-cognitive-complexity", "flake8-commas", "flake8-comprehensions", "flake8-debugger", "flake8-django", "flake8-docstrings", "flake8-eradicate", "flake8-executable", "flake8-expression-complexity", "flake8-fixme", "flake8-functions", "flake8-future-import", "flake8-import-order", "flake8-isort", "flake8-logging-format", "flake8-mock", "flake8-mutable", "flake8-mypy", "flake8-pep3101", "flake8-pie", "flake8-print", "flake8-printf-formatting", "flake8-pyi", "flake8-pytest", "flake8-pytest-style", "flake8-quotes", "flake8-requirements", "flake8-rst-docstrings", "flake8-scrapy", "flake8-spellcheck", "flake8-sql", "flake8-strict", "flake8-string-format", "flake8-tidy-imports", "flake8-todo", "flake8-use-fstring", "flake8-variables-names", "isort", "mccabe", "pandas-vet", "pep8-naming", "pylint", "pytest", "typing-extensions", "wemake-python-styleguide"] + +[[package]] +name = "gprof2dot" +version = "2021.2.21" +description = "Generate a dot graph from the output of several profilers." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" [package.extras] eventlet = ["eventlet (>=0.24.1)"] @@ -682,102 +770,100 @@ setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] [[package]] -category = "main" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" optional = false python-versions = ">=3.6" -version = "0.12.0" [[package]] -category = "main" -description = "Python wrapper for hiredis" name = "hiredis" +version = "2.0.0" +description = "Python wrapper for hiredis" +category = "main" optional = false python-versions = ">=3.6" -version = "2.0.0" [[package]] -category = "main" -description = "A collection of framework independent HTTP protocol utils." -marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" name = "httptools" +version = "0.1.2" +description = "A collection of framework independent HTTP protocol utils." +category = "main" optional = false python-versions = "*" -version = "0.1.2" [package.extras] -test = ["Cython (0.29.22)"] +test = ["Cython (==0.29.22)"] [[package]] -category = "main" -description = "A featureful, immutable, and correct URL for Python." name = "hyperlink" +version = "21.0.0" +description = "A featureful, immutable, and correct URL for Python." +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "21.0.0" [package.dependencies] idna = ">=2.5" [[package]] -category = "dev" -description = "File identification library for Python" name = "identify" +version = "2.2.13" +description = "File identification library for Python" +category = "dev" optional = false python-versions = ">=3.6.1" -version = "2.2.11" [package.extras] license = ["editdistance-s"] [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" +version = "3.2" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" -version = "3.2" [[package]] -category = "main" -description = "A small library that versions your Python projects." name = "incremental" +version = "21.3.0" +description = "A small library that versions your Python projects." +category = "main" optional = false python-versions = "*" -version = "21.3.0" [package.extras] scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] [[package]] -category = "dev" -description = "iniconfig: brain-dead simple config-ini parsing" name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = "*" -version = "1.1.1" [[package]] -category = "dev" -description = "IPython: Productive Interactive Computing" name = "ipython" +version = "7.27.0" +description = "IPython: Productive Interactive Computing" +category = "dev" optional = false python-versions = ">=3.7" -version = "7.25.0" [package.dependencies] -appnope = "*" +appnope = {version = "*", markers = "sys_platform == \"darwin\""} backcall = "*" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" jedi = ">=0.16" matplotlib-inline = "*" -pexpect = ">4.3" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} pickleshare = "*" prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" pygments = "*" -setuptools = ">=18.5" traitlets = ">=4.2" [package.extras] @@ -792,57 +878,49 @@ qtconsole = ["qtconsole"] test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.17)"] [[package]] -category = "dev" -description = "Vestigial utilities from IPython" -name = "ipython-genutils" -optional = false -python-versions = "*" -version = "0.2.0" - -[[package]] -category = "dev" -description = "A Python utility / library to sort Python imports." name = "isort" +version = "5.9.3" +description = "A Python utility / library to sort Python imports." +category = "dev" optional = false python-versions = ">=3.6.1,<4.0" -version = "5.9.2" [package.extras] -colors = ["colorama (>=0.4.3,<0.5.0)"] pipfile_deprecated_finder = ["pipreqs", "requirementslib"] -plugins = ["setuptools"] requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] [[package]] -category = "dev" -description = "Simple immutable types for python." name = "itypes" +version = "1.2.0" +description = "Simple immutable types for python." +category = "dev" optional = false python-versions = "*" -version = "1.2.0" [[package]] -category = "dev" -description = "An autocompletion tool for Python that can be used for text editors." name = "jedi" +version = "0.18.0" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" optional = false python-versions = ">=3.6" -version = "0.18.0" [package.dependencies] parso = ">=0.8.0,<0.9.0" [package.extras] -qa = ["flake8 (3.8.3)", "mypy (0.782)"] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"] [[package]] -category = "main" -description = "A very fast and expressive template engine." name = "jinja2" +version = "3.0.1" +description = "A very fast and expressive template engine." +category = "main" optional = false python-versions = ">=3.6" -version = "3.0.1" [package.dependencies] MarkupSafe = ">=2.0" @@ -851,55 +929,91 @@ MarkupSafe = ">=2.0" i18n = ["Babel (>=2.7)"] [[package]] -category = "main" -description = "JSON Matching Expressions" name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.10.0" [[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." -name = "markupsafe" +name = "libcst" +version = "0.3.20" +description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7 and 3.8 programs." +category = "dev" optional = false python-versions = ">=3.6" + +[package.dependencies] +pyyaml = ">=5.2" +typing-extensions = ">=3.7.4.2" +typing-inspect = ">=0.4.0" + +[package.extras] +dev = ["black (==20.8b1)", "codecov (>=2.1.4)", "coverage (>=4.5.4)", "fixit (==0.1.1)", "flake8 (>=3.7.8)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "isort (==5.5.3)", "jupyter (>=1.0.0)", "nbsphinx (>=0.4.2)", "pyre-check (==0.0.41)", "sphinx-rtd-theme (>=0.4.3)", "prompt-toolkit (>=2.0.9)", "tox (>=3.18.1)"] + +[[package]] +name = "markupsafe" version = "2.0.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.6" [[package]] -category = "dev" -description = "Inline Matplotlib backend for Jupyter" name = "matplotlib-inline" +version = "0.1.2" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" optional = false python-versions = ">=3.5" -version = "0.1.2" [package.dependencies] traitlets = "*" [[package]] -category = "dev" -description = "McCabe checker, plugin for flake8" name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = "*" -version = "0.6.1" [[package]] -category = "main" -description = "MessagePack (de)serializer." +name = "monkeytype" +version = "21.5.0" +description = "Generating type annotations from sampled production types" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +libcst = ">=0.3.7" +mypy-extensions = "*" + +[[package]] name = "msgpack" +version = "1.0.2" +description = "MessagePack (de)serializer." +category = "main" optional = false python-versions = "*" -version = "1.0.2" [[package]] +name = "multidict" +version = "5.1.0" +description = "multidict implementation" category = "dev" -description = "Optional static typing for Python" +optional = false +python-versions = ">=3.6" + +[[package]] name = "mypy" +version = "0.910" +description = "Optional static typing for Python" +category = "dev" optional = false python-versions = ">=3.5" -version = "0.910" [package.dependencies] mypy-extensions = ">=0.4.3,<0.5.0" @@ -911,125 +1025,125 @@ dmypy = ["psutil (>=4.0)"] python2 = ["typed-ast (>=1.4.0,<1.5.0)"] [[package]] -category = "dev" -description = "Experimental type system extensions for programs checked with the mypy typechecker." name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" optional = false python-versions = "*" -version = "0.4.3" [[package]] -category = "main" -description = "New Relic Python Agent" name = "newrelic" +version = "5.24.0.153" +description = "New Relic Python Agent" +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "5.24.0.153" [package.extras] infinite-tracing = ["grpcio (<2)", "protobuf (<4)"] [[package]] -category = "dev" -description = "Node.js virtual environment builder" name = "nodeenv" +version = "1.6.0" +description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = "*" -version = "1.6.0" [[package]] -category = "dev" -description = "Detecting the n+1 queries problem in Python" name = "nplusone" +version = "1.0.0" +description = "Detecting the n+1 queries problem in Python" +category = "dev" optional = false python-versions = "*" -version = "1.0.0" [package.dependencies] blinker = ">=1.3" six = ">=1.9.0" [[package]] -category = "dev" -description = "Core utilities for Python packages" name = "packaging" +version = "21.0" +description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.6" -version = "21.0" [package.dependencies] pyparsing = ">=2.0.2" [[package]] -category = "dev" -description = "A Python Parser" name = "parso" +version = "0.8.2" +description = "A Python Parser" +category = "dev" optional = false python-versions = ">=3.6" -version = "0.8.2" [package.extras] -qa = ["flake8 (3.8.3)", "mypy (0.782)"] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] testing = ["docopt", "pytest (<6.0.0)"] [[package]] -category = "dev" -description = "Utility library for gitignore style pattern matching of file paths." name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "0.9.0" [[package]] -category = "dev" -description = "Pexpect allows easy control of interactive console applications." -marker = "sys_platform != \"win32\"" name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" optional = false python-versions = "*" -version = "4.8.0" [package.dependencies] ptyprocess = ">=0.5" [[package]] -category = "dev" -description = "Tiny 'shelve'-like database with concurrency support" name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" optional = false python-versions = "*" -version = "0.7.5" [[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." name = "platformdirs" +version = "2.3.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.6" -version = "2.1.0" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] -category = "dev" -description = "plugin and hook calling mechanisms for python" name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" +python-versions = ">=3.6" [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] -category = "dev" -description = "A framework for managing and maintaining multi-language pre-commit hooks." name = "pre-commit" +version = "2.15.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.6.1" -version = "2.13.0" [package.dependencies] cfgv = ">=2.0.0" @@ -1040,123 +1154,171 @@ toml = "*" virtualenv = ">=20.0.8" [[package]] -category = "main" -description = "Python client for the Prometheus monitoring system." name = "prometheus-client" +version = "0.11.0" +description = "Python client for the Prometheus monitoring system." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.11.0" [package.extras] twisted = ["twisted"] [[package]] -category = "dev" -description = "Library for building powerful interactive command lines in Python" name = "prompt-toolkit" +version = "3.0.20" +description = "Library for building powerful interactive command lines in Python" +category = "dev" optional = false -python-versions = ">=3.6.1" -version = "3.0.19" +python-versions = ">=3.6.2" [package.dependencies] wcwidth = "*" [[package]] -category = "main" -description = "psycopg2 - Python-PostgreSQL Database Adapter" name = "psycopg2-binary" +version = "2.9.1" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" optional = false python-versions = ">=3.6" -version = "2.9.1" [[package]] -category = "dev" -description = "Run a subprocess in a pseudo terminal" -marker = "sys_platform != \"win32\"" name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" optional = false python-versions = "*" -version = "0.7.0" [[package]] -category = "dev" -description = "library with cross-python path, ini-parsing, io, code, log facilities" name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.10.0" [[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" category = "main" -description = "Python style guide checker" +optional = false +python-versions = "*" + +[[package]] +name = "pyasn1-modules" +version = "0.2.8" +description = "A collection of ASN.1-based protocols modules." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.5.0" + +[[package]] name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.6.0" [[package]] -category = "main" -description = "C parser in Python" name = "pycparser" +version = "2.20" +description = "C parser in Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.20" [[package]] +name = "pydocstyle" +version = "6.1.1" +description = "Python docstring style checker" category = "dev" -description = "passive checker of Python programs" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +snowballstemmer = "*" + +[package.extras] +toml = ["toml"] + +[[package]] name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.2.0" [[package]] -category = "main" -description = "Pygments is a syntax highlighting package written in Python." name = "pygments" +version = "2.10.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" optional = false python-versions = ">=3.5" -version = "2.9.0" [[package]] -category = "main" -description = "Hamcrest framework for matcher objects" name = "pyhamcrest" +version = "2.0.2" +description = "Hamcrest framework for matcher objects" +category = "main" optional = false python-versions = ">=3.5" -version = "2.0.2" [[package]] +name = "pyopenssl" +version = "20.0.1" +description = "Python wrapper module around the OpenSSL library" category = "main" -description = "Python One Time Password Library" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.dependencies] +cryptography = ">=3.2" +six = ">=1.5.2" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + +[[package]] name = "pyotp" +version = "2.3.0" +description = "Python One Time Password Library" +category = "main" optional = false python-versions = "*" -version = "2.3.0" [[package]] -category = "dev" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] -category = "dev" -description = "pytest: simple powerful testing with Python" name = "pytest" +version = "6.2.5" +description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.6" -version = "6.2.4" [package.dependencies] -atomicwrites = ">=1.0" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0.0a1" +pluggy = ">=0.12,<2.0" py = ">=1.8.2" toml = "*" @@ -1164,12 +1326,12 @@ toml = "*" testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "dev" -description = "Pytest plugin for measuring coverage." name = "pytest-cov" +version = "2.12.1" +description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.12.1" [package.dependencies] coverage = ">=5.2.1" @@ -1180,12 +1342,12 @@ toml = "*" testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] [[package]] -category = "dev" -description = "A Django plugin for pytest." name = "pytest-django" +version = "4.4.0" +description = "A Django plugin for pytest." +category = "dev" optional = false python-versions = ">=3.5" -version = "4.4.0" [package.dependencies] pytest = ">=5.4.0" @@ -1195,139 +1357,133 @@ docs = ["sphinx", "sphinx-rtd-theme"] testing = ["django", "django-configurations (>=2.0)"] [[package]] -category = "dev" -description = "selects tests affected by changed files and methods" name = "pytest-testmon" +version = "1.1.2" +description = "selects tests affected by changed files and methods" +category = "dev" optional = false python-versions = ">=3.6" -version = "1.1.1" [package.dependencies] -coverage = ">=4,<6" +coverage = ">=4,<7" pytest = ">=5,<7" [[package]] -category = "main" -description = "Extensions to the standard Python datetime module" name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -version = "2.8.2" [package.dependencies] six = ">=1.5" [[package]] -category = "main" -description = "Read key-value pairs from a .env file and set them as environment variables" name = "python-dotenv" +version = "0.19.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" optional = false python-versions = ">=3.5" -version = "0.19.0" [package.extras] cli = ["click (>=5.0)"] [[package]] -category = "main" -description = "HTTP REST client, simplified for Python" name = "python-http-client" +version = "3.3.2" +description = "HTTP REST client, simplified for Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.3.2" [[package]] -category = "main" -description = "World timezone definitions, modern and historical" name = "pytz" +version = "2021.1" +description = "World timezone definitions, modern and historical" +category = "main" optional = false python-versions = "*" -version = "2021.1" [[package]] -category = "main" -description = "YAML parser and emitter for Python" name = "pyyaml" +version = "5.4.1" +description = "YAML parser and emitter for Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -version = "5.4.1" [[package]] -category = "main" -description = "Python client for Redis key-value store" name = "redis" +version = "3.5.3" +description = "Python client for Redis key-value store" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "3.5.3" [package.extras] hiredis = ["hiredis (>=0.1.3)"] [[package]] -category = "dev" -description = "Alternative regular expression module, to replace re." name = "regex" +version = "2021.8.28" +description = "Alternative regular expression module, to replace re." +category = "dev" optional = false python-versions = "*" -version = "2021.7.6" [[package]] -category = "main" -description = "Python HTTP for Humans." name = "requests" +version = "2.26.0" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -version = "2.26.0" [package.dependencies] certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" -[package.dependencies.charset-normalizer] -python = ">=3" -version = ">=2.0.0,<2.1.0" - -[package.dependencies.idna] -python = ">=3" -version = ">=2.5,<4" - [package.extras] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] -category = "main" -description = "An Amazon S3 Transfer Manager" name = "s3transfer" +version = "0.5.0" +description = "An Amazon S3 Transfer Manager" +category = "main" optional = false python-versions = ">= 3.6" -version = "0.5.0" [package.dependencies] botocore = ">=1.12.36,<2.0a.0" [package.extras] -crt = ["botocore (>=1.20.29,<2.0a.0)"] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] [[package]] -category = "main" -description = "Twilio SendGrid library for Python" name = "sendgrid" +version = "6.8.1" +description = "Twilio SendGrid library for Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "6.7.1" [package.dependencies] python-http-client = ">=3.2.1" starkbank-ecdsa = ">=1.0.0" [[package]] -category = "main" -description = "Python client for Sentry (https://sentry.io)" name = "sentry-sdk" +version = "1.3.1" +description = "Python client for Sentry (https://sentry.io)" +category = "main" optional = false python-versions = "*" -version = "1.3.0" [package.dependencies] certifi = "*" @@ -1351,213 +1507,232 @@ sqlalchemy = ["sqlalchemy (>=1.2)"] tornado = ["tornado (>=5)"] [[package]] -category = "main" -description = "ridiculously fast object serialization" name = "serpy" +version = "0.3.1" +description = "ridiculously fast object serialization" +category = "main" optional = false python-versions = "*" -version = "0.3.1" [package.dependencies] six = "*" [[package]] +name = "service-identity" +version = "21.1.0" +description = "Service identity verification for pyOpenSSL & cryptography." category = "main" -description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = ">=19.1.0" +cryptography = "*" +pyasn1 = "*" +pyasn1-modules = "*" +six = "*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "pytest", "sphinx", "furo", "idna", "pyopenssl"] +docs = ["sphinx", "furo"] +idna = ["idna"] +tests = ["coverage[toml] (>=5.0.2)", "pytest"] + +[[package]] name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.16.0" [[package]] -category = "main" -description = "A non-validating SQL parser." +name = "snowballstemmer" +version = "2.1.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] name = "sqlparse" +version = "0.4.1" +description = "A non-validating SQL parser." +category = "main" optional = false python-versions = ">=3.5" -version = "0.4.1" [[package]] -category = "main" -description = "A lightweight and fast pure python ECDSA library" name = "starkbank-ecdsa" +version = "1.1.1" +description = "A lightweight and fast pure python ECDSA library" +category = "main" optional = false python-versions = "*" -version = "1.1.1" [[package]] -category = "dev" -description = "The most basic Text::Unidecode port" name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +category = "dev" optional = false python-versions = "*" -version = "1.3" [[package]] -category = "main" -description = "Python Library for Tom's Obvious, Minimal Language" name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.10.2" [[package]] +name = "tomli" +version = "1.2.1" +description = "A lil' TOML parser" category = "dev" -description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.6" + +[[package]] name = "traitlets" +version = "5.1.0" +description = "Traitlets Python configuration system" +category = "dev" optional = false python-versions = ">=3.7" -version = "5.0.5" - -[package.dependencies] -ipython-genutils = "*" [package.extras] test = ["pytest"] [[package]] -category = "main" -description = "An asynchronous networking framework written in Python" name = "twisted" +version = "20.3.0" +description = "An asynchronous networking framework written in Python" +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "20.3.0" [package.dependencies] -Automat = ">=0.3.0" -PyHamcrest = ">=1.9.0,<1.10.0 || >1.10.0" attrs = ">=19.2.0" +Automat = ">=0.3.0" constantly = ">=15.1" hyperlink = ">=17.1.1" +idna = {version = ">=0.6,<2.3 || >2.3", optional = true, markers = "extra == \"tls\""} incremental = ">=16.10.1" +PyHamcrest = ">=1.9.0,<1.10.0 || >1.10.0" +pyopenssl = {version = ">=16.0.0", optional = true, markers = "extra == \"tls\""} +service_identity = {version = ">=18.1.0", optional = true, markers = "extra == \"tls\""} "zope.interface" = ">=4.4.2" [package.extras] -all_non_platform = ["pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,<2.3 || >2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] +all_non_platform = ["pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,!=2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] conch = ["pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)"] dev = ["pyflakes (>=1.0.0)", "twisted-dev-tools (>=0.0.2)", "python-subunit", "sphinx (>=1.3.1)", "towncrier (>=17.4.0)"] http2 = ["h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)"] -macos_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,<2.3 || >2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] -osx_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,<2.3 || >2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] +macos_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,!=2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] +osx_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,!=2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] serial = ["pyserial (>=3.0)", "pywin32 (!=226)"] soap = ["soappy"] -tls = ["pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,<2.3 || >2.3)"] -windows_platform = ["pywin32 (!=226)", "pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,<2.3 || >2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] +tls = ["pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,!=2.3)"] +windows_platform = ["pywin32 (!=226)", "pyopenssl (>=16.0.0)", "service_identity (>=18.1.0)", "idna (>=0.6,!=2.3)", "pyasn1", "cryptography (>=2.5)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "soappy", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)"] [[package]] -category = "main" -description = "Compatibility API between asyncio/Twisted/Trollius" name = "txaio" +version = "21.2.1" +description = "Compatibility API between asyncio/Twisted/Trollius" +category = "main" optional = false python-versions = ">=3.6" -version = "21.2.1" [package.extras] all = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)"] -dev = ["wheel", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "pep8 (>=1.6.2)", "sphinx (>=1.2.3)", "pyenchant (>=1.6.6)", "sphinxcontrib-spelling (>=2.1.2)", "sphinx-rtd-theme (>=0.1.9)", "tox (>=2.1.1)", "mock (1.3.0)", "twine (>=1.6.5)", "tox-gh-actions (>=2.2.0)"] +dev = ["wheel", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "pep8 (>=1.6.2)", "sphinx (>=1.2.3)", "pyenchant (>=1.6.6)", "sphinxcontrib-spelling (>=2.1.2)", "sphinx-rtd-theme (>=0.1.9)", "tox (>=2.1.1)", "mock (==1.3.0)", "twine (>=1.6.5)", "tox-gh-actions (>=2.2.0)"] twisted = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)"] [[package]] +name = "typing-extensions" +version = "3.10.0.2" +description = "Backported and Experimental Type Hints for Python 3.5+" category = "dev" -description = "a fork of Python 2 and 3 ast modules with type comment support" -name = "typed-ast" optional = false python-versions = "*" -version = "1.4.3" [[package]] +name = "typing-inspect" +version = "0.7.1" +description = "Runtime inspection utilities for typing module." category = "dev" -description = "Backported and Experimental Type Hints for Python 3.5+" -name = "typing-extensions" optional = false python-versions = "*" -version = "3.10.0.0" + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" [[package]] -category = "dev" -description = "URI templates" name = "uritemplate" +version = "3.0.1" +description = "URI templates" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.0.1" [[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" +version = "1.26.6" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.26.6" [package.extras] brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] -category = "main" -description = "The lightning-fast ASGI server." name = "uvicorn" +version = "0.13.4" +description = "The lightning-fast ASGI server." +category = "main" optional = false python-versions = "*" -version = "0.13.4" [package.dependencies] click = ">=7.0.0,<8.0.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} h11 = ">=0.8" - -[package.dependencies.PyYAML] -optional = true -version = ">=5.1" - -[package.dependencies.colorama] -optional = true -version = ">=0.4" - -[package.dependencies.httptools] -optional = true -version = ">=0.1.0,<0.2.0" - -[package.dependencies.python-dotenv] -optional = true -version = ">=0.13" - -[package.dependencies.uvloop] -optional = true -version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1" - -[package.dependencies.watchgod] -optional = true -version = ">=0.6" - -[package.dependencies.websockets] -optional = true -version = ">=8.0.0,<9.0.0" +httptools = {version = ">=0.1.0,<0.2.0", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchgod = {version = ">=0.6", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=8.0.0,<9.0.0", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["websockets (>=8.0.0,<9.0.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "httptools (>=0.1.0,<0.2.0)", "uvloop (>=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1)", "colorama (>=0.4)"] +standard = ["websockets (>=8.0.0,<9.0.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "httptools (>=0.1.0,<0.2.0)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] [[package]] -category = "main" -description = "Fast implementation of asyncio event loop on top of libuv" -marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" name = "uvloop" +version = "0.16.0" +description = "Fast implementation of asyncio event loop on top of libuv" +category = "main" optional = false python-versions = ">=3.7" -version = "0.15.3" [package.extras] -dev = ["Cython (>=0.29.20,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)", "aiohttp", "flake8 (>=3.8.4,<3.9.0)", "psutil", "pycodestyle (>=2.6.0,<2.7.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] -docs = ["Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)"] -test = ["aiohttp", "flake8 (>=3.8.4,<3.9.0)", "psutil", "pycodestyle (>=2.6.0,<2.7.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] +dev = ["Cython (>=0.29.24,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["aiohttp", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=19.0.0,<19.1.0)", "mypy (>=0.800)"] [[package]] -category = "dev" -description = "Virtual Python Environment builder" name = "virtualenv" +version = "20.7.2" +description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "20.6.0" [package.dependencies] "backports.entry-points-selectable" = ">=1.0.4" @@ -1568,42 +1743,51 @@ six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] -category = "main" -description = "Simple, modern file watching and code reload in python." name = "watchgod" +version = "0.7" +description = "Simple, modern file watching and code reload in python." +category = "main" optional = false python-versions = ">=3.5" -version = "0.7" [[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" optional = false python-versions = "*" -version = "0.2.5" [[package]] -category = "main" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" name = "websockets" +version = "8.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +category = "main" optional = false python-versions = ">=3.6.1" -version = "8.1" [[package]] -category = "main" -description = "Interfaces for Python" -name = "zope.interface" +name = "yarl" +version = "1.6.3" +description = "Yet another URL library" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "5.4.0" +python-versions = ">=3.6" [package.dependencies] -setuptools = "*" +idna = ">=2.0" +multidict = ">=4.0" + +[[package]] +name = "zope.interface" +version = "5.4.0" +description = "Interfaces for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] docs = ["sphinx", "repoze.sphinx.autointerface"] @@ -1611,27 +1795,66 @@ test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [[package]] -category = "main" -description = "" name = "zxcvbn" +version = "4.4.28" +description = "" +category = "main" optional = false python-versions = "*" -version = "4.4.28" [metadata] -content-hash = "21280d862f7eb669a042b945778159435f8e8849cef375b0faec116876d17b76" -lock-version = "1.0" +lock-version = "1.1" python-versions = "^3.9" +content-hash = "1ce1944198f0e0520b003918a1f8d8f810adc3ff2dc6c993f8476fbfea071bab" [metadata.files] +aiohttp = [ + {file = "aiohttp-3.7.4.post0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-win32.whl", hash = "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287"}, + {file = "aiohttp-3.7.4.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-win32.whl", hash = "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f"}, + {file = "aiohttp-3.7.4.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-win32.whl", hash = "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16"}, + {file = "aiohttp-3.7.4.post0-cp38-cp38-win_amd64.whl", hash = "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-win32.whl", hash = "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9"}, + {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, + {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, +] +aiohttp-cors = [ + {file = "aiohttp-cors-0.7.0.tar.gz", hash = "sha256:4d39c6d7100fd9764ed1caf8cebf0eb01bf5e3f24e2e073fda6234bc48b19f5d"}, + {file = "aiohttp_cors-0.7.0-py3-none-any.whl", hash = "sha256:0451ba59fdf6909d0e2cd21e4c0a43752bc0703d33fc78ae94d9d9321710193e"}, +] aioredis = [ {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, {file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"}, ] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] appnope = [ {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"}, {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, @@ -1664,8 +1887,8 @@ automat = [ {file = "Automat-20.2.0.tar.gz", hash = "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33"}, ] autopep8 = [ - {file = "autopep8-1.5.5-py2.py3-none-any.whl", hash = "sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea"}, - {file = "autopep8-1.5.5.tar.gz", hash = "sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443"}, + {file = "autopep8-1.5.7-py2.py3-none-any.whl", hash = "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9"}, + {file = "autopep8-1.5.7.tar.gz", hash = "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, @@ -1681,18 +1904,19 @@ better-exceptions = [ {file = "better_exceptions-0.3.3.tar.gz", hash = "sha256:e4e6bc18444d5f04e6e894b10381e5e921d3d544240418162c7db57e9eb3453b"}, ] black = [ - {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, + {file = "black-21.8b0-py3-none-any.whl", hash = "sha256:2a0f9a8c2b2a60dbcf1ccb058842fb22bdbbcb2f32c6cc02d9578f90b92ce8b7"}, + {file = "black-21.8b0.tar.gz", hash = "sha256:570608d28aa3af1792b98c4a337dbac6367877b47b12b88ab42095cfc1a627c2"}, ] blinker = [ {file = "blinker-1.4.tar.gz", hash = "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"}, ] boto3 = [ - {file = "boto3-1.18.6-py3-none-any.whl", hash = "sha256:68fbc8b7c13448f53164692163cc056fa242f8d7c39abbb77efc67b174b8f2a9"}, - {file = "boto3-1.18.6.tar.gz", hash = "sha256:a0f5a806d072bd532c86ef10a2a5f7f1ca7e8e0e506561a21ab5d462a93aa810"}, + {file = "boto3-1.18.36-py3-none-any.whl", hash = "sha256:a7fccb61d95230322dd812629455df14167307c569077fa89d297eae73605ffb"}, + {file = "boto3-1.18.36.tar.gz", hash = "sha256:4df1085f5c24504a1b1a6584947f27b67c26eda123f29d3cecce9b2fd683e09b"}, ] botocore = [ - {file = "botocore-1.21.6-py3-none-any.whl", hash = "sha256:c92dc2b69aec36b7482e5b05ac0a00d65ac972d745a74546942f09c95e68d335"}, - {file = "botocore-1.21.6.tar.gz", hash = "sha256:cabff036f702411f47c6dae09134315e0b524c8eda6bb1de99ee75fc1ee07f7f"}, + {file = "botocore-1.21.36-py3-none-any.whl", hash = "sha256:e3e522fbe0bad1197aa7182451dc05f650310e77cf0a77749f6a5e82794c53de"}, + {file = "botocore-1.21.36.tar.gz", hash = "sha256:5b9a7d30e44b8a0a2bbbde62ae01bf6c349017e836985a0248552b00bbce7fae"}, ] certifi = [ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, @@ -1746,8 +1970,8 @@ cffi = [ {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, ] cfgv = [ - {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, - {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] channels = [ {file = "channels-3.0.4-py3-none-any.whl", hash = "sha256:0ff0422b4224d10efac76e451575517f155fe7c97d369b5973b116f22eeaf86c"}, @@ -1757,9 +1981,13 @@ channels-redis = [ {file = "channels_redis-3.3.0-py3-none-any.whl", hash = "sha256:1abd5820ff1ed4ac627f8a219ad389e4c87e52e47a230929a7a474e95dd2c6c2"}, {file = "channels_redis-3.3.0.tar.gz", hash = "sha256:0a18ce279c15ba79b7985bb12b2d6dd0ac8a14e4ad6952681f4422a4cc4a5ea9"}, ] +chardet = [ + {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, + {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, +] charset-normalizer = [ - {file = "charset-normalizer-2.0.3.tar.gz", hash = "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12"}, - {file = "charset_normalizer-2.0.3-py3-none-any.whl", hash = "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1"}, + {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, + {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, ] click = [ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, @@ -1836,18 +2064,23 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] cryptography = [ - {file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"}, - {file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"}, - {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"}, - {file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"}, - {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, - {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, - {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, - {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, - {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, + {file = "cryptography-3.4.8-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:a00cf305f07b26c351d8d4e1af84ad7501eca8a342dedf24a7acb0e7b7406e14"}, + {file = "cryptography-3.4.8-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:f44d141b8c4ea5eb4dbc9b3ad992d45580c1d22bf5e24363f2fbf50c2d7ae8a7"}, + {file = "cryptography-3.4.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0a7dcbcd3f1913f664aca35d47c1331fce738d44ec34b7be8b9d332151b0b01e"}, + {file = "cryptography-3.4.8-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34dae04a0dce5730d8eb7894eab617d8a70d0c97da76b905de9efb7128ad7085"}, + {file = "cryptography-3.4.8-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1eb7bb0df6f6f583dd8e054689def236255161ebbcf62b226454ab9ec663746b"}, + {file = "cryptography-3.4.8-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:9965c46c674ba8cc572bc09a03f4c649292ee73e1b683adb1ce81e82e9a6a0fb"}, + {file = "cryptography-3.4.8-cp36-abi3-win32.whl", hash = "sha256:21ca464b3a4b8d8e86ba0ee5045e103a1fcfac3b39319727bc0fc58c09c6aff7"}, + {file = "cryptography-3.4.8-cp36-abi3-win_amd64.whl", hash = "sha256:3520667fda779eb788ea00080124875be18f2d8f0848ec00733c0ec3bb8219fc"}, + {file = "cryptography-3.4.8-pp36-pypy36_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d2a6e5ef66503da51d2110edf6c403dc6b494cc0082f85db12f54e9c5d4c3ec5"}, + {file = "cryptography-3.4.8-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a305600e7a6b7b855cd798e00278161b681ad6e9b7eca94c721d5f588ab212af"}, + {file = "cryptography-3.4.8-pp36-pypy36_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:3fa3a7ccf96e826affdf1a0a9432be74dc73423125c8f96a909e3835a5ef194a"}, + {file = "cryptography-3.4.8-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:d9ec0e67a14f9d1d48dd87a2531009a9b251c02ea42851c060b25c782516ff06"}, + {file = "cryptography-3.4.8-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5b0fbfae7ff7febdb74b574055c7466da334a5371f253732d7e2e7525d570498"}, + {file = "cryptography-3.4.8-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94fff993ee9bc1b2440d3b7243d488c6a3d9724cc2b09cdb297f6a886d040ef7"}, + {file = "cryptography-3.4.8-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:8695456444f277af73a4877db9fc979849cd3ee74c198d04fc0776ebc3db52b9"}, + {file = "cryptography-3.4.8-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e"}, + {file = "cryptography-3.4.8.tar.gz", hash = "sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c"}, ] daphne = [ {file = "daphne-3.0.2-py3-none-any.whl", hash = "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393"}, @@ -1862,12 +2095,12 @@ distlib = [ {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] django = [ - {file = "Django-3.2.5-py3-none-any.whl", hash = "sha256:c58b5f19c5ae0afe6d75cbdd7df561e6eb929339985dbbda2565e1cabb19a62e"}, - {file = "Django-3.2.5.tar.gz", hash = "sha256:3da05fea54fdec2315b54a563d5b59f3b4e2b1e69c3a5841dda35019c01855cd"}, + {file = "Django-3.2.7-py3-none-any.whl", hash = "sha256:e93c93565005b37ddebf2396b4dc4b6913c1838baa82efdfb79acedd5816c240"}, + {file = "Django-3.2.7.tar.gz", hash = "sha256:95b318319d6997bac3595517101ad9cc83fe5672ac498ba48d1a410f47afecd2"}, ] django-cachalot = [ - {file = "django-cachalot-2.4.2.tar.gz", hash = "sha256:67d3a783a8f61191cf8a1c1db944b08e276e93735434aafdee8d721bfd9e4901"}, - {file = "django_cachalot-2.4.2-py3-none-any.whl", hash = "sha256:1d5c47e56425afc0b7131696d7894ed5c9d85cb6994282a02fe3d8bc274e1bd3"}, + {file = "django-cachalot-2.4.3.tar.gz", hash = "sha256:2c81390f53d8c2e0ae6f266cff170b5681dad2416e09266ca7ca25f50e892a53"}, + {file = "django_cachalot-2.4.3-py3-none-any.whl", hash = "sha256:d56ffa280da8317019959801703659616adcf60e1bf6513bd7e154b0b0851014"}, ] django-clacks = [ {file = "django-clacks-0.2.0.tar.gz", hash = "sha256:aa0ae44ef34b2db663c2fa214fb3a5d5d843d403df35966c78b5b7b725f4e670"}, @@ -1926,17 +2159,33 @@ djangorestframework-stubs = [ docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +] +factory-boy = [ + {file = "factory_boy-3.2.0-py2.py3-none-any.whl", hash = "sha256:1d3db4b44b8c8c54cdd8b83ae4bdb9aeb121e464400035f1f03ae0e1eade56a4"}, + {file = "factory_boy-3.2.0.tar.gz", hash = "sha256:401cc00ff339a022f84d64a4339503d1689e8263a4478d876e58a3295b155c5b"}, +] faker = [ - {file = "Faker-8.10.3-py3-none-any.whl", hash = "sha256:f27a2a5c34042752f9d5fea2a9667aed5265d7d7bdd5ce83bc03b2f8a540d148"}, - {file = "Faker-8.10.3.tar.gz", hash = "sha256:771b21ab55924867ac865f4b0c2f547c200172293b1056be16289584ef1215cb"}, + {file = "Faker-8.12.1-py3-none-any.whl", hash = "sha256:6714c153433086681b26e5c95ee314ee0fcd45ec05f2426097543dd4c70789a6"}, + {file = "Faker-8.12.1.tar.gz", hash = "sha256:810859626d19e62a2a13aa4a08d59ada131f0522431eec163b09b6df147a25b9"}, ] filelock = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] -flake9 = [ - {file = "flake9-3.8.3.post2-py3-none-any.whl", hash = "sha256:47dced969a802a8892740bcaa35ae07232709b2ade803c45f48dd03ccb7f825f"}, - {file = "flake9-3.8.3.post2.tar.gz", hash = "sha256:daefdbfb3d320eb215a4a52c62a4b4a027cbe11d39f5dab30df908b40fce5ba7"}, +flake8 = [ + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] +flake8-docstrings = [ + {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, + {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, +] +flakehell = [ + {file = "flakehell-0.9.0-py3-none-any.whl", hash = "sha256:48a3a9b46136240e52b3b32a78a0826c45f6dcf7d980c30f758c1db5b1439c0b"}, + {file = "flakehell-0.9.0.tar.gz", hash = "sha256:208836d8d24194d50cfa4c1fc99f681f3c537cc232edcd06455abc2971460893"}, ] gprof2dot = [ {file = "gprof2dot-2021.2.21.tar.gz", hash = "sha256:1223189383b53dcc8ecfd45787ac48c0ed7b4dbc16ee8b88695d053eea1acabf"}, @@ -2014,8 +2263,8 @@ hyperlink = [ {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, ] identify = [ - {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, - {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, + {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"}, + {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, @@ -2030,16 +2279,12 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] ipython = [ - {file = "ipython-7.25.0-py3-none-any.whl", hash = "sha256:aa21412f2b04ad1a652e30564fff6b4de04726ce875eab222c8430edc6db383a"}, - {file = "ipython-7.25.0.tar.gz", hash = "sha256:54bbd1fe3882457aaf28ae060a5ccdef97f212a741754e420028d4ec5c2291dc"}, -] -ipython-genutils = [ - {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, - {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, + {file = "ipython-7.27.0-py3-none-any.whl", hash = "sha256:75b5e060a3417cf64f138e0bb78e58512742c57dc29db5a5058a2b1f0c10df02"}, + {file = "ipython-7.27.0.tar.gz", hash = "sha256:58b55ebfdfa260dad10d509702dc2857cb25ad82609506b070cf2d7b7df5af13"}, ] isort = [ - {file = "isort-5.9.2-py3-none-any.whl", hash = "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813"}, - {file = "isort-5.9.2.tar.gz", hash = "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e"}, + {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, + {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, ] itypes = [ {file = "itypes-1.2.0-py2.py3-none-any.whl", hash = "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6"}, @@ -2057,13 +2302,27 @@ jmespath = [ {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, ] +libcst = [ + {file = "libcst-0.3.20-py3-none-any.whl", hash = "sha256:d213e833fdbad43c4fcaf9c952a695b36d601dce1c527ec724e75aa36e60834f"}, + {file = "libcst-0.3.20.tar.gz", hash = "sha256:9d50d4eab28b570e254cc63287ce3009b945be4114c7a29662b67204cfc18060"}, +] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -2072,14 +2331,21 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -2089,6 +2355,9 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -2101,6 +2370,10 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +monkeytype = [ + {file = "MonkeyType-21.5.0-py3-none-any.whl", hash = "sha256:0e676bf6019ec6880217436220c86f706eaebcfe4f2bbda8042d5817746379ce"}, + {file = "MonkeyType-21.5.0.tar.gz", hash = "sha256:e47089f032e65f9bc7b4146725285837b75136f28e3025b434d494f6d1b3cb87"}, +] msgpack = [ {file = "msgpack-1.0.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9"}, {file = "msgpack-1.0.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a99b144475230982aee16b3d249170f1cccebf27fb0a08e9f603b69637a62192"}, @@ -2131,6 +2404,45 @@ msgpack = [ {file = "msgpack-1.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:d8167b84af26654c1124857d71650404336f4eb5cc06900667a493fc619ddd9f"}, {file = "msgpack-1.0.2.tar.gz", hash = "sha256:fae04496f5bc150eefad4e9571d1a76c55d021325dcd484ce45065ebbdd00984"}, ] +multidict = [ + {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, + {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, + {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, + {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, + {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, + {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, + {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, + {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, + {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, + {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, + {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, + {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, + {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, + {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, + {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, + {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, + {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, + {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, + {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, + {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, + {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, + {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, + {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, + {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, + {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, + {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, + {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, + {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, + {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, + {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, + {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, + {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, + {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, + {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, + {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, + {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, + {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, +] mypy = [ {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, @@ -2204,24 +2516,24 @@ pickleshare = [ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] platformdirs = [ - {file = "platformdirs-2.1.0-py3-none-any.whl", hash = "sha256:b2b30ae52404f93e2024e85bba29329b85715d6b2f18ffe90ecd25a5c67553df"}, - {file = "platformdirs-2.1.0.tar.gz", hash = "sha256:1964be5aba107a7ccb7de0e6f1f1bfde0dee51641f0e733028121f8e02e2e16b"}, + {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, + {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, ] prometheus-client = [ {file = "prometheus_client-0.11.0-py2.py3-none-any.whl", hash = "sha256:b014bc76815eb1399da8ce5fc84b7717a3e63652b0c0f8804092c9363acab1b2"}, {file = "prometheus_client-0.11.0.tar.gz", hash = "sha256:3a8baade6cb80bcfe43297e33e7623f3118d660d41387593758e2fb1ea173a86"}, ] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.19-py3-none-any.whl", hash = "sha256:7089d8d2938043508aa9420ec18ce0922885304cddae87fb96eebca942299f88"}, - {file = "prompt_toolkit-3.0.19.tar.gz", hash = "sha256:08360ee3a3148bdb5163621709ee322ec34fc4375099afa4bbf751e9b7b7fa4f"}, + {file = "prompt_toolkit-3.0.20-py3-none-any.whl", hash = "sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c"}, + {file = "prompt_toolkit-3.0.20.tar.gz", hash = "sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c"}, ] psycopg2-binary = [ {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"}, @@ -2262,26 +2574,64 @@ py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] +pyasn1 = [ + {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, + {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, + {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, + {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, + {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, + {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, + {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, + {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, + {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, + {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] +pyasn1-modules = [ + {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, + {file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"}, + {file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"}, + {file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"}, + {file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"}, + {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, + {file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"}, + {file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"}, + {file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"}, + {file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"}, + {file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"}, + {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, + {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, +] pycodestyle = [ - {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, - {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pycparser = [ {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, ] +pydocstyle = [ + {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, + {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, +] pyflakes = [ - {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, - {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, - {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pyhamcrest = [ {file = "PyHamcrest-2.0.2-py3-none-any.whl", hash = "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"}, {file = "PyHamcrest-2.0.2.tar.gz", hash = "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316"}, ] +pyopenssl = [ + {file = "pyOpenSSL-20.0.1-py2.py3-none-any.whl", hash = "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b"}, + {file = "pyOpenSSL-20.0.1.tar.gz", hash = "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51"}, +] pyotp = [ {file = "pyotp-2.3.0-py2.py3-none-any.whl", hash = "sha256:c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0"}, {file = "pyotp-2.3.0.tar.gz", hash = "sha256:fc537e8acd985c5cbf51e11b7d53c42276fee017a73aec7c07380695671ca1a1"}, @@ -2291,8 +2641,8 @@ pyparsing = [ {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pytest = [ - {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, - {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -2303,7 +2653,7 @@ pytest-django = [ {file = "pytest_django-4.4.0-py3-none-any.whl", hash = "sha256:65783e78382456528bd9d79a35843adde9e6a47347b20464eb2c885cb0f1f606"}, ] pytest-testmon = [ - {file = "pytest-testmon-1.1.1.tar.gz", hash = "sha256:c8810f991545e352f646fb382e5962ff54b8aa52b09d62d35ae04f0d7a9c58d9"}, + {file = "pytest-testmon-1.1.2.tar.gz", hash = "sha256:91f4513f7e5a1cf4f1eda25ab7f310497abe30e5f19b612fd80ba7d5f60b58a6"}, ] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, @@ -2356,47 +2706,47 @@ redis = [ {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, ] regex = [ - {file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222"}, - {file = "regex-2021.7.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407"}, - {file = "regex-2021.7.6-cp36-cp36m-win32.whl", hash = "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b"}, - {file = "regex-2021.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb"}, - {file = "regex-2021.7.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad"}, - {file = "regex-2021.7.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895"}, - {file = "regex-2021.7.6-cp37-cp37m-win32.whl", hash = "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5"}, - {file = "regex-2021.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f"}, - {file = "regex-2021.7.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761"}, - {file = "regex-2021.7.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068"}, - {file = "regex-2021.7.6-cp38-cp38-win32.whl", hash = "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0"}, - {file = "regex-2021.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4"}, - {file = "regex-2021.7.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"}, - {file = "regex-2021.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3"}, - {file = "regex-2021.7.6-cp39-cp39-win32.whl", hash = "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035"}, - {file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"}, - {file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"}, + {file = "regex-2021.8.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2"}, + {file = "regex-2021.8.28-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a"}, + {file = "regex-2021.8.28-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0"}, + {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb"}, + {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a"}, + {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308"}, + {file = "regex-2021.8.28-cp310-cp310-win32.whl", hash = "sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed"}, + {file = "regex-2021.8.28-cp310-cp310-win_amd64.whl", hash = "sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8"}, + {file = "regex-2021.8.28-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c"}, + {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c"}, + {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13"}, + {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0"}, + {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1"}, + {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f"}, + {file = "regex-2021.8.28-cp36-cp36m-win32.whl", hash = "sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354"}, + {file = "regex-2021.8.28-cp36-cp36m-win_amd64.whl", hash = "sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645"}, + {file = "regex-2021.8.28-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a"}, + {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e"}, + {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892"}, + {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791"}, + {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759"}, + {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906"}, + {file = "regex-2021.8.28-cp37-cp37m-win32.whl", hash = "sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a"}, + {file = "regex-2021.8.28-cp37-cp37m-win_amd64.whl", hash = "sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc"}, + {file = "regex-2021.8.28-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd"}, + {file = "regex-2021.8.28-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797"}, + {file = "regex-2021.8.28-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f"}, + {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256"}, + {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b"}, + {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e"}, + {file = "regex-2021.8.28-cp38-cp38-win32.whl", hash = "sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d"}, + {file = "regex-2021.8.28-cp38-cp38-win_amd64.whl", hash = "sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2"}, + {file = "regex-2021.8.28-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468"}, + {file = "regex-2021.8.28-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb"}, + {file = "regex-2021.8.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d"}, + {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983"}, + {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8"}, + {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed"}, + {file = "regex-2021.8.28-cp39-cp39-win32.whl", hash = "sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374"}, + {file = "regex-2021.8.28-cp39-cp39-win_amd64.whl", hash = "sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73"}, + {file = "regex-2021.8.28.tar.gz", hash = "sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1"}, ] requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, @@ -2407,21 +2757,29 @@ s3transfer = [ {file = "s3transfer-0.5.0.tar.gz", hash = "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c"}, ] sendgrid = [ - {file = "sendgrid-6.7.1-py3-none-any.whl", hash = "sha256:2558a8b2cf12677ceb99f8b611d914af5b9a2fd7ff3c0578e8299b4224e10071"}, - {file = "sendgrid-6.7.1.tar.gz", hash = "sha256:1c1cca97ab968f81af43ddbbe44aade5a689da27e3e4975dc366042499620abe"}, + {file = "sendgrid-6.8.1-py3-none-any.whl", hash = "sha256:4ae65a2657e7b2ff01a3c67c4fcfe6ecd579783870ab09d39291ed133a69299c"}, + {file = "sendgrid-6.8.1.tar.gz", hash = "sha256:75fa5094afb216bf11c60e9147c604889fa4012a3fc6ab7715fdc12e03ac488d"}, ] sentry-sdk = [ - {file = "sentry-sdk-1.3.0.tar.gz", hash = "sha256:5210a712dd57d88d225c1fc3fe3a3626fee493637bcd54e204826cf04b8d769c"}, - {file = "sentry_sdk-1.3.0-py2.py3-none-any.whl", hash = "sha256:6864dcb6f7dec692635e5518c2a5c80010adf673c70340817f1a1b713d65bb41"}, + {file = "sentry-sdk-1.3.1.tar.gz", hash = "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c"}, + {file = "sentry_sdk-1.3.1-py2.py3-none-any.whl", hash = "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"}, ] serpy = [ {file = "serpy-0.3.1-py2.py3-none-any.whl", hash = "sha256:750ded3df0671918b81d6efcab2b85cac12f9fcc2bce496c24a0ffa65d84b5da"}, {file = "serpy-0.3.1.tar.gz", hash = "sha256:3772b2a9923fbf674000ff51abebf6ea8f0fca0a2cfcbfa0d63ff118193d1ec5"}, ] +service-identity = [ + {file = "service-identity-21.1.0.tar.gz", hash = "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34"}, + {file = "service_identity-21.1.0-py2.py3-none-any.whl", hash = "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +snowballstemmer = [ + {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, + {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, +] sqlparse = [ {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"}, {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"}, @@ -2437,9 +2795,13 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomli = [ + {file = "tomli-1.2.1-py3-none-any.whl", hash = "sha256:8dd0e9524d6f386271a36b41dbf6c57d8e32fd96fd22b6584679dc569d20899f"}, + {file = "tomli-1.2.1.tar.gz", hash = "sha256:a5b75cb6f3968abb47af1b40c1819dc519ea82bcc065776a866e8d74c5ca9442"}, +] traitlets = [ - {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"}, - {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"}, + {file = "traitlets-5.1.0-py3-none-any.whl", hash = "sha256:03f172516916220b58c9f19d7f854734136dd9528103d04e9bf139a92c9f54c4"}, + {file = "traitlets-5.1.0.tar.gz", hash = "sha256:bd382d7ea181fbbcce157c133db9a829ce06edffe097bcf3ab945b435452b46d"}, ] twisted = [ {file = "Twisted-20.3.0-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:cdbc4c7f0cd7a2218b575844e970f05a1be1861c607b0e048c9bceca0c4d42f7"}, @@ -2470,42 +2832,15 @@ txaio = [ {file = "txaio-21.2.1-py2.py3-none-any.whl", hash = "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb"}, {file = "txaio-21.2.1.tar.gz", hash = "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8"}, ] -typed-ast = [ - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, - {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, - {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, - {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, - {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, - {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, - {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, - {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, - {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, - {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, -] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, +] +typing-inspect = [ + {file = "typing_inspect-0.7.1-py2-none-any.whl", hash = "sha256:b1f56c0783ef0f25fb064a01be6e5407e54cf4a4bf4f3ba3fe51e0bd6dcea9e5"}, + {file = "typing_inspect-0.7.1-py3-none-any.whl", hash = "sha256:3cd7d4563e997719a710a3bfe7ffb544c6b72069b6812a02e9b414a8fa3aaa6b"}, + {file = "typing_inspect-0.7.1.tar.gz", hash = "sha256:047d4097d9b17f46531bf6f014356111a1b6fb821a24fe7ac909853ca2a782aa"}, ] uritemplate = [ {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, @@ -2520,20 +2855,26 @@ uvicorn = [ {file = "uvicorn-0.13.4.tar.gz", hash = "sha256:3292251b3c7978e8e4a7868f4baf7f7f7bb7e40c759ecc125c37e99cdea34202"}, ] uvloop = [ - {file = "uvloop-0.15.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:e71fb9038bfcd7646ca126c5ef19b17e48d4af9e838b2bcfda7a9f55a6552a32"}, - {file = "uvloop-0.15.3-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7522df4e45e4f25b50adbbbeb5bb9847495c438a628177099d2721f2751ff825"}, - {file = "uvloop-0.15.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae2b325c0f6d748027f7463077e457006b4fdb35a8788f01754aadba825285ee"}, - {file = "uvloop-0.15.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0de811931e90ae2da9e19ce70ffad73047ab0c1dba7c6e74f9ae1a3aabeb89bd"}, - {file = "uvloop-0.15.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7f4b8a905df909a407c5791fb582f6c03b0d3b491ecdc1cdceaefbc9bf9e08f6"}, - {file = "uvloop-0.15.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d8ffe44ae709f839c54bacf14ed283f41bee90430c3b398e521e10f8d117b3a"}, - {file = "uvloop-0.15.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:63a3288abbc9c8ee979d7e34c34e780b2fbab3e7e53d00b6c80271119f277399"}, - {file = "uvloop-0.15.3-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5cda65fc60a645470b8525ce014516b120b7057b576fa876cdfdd5e60ab1efbb"}, - {file = "uvloop-0.15.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ff05116ede1ebdd81802df339e5b1d4cab1dfbd99295bf27e90b4cec64d70e9"}, - {file = "uvloop-0.15.3.tar.gz", hash = "sha256:905f0adb0c09c9f44222ee02f6b96fd88b493478fffb7a345287f9444e926030"}, + {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d"}, + {file = "uvloop-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c"}, + {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64"}, + {file = "uvloop-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9"}, + {file = "uvloop-0.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638"}, + {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450"}, + {file = "uvloop-0.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805"}, + {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382"}, + {file = "uvloop-0.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee"}, + {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464"}, + {file = "uvloop-0.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab"}, + {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f"}, + {file = "uvloop-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897"}, + {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f"}, + {file = "uvloop-0.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861"}, + {file = "uvloop-0.16.0.tar.gz", hash = "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228"}, ] virtualenv = [ - {file = "virtualenv-20.6.0-py2.py3-none-any.whl", hash = "sha256:e4fc84337dce37ba34ef520bf2d4392b392999dbe47df992870dc23230f6b758"}, - {file = "virtualenv-20.6.0.tar.gz", hash = "sha256:51df5d8a2fad5d1b13e088ff38a433475768ff61f202356bb9812c454c20ae45"}, + {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"}, + {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, ] watchgod = [ {file = "watchgod-0.7-py3-none-any.whl", hash = "sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7"}, @@ -2567,6 +2908,45 @@ websockets = [ {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, ] +yarl = [ + {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, + {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, + {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, + {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, + {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, + {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, + {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, + {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, + {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, + {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, + {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, + {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, + {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, +] "zope.interface" = [ {file = "zope.interface-5.4.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:7df1e1c05304f26faa49fa752a8c690126cf98b40b91d54e6e9cc3b7d6ffe8b7"}, {file = "zope.interface-5.4.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c98384b254b37ce50eddd55db8d381a5c53b4c10ee66e1e7fe749824f894021"}, diff --git a/pyproject.toml b/pyproject.toml index 961df943..94500acc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ requests = "^2.26.0" ipython = "^7.19.0" coverage = "^5.3.1" django-stubs = "^1.7.0" -black = "^20.8b1" +black = {extras = ["d"], version = "^21.6b0"} djangorestframework-stubs = "^1.3.0" PyYAML = "^5.4.1" autoflake = "^1.4" @@ -45,20 +45,26 @@ pytest-cov = "^2.12.0" pytest-django = "^4.3.0" isort = "^5.8.0" pre-commit = "^2.13.0" -flake9 = "^3.8.3" better-exceptions = "^0.3.3" pytest-testmon = "^1.1.1" django-querycount = "^0.7.0" nplusone = "^1.0.0" faker = "^8.6.0" docopt = "^0.6.2" +flakehell = "^0.9.0" +flake8-docstrings = "^1.6.0" +factory-boy = "^3.2.0" +MonkeyType = "^21.5.0" [tool.pytest.ini_options] python_files = "tests.py test_*.py *_tests.py" filterwarnings = """ -ignore::django.utils.deprecation.RemovedInDjango40Warning -ignore::django.utils.deprecation.RemovedInDjango41Warning -""" + ignore::django.utils.deprecation.RemovedInDjango40Warning + ignore::django.utils.deprecation.RemovedInDjango41Warning + ignore:Using or importing the ABCs.*:DeprecationWarning""" + +[tool.mypy] +plugins = ["mypy_django_plugin.main"] [tool.coverage.run] source = ["src"] @@ -75,8 +81,7 @@ omit = [ "*/urls.py", "ractf/management/*", "gunicorn_config.py", - "*/migrations/*.py" -] + "*/migrations/*.py"] [tool.coverage.report] fail_under = 80 @@ -85,22 +90,29 @@ skip_covered = true exclude_lines = [ "pragma: no cover", "raise NotImplementedError", - "pass" -] + "pass"] [tool.black] -exclude = 'migrations' +exclude = "migrations" line_length = 120 [tool.isort] profile = "black" multi_line_output = 3 -[tool.flake8] -exclude = "*migrations*,*settings*" +[tool.flakehell.plugins] +flake8-docstrings = ["+*"] +pycodestyle = ["+*", "-W503"] +pyflakes = ["+*"] + +[tool.flakehell] ignore = "W503" -max-line-length = 200 -max-complexity = 25 +max_line_length = 120 +exclude = ["migrations", "settings"] +extended_default_ignore = [] + +[mypy.plugins.django-stubs] +django_settings_module = "core.settings.lint" [build-system] requires = ["poetry-core>=1.0.0a5"] diff --git a/scripts/clean_db.py b/scripts/clean_db.py index 8f96d07c..18032969 100644 --- a/scripts/clean_db.py +++ b/scripts/clean_db.py @@ -1,8 +1,11 @@ +"""Script for clearing the connected database of all data.""" + import os from os import getenv import psycopg2 + with psycopg2.connect( user=getenv("SQL_USER"), password=getenv("SQL_PASSWORD"), diff --git a/scripts/fake/__main__.py b/scripts/fake/__main__.py index c0691ba1..467a1ad5 100644 --- a/scripts/fake/__main__.py +++ b/scripts/fake/__main__.py @@ -5,8 +5,8 @@ from django import db from faker import Faker -from challenge.models import Category, Challenge, Score, Solve -from member.models import Member +from challenges.models import Category, Challenge, Score, Solve +from teams.models import Member from scripts.fake.config import ( CATEGORIES, CHALLENGES, @@ -17,7 +17,7 @@ arguments, ) from scripts.fake.utils import TimedLog, random_rpn_op -from team.models import Team +from teams.models import Team if not arguments.get("--force") and Member.objects.count() > 0: diff --git a/scripts/fake/config.py b/scripts/fake/config.py index 86d1e7bf..c07ea895 100644 --- a/scripts/fake/config.py +++ b/scripts/fake/config.py @@ -18,7 +18,8 @@ class PostgreSQL: @classproperty def dsn(cls) -> str: - return f"postgres://{cls.USER}:{cls.PASSWORD}@{cls.HOST}:{cls.PORT}/template1" + """Return the DSN for connecting to the configured database.""" + return f"postgres://{cls.USER}:{cls.PASSWORD}@{cls.HOST}:{cls.PORT}/{cls.DATABASE}" USERS, CATEGORIES, TEAMS, CHALLENGES, SOLVES = ( diff --git a/src/admin/apps.py b/src/admin/apps.py deleted file mode 100644 index 043072c1..00000000 --- a/src/admin/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.apps import AppConfig - -from config import config - - -class AdminConfig(AppConfig): - name = "admin" - - def ready(self): - config.load() diff --git a/src/admin/models.py b/src/admin/models.py deleted file mode 100644 index 6b202199..00000000 --- a/src/admin/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/src/admin/urls.py b/src/admin/urls.py deleted file mode 100644 index 4edbcc32..00000000 --- a/src/admin/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path -from rest_framework.routers import DefaultRouter - -from admin import views - -router = DefaultRouter() - -urlpatterns = [ - path("self_check/", views.SelfCheckView.as_view(), name="self-check"), -] diff --git a/src/admin/views.py b/src/admin/views.py deleted file mode 100644 index cc88003d..00000000 --- a/src/admin/views.py +++ /dev/null @@ -1,17 +0,0 @@ -from rest_framework.permissions import IsAdminUser -from rest_framework.views import APIView - -from backend.response import FormattedResponse -from challenge.models import Challenge - - -class SelfCheckView(APIView): - permission_classes = [IsAdminUser] - - def get(self, request): - issues = [] - - for challenge in Challenge.objects.all(): - issues += challenge.self_check() - - return FormattedResponse(issues) diff --git a/src/andromeda/apps.py b/src/andromeda/apps.py index 89181bf9..7006d77f 100644 --- a/src/andromeda/apps.py +++ b/src/andromeda/apps.py @@ -1,5 +1,9 @@ +"""App for the andromeda integration.""" + from django.apps import AppConfig class AndromedaConfig(AppConfig): + """AppConfig for the andromeda integration.""" + name = "andromeda" diff --git a/src/andromeda/client.py b/src/andromeda/client.py index 518f2b0a..a57fbf79 100644 --- a/src/andromeda/client.py +++ b/src/andromeda/client.py @@ -1,13 +1,16 @@ +"""A simple andromeda API client.""" + from uuid import UUID import requests from django.conf import settings from rest_framework.status import HTTP_200_OK -from backend.exceptions import FormattedException +from core.exceptions import FormattedException def post(path, **kwargs): + """Send a post request to andromeda.""" response = requests.post( f"{settings.ANDROMEDA_URL}/{path}", headers={"Authorization": settings.ANDROMEDA_API_KEY}, **kwargs ) @@ -17,6 +20,7 @@ def post(path, **kwargs): def get(path, **kwargs): + """Send a get request to andromeda.""" response = requests.get( f"{settings.ANDROMEDA_URL}/{path}", headers={"Authorization": settings.ANDROMEDA_API_KEY}, **kwargs ) @@ -26,18 +30,22 @@ def get(path, **kwargs): def get_instance(user_id, job_id): + """Get a challenge instance of a given job id for a user.""" return post("", json={"user": str(user_id), "job": job_id}) def request_reset(user_id, job_id): + """Reset a challenge instance of a given job id for a user.""" return post("reset", json={"user": str(user_id), "job": job_id}) def list_jobs(): + """Get a list of all jobs running on the andromeda host.""" return get("jobs") def restart_job(job_id): + """Restarts a job with a given uuid.""" try: UUID(job_id) except ValueError: @@ -46,12 +54,15 @@ def restart_job(job_id): def list_instances(): + """List all the instances of challenges on the andromeda host.""" return get("instances") def sysinfo(): + """Get the current system info of the andromeda host.""" return get("sysinfo") def submit_job(job_spec): + """Submit a job to the andromeda host.""" return post("job/submit", json=job_spec) diff --git a/src/andromeda/models.py b/src/andromeda/models.py deleted file mode 100644 index 6b202199..00000000 --- a/src/andromeda/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/src/andromeda/serializers.py b/src/andromeda/serializers.py index 6386cc68..ddc4ff42 100644 --- a/src/andromeda/serializers.py +++ b/src/andromeda/serializers.py @@ -1,10 +1,16 @@ +"""Serializers for the andromeda integration.""" + from rest_framework import serializers class JobSubmitSerializer(serializers.Serializer): + """Serializer for job submissions associated with a challenge id.""" + challenge_id = serializers.IntegerField() job_spec = serializers.JSONField() class JobSubmitRawSerializer(serializers.Serializer): + """Serializer for job submissions.""" + job_spec = serializers.JSONField() diff --git a/src/andromeda/tests.py b/src/andromeda/tests.py deleted file mode 100644 index a39b155a..00000000 --- a/src/andromeda/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/andromeda/urls.py b/src/andromeda/urls.py index ca4feca5..acf24aca 100644 --- a/src/andromeda/urls.py +++ b/src/andromeda/urls.py @@ -1,3 +1,5 @@ +"""URL routes for the andromeda integration.""" + from django.urls import path from andromeda import views diff --git a/src/andromeda/views.py b/src/andromeda/views.py index f47ffb6e..f824de98 100644 --- a/src/andromeda/views.py +++ b/src/andromeda/views.py @@ -1,72 +1,95 @@ +"""API endpoints for the andromeda integration.""" + from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.status import HTTP_403_FORBIDDEN from rest_framework.views import APIView from andromeda import client -from andromeda.serializers import JobSubmitSerializer -from backend.response import FormattedResponse -from challenge.models import Challenge +from andromeda.serializers import JobSubmitRawSerializer, JobSubmitSerializer +from challenges.models import Challenge from config import config +from core.response import FormattedResponse class GetInstanceView(APIView): + """Endpoint for getting an instance of a given challenge.""" + permission_classes = (IsAuthenticated,) throttle_scope = "challenge_instance_get" def get(self, request, job_id): + """Given a job id, return an instance of the relevant challenge for this user.""" if not config.get("enable_challenge_server"): return FormattedResponse(m="challenge_server_disabled", status=HTTP_403_FORBIDDEN) - return FormattedResponse(client.get_instance(request.user.team.id, job_id)) + return FormattedResponse(client.get_instance(request.user.team.pk, job_id)) class ResetInstanceView(APIView): + """Endpoint for resetting an instance of a given challenge.""" + permission_classes = (IsAuthenticated,) throttle_scope = "challenge_instance_reset" def get(self, request, job_id): + """Given a job id, return a new instance of the relevant challenge for this user.""" if not config.get("enable_challenge_server"): return FormattedResponse(m="challenge_server_disabled", status=HTTP_403_FORBIDDEN) - return FormattedResponse(client.request_reset(request.user.team.id, job_id)) + return FormattedResponse(client.request_reset(request.user.team.pl, job_id)) class ListJobsView(APIView): + """Endpoint for listing the jobs on the andromeda host.""" + permission_classes = (IsAdminUser,) throttle_scope = "andromeda_view_jobs" def get(self, request): + """Return a list of all jobs that have been submitted to an andromeda host.""" return FormattedResponse(client.list_jobs()) class RestartJobView(APIView): + """Endpoint for restarting a job on the andromeda host.""" + permission_classes = (IsAdminUser,) throttle_scope = "andromeda_manage_jobs" def post(self, request): + """Given a job id, restart all instances of that challenge on the andromeda host.""" return FormattedResponse(client.restart_job(request.data["job_id"])) class ListInstancesView(APIView): + """Endpoint for listing all instances of challenges on the andromeda host.""" + permission_classes = (IsAdminUser,) throttle_scope = "andromeda_view_jobs" def get(self, request): + """Get a list of all challenge instances on the andromeda host.""" return FormattedResponse(client.list_instances()) class SysinfoView(APIView): + """Endpoint for getting the system info of an andromeda host.""" + permission_classes = (IsAdminUser,) throttle_scope = "andromeda_view_sysinfo" def get(self, request): + """Get the reported system info(free ram, cpu, etc) of the andromeda host.""" return FormattedResponse(client.sysinfo()) class JobSubmitView(APIView): + """Endpoint to submit a job to the andromeda host and link it to a given challenge.""" + permission_classes = (IsAdminUser,) throttle_scope = "andromeda_manage_jobs" def post(self, request): + """Submit a job to the andromeda host then add it to the challenge's challenge_metadata.""" serializer = JobSubmitSerializer(request.data) challenge = get_object_or_404(Challenge.objects, id=serializer.data["challenge_id"]) response = client.submit_job(serializer.data["job_spec"]) @@ -76,10 +99,13 @@ def post(self, request): class JobSubmitRawView(APIView): + """Endpoint to submit a job to the andromeda host.""" + permission_classes = (IsAdminUser,) throttle_scope = "andromeda_manage_jobs" def post(self, request): - serializer = JobSubmitSerializer(request.data) + """Submit a job to the andromeda host.""" + serializer = JobSubmitRawSerializer(request.data) response = client.submit_job(serializer.data["job_spec"]) return FormattedResponse(response) diff --git a/src/announcements/apps.py b/src/announcements/apps.py deleted file mode 100644 index 47f9f3f0..00000000 --- a/src/announcements/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class AnnouncementsConfig(AppConfig): - name = "announcements" diff --git a/src/announcements/migrations/0001_initial.py b/src/announcements/migrations/0001_initial.py deleted file mode 100644 index 7ece6ba4..00000000 --- a/src/announcements/migrations/0001_initial.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.2 on 2020-04-28 20:42 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Announcement', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=255)), - ('body', models.TextField()), - ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), - ], - ), - ] diff --git a/src/announcements/tests.py b/src/announcements/tests.py deleted file mode 100644 index a39b155a..00000000 --- a/src/announcements/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/authentication/apps.py b/src/authentication/apps.py index cc54f82e..d0bc3ae6 100644 --- a/src/authentication/apps.py +++ b/src/authentication/apps.py @@ -1,7 +1,11 @@ -from plugins.apps import PluginConfig +"""Configuration options and startup hooks for the authentication app.""" + +from core.apps import PluginConfig class AuthConfig(PluginConfig): + """Define providers and relevant metadata for the authentication app.""" + name = "authentication" provides = [ "authentication.basic_auth.BasicAuthLoginProvider", diff --git a/src/authentication/basic_auth.py b/src/authentication/basic_auth.py index a36bbdf7..31e09c14 100644 --- a/src/authentication/basic_auth.py +++ b/src/authentication/basic_auth.py @@ -1,17 +1,23 @@ +"""Define all our most basic custom authentication providers.""" + from django.contrib.auth import authenticate, get_user_model, password_validation from rest_framework.exceptions import ValidationError from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED from authentication.providers import LoginProvider, RegistrationProvider, TokenProvider -from backend.exceptions import FormattedException -from backend.signals import login, login_reject +from core.exceptions import FormattedException +from core.signals import login, login_reject +from teams.models import Member class BasicAuthRegistrationProvider(RegistrationProvider): + """A basic authentication provider for user registration.""" + name = "basic_auth" required_fields = ["username", "email", "password"] - def validate(self, data): + def validate(self, data: dict) -> dict: + """Validate the provided registration form.""" if not all(key in data for key in self.required_fields): raise ValidationError("A required field was not found.") @@ -20,8 +26,9 @@ def validate(self, data): return {key: data[key] for key in self.required_fields} - def register_user(self, username, email, password, **kwargs): - user = get_user_model()(username=username, email=email) + def register_user(self, username, email, password, **_): + """Register the provided account details once they have been validated.""" + user = Member(username=username, email=email) try: password_validation.validate_password(password, user) @@ -33,9 +40,12 @@ def register_user(self, username, email, password, **kwargs): class BasicAuthLoginProvider(LoginProvider): + """A basic authentication provider for user logins.""" + name = "basic_auth" - def login_user(self, username, password, context, **kwargs): + def login_user(self, username, password, context, **_): + """Given all the relevant credentials, authenticate a user's session.""" user = authenticate(request=context.get("request"), username=username, password=password) if not user: login_reject.send(sender=self.__class__, username=username, reason="creds") @@ -53,7 +63,7 @@ def login_user(self, username, password, context, **kwargs): status=HTTP_401_UNAUTHORIZED, ) - if not user.can_login(): + if not user.can_login: login_reject.send(sender=self.__class__, username=username, reason="closed") raise FormattedException(m="login_not_open", d={"reason": "login_not_open"}, status=HTTP_401_UNAUTHORIZED) @@ -62,7 +72,10 @@ def login_user(self, username, password, context, **kwargs): class BasicAuthTokenProvider(TokenProvider): + """A basic authentication provider for token-based authentication.""" + name = "basic_auth" - def issue_token(self, user, **kwargs): + def issue_token(self, user, **_): + """Issue a token for the provided user.""" return user.issue_token() diff --git a/src/authentication/logic/utils.py b/src/authentication/logic/utils.py new file mode 100644 index 00000000..f5313fd6 --- /dev/null +++ b/src/authentication/logic/utils.py @@ -0,0 +1,16 @@ +"""Logic used primarily in the authentication app, or methods for auth-specific business logic.""" + +from datetime import datetime, timedelta + +import pyotp +from django.utils import timezone + + +def one_day_hence() -> datetime: + """Return the day after the current date.""" + return timezone.now() + timedelta(days=1) + + +def random_backup_code() -> str: + """Return a random 8 character base32 string.""" + return pyotp.random_base32(8) diff --git a/src/authentication/migrations/0002_invitecode_auto_team.py b/src/authentication/migrations/0002_invitecode_auto_team.py index a0f2e2bd..6c537def 100644 --- a/src/authentication/migrations/0002_invitecode_auto_team.py +++ b/src/authentication/migrations/0002_invitecode_auto_team.py @@ -9,14 +9,14 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('authentication', '0001_initial'), - ('team', '0001_initial'), + ("authentication", "0001_initial"), + ("teams", "0001_initial"), ] operations = [ migrations.AddField( - model_name='invitecode', - name='auto_team', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='team.Team'), + model_name="invitecode", + name="auto_team", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="teams.Team"), ), ] diff --git a/src/authentication/migrations/0003_passwordresettoken.py b/src/authentication/migrations/0003_passwordresettoken.py index f1f14c71..f8fc20e5 100644 --- a/src/authentication/migrations/0003_passwordresettoken.py +++ b/src/authentication/migrations/0003_passwordresettoken.py @@ -1,5 +1,7 @@ # Generated by Django 3.0.5 on 2020-08-08 19:51 +from datetime import timedelta + import django.db.models.deletion import django.utils.timezone from django.conf import settings @@ -22,7 +24,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('token', models.CharField(max_length=64)), ('issued', models.DateTimeField(default=django.utils.timezone.now)), - ('expires', models.DateTimeField(default=authentication.models.one_day)), + ('expires', models.DateTimeField(default=lambda: django.utils.timezone.now() + timedelta(days=1))), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), diff --git a/src/authentication/migrations/0010_auto_20210615_1726.py b/src/authentication/migrations/0010_auto_20210615_1726.py new file mode 100644 index 00000000..a68fdb3f --- /dev/null +++ b/src/authentication/migrations/0010_auto_20210615_1726.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.4 on 2021-06-15 17:26 + +from django.db import migrations, models + +import authentication.logic.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0009_auto_20200831_2020'), + ] + + operations = [ + migrations.AlterField( + model_name='backupcode', + name='code', + field=models.CharField(default=authentication.logic.utils.random_backup_code, max_length=8), + ), + migrations.AlterField( + model_name='passwordresettoken', + name='expires', + field=models.DateTimeField(default=authentication.logic.utils.one_day_hence), + ), + ] diff --git a/src/authentication/mixins.py b/src/authentication/mixins.py new file mode 100644 index 00000000..a2466296 --- /dev/null +++ b/src/authentication/mixins.py @@ -0,0 +1,20 @@ +"""Class mixins for authentication views and models.""" + +from django.utils.decorators import method_decorator +from django.views.decorators.debug import sensitive_post_parameters +from rest_framework.views import APIView + +hide_password = method_decorator( + sensitive_post_parameters( + "password", + ) +) + + +class HidePasswordMixin: + """A mixin to mark the 'password' field as a sensitive POST parameter.""" + + @hide_password + def dispatch(self, *args, **kwargs): + """Override dispatch with the hide_password decorator.""" + return super(APIView, self).dispatch(*args, **kwargs) diff --git a/src/authentication/models.py b/src/authentication/models.py index 78f47917..8f3edc78 100644 --- a/src/authentication/models.py +++ b/src/authentication/models.py @@ -1,79 +1,88 @@ +"""Database models for use in authentication.""" + import binascii import os -from datetime import timedelta +from typing import Iterable import pyotp -from django.conf import settings from django.db import models -from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin -from team.models import Team +from authentication.logic import utils class Token(ExportModelOperationsMixin("token"), models.Model): + """A Token used for users to authenticate with RACTF.""" + key = models.CharField(max_length=40, primary_key=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="tokens", on_delete=models.CASCADE) + user = models.ForeignKey("teams.Member", related_name="tokens", on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) owner = models.ForeignKey( - settings.AUTH_USER_MODEL, + "teams.Member", related_name="owned_tokens", on_delete=models.CASCADE, blank=True, null=True, ) - def save(self, *args, **kwargs): - if not self.key: - self.key = self.generate_key() - if not self.owner: - self.owner = self.user + def save(self, *args, **kwargs) -> None: + """Ensure that self.key and self.user are populated with defaults.""" + self.key = self.key or self.generate_key() + self.owner = self.owner or self.user return super().save(*args, **kwargs) - def generate_key(self): + def generate_key(self) -> str: + """Generate an arbitrary random key for use in the token.""" return binascii.hexlify(os.urandom(20)).decode() def __str__(self): + """Return this token's key.""" return self.key class InviteCode(ExportModelOperationsMixin("invite_code"), models.Model): + """Invite codes for admins to issue, allowing new users to register.""" + code = models.CharField(max_length=64, unique=True) uses = models.IntegerField(default=0) max_uses = models.IntegerField() fully_used = models.BooleanField(default=False) - auto_team = models.ForeignKey(Team, on_delete=models.CASCADE, null=True) - - -def one_day(): - return timezone.now() + timedelta(days=1) + auto_team = models.ForeignKey("team.Team", on_delete=models.CASCADE, null=True) class PasswordResetToken(ExportModelOperationsMixin("password_reset_token"), models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + """Auto-expiring tokens used by users to reset their passwords.""" + + user = models.ForeignKey("teams.Member", on_delete=models.CASCADE) token = models.CharField(max_length=255) issued = models.DateTimeField(auto_now_add=True) - expires = models.DateTimeField(default=one_day) + expires = models.DateTimeField(default=utils.one_day_hence) class BackupCode(ExportModelOperationsMixin("backup_code"), models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="backup_codes", on_delete=models.CASCADE) - code = models.CharField(max_length=8) + """Backup codes for users to authenticate after they have lost a 2FA provider.""" + + user = models.ForeignKey("teams.Member", related_name="backup_codes", on_delete=models.CASCADE) + code = models.CharField(max_length=8, default=utils.random_backup_code) class Meta: + """Specify fields which should be used for composite uniqueness.""" + unique_together = [("user", "code")] @staticmethod - def generate(user): - BackupCode.objects.filter(user=user).delete() - codes = [BackupCode(user=user, code=pyotp.random_base32(8)) for i in range(10)] - BackupCode.objects.bulk_create(codes) + def generate_for(user) -> Iterable[str]: + """Generate backup codes for the given user.""" + backup_codes = [BackupCode(user=user) for _ in range(10)] + BackupCode.objects.bulk_create(backup_codes) return BackupCode.objects.filter(user=user).values_list("code", flat=True) class TOTPDevice(ExportModelOperationsMixin("totp_device"), models.Model): + """TOTP Devices used by users as an extra factor of authentication.""" + user = models.OneToOneField( - settings.AUTH_USER_MODEL, + "teams.Member", related_name="totp_device", on_delete=models.CASCADE, null=True, @@ -84,5 +93,6 @@ class TOTPDevice(ExportModelOperationsMixin("totp_device"), models.Model): totp_secret = models.CharField(null=True, max_length=16, default=pyotp.random_base32) verified = models.BooleanField(default=False) - def validate_token(self, token): + def validate_token(self, token: str) -> bool: + """Validate the provided token using the TOTP secret for this device.""" return pyotp.TOTP(self.totp_secret).verify(token, valid_window=1) diff --git a/src/authentication/permissions.py b/src/authentication/permissions.py index 5eb4497e..a290f004 100644 --- a/src/authentication/permissions.py +++ b/src/authentication/permissions.py @@ -1,13 +1,21 @@ +"""Set any relevant custom permissions classes for the project.""" + from rest_framework.permissions import BasePermission class HasTwoFactor(BasePermission): - def has_permission(self, request, view): + """Permission to add to any views requiring Two Factor authentication.""" + + def has_permission(self, request, _): + """Check that the user is authenticated, and that they have 2FA enabled.""" return request.user and request.user.is_authenticated and request.user.has_2fa() class VerifyingTwoFactor(BasePermission): - def has_permission(self, request, view): + """Permission for verifying that the user's TOTP code is valid.""" + + def has_permission(self, request, _): + """Check that the user is authenticated, and has a valid verified TOTP device.""" return ( request.user and request.user.is_authenticated diff --git a/src/authentication/providers.py b/src/authentication/providers.py index 36e6c773..a668bd69 100644 --- a/src/authentication/providers.py +++ b/src/authentication/providers.py @@ -1,49 +1,63 @@ +"""Custom base authentication providers for explicit provider definition.""" + +# TODO: Look for a nicer solution to this, perhaps this logic is already implemented in DRF/Django? + import abc -from django.contrib.auth import get_user_model from django.core.validators import EmailValidator +from django.db.models import Q from rest_framework.exceptions import ValidationError from config import config -from plugins.providers import Provider +from core.providers import Provider +from teams.models import Member class RegistrationProvider(Provider, abc.ABC): # pragma: no cover + """A Provider for user registration.""" + type = "registration" @abc.abstractmethod def validate(self, data): + """Validate the provided form data.""" pass @abc.abstractmethod def register_user(self, **kwargs): + """Register the user once the form data passes validation.""" pass - def validate_email(self, email): + def validate_email(self, email: str) -> None: + """Validate the email provided with Django's builtin validator.""" allow_domain = config.get("email_allow") - if allow_domain: - email_validator = EmailValidator(allow_domain) - else: - email_validator = EmailValidator() + email_validator = EmailValidator(allow_domain or ...) if email_validator(email): raise ValidationError("invalid_email") def check_email_or_username_in_use(self, email=None, username=None): - if get_user_model().objects.filter(username=username) or get_user_model().objects.filter(email=email): + """Ensure that the provided email and username do not already exist.""" + if Member.objects.filter(Q(username=username) | Q(email=email)): raise ValidationError("email_or_username_in_use") class LoginProvider(Provider, abc.ABC): # pragma: no cover + """A Provider for user logins.""" + type = "login" @abc.abstractmethod def login_user(self, **kwargs): + """Athenticate this user with their session.""" pass class TokenProvider(Provider, abc.ABC): # pragma: no cover + """A Provider for token-based logins.""" + type = "token" @abc.abstractmethod def issue_token(self, user, **kwargs): + """Issue a token for this user.""" pass diff --git a/src/authentication/serializers.py b/src/authentication/serializers.py index 34662dda..efc28ee8 100644 --- a/src/authentication/serializers.py +++ b/src/authentication/serializers.py @@ -1,9 +1,11 @@ +"""Serializers used for the authentication app.""" + import secrets import time from smtplib import SMTPException from django.conf import settings -from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth import password_validation from django.utils import timezone from rest_framework import serializers from rest_framework.generics import get_object_or_404 @@ -14,37 +16,46 @@ ) from authentication.models import InviteCode, PasswordResetToken -from backend.exceptions import FormattedException -from backend.mail import send_email -from backend.signals import register from config import config -from plugins import providers -from team.models import Team +from core import providers +from core.exceptions import FormattedException +from core.mail import send_email +from core.signals import register +from teams.models import Team, Member class LoginSerializer(serializers.Serializer): + """Serialize fields used in the login form.""" + username = serializers.CharField() password = serializers.CharField(trim_whitespace=False) def validate(self, data): + """Validate data used in the login form.""" user = providers.get_provider("login").login_user(**data, context=self.context) data["user"] = user return data class LoginTwoFactorSerializer(serializers.Serializer): + """Serialize fields used in a login for 2FA-enabled accounts.""" + username = serializers.CharField() password = serializers.CharField(trim_whitespace=False) tfa = serializers.CharField(max_length=255, allow_null=True, allow_blank=True) def validate(self, data): + """Validate data using the relevant provider for this user.""" user = providers.get_provider("login").login_user(**data, context=self.context) data["user"] = user return data class RegistrationSerializer(serializers.Serializer): + """Serialize fields used for registering new accounts.""" + def validate(self, _): + """Validate relevant fields for a new account's field data.""" register_end_time = config.get("register_end_time") if not (config.get("enable_registration") and time.time() >= config.get("register_start_time")) and ( register_end_time < 0 or register_end_time > time.time() @@ -59,9 +70,10 @@ def validate(self, _): return validated_data def create(self, validated_data): + """Create a user, given all the relevant form fields.""" user = providers.get_provider("registration").register_user(**validated_data, context=self.context) - if not get_user_model().objects.all().exists(): + if not Member.objects.all().exists(): user.is_staff = True user.is_superuser = True @@ -84,19 +96,10 @@ def create(self, validated_data): user.email, f"{config.get('event_name')} - Verify your email", "verify", - url=settings.FRONTEND_URL + "verify?id={}&secret={}".format(user.id, user.email_token), + url=settings.FRONTEND_URL + "verify?id={}&secret={}".format(user.pk, user.email_token), event_name=config.get("event_name"), ) - except SMTPException: # pragma: no cover - prod error handling - # Whilst the API can resend verification emails, - # the frontend doesnt have that implemented, in - # addition to that, if smtp fails that early they are - # going to have to do something regardless, so it is - # easier [for us] to fail out of creating the user and - # leave them on the register page, telling them something - # went wrong then being confused why their email isnt there. - # [Via Dave on Discord] - + except SMTPException: user.delete() raise FormattedException(m="creation_failed") @@ -126,21 +129,25 @@ def create(self, validated_data): return {} def to_representation(self, instance): + """Get a dictionary representation of this serializer's data.""" representation = super(RegistrationSerializer, self).to_representation(instance) representation.pop("password", None) return representation class PasswordResetSerializer(serializers.Serializer): + """Serialize fields used for resetting a user's data.""" + uid = serializers.IntegerField() token = serializers.CharField(max_length=128) password = serializers.CharField() def validate(self, data): + """Validate and return data sent for a password reset request.""" uid = data.get("uid") token = data.get("token") password = data.get("password") - user = get_object_or_404(get_user_model(), id=uid) + user = get_object_or_404(Member, pk=uid) reset_token = get_object_or_404(PasswordResetToken, token=token, user_id=uid, expires__gt=timezone.now()) password_validation.validate_password(password, reset_token) data["user"] = user @@ -149,24 +156,30 @@ def validate(self, data): class EmailVerificationSerializer(serializers.Serializer): + """Serialize fields used for verifying an account by email.""" + uid = serializers.IntegerField() token = serializers.CharField(max_length=64) def validate(self, data): + """Validate the user ID and token, raising an error if the user is already verified.""" uid = int(data.get("uid")) token = data.get("token") - user = get_object_or_404(get_user_model(), id=uid, email_token=token) + user = get_object_or_404(Member, id=uid, email_token=token) if user.email_verified: raise serializers.ValidationError("email is already verified") data["user"] = user return data -class EmailSerializer(serializers.Serializer): +class ResendEmailSerializer(serializers.Serializer): + """Serialize fields used for resending a verification email to a user.""" + email = serializers.EmailField() def validate(self, data): - user = get_object_or_404(get_user_model(), email=data.get("email")) + """Validate the provided email, ensuring that it links to a real user.""" + user = get_object_or_404(Member, email=data.get("email")) if user.email_verified: raise serializers.ValidationError("email is already verified") data["user"] = user @@ -174,10 +187,13 @@ def validate(self, data): class ChangePasswordSerializer(serializers.Serializer): + """Serialize fields used for changing a user's password.""" + password = serializers.CharField() old_password = serializers.CharField() def validate(self, data): + """Validate the provided user, along with the old and new passwords.""" user = self.context["request"].user password = data.get("password") old_password = data.get("old_password") @@ -188,18 +204,26 @@ def validate(self, data): class GenerateInvitesSerializer(serializers.Serializer): + """Serialize fields used for generating invite links.""" + amount = serializers.IntegerField(max_value=10000) max_uses = serializers.IntegerField(required=False, default=1) auto_team = serializers.IntegerField(required=False, default=None) class InviteCodeSerializer(serializers.ModelSerializer): + """Serialize fields used for the InviteCode model.""" + class Meta: + """Specify the model and fields to serialize.""" + model = InviteCode fields = ["id", "code", "uses", "max_uses", "auto_team"] class CreateBotSerializer(serializers.Serializer): + """Serialize fields used for creating a bot account.""" + username = serializers.CharField() is_visible = serializers.BooleanField() is_staff = serializers.BooleanField() diff --git a/src/authentication/tests.py b/src/authentication/tests.py deleted file mode 100644 index ca8b47c5..00000000 --- a/src/authentication/tests.py +++ /dev/null @@ -1,920 +0,0 @@ -from unittest import mock - -import pyotp -from django.contrib.auth import get_user_model -from django.http import HttpRequest -from django.urls import reverse -from rest_framework.request import Request -from rest_framework.status import ( - HTTP_200_OK, - HTTP_201_CREATED, - HTTP_400_BAD_REQUEST, - HTTP_401_UNAUTHORIZED, - HTTP_403_FORBIDDEN, - HTTP_404_NOT_FOUND, -) -from rest_framework.test import APITestCase - -from authentication.models import ( - BackupCode, - InviteCode, - PasswordResetToken, - Token, - TOTPDevice, -) -from authentication.views import ( - AddTwoFactorView, - ChangePasswordView, - CreateBotView, - DesudoView, - DoPasswordResetView, - LoginTwoFactorView, - LoginView, - RegenerateBackupCodesView, - RegistrationView, - RequestPasswordResetView, - VerifyEmailView, - VerifyTwoFactorView, -) -from config import config -from team.models import Team - - -def get_fake_time(): - return 0 - - -class RegisterTestCase(APITestCase): - def setUp(self): - RegistrationView.throttle_scope = "" - - def test_register(self): - data = { - "username": "user1", - "password": "uO7*$E@0ngqL", - "email": "user@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_201_CREATED) - - def test_register_with_mail(self): - with self.settings( - MAIL={"SEND_ADDRESS": "no-reply@ractf.co.uk", "SEND_NAME": "RACTF", "SEND": True, "SEND_MODE": "SES"} - ): - data = { - "username": "user1", - "password": "uO7*$E@0ngqL", - "email": "user@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_201_CREATED) - - def test_register_weak_password(self): - data = { - "username": "user2", - "password": "password", - "email": "user2@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_register_duplicate_username(self): - data = { - "username": "user3", - "password": "uO7*$E@0ngqL", - "email": "user3@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_201_CREATED) - data = { - "username": "user3", - "password": "uO7*$E@0ngqL", - "email": "user4@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_register_duplicate_email(self): - data = { - "username": "user4", - "password": "uO7*$E@0ngqL", - "email": "user4@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_201_CREATED) - data = { - "username": "user5", - "password": "uO7*$E@0ngqL", - "email": "user4@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - @mock.patch("time.time", side_effect=get_fake_time) - def test_register_closed(self, mock_obj): - config.set("enable_prelogin", False) - data = { - "username": "user6", - "password": "uO7*$E@0ngqL", - "email": "user6@example.org", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - config.set("enable_prelogin", True) - - def test_register_admin(self): - data = { - "username": "user6", - "password": "uO7*$E@0ngqL", - "email": "user6@example.org", - } - self.client.post(reverse("register"), data) - self.assertTrue(get_user_model().objects.filter(username=data["username"]).first().is_staff) - - def test_register_second(self): - data = { - "username": "user6", - "password": "uO7*$E@0ngqL", - "email": "user6@example.org", - } - self.client.post(reverse("register"), data) - data = { - "username": "user7", - "password": "uO7*$E@0ngqL", - "email": "user7@example.org", - } - self.client.post(reverse("register"), data) - self.assertFalse(get_user_model().objects.filter(username=data["username"]).first().is_staff) - - def test_register_malformed(self): - data = { - "username": "user6", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_register_invalid_email(self): - data = { - "username": "user6", - "password": "uO7*$E@0ngqL", - "email": "user6", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_register_teams_disabled(self): - config.set("enable_teams", False) - data = { - "username": "user10", - "password": "uO7*$E@0ngqL", - "email": "user10@example.com", - } - response = self.client.post(reverse("register"), data) - config.set("enable_teams", True) - self.assertEqual(response.status_code, HTTP_201_CREATED) - self.assertEqual(get_user_model().objects.get(username="user10").team.name, "user10") - - -class EmailResendTestCase(APITestCase): - def test_email_resend(self): - with self.settings(RATELIMIT_ENABLE=False): - user = get_user_model()(username="test_verify_user", email_verified=False, email="tvu@example.com") - user.save() - response = self.client.post(reverse("resend-email"), {"email": "tvu@example.com"}) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_already_verified_email_resend(self): - with self.settings(RATELIMIT_ENABLE=False): - user = get_user_model()(username="resend-email", email_verified=True, email="tvu@example.com") - user.save() - response = self.client.post(reverse("resend-email"), {"email": "tvu@example.com"}) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_non_existing_email_resend(self): - with self.settings(RATELIMIT_ENABLE=False): - response = self.client.post(reverse("resend-email"), {"email": "nonexisting@example.com"}) - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - - -class SudoTestCase(APITestCase): - def test_sudo(self): - user = get_user_model()(username="sudotest", is_staff=True, email="sudotest@example.com", is_superuser=True) - user.save() - user2 = get_user_model()(username="sudotest2", email="sudotest2@example.com") - user2.save() - - self.client.force_authenticate(user) - req = self.client.post(reverse("sudo"), {"id": user2.id}) - self.assertEqual(req.status_code, HTTP_200_OK) - - -class DesudoTestCase(APITestCase): - def test_desudo(self): - user2 = get_user_model()(username="sudotest2", email="sudotest2@example.com") - user2.save() - - request = Request(HttpRequest()) - request.sudo_from = user2 - - response = DesudoView().post(request) - self.assertTrue("token" in response.data["d"]) - - -class GenerateInvitesTestCase(APITestCase): - def test_response_length(self): - user = get_user_model()(username="resend-email", is_staff=True, email="tvu@example.com", is_superuser=True) - user.save() - self.client.force_authenticate(user=user) - team = Team.objects.create(owner=user, name=user.username, password="123123") - response = self.client.post(reverse("generate-invites"), {"amount": 15, "auto_team": team.id, "max_uses": 1}) - self.assertEqual(len(response.data["d"]["invite_codes"]), 15) - - def test_invites_viewset(self): - user = get_user_model()(username="resend-email", is_staff=True, email="tvu@example.com", is_superuser=True) - user.save() - self.client.force_authenticate(user=user) - self.client.post(reverse("generate-invites"), {"amount": 15, "max_uses": 1}) - response = self.client.get(reverse("invites-list")) - self.assertEqual(len(response.data["d"]["results"]), 15) - - -class InviteRequiredRegistrationTestCase(APITestCase): - def setUp(self): - RegistrationView.throttle_scope = "" - config.set("invite_required", True) - InviteCode(code="test1", max_uses=10).save() - InviteCode(code="test2", max_uses=1).save() - InviteCode(code="test3", max_uses=1).save() - user = get_user_model()( - username="invtestadmin", - email="invtestadmin@example.org", - email_verified=True, - is_superuser=True, - is_staff=True, - ) - user.set_password("password") - user.save() - self.user = user - team = Team(name="team", password="password", owner=user) - team.save() - self.team = team - InviteCode(code="test4", max_uses=1, auto_team=team).save() - - def tearDown(self): - config.set("invite_required", False) - - def test_register_invite_required_missing_invite(self): - data = { - "username": "user7", - "password": "uO7*$E@0ngqL", - "email": "user7@example.com", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_register_invite_required_valid(self): - data = { - "username": "user8", - "password": "uO7*$E@0ngqL", - "email": "user8@example.com", - "invite": "test1", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_201_CREATED) - - def test_register_invite_required_invalid(self): - data = { - "username": "user8", - "password": "uO7*$E@0ngqL", - "email": "user8@example.com", - "invite": "test1---", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_register_invite_required_already_used(self): - data = { - "username": "user9", - "password": "uO7*$E@0ngqL", - "email": "user9@example.com", - "invite": "test2", - } - response = self.client.post(reverse("register"), data) - data = { - "username": "user10", - "password": "uO7*$E@0ngqL", - "email": "user10@example.com", - "invite": "test2", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_register_invite_required_valid_maxing_uses(self): - data = { - "username": "user11", - "password": "uO7*$E@0ngqL", - "email": "user11@example.com", - "invite": "test3", - } - response = self.client.post(reverse("register"), data) - self.assertEqual(response.status_code, HTTP_201_CREATED) - - def test_register_invite_required_auto_team(self): - data = { - "username": "user12", - "password": "uO7*$E@0ngqL", - "email": "user12@example.com", - "invite": "test4", - } - self.client.post(reverse("register"), data) - self.assertEqual(get_user_model().objects.get(username="user12").team.id, self.team.id) - - -class LogoutTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="logout-test", email="logout-test@example.org") - user.set_password("password") - user.email_verified = True - user.save() - self.user = user - - def test_logout(self): - self.client.post(reverse("login"), data={"username": self.user.username, "password": "password", "otp": ""}) - self.client.force_authenticate(user=self.user) - response = self.client.post(reverse("logout")) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_logout_not_logged_in(self): - response = self.client.post(reverse("logout")) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - -class LoginTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="login-test", email="login-test@example.org") - user.set_password("password") - user.email_verified = True - user.save() - self.user = user - LoginView.throttle_scope = "" - - def test_login(self): - self.user.set_password("password") - data = { - "username": "login-test", - "password": "password", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_login_invalid(self): - data = { - "username": "login-test", - "password": "a", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_missing_data(self): - data = { - "username": "login-test", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_login_email_not_verified(self): - self.user.email_verified = False - self.user.save() - data = { - "username": "login-test", - "password": "password", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - @mock.patch("time.time", side_effect=get_fake_time) - def test_login_login_closed(self, mock_obj): - data = { - "username": "login-test", - "password": "password", - } - config.set("enable_prelogin", False) - response = self.client.post(reverse("login"), data) - config.set("enable_prelogin", True) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_inactive(self): - self.user.is_active = False - self.user.save() - data = { - "username": "login-test", - "password": "password", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - self.user.is_active = True - self.user.save() - - def test_login_with_email(self): - self.user.set_password("password") - data = { - "username": "login-test@example.org", - "password": "password", - "otp": "", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_login_wrong_user(self): - data = { - "username": "login-", - "password": "password", - "otp": "", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_wrong_password(self): - data = { - "username": "login-test", - "password": "passw", - "otp": "", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_malformed(self): - data = { - "username": "login-test", - "otp": "", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_login_2fa_required(self): - TOTPDevice(user=self.user, verified=True).save() - data = { - "username": "login-test", - "password": "password", - } - response = self.client.post(reverse("login"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - -class Login2FATestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="login-test", email="login-test@example.org") - user.set_password("password") - user.email_verified = True - user.save() - TOTPDevice(user=user, verified=True).save() - self.user = user - LoginTwoFactorView.throttle_scope = "" - - def test_login_2fa(self): - secret = TOTPDevice.objects.get(user=self.user).totp_secret - totp = pyotp.TOTP(secret) - data = { - "username": "login-test", - "password": "password", - "tfa": totp.now(), - } - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_login_2fa_invalid(self): - data = { - "username": "login-test", - "password": "password", - "tfa": "123456", - } - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_2fa_without_2fa(self): - user = get_user_model()(username="login-test-no-2fa", email="login-test-no-2fa@example.org") - user.set_password("password") - user.email_verified = True - user.save() - data = {"username": "login-test-no-2fa", "password": "password", "tfa": "123456"} - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_2fa_missing(self): - data = { - "username": "login-test", - "password": "password", - } - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_login_2fa_backup_code(self): - BackupCode(user=self.user, code="12345678").save() - data = { - "username": "login-test", - "password": "password", - "tfa": "12345678", - } - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_login_2fa_backup_code_invalid(self): - BackupCode(user=self.user, code="12345678").save() - data = { - "username": "login-test", - "password": "password", - "tfa": "87654321", - } - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_login_2fa_invalid_code(self): - data = { - "username": "login-test", - "password": "password", - "tfa": "123456789", - } - response = self.client.post(reverse("login-2fa"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - -class TokenTestCase(APITestCase): - def test_token_str(self): - user = get_user_model()(username="token-test", email="token-test@example.org") - user.save() - tok = Token(key="a" * 40, user=user) - self.assertEqual(str(tok), "a" * 40) - - def test_token_preserves_key(self): - user = get_user_model()(username="token-test-2", email="token-test-2@example.org") - user.save() - token = Token(key="a" * 40, user=user) - token.save() - self.assertEqual(token.key, "a" * 40) - - -class TFATestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="2fa-test", email="2fa-test@example.org") - user.set_password("password") - user.email_verified = True - user.save() - self.user = user - AddTwoFactorView.throttle_scope = "" - VerifyTwoFactorView.throttle_scope = "" - - def test_add_2fa_unauthenticated(self): - response = self.client.post(reverse("add-2fa")) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - self.assertFalse(self.user.has_2fa()) - - def test_add_2fa(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - self.assertFalse(self.user.has_2fa()) - self.assertNotEqual(self.user.totp_device, None) - - def test_add_2fa_twice(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - response = self.client.post(reverse("add-2fa")) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_verify_2fa(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - secret = self.user.totp_device.totp_secret - totp = pyotp.TOTP(secret) - self.client.post(reverse("verify-2fa"), data={"otp": totp.now()}) - self.assertTrue(self.user.has_2fa()) - - def test_verify_2fa_invalid(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - self.client.post(reverse("verify-2fa"), data={"otp": "123456"}) - self.assertFalse(self.user.totp_device.verified) - - def test_add_2fa_with_2fa(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - secret = self.user.totp_device.totp_secret - totp = pyotp.TOTP(secret) - self.client.post(reverse("verify-2fa"), data={"otp": totp.now()}) - response = self.client.post(reverse("add-2fa")) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_remove_2fa(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - totp_device = get_user_model().objects.get(id=self.user.id).totp_device - totp_device.verified = True - totp_device.save() - self.client.force_authenticate(user=get_user_model().objects.get(id=self.user.id)) - response = self.client.post(reverse("remove-2fa"), data={"otp": pyotp.TOTP(totp_device.totp_secret).now()}) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_remove_2fa_fail(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - totp_device = get_user_model().objects.get(id=self.user.id).totp_device - totp_device.verified = True - totp_device.save() - self.client.force_authenticate(user=get_user_model().objects.get(id=self.user.id)) - response = self.client.post(reverse("remove-2fa"), data={"otp": "invalid_otp"}) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_remove_2fa_removes_2fa(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - totp_device = get_user_model().objects.get(id=self.user.id).totp_device - totp_device.verified = True - totp_device.save() - self.client.force_authenticate(user=get_user_model().objects.get(id=self.user.id)) - self.client.post(reverse("remove-2fa"), data={"otp": pyotp.TOTP(totp_device.totp_secret).now()}) - self.assertFalse(get_user_model().objects.get(id=self.user.id).has_2fa()) - - def test_remove_2fa_no_2fa(self): - self.client.force_authenticate(user=self.user) - self.client.post(reverse("add-2fa")) - user = get_user_model().objects.get(id=self.user.id) - user.totp_device = None - user.save() - response = self.client.post(reverse("remove-2fa")) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - -class RequestPasswordResetTestCase(APITestCase): - def setUp(self): - RequestPasswordResetView.throttle_scope = "" - - def test_password_reset_request_invalid(self): - response = self.client.post(reverse("request-password-reset"), data={"email": "user10@example.org"}) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_password_reset_request_valid(self): - with self.settings( - MAIL={"SEND_ADDRESS": "no-reply@ractf.co.uk", "SEND_NAME": "RACTF", "SEND": True, "SEND_MODE": "SES"} - ): - get_user_model()(username="test-password-rest", email="user10@example.org", email_verified=True).save() - response = self.client.post(reverse("request-password-reset"), data={"email": "user10@example.org"}) - self.assertEqual(response.status_code, HTTP_200_OK) - - -class DoPasswordResetTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="pr-test", email="pr-test@example.org") - user.set_password("password") - user.email_verified = True - user.save() - PasswordResetToken(user=user, token="testtoken").save() - self.user = user - DoPasswordResetView.throttle_scope = "" - - def test_password_reset(self): - data = { - "uid": self.user.id, - "token": "testtoken", - "password": "uO7*$E@0ngqL", - } - response = self.client.post(reverse("do-password-reset"), data) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_password_reset_issues_token(self): - data = { - "uid": self.user.id, - "token": "testtoken", - "password": "uO7*$E@0ngqL", - } - response = self.client.post(reverse("do-password-reset"), data) - self.assertTrue("token" in response.data["d"]) - - def test_password_reset_bad_token(self): - data = { - "uid": self.user.id, - "token": "abc", - "password": "uO7*$E@0ngqL", - } - response = self.client.post(reverse("do-password-reset"), data) - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - - def test_password_reset_weak_password(self): - data = { - "uid": self.user.id, - "token": "testtoken", - "password": "password", - } - response = self.client.post(reverse("do-password-reset"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_password_reset_login_disabled(self): - config.set("enable_login", False) - data = { - "uid": self.user.id, - "token": "testtoken", - "password": "uO7*$E@0ngqL", - } - response = self.client.post(reverse("do-password-reset"), data) - config.set("enable_login", True) - self.assertFalse("token" in response.data["d"]) - - @mock.patch("time.time", side_effect=get_fake_time) - def test_password_reset_cant_login_yet(self, obj): - config.set("enable_prelogin", False) - data = { - "uid": self.user.id, - "token": "testtoken", - "password": "uO7*$E@0ngqL", - } - response = self.client.post(reverse("do-password-reset"), data) - config.set("enable_prelogin", True) - self.assertFalse("token" in response.data["d"]) - - -class VerifyEmailTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="ev-test", email="ev-test@example.org") - user.set_password("password") - user.save() - self.user = user - VerifyEmailView.throttle_scope = "" - - def test_email_verify(self): - data = { - "uid": self.user.id, - "token": self.user.email_token, - } - response = self.client.post(reverse("verify-email"), data) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_email_verify_invalid(self): - data = { - "uid": 123, - "token": "haha brr", - } - response = self.client.post(reverse("verify-email"), data) - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - - def test_email_verify_nologin(self): - config.set("enable_login", False) - - data = { - "uid": self.user.id, - "token": self.user.email_token, - } - response = self.client.post(reverse("verify-email"), data) - config.set("enable_login", False) - self.assertEqual(response.data["d"], "") - - def test_email_verify_twice(self): - data = { - "uid": self.user.id, - "token": self.user.email_token, - } - response = self.client.post(reverse("verify-email"), data) - response = self.client.post(reverse("verify-email"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_email_verify_bad_token(self): - data = { - "uid": self.user.id, - "token": "abc", - } - response = self.client.post(reverse("verify-email"), data) - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - - -class ChangePasswordTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="cp-test", email="cp-test@example.org") - user.set_password("password") - user.save() - self.user = user - ChangePasswordView.throttle_scope = "" - - def test_change_password(self): - self.client.force_authenticate(user=self.user) - data = { - "old_password": "password", - "password": "uO7*$E@0ngqL", - } - response = self.client.post(reverse("change-password"), data) - - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_change_password_weak(self): - self.client.force_authenticate(user=self.user) - data = { - "old_password": "password", - "password": "password", - } - response = self.client.post(reverse("change-password"), data) - self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) - - def test_change_password_invalid_old(self): - self.client.force_authenticate(user=self.user) - data = { - "old_password": "passwordddddddd", - "password": "password", - } - response = self.client.post(reverse("change-password"), data) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - -class RegerateBackupCodesTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="backupcode-test", email="backupcode-test@example.org") - user.set_password("password") - user.save() - TOTPDevice(user=user, verified=True).save() - self.user = user - RegenerateBackupCodesView.throttle_scope = "" - - def test_regenerate_backup_codes_count(self): - self.client.force_authenticate(user=self.user) - response = self.client.post(reverse("regenerate-backup-codes")) - self.assertEqual(len(response.data["d"]["backup_codes"]), 10) - - def test_regenerate_backup_codes_length(self): - self.client.force_authenticate(user=self.user) - response = self.client.post(reverse("regenerate-backup-codes")) - self.assertEqual(sum([len(x) for x in response.data["d"]["backup_codes"]]), 80) - - def test_regenerate_backup_codes_unique(self): - self.client.force_authenticate(user=self.user) - first_response = self.client.post(reverse("regenerate-backup-codes")) - second_response = self.client.post(reverse("regenerate-backup-codes")) - self.assertFalse(set(first_response.data["d"]["backup_codes"]) & set(second_response.data["d"]["backup_codes"])) - - def test_regenerate_backup_codes_no_2fa(self): - user = get_user_model().objects.get(id=self.user.id) - user.totp_device.delete() - user.save() - self.client.force_authenticate(user=get_user_model().objects.get(id=self.user.id)) - response = self.client.post(reverse("regenerate-backup-codes")) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - -class CreateBotUserTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="bot-test", email="bot-test@example.org", is_staff=True, is_superuser=True) - user.set_password("password") - user.save() - self.user = user - CreateBotView.throttle_scope = "" - - def test_unauthenticated(self): - response = self.client.post( - reverse("create-bot"), - data={ - "username": "bottest", - "is_visible": False, - "is_staff": True, - "is_superuser": True, - }, - ) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_authenticated_admin(self): - self.client.force_authenticate(self.user) - response = self.client.post( - reverse("create-bot"), - data={ - "username": "bottest", - "is_visible": False, - "is_staff": True, - "is_superuser": True, - }, - ) - self.assertEqual(response.status_code, HTTP_201_CREATED) - - def test_authenticated_not_admin(self): - self.user.is_staff = False - self.user.is_superuser = False - self.user.save() - self.client.force_authenticate(self.user) - response = self.client.post( - reverse("create-bot"), - data={ - "username": "bottest", - "is_visible": False, - "is_staff": True, - "is_superuser": True, - }, - ) - self.user.is_staff = True - self.user.is_superuser = True - self.user.save() - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_issues_token(self): - self.client.force_authenticate(self.user) - response = self.client.post( - reverse("create-bot"), - data={ - "username": "bottest", - "is_visible": False, - "is_staff": True, - "is_superuser": True, - }, - ) - self.assertTrue("token" in response.data["d"]) diff --git a/src/authentication/tests/factories.py b/src/authentication/tests/factories.py new file mode 100644 index 00000000..bc079e4a --- /dev/null +++ b/src/authentication/tests/factories.py @@ -0,0 +1 @@ +"""Model factories for generating fake data for use in tests.""" diff --git a/src/authentication/tests/test_admin.py b/src/authentication/tests/test_admin.py new file mode 100644 index 00000000..9ab5b95b --- /dev/null +++ b/src/authentication/tests/test_admin.py @@ -0,0 +1,135 @@ +"""Tests for admin only authentication api routes.""" + +from django.http import HttpRequest +from django.urls import reverse +from rest_framework import status +from rest_framework.request import Request +from rest_framework.test import APITestCase + +from authentication import views +from teams.models import Member, Team + + +class SudoTestCase(APITestCase): + """Tests for the sudo view.""" + + def test_sudo(self): + """Sudo as a user should return 200.""" + user = Member(username="sudotest", is_staff=True, email="sudotest@example.com", is_superuser=True) + user.save() + user2 = Member(username="sudotest2", email="sudotest2@example.com") + user2.save() + + self.client.force_authenticate(user) + req = self.client.post(reverse("sudo"), {"id": user2.pk}) + self.assertEqual(req.status_code, status.HTTP_200_OK) + + +class DeSudoTestCase(APITestCase): + """Tests for the desudo view.""" + + def test_desudo(self): + """Dropping sudo should return 200.""" + user2 = Member(username="sudotest2", email="sudotest2@example.com") + user2.save() + + request = Request(HttpRequest()) + request.sudo_from = user2 + + response = views.DesudoView().post(request) + self.assertTrue("token" in response.data["d"]) + + +class CreateBotUserTestCase(APITestCase): + """Tests for creating a bot user.""" + + def setUp(self): + """Create a staff user for use in tests.""" + user = Member(username="bot-test", email="bot-test@example.org", is_staff=True, is_superuser=True) + user.set_password("password") + user.save() + self.user = user + views.CreateBotView.throttle_scope = "" + + def test_unauthenticated(self): + """An unauthenticated user should not be able to make a bot.""" + response = self.client.post( + reverse("create-bot"), + data={ + "username": "bottest", + "is_visible": False, + "is_staff": True, + "is_superuser": True, + }, + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_authenticated_admin(self): + """An admin user should not be able to make a bot.""" + self.client.force_authenticate(self.user) + response = self.client.post( + reverse("create-bot"), + data={ + "username": "bottest", + "is_visible": False, + "is_staff": True, + "is_superuser": True, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_authenticated_not_admin(self): + """A non-admin user should not be able to make a bot.""" + self.user.is_staff = False + self.user.is_superuser = False + self.user.save() + self.client.force_authenticate(self.user) + response = self.client.post( + reverse("create-bot"), + data={ + "username": "bottest", + "is_visible": False, + "is_staff": True, + "is_superuser": True, + }, + ) + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_issues_token(self): + """Creating a bot user should return the bot's token.""" + self.client.force_authenticate(self.user) + response = self.client.post( + reverse("create-bot"), + data={ + "username": "bottest", + "is_visible": False, + "is_staff": True, + "is_superuser": True, + }, + ) + self.assertTrue("token" in response.data["d"]) + + +class GenerateInvitesTestCase(APITestCase): + """Tests for generating invite codes.""" + + def test_response_length(self): + """Test the specified amount of invite codes are generated.""" + user = Member(username="resend-email", is_staff=True, email="tvu@example.com", is_superuser=True) + user.save() + self.client.force_authenticate(user=user) + team = Team.objects.create(owner=user, name=user.username, password="123123") + response = self.client.post(reverse("generate-invites"), {"amount": 15, "auto_team": team.pk, "max_uses": 1}) + self.assertEqual(len(response.data["d"]["invite_codes"]), 15) + + def test_invites_viewset(self): + """Test the invite codes are listed correctly.""" + user = Member(username="resend-email", is_staff=True, email="tvu@example.com", is_superuser=True) + user.save() + self.client.force_authenticate(user=user) + self.client.post(reverse("generate-invites"), {"amount": 15, "max_uses": 1}) + response = self.client.get(reverse("invites-list")) + self.assertEqual(len(response.data["d"]["results"]), 15) diff --git a/src/authentication/tests/test_login.py b/src/authentication/tests/test_login.py new file mode 100644 index 00000000..1974bbe4 --- /dev/null +++ b/src/authentication/tests/test_login.py @@ -0,0 +1,387 @@ +"""Tests related to login functionality.""" + +from unittest import mock + +import pyotp +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from authentication import views +from authentication.models import BackupCode, Token, TOTPDevice +from authentication.tests import utils +from config import config +from teams.models import Member + + +class LogoutTestCase(APITestCase): + """Tests related to the logout endpoint.""" + + def setUp(self): + """Create a user for testing.""" + user = Member(username="logout-test", email="logout-test@example.org") + user.set_password("password") + user.email_verified = True + user.save() + self.user = user + + def test_logout(self): + """An authenticated user logging out should return 200.""" + self.client.post(reverse("login"), data={"username": self.user.username, "password": "password", "otp": ""}) + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("logout")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_logout_not_logged_in(self): + """An unauthenticated user logging out should return 401.""" + response = self.client.post(reverse("logout")) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class LoginTestCase(APITestCase): + """Tests related to the login endpoint.""" + + def setUp(self): + """Create a user for testing.""" + user = Member(username="login-test", email="login-test@example.org") + user.set_password("password") + user.email_verified = True + user.save() + self.user = user + views.LoginView.throttle_scope = "" + + def test_login(self): + """Logging in with valid credentials should return 200.""" + self.user.set_password("password") + data = { + "username": "login-test", + "password": "password", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_invalid(self): + """Logging in with invalid credentials should return 401.""" + data = { + "username": "login-test", + "password": "a", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_missing_data(self): + """Logging in with a malfored request should return 400.""" + data = { + "username": "login-test", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_login_email_not_verified(self): + """Logging in without a verified email should return 401.""" + self.user.email_verified = False + self.user.save() + data = { + "username": "login-test", + "password": "password", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @mock.patch("time.time", side_effect=utils.get_fake_time) + def test_login_login_closed(self, mock_obj): + """Logging in when login is closed should return 401.""" + data = { + "username": "login-test", + "password": "password", + } + config.set("enable_prelogin", False) + response = self.client.post(reverse("login"), data) + config.set("enable_prelogin", True) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_inactive(self): + """Logging in with an inactive account should return 401.""" + self.user.is_active = False + self.user.save() + data = { + "username": "login-test", + "password": "password", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.user.is_active = True + self.user.save() + + def test_login_with_email(self): + """Logging in with valid credentials, but using email, should return 200.""" + self.user.set_password("password") + data = { + "username": "login-test@example.org", + "password": "password", + "otp": "", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_wrong_user(self): + """Logging in with an incorrect username should return 401.""" + data = { + "username": "login-", + "password": "password", + "otp": "", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_wrong_password(self): + """Logging in with an incorrect password should return 401.""" + data = { + "username": "login-test", + "password": "passw", + "otp": "", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_malformed(self): + """Logging in with a malformed request should return 400.""" + data = { + "username": "login-test", + "otp": "", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_login_2fa_required(self): + """Test logging in with no 2fa code when 2fa is active returns 401.""" + TOTPDevice(user=self.user, verified=True).save() + data = { + "username": "login-test", + "password": "password", + } + response = self.client.post(reverse("login"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class Login2FATestCase(APITestCase): + """Tests for the login 2fa view.""" + + def setUp(self): + """Create a user with 2fa enabled for testing.""" + user = Member(username="login-test", email="login-test@example.org") + user.set_password("password") + user.email_verified = True + user.save() + TOTPDevice(user=user, verified=True).save() + self.user = user + views.LoginTwoFactorView.throttle_scope = "" + + def test_login_2fa(self): + """Logging in with correct credentials and 2fa should return 200.""" + secret = TOTPDevice.objects.get(user=self.user).totp_secret + totp = pyotp.TOTP(secret) + data = { + "username": "login-test", + "password": "password", + "tfa": totp.now(), + } + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_2fa_invalid(self): + """ + Logging in with correct credentials and incorrect 2fa should return 200. + + Regression test for CVE-2021-21329. + """ + data = { + "username": "login-test", + "password": "password", + "tfa": "123456", + } + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_2fa_without_2fa(self): + """Logging in using the 2fa view when 2fa is not enabled should return 401.""" + user = Member(username="login-test-no-2fa", email="login-test-no-2fa@example.org") + user.set_password("password") + user.email_verified = True + user.save() + data = {"username": "login-test-no-2fa", "password": "password", "tfa": "123456"} + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_2fa_missing(self): + """Logging in without a 2fa code should return 400.""" + data = { + "username": "login-test", + "password": "password", + } + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_login_2fa_backup_code(self): + """Logging in with correct credentials and a backup code should return 200.""" + BackupCode(user=self.user, code="12345678").save() + data = { + "username": "login-test", + "password": "password", + "tfa": "12345678", + } + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_2fa_backup_code_invalid(self): + """Logging in with correct credentials and an invalid backup code should return 401.""" + BackupCode(user=self.user, code="12345678").save() + data = { + "username": "login-test", + "password": "password", + "tfa": "87654321", + } + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_2fa_invalid_code(self): + """ + Logging in with correct credentials but a 2fa code that isnt possibly valid should return 401. + + Regression test for CVE-2021-21329. + """ + data = { + "username": "login-test", + "password": "password", + "tfa": "123456789", + } + response = self.client.post(reverse("login-2fa"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TokenTestCase(APITestCase): + """Tests for the Token database model.""" + + def test_token_str(self): + """The string representation should be equal to the token value.""" + user = Member(username="token-test", email="token-test@example.org") + user.save() + tok = Token(key="a" * 40, user=user) + self.assertEqual(str(tok), "a" * 40) + + def test_token_preserves_key(self): + """Saving a token should not change its value.""" + user = Member(username="token-test-2", email="token-test-2@example.org") + user.save() + token = Token(key="a" * 40, user=user) + token.save() + self.assertEqual(token.key, "a" * 40) + + +class TFATestCase(APITestCase): + """Tests for api endpoints that manage 2fa.""" + + def setUp(self): + """Create a user for testing and remove ratelimits.""" + user = Member(username="2fa-test", email="2fa-test@example.org") + user.set_password("password") + user.email_verified = True + user.save() + self.user = user + views.AddTwoFactorView.throttle_scope = "" + views.VerifyTwoFactorView.throttle_scope = "" + + def test_add_2fa_unauthenticated(self): + """Adding 2fa when not logged in should return 401.""" + response = self.client.post(reverse("add-2fa")) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_add_2fa(self): + """Adding 2fa when authenticated should add a totp device, but not active 2fa.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + self.assertFalse(self.user.has_2fa()) + self.assertNotEqual(self.user.totp_device, None) + + def test_add_2fa_twice(self): + """Adding 2fa twice should overwrite the original totp device.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + response = self.client.post(reverse("add-2fa")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_verify_2fa(self): + """Verifying a totp device should activate totp on that user.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + secret = self.user.totp_device.totp_secret + totp = pyotp.TOTP(secret) + self.client.post(reverse("verify-2fa"), data={"otp": totp.now()}) + self.assertTrue(self.user.has_2fa()) + + def test_verify_2fa_invalid(self): + """Verifying 2fa with an invalid code should be rejected.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + self.client.post(reverse("verify-2fa"), data={"otp": "123456"}) + self.assertFalse(self.user.totp_device.verified) + + def test_add_2fa_with_2fa(self): + """Adding 2fa when 2fa is already verified should return 403.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + secret = self.user.totp_device.totp_secret + totp = pyotp.TOTP(secret) + self.client.post(reverse("verify-2fa"), data={"otp": totp.now()}) + response = self.client.post(reverse("add-2fa")) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_remove_2fa(self): + """Removing 2fa with a valid 2fa code should return 200.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + self.user.refresh_from_db() + totp_device = self.user.totp_device + totp_device.verified = True + totp_device.save() + self.user.refresh_from_db() + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("remove-2fa"), data={"otp": pyotp.TOTP(totp_device.totp_secret).now()}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_remove_2fa_fail(self): + """Removing 2fa with an invalid 2fa code should return 401.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + self.user.refresh_from_db() + totp_device = self.user.totp_device + totp_device.verified = True + totp_device.save() + self.user.refresh_from_db() + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("remove-2fa"), data={"otp": "invalid_otp"}) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_remove_2fa_removes_2fa(self): + """Removing 2fa with a valid 2fa code should disable 2fa on the user.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + self.user.refresh_from_db() + totp_device = self.user.totp_device + totp_device.verified = True + totp_device.save() + self.user.refresh_from_db() + self.client.force_authenticate(user=self.user) + self.client.post(reverse("remove-2fa"), data={"otp": pyotp.TOTP(totp_device.totp_secret).now()}) + self.user.refresh_from_db() + self.assertFalse(self.user.has_2fa()) + + def test_remove_2fa_no_2fa(self): + """Removing 2fa without active 2fa should return 403.""" + self.client.force_authenticate(user=self.user) + self.client.post(reverse("add-2fa")) + self.user.refresh_from_db() + user.totp_device = None + user.save() + response = self.client.post(reverse("remove-2fa")) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/src/authentication/tests/test_password.py b/src/authentication/tests/test_password.py new file mode 100644 index 00000000..6733aadf --- /dev/null +++ b/src/authentication/tests/test_password.py @@ -0,0 +1,159 @@ +"""Test any password or password-reset related logic in authentication.""" +import time +from unittest import mock + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from authentication import views +from authentication.models import PasswordResetToken +from authentication.tests import utils +from config import config +from teams.models import Member + + +class RequestPasswordResetTestCase(APITestCase): + """Tests related to requesting a password reset.""" + + def setUp(self): + """Remove the ratelimit.""" + views.RequestPasswordResetView.throttle_scope = "" + + def test_password_reset_request_invalid(self): + """Requesting a password reset on an invalid email should return 200.""" + response = self.client.post(reverse("request-password-reset"), data={"email": "user10@example.org"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_password_reset_request_valid(self): + """Requesting a password reset on a valid email should return 200.""" + with self.settings( + MAIL={"SEND_ADDRESS": "no-reply@ractf.co.uk", "SEND_NAME": "RACTF", "SEND": True, "SEND_MODE": "SES"} + ): + Member(username="test-password-rest", email="user10@example.org", email_verified=True).save() + response = self.client.post(reverse("request-password-reset"), data={"email": "user10@example.org"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class DoPasswordResetTestCase(APITestCase): + """Tests related to completing a password reset.""" + + def setUp(self): + """Remove the ratelimit and create a test user.""" + user = Member(username="pr-test", email="pr-test@example.org") + user.set_password("password") + user.email_verified = True + user.save() + PasswordResetToken(user=user, token="testtoken").save() + self.user = user + views.DoPasswordResetView.throttle_scope = "" + + def test_password_reset(self): + """Completing a password reset should return 200.""" + data = { + "uid": self.user.pk, + "token": "testtoken", + "password": "uO7*$E@0ngqL", + } + response = self.client.post(reverse("do-password-reset"), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_password_reset_issues_token(self): + """Completing a password reset should issue a token.""" + config.set("start_time", time.time() - 50000) + data = { + "uid": self.user.pk, + "token": "testtoken", + "password": "uO7*$E@0ngqL", + } + response = self.client.post(reverse("do-password-reset"), data) + print(response.data) + self.assertTrue("token" in response.data["d"]) + + def test_password_reset_bad_token(self): + """Attempting a password reset with an invalid token should 404.""" + data = { + "uid": self.user.pk, + "token": "abc", + "password": "uO7*$E@0ngqL", + } + response = self.client.post(reverse("do-password-reset"), data) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_password_reset_weak_password(self): + """Weak passwords should be rejected.""" + data = { + "uid": self.user.pk, + "token": "testtoken", + "password": "password", + } + response = self.client.post(reverse("do-password-reset"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_password_reset_login_disabled(self): + """Tokens should not be issued when login is disabled.""" + config.set("enable_login", False) + data = { + "uid": self.user.pk, + "token": "testtoken", + "password": "uO7*$E@0ngqL", + } + response = self.client.post(reverse("do-password-reset"), data) + config.set("enable_login", True) + self.assertFalse("token" in response.data["d"]) + + @mock.patch("time.time", side_effect=utils.get_fake_time) + def test_password_reset_cant_login_yet(self, obj): + """Tokens should not be issued before login is enabled.""" + config.set("enable_prelogin", False) + data = { + "uid": self.user.pk, + "token": "testtoken", + "password": "uO7*$E@0ngqL", + } + response = self.client.post(reverse("do-password-reset"), data) + config.set("enable_prelogin", True) + self.assertFalse("token" in response.data["d"]) + + +class ChangePasswordTestCase(APITestCase): + """Tests related to changing passwords.""" + + def setUp(self): + """Create a user for testing.""" + user = Member(username="cp-test", email="cp-test@example.org") + user.set_password("password") + user.save() + self.user = user + views.ChangePasswordView.throttle_scope = "" + + def test_change_password(self): + """Changing the password should return 200.""" + self.client.force_authenticate(user=self.user) + data = { + "old_password": "password", + "password": "uO7*$E@0ngqL", + } + response = self.client.post(reverse("change-password"), data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_change_password_weak(self): + """Setting a weak password should be denied.""" + self.client.force_authenticate(user=self.user) + data = { + "old_password": "password", + "password": "password", + } + response = self.client.post(reverse("change-password"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_change_password_invalid_old(self): + """Changing a password with an incorrect old password should be denied.""" + self.client.force_authenticate(user=self.user) + data = { + "old_password": "passwordddddddd", + "password": "password", + } + response = self.client.post(reverse("change-password"), data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/src/authentication/tests/test_register.py b/src/authentication/tests/test_register.py new file mode 100644 index 00000000..67a58c47 --- /dev/null +++ b/src/authentication/tests/test_register.py @@ -0,0 +1,389 @@ +"""Tests for registration related authentication api endpoints.""" + +from unittest import mock + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from authentication import views +from authentication.models import InviteCode, TOTPDevice +from authentication.tests import utils +from config import config +from teams.models import Member, Team + + +class RegisterTestCase(APITestCase): + """Tests for the register endpoint.""" + + def setUp(self): + """Remove the ratelimit.""" + views.RegistrationView.throttle_scope = "" + + def test_register(self): + """Registering a user should return 201.""" + data = { + "username": "user1", + "password": "uO7*$E@0ngqL", + "email": "user@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_register_with_mail(self): + """Registering a user with mail enabled should return True.""" + with self.settings( + MAIL={"SEND_ADDRESS": "no-reply@ractf.co.uk", "SEND_NAME": "RACTF", "SEND": True, "SEND_MODE": "SES"} + ): + data = { + "username": "user1", + "password": "uO7*$E@0ngqL", + "email": "user@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_register_weak_password(self): + """Registering with a weak password should return 400.""" + data = { + "username": "user2", + "password": "password", + "email": "user2@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_duplicate_username(self): + """Registering a taken username should return 400.""" + data = { + "username": "user3", + "password": "uO7*$E@0ngqL", + "email": "user3@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + data = { + "username": "user3", + "password": "uO7*$E@0ngqL", + "email": "user4@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_duplicate_email(self): + """Registering a taken email should return 400.""" + data = { + "username": "user4", + "password": "uO7*$E@0ngqL", + "email": "user4@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + data = { + "username": "user5", + "password": "uO7*$E@0ngqL", + "email": "user4@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @mock.patch("time.time", side_effect=utils.get_fake_time) + def test_register_closed(self, mock_obj): + """Registering when registration is closed should return 403.""" + config.set("enable_prelogin", False) + data = { + "username": "user6", + "password": "uO7*$E@0ngqL", + "email": "user6@example.org", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + config.set("enable_prelogin", True) + + def test_register_admin(self): + """The first registered user should be staff.""" + data = { + "username": "user6", + "password": "uO7*$E@0ngqL", + "email": "user6@example.org", + } + self.client.post(reverse("register"), data) + self.assertTrue(Member.objects.filter(username=data["username"]).first().is_staff) + + def test_register_second(self): + """Users after the first registered user should not automatically be staff.""" + data = { + "username": "user6", + "password": "uO7*$E@0ngqL", + "email": "user6@example.org", + } + self.client.post(reverse("register"), data) + data = { + "username": "user7", + "password": "uO7*$E@0ngqL", + "email": "user7@example.org", + } + self.client.post(reverse("register"), data) + self.assertFalse(Member.objects.filter(username=data["username"]).first().is_staff) + + def test_register_malformed(self): + """A malformed registration should return 400.""" + data = { + "username": "user6", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_invalid_email(self): + """Registering with an invalid email should return 400.""" + data = { + "username": "user6", + "password": "uO7*$E@0ngqL", + "email": "user6", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_teams_disabled(self): + """Registering with teams disabled should automatically create a new team.""" + config.set("enable_teams", False) + data = { + "username": "user10", + "password": "uO7*$E@0ngqL", + "email": "user10@example.com", + } + response = self.client.post(reverse("register"), data) + config.set("enable_teams", True) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Member.objects.get(username="user10").team.name, "user10") + + +class EmailResendTestCase(APITestCase): + """Tests for resending email verification.""" + + def test_email_resend(self): + """Resending a verification email should always return 200.""" + with self.settings(RATELIMIT_ENABLE=False): + user = Member(username="test_verify_user", email_verified=False, email="tvu@example.com") + user.save() + response = self.client.post(reverse("resend-email"), {"email": "tvu@example.com"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_already_verified_email_resend(self): + """Resending a verification email should always return 200.""" + with self.settings(RATELIMIT_ENABLE=False): + user = Member(username="resend-email", email_verified=True, email="tvu@example.com") + user.save() + response = self.client.post(reverse("resend-email"), {"email": "tvu@example.com"}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_non_existing_email_resend(self): + """Resending a verification email should always return 200.""" + with self.settings(RATELIMIT_ENABLE=False): + response = self.client.post(reverse("resend-email"), {"email": "nonexisting@example.com"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class VerifyEmailTestCase(APITestCase): + """Tests for verifying a users email.""" + + def setUp(self): + """Create a test user and remove the ratelimits.""" + user = Member(username="ev-test", email="ev-test@example.org") + user.set_password("password") + user.save() + self.user = user + views.VerifyEmailView.throttle_scope = "" + + def test_email_verify(self): + """Verifying an email should return 200.""" + data = { + "uid": self.user.pk, + "token": self.user.email_token, + } + response = self.client.post(reverse("verify-email"), data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_email_verify_invalid(self): + """Verifying email with an invalid user should return 404.""" + data = { + "uid": 123, + "token": "haha brr", + } + response = self.client.post(reverse("verify-email"), data) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_email_verify_nologin(self): + """Verifying email when login is disabled should not issue a token.""" + config.set("enable_login", False) + + data = { + "uid": self.user.pk, + "token": self.user.email_token, + } + response = self.client.post(reverse("verify-email"), data) + config.set("enable_login", False) + self.assertEqual(response.data["d"], "") + + def test_email_verify_twice(self): + """Verifying an already verified email should return 400.""" + data = { + "uid": self.user.pk, + "token": self.user.email_token, + } + response = self.client.post(reverse("verify-email"), data) + response = self.client.post(reverse("verify-email"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_email_verify_bad_token(self): + """Verifying email with an invalid token should return 404.""" + data = { + "uid": self.user.pk, + "token": "abc", + } + response = self.client.post(reverse("verify-email"), data) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class InviteRequiredRegistrationTestCase(APITestCase): + """Tests for registration when invites are required.""" + + def setUp(self): + """Create some invites, users, teams for testing.""" + views.RegistrationView.throttle_scope = "" + config.set("invite_required", True) + InviteCode(code="test1", max_uses=10).save() + InviteCode(code="test2", max_uses=1).save() + InviteCode(code="test3", max_uses=1).save() + user = Member( + username="invtestadmin", + email="invtestadmin@example.org", + email_verified=True, + is_superuser=True, + is_staff=True, + ) + user.set_password("password") + user.save() + self.user = user + team = Team(name="team", password="password", owner=user) + team.save() + self.team = team + InviteCode(code="test4", max_uses=1, auto_team=team).save() + + def tearDown(self): + """Undo changes to config.""" + config.set("invite_required", False) + + def test_register_invite_required_missing_invite(self): + """Registering without an invite should return 400.""" + data = { + "username": "user7", + "password": "uO7*$E@0ngqL", + "email": "user7@example.com", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_invite_required_valid(self): + """Registering with a valid invite should return 201.""" + data = { + "username": "user8", + "password": "uO7*$E@0ngqL", + "email": "user8@example.com", + "invite": "test1", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_register_invite_required_invalid(self): + """Registering with an invalid invite should return 403.""" + data = { + "username": "user8", + "password": "uO7*$E@0ngqL", + "email": "user8@example.com", + "invite": "test1---", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_register_invite_required_already_used(self): + """Registering with an already used invite should return 403.""" + data = { + "username": "user9", + "password": "uO7*$E@0ngqL", + "email": "user9@example.com", + "invite": "test2", + } + response = self.client.post(reverse("register"), data) + data = { + "username": "user10", + "password": "uO7*$E@0ngqL", + "email": "user10@example.com", + "invite": "test2", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_register_invite_required_valid_maxing_uses(self): + """Registering when the invite is one use off max should return 201.""" + data = { + "username": "user11", + "password": "uO7*$E@0ngqL", + "email": "user11@example.com", + "invite": "test3", + } + response = self.client.post(reverse("register"), data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_register_invite_required_auto_team(self): + """Registering with an auto team invite should add the user to that team.""" + data = { + "username": "user12", + "password": "uO7*$E@0ngqL", + "email": "user12@example.com", + "invite": "test4", + } + self.client.post(reverse("register"), data) + self.assertEqual(Member.objects.get(username="user12").team.pk, self.team.pk) + + +class RegenerateBackupCodesTestCase(APITestCase): + """Tests for regenerating backup codes.""" + + def setUp(self): + """Create a user and totp device for testing, remove the ratelimit.""" + user = Member(username="backupcode-test", email="backupcode-test@example.org") + user.set_password("password") + user.save() + TOTPDevice(user=user, verified=True).save() + self.user = user + views.RegenerateBackupCodesView.throttle_scope = "" + + def test_regenerate_backup_codes_count(self): + """Test the correct amount of backup codes are regenerated.""" + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("regenerate-backup-codes")) + self.assertEqual(len(response.data["d"]["backup_codes"]), 10) + + def test_regenerate_backup_codes_length(self): + """Backup codes should be 8 characters each.""" + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("regenerate-backup-codes")) + self.assertEqual(sum([len(x) for x in response.data["d"]["backup_codes"]]), 80) + + def test_regenerate_backup_codes_unique(self): + """Each backup code should be unique.""" + self.client.force_authenticate(user=self.user) + first_response = self.client.post(reverse("regenerate-backup-codes")) + second_response = self.client.post(reverse("regenerate-backup-codes")) + self.assertFalse(set(first_response.data["d"]["backup_codes"]) & set(second_response.data["d"]["backup_codes"])) + + def test_regenerate_backup_codes_no_2fa(self): + """Backup codes should not be able to be generated if 2fa is disabled.""" + self.user.refresh_from_db() + user = self.user + user.totp_device.delete() + user.save() + self.client.force_authenticate(user=user) + response = self.client.post(reverse("regenerate-backup-codes")) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/src/authentication/tests/utils.py b/src/authentication/tests/utils.py new file mode 100644 index 00000000..95d2a98e --- /dev/null +++ b/src/authentication/tests/utils.py @@ -0,0 +1,7 @@ +"""Utilities for use in authentication tests.""" + + +def get_fake_time() -> int: + """Return a fake time in tests.""" + # TODO: Make this use a 'faker' provided time. + return 0 diff --git a/src/authentication/urls.py b/src/authentication/urls.py index c06092a8..f1d2e28c 100644 --- a/src/authentication/urls.py +++ b/src/authentication/urls.py @@ -1,3 +1,5 @@ +"""URL routes for use in the authentication app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter diff --git a/src/authentication/views.py b/src/authentication/views.py index d0a6ee57..c19c1841 100644 --- a/src/authentication/views.py +++ b/src/authentication/views.py @@ -1,284 +1,285 @@ +"""Views and relevant logic for use in authentication.""" + import random import secrets import string from django.conf import settings -from django.contrib.auth import get_user_model from django.core.validators import EmailValidator from django.db import transaction -from django.utils.decorators import method_decorator -from django.views.decorators.debug import sensitive_post_parameters +from django.db.models import QuerySet from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import permissions +from core import providers +from rest_framework import permissions, status from rest_framework.generics import CreateAPIView, GenericAPIView, get_object_or_404 -from rest_framework.status import ( - HTTP_201_CREATED, - HTTP_400_BAD_REQUEST, - HTTP_401_UNAUTHORIZED, -) +from rest_framework.request import Request from rest_framework.views import APIView from authentication import serializers +from authentication.mixins import HidePasswordMixin from authentication.models import BackupCode, InviteCode, PasswordResetToken, TOTPDevice from authentication.permissions import HasTwoFactor, VerifyingTwoFactor -from authentication.serializers import ( - ChangePasswordSerializer, - CreateBotSerializer, - EmailSerializer, - EmailVerificationSerializer, - GenerateInvitesSerializer, - InviteCodeSerializer, - RegistrationSerializer, -) -from backend.mail import send_email -from backend.permissions import IsBot, IsSudo -from backend.response import FormattedResponse -from backend.signals import ( - add_2fa, - change_password, - email_verified, - logout, - password_reset, - password_reset_start, - password_reset_start_reject, - remove_2fa, - verify_2fa, -) -from backend.viewsets import AdminListModelViewSet from config import config -from plugins import providers -from team.models import Team +from core import providers, signals +from core.mail import send_email +from core.permissions import IsBot, IsSudo +from core.response import FormattedResponse +from core.types import AuthenticatedRequest +from core.viewsets import AdminListModelViewSet +from teams.models import Member, Team + +INVITE_CHARACTERS = string.ascii_letters + string.digits -hide_password = method_decorator( - sensitive_post_parameters( - "password", - ) -) +class LoginView(APIView, HidePasswordMixin): + """View for validating login fields and authenticating users.""" -class LoginView(APIView): permission_classes = (~permissions.IsAuthenticated,) serializer_class = serializers.LoginSerializer throttle_scope = "login" - @hide_password - def dispatch(self, *args, **kwargs): - return super(LoginView, self).dispatch(*args, **kwargs) - - def post(self, request, *args, **kwargs): + def post(self, request: AuthenticatedRequest, *args, **kwargs) -> FormattedResponse: + """Validate provided login data, and return the relevant login token.""" serializer = self.serializer_class(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) user = serializer.validated_data["user"] if user.has_2fa(): - return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={"reason": "2fa_required"}, m="2fa_required") + return FormattedResponse( + status=status.HTTP_401_UNAUTHORIZED, d={"reason": "2fa_required"}, m="2fa_required" + ) token = providers.get_provider("token").issue_token(user) return FormattedResponse({"token": token}) -class RegistrationView(CreateAPIView): - model = get_user_model() +class RegistrationView(CreateAPIView, HidePasswordMixin): + """View for validating and registering new users.""" + + model = Member permission_classes = (~permissions.IsAuthenticated,) - serializer_class = RegistrationSerializer + serializer_class = serializers.RegistrationSerializer throttle_scope = "register" - @hide_password - def dispatch(self, *args, **kwargs): - return super(RegistrationView, self).dispatch(*args, **kwargs) - class LogoutView(APIView): + """View for deleting user login tokens.""" + permission_classes = (permissions.IsAuthenticated & ~IsBot,) - def post(self, request): - logout.send(sender=self.__class__, user=request.user) + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Logout the user associated with the provided request.""" + signals.logout.send(sender=LogoutView, user=request.user) request.user.tokens.all().delete() return FormattedResponse() class AddTwoFactorView(APIView): + """View for adding two-factor authentication as a requirement for the user.""" + permission_classes = (permissions.IsAuthenticated & ~HasTwoFactor & ~IsBot,) throttle_scope = "2fa" - def post(self, request): - if TOTPDevice.objects.filter(user=request.user).exists(): - TOTPDevice.objects.get(user=request.user).delete() - totp_device = TOTPDevice(user=request.user) - totp_device.save() - add_2fa.send(sender=self.__class__, user=request.user) + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Delete any existing TOTP Devices, provision a new one and return the relevant secret.""" + TOTPDevice.objects.filter(user=request.user).delete() + totp_device = TOTPDevice.objects.create(user=request.user) + # TODO: Move this signal to be a post_save on the TOTPDevice model. + signals.add_2fa.send(sender=AddTwoFactorView, user=request.user) return FormattedResponse({"totp_secret": totp_device.totp_secret}) class VerifyTwoFactorView(APIView): + """View for verifying a user's 2FA code.""" + permission_classes = (permissions.IsAuthenticated & VerifyingTwoFactor & ~IsBot,) throttle_scope = "2fa" - def post(self, request): - if request.user.totp_device is not None and request.user.totp_device.validate_token(request.data["otp"]): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Validate the provided OTP token and verify the request.""" + otp_token = request.data.get("otp", "") + + if request.user.totp_device is not None and request.user.totp_device.validate_token(otp_token): request.user.totp_device.verified = True request.user.totp_device.save() - backup_codes = BackupCode.generate(request.user) - verify_2fa.send(sender=self.__class__, user=request.user) + backup_codes = BackupCode.generate_for(request.user) + signals.verify_2fa.send(sender=VerifyTwoFactorView, user=request.user) return FormattedResponse({"valid": True, "backup_codes": backup_codes}) return FormattedResponse({"valid": False}) class RemoveTwoFactorView(APIView): + """View for removing 2FA from a user's account.""" + permission_classes = (permissions.IsAuthenticated & HasTwoFactor & ~IsBot,) throttle_scope = "2fa" - def post(self, request): - code = request.data["otp"] - if request.user.totp_device.validate_token(code): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Remove the user's TOTP device and send them an email.""" + otp_token = request.data.get("otp", "") + + if request.user.totp_device.validate_token(otp_token): request.user.totp_device.delete() request.user.save() + # TODO: Move this signal to be a post_delete on the TOTPDevice model. remove_2fa.send(sender=self.__class__, user=request.user) send_email(request.user.email, f"{config.get('event_name')} - 2FA Has Been Disabled", "2fa_removed") return FormattedResponse() - return FormattedResponse(status=HTTP_401_UNAUTHORIZED, m="code_incorrect") + return FormattedResponse(status=status.HTTP_401_UNAUTHORIZED, m="code_incorrect") + +class LoginTwoFactorView(APIView, HidePasswordMixin): + """View for logging in a user with 2FA enabled.""" -class LoginTwoFactorView(APIView): permission_classes = (~permissions.IsAuthenticated,) serializer_class = serializers.LoginTwoFactorSerializer throttle_scope = "login" - @hide_password - def dispatch(self, *args, **kwargs): - return super(LoginTwoFactorView, self).dispatch(*args, **kwargs) - - def issue_token(self, user): - token = providers.get_provider("token").issue_token(user) - return FormattedResponse({"token": token}) - - def post(self, request, *args, **kwargs): + def post(self, request: Request, *args, **kwargs) -> FormattedResponse: + """Log in a user using their provided two factor auth token.""" serializer = self.serializer_class(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) + user = serializer.validated_data["user"] + provider = providers.get_provider("token") if not user.has_2fa(): - return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={"reason": "2fa_not_enabled"}, m="2fa_not_enabled") + return FormattedResponse( + status=status.HTTP_401_UNAUTHORIZED, d={"reason": "2fa_not_enabled"}, m="2fa_not_enabled" + ) token = serializer.data["tfa"] if len(token) == 6: if user.totp_device is not None and user.totp_device.validate_token(token): - return self.issue_token(user) + return FormattedResponse({"token": provider.issue_token(user)}) + elif len(token) == 8: - for code in user.backup_codes.all(): - if token == code.code: - code.delete() - return self.issue_token(user) + for code in user.backup_codes.filter(code=token): + code.delete() + return FormattedResponse({"token": provider.issue_token(user)}) - return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={"reason": "login_failed"}, m="login_failed") + return FormattedResponse(status=status.HTTP_401_UNAUTHORIZED, d={"reason": "login_failed"}, m="login_failed") class RegenerateBackupCodesView(APIView): + """View for re-generating a user's backup codes.""" + permission_classes = (permissions.IsAuthenticated & HasTwoFactor & ~IsBot,) serializer_class = serializers.LoginTwoFactorSerializer throttle_scope = "2fa" - def post(self, request, *args, **kwargs): - backup_codes = BackupCode.generate(request.user) + def post(self, request: AuthenticatedRequest, *args, **kwargs) -> FormattedResponse: + """Regenerate the user's backup codes, and return the new set.""" + BackupCode.objects.filter(user=request.user).delete() + backup_codes = BackupCode.generate_for(request.user) return FormattedResponse({"backup_codes": backup_codes}) class RequestPasswordResetView(APIView): + """View for requesting a password reset on the user's account.""" + permission_classes = (~permissions.IsAuthenticated,) throttle_scope = "request_password_reset" - def post(self, request): - email = request.data["email"] + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Given an email, trigger a password reset request on the relevant user.""" + email = request.data.get("email", "") email_validator = EmailValidator() email_validator(email) - # prevent timing attack - is this necessary? + + # The following logic may be needed to prevent a timing attack. + # TODO: Evaluate whether this is necessary. try: - user = get_user_model().objects.get(email=email, email_verified=True) - token = PasswordResetToken(user=user, token=secrets.token_hex()) - token.save() - uid = user.id - token = token.token - password_reset_start.send(sender=self.__class__, user=user) - except get_user_model().DoesNotExist: - password_reset_start_reject.send(sender=self.__class__, email=email) - uid = -1 - token = "" - email = "noreply@ractf.co.uk" + user = Member.objects.get(email=email, email_verified=True) + token = PasswordResetToken.objects.create(user=user, token=secrets.token_hex()) + signals.password_reset_start.send(RequestPasswordResetView, user=user) + user_id, token = user.pk, token.token + + except Member.DoesNotExist: + signals.password_reset_start_reject.send(RequestPasswordResetView, email=email) + user_id, token, email = -1, "", "noreply@ractf.co.uk" if settings.MAIL["SEND"]: send_email( email, f"{config.get('event_name')} - Reset Your Password", "password_reset", - url=settings.FRONTEND_URL + "password_reset?id={}&secret={}".format(uid, token), + url=settings.FRONTEND_URL + f"password_reset?id={user_id}&secret={token}", ) return FormattedResponse() -class DoPasswordResetView(GenericAPIView): +class DoPasswordResetView(GenericAPIView, HidePasswordMixin): + """View for fulfilling a user's password reset request.""" + permission_classes = (~permissions.IsAuthenticated,) serializer_class = serializers.PasswordResetSerializer throttle_scope = "password_reset" - @hide_password - def dispatch(self, *args, **kwargs): - return super(DoPasswordResetView, self).dispatch(*args, **kwargs) - - def post(self, request): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Validate the provided token and password, and issue a new one.""" serializer = self.serializer_class(data=request.data, context={"request": request}) if not serializer.is_valid(): - return FormattedResponse(d=serializer.errors, m="bad_request", status=HTTP_400_BAD_REQUEST) + return FormattedResponse(d=serializer.errors, m="bad_request", status=status.HTTP_400_BAD_REQUEST) + data = serializer.validated_data - user = data["user"] - password = data["password"] + user, password = data["user"], data["password"] user.set_password(password) user.save() data["reset_token"].delete() - password_reset.send(sender=self.__class__, user=user) - if user.can_login(): + signals.password_reset.send(DoPasswordResetView, user=user) + if user.can_login: return FormattedResponse({"token": user.issue_token()}) - else: - return FormattedResponse() + return FormattedResponse() class VerifyEmailView(GenericAPIView): + """View for verifying the provided user's email address.""" + permission_classes = (~permissions.IsAuthenticated,) throttle_scope = "verify_email" - serializer_class = EmailVerificationSerializer + serializer_class = serializers.EmailVerificationSerializer - def post(self, request): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Verify the user and verification token, then verify the user.""" + # TODO: Use Django forms in these situations to reduce repeated logic. serializer = self.serializer_class(data=request.data, context={"request": request}) if not serializer.is_valid(): return FormattedResponse( m="invalid_token_or_uid", d=serializer.errors, - status=HTTP_400_BAD_REQUEST, + status=status.HTTP_400_BAD_REQUEST, ) + user = serializer.validated_data["user"] user.email_verified = True user.is_visible = True user.save() - email_verified.send(sender=self.__class__, user=user) - if user.can_login(): + + signals.email_verified.send(sender=VerifyEmailView, user=user) + if user.can_login: return FormattedResponse({"token": user.issue_token()}) else: return FormattedResponse() class ResendEmailView(GenericAPIView): + """View for resending the user's verification email.""" + permission_classes = (~permissions.IsAuthenticated,) throttle_scope = "resend_verify_email" - serializer_class = EmailSerializer + serializer_class = serializers.ResendEmailSerializer - def post(self, request): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Validate the provided email, and send the relevant verification email.""" serializer = self.serializer_class(data=request.data, context={"request": request}) if not serializer.is_valid(): return FormattedResponse( m="invalid_token_or_uid", d=serializer.errors, - status=HTTP_400_BAD_REQUEST, + status=status.HTTP_400_BAD_REQUEST, ) # Already verified email is checked in the email serializer. @@ -287,72 +288,85 @@ def post(self, request): user.email, f"{config.get('event_name')} - Verify your email", "verify", - url=settings.FRONTEND_URL + "verify?id={}&secret={}".format(user.id, user.email_token), + url=settings.FRONTEND_URL + f"verify?id={user.pk}&secret={user.email_token}", ) return FormattedResponse("email_resent") -class ChangePasswordView(APIView): +class ChangePasswordView(APIView, HidePasswordMixin): + """View for changing the provided user's password.""" + permission_classes = (permissions.IsAuthenticated & ~IsBot,) throttle_scope = "change_password" - serializer_class = ChangePasswordSerializer - - @hide_password - def dispatch(self, *args, **kwargs): - return super(ChangePasswordView, self).dispatch(*args, **kwargs) + serializer_class = serializers.ChangePasswordSerializer - def post(self, request): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Validate the provided password, and send the password changed signal.""" serializer = self.serializer_class(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) - user = request.user - password = serializer.validated_data["password"] + user, password = request.user, serializer.validated_data["password"] user.set_password(password) user.save() - change_password.send(sender=self.__class__, user=user) + signals.change_password.send(ChangePasswordView, user=user) return FormattedResponse() class GenerateInvitesView(APIView): + """View used by admins to generate invites.""" + permission_classes = (permissions.IsAdminUser,) - serializer_class = GenerateInvitesSerializer + serializer_class = serializers.GenerateInvitesSerializer + + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Generate a set of new invite codes, and return them to the user.""" + invite_codes, team = [], None - def post(self, request): serializer = self.serializer_class(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) - codes = [] active_codes = InviteCode.objects.count() + + auto_team, amount, max_uses = (serializer.validated_data[key] for key in ("auto_team", "amount", "max_uses")) + if serializer.validated_data["auto_team"]: - team = get_object_or_404(Team, id=serializer.validated_data["auto_team"]) + team = get_object_or_404(Team, pk=auto_team) + with transaction.atomic(): - for i in range(active_codes, serializer.validated_data["amount"] + active_codes): - code = f"{''.join([random.choice(string.ascii_letters + string.digits) for _ in range(8)])}{hex(i)[2:]}" - codes.append(code) - invite = InviteCode(code=code, max_uses=serializer.validated_data["max_uses"]) - if serializer.validated_data["auto_team"]: + for current_code in range(active_codes, amount + active_codes): + code = "".join(random.choice(INVITE_CHARACTERS) for _ in range(8)) + code += hex(current_code)[2:] + invite_codes.append(code) + invite = InviteCode(code=code, max_uses=max_uses) + if auto_team: invite.auto_team = team invite.save() - return FormattedResponse({"invite_codes": codes}) + return FormattedResponse({"invite_codes": invite_codes}) class InviteViewSet(AdminListModelViewSet): + """A viewset for querying on all invite codes in the database.""" + permission_classes = (permissions.IsAdminUser,) - admin_serializer_class = InviteCodeSerializer - list_admin_serializer_class = InviteCodeSerializer + admin_serializer_class = serializers.InviteCodeSerializer + list_admin_serializer_class = serializers.InviteCodeSerializer filter_backends = [DjangoFilterBackend] filterset_fields = ["code", "fully_used", "auto_team"] - def get_queryset(self): + def get_queryset(self) -> "QuerySet[InviteCode]": + """Return all InviteCodes, ordered by ID.""" return InviteCode.objects.order_by("id") class CreateBotView(APIView): + """View for admins to create a new bot user.""" + permission_classes = (permissions.IsAdminUser & ~IsBot,) - serializer_class = CreateBotSerializer + serializer_class = serializers.CreateBotSerializer - def post(self, request): + def post(self, request: AuthenticatedRequest) -> FormattedResponse: + """Create a new member with the 'is_bot' attribute, then return the bot token.""" serializer = self.serializer_class(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) - bot = get_user_model()( + bot = Member.objects.create( username=serializer.data["username"], email_verified=True, is_visible=serializer.data["is_visible"], @@ -361,21 +375,25 @@ def post(self, request): is_bot=True, email=serializer.data["username"] + "@bot.ractf", ) - bot.save() - return FormattedResponse(d={"token": bot.issue_token()}, status=HTTP_201_CREATED) + return FormattedResponse(d={"token": bot.issue_token()}, status=status.HTTP_201_CREATED) class SudoView(APIView): + """View for bots to authenticate as a normal user.""" + permission_classes = (permissions.IsAdminUser & ~IsBot & ~IsSudo,) - def post(self, request): - id = request.data["id"] - user = get_object_or_404(get_user_model(), id=id) + def post(self, request: Request) -> FormattedResponse: + """Get the associated Member object, and issue a new token for them.""" + user = get_object_or_404(Member, pk=request.data["id"]) return FormattedResponse(d={"token": user.issue_token(owner=request.user)}) class DesudoView(APIView): + """View to return a bot user to their original state.""" + permission_classes = (IsSudo,) - def post(self, request): + def post(self, request: Request) -> FormattedResponse: + """Issue a new normal token for this user, and return it.""" return FormattedResponse(d={"token": request.sudo_from.issue_token()}) diff --git a/src/backend/mixins.py b/src/backend/mixins.py deleted file mode 100644 index a42c6d44..00000000 --- a/src/backend/mixins.py +++ /dev/null @@ -1,3 +0,0 @@ -class IncorrectSolvesMixin: - def get_incorrect_solves(self, instance): - return instance.solves.filter(correct=False).count() diff --git a/src/backend/tests.py b/src/backend/tests.py deleted file mode 100644 index c7e55df0..00000000 --- a/src/backend/tests.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.core.exceptions import ValidationError -from django.http import HttpRequest -from rest_framework.request import Request -from rest_framework.status import HTTP_404_NOT_FOUND -from rest_framework.test import APITestCase - -from backend.permissions import ReadOnlyBot -from backend.validators import printable_name -from member.models import Member - - -class CatchAllTestCase(APITestCase): - def test_catchall_404s(self): - response = self.client.get("/sdgodgsjds") - self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) - - -class ReadOnlyBotTestCase(APITestCase): - def test_is_bot_safe_method(self): - request = Request(HttpRequest()) - request.method = "GET" - request.user = Member(username="bot-test", email="bot-test@gmail.com", is_bot=True) - self.assertTrue(ReadOnlyBot().has_permission(request, None)) - - def test_is_bot_unsafe_method(self): - request = Request(HttpRequest()) - request.method = "POST" - request.user = Member(username="bot-test", email="bot-test@gmail.com", is_bot=True) - self.assertFalse(ReadOnlyBot().has_permission(request, None)) - - -class ValidatorTestCase(APITestCase): - def test_unprintable_name(self): - self.assertRaises(ValidationError, lambda: printable_name(b"\x00".decode("latin-1"))) - - def test_printable_name(self): - self.assertIsNone(printable_name("abc")) diff --git a/src/backend/views.py b/src/backend/views.py deleted file mode 100644 index d057f7d2..00000000 --- a/src/backend/views.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.conf import settings -from django.shortcuts import render -from django.views.generic import TemplateView - - -class CatchAllView(TemplateView): - def get(self, request, *args, **kwargs): - return render(template_name="404.html", context={"link": settings.FRONTEND_URL}, request=request, status=404) diff --git a/src/challenge/apps.py b/src/challenge/apps.py deleted file mode 100644 index a6d91f86..00000000 --- a/src/challenge/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -from importlib import import_module - -from django.apps import AppConfig - - -class ChallengeConfig(AppConfig): - name = "challenge" - - def ready(self): - import_module("challenge.signals", "challenge") diff --git a/src/challenge/migrations/0002_auto_20200808_1337.py b/src/challenge/migrations/0002_auto_20200808_1337.py deleted file mode 100644 index 847a3824..00000000 --- a/src/challenge/migrations/0002_auto_20200808_1337.py +++ /dev/null @@ -1,83 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-08 13:37 - -import os - -import django.contrib.postgres.indexes -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("challenge", "0001_initial"), - ("team", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="solve", - name="solved_by", - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="solves", to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name="solve", - name="team", - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name="solves", to="team.Team"), - ), - migrations.AddField( - model_name="score", - name="team", - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name="scores", to="team.Team"), - ), - migrations.AddField( - model_name="score", - name="user", - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="scores", to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name="file", - name="challenge", - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="file_set", to="challenge.Challenge"), - ), - migrations.AddField( - model_name="challenge", - name="category", - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="category_challenges", to="challenge.Category"), - ), - migrations.AddField( - model_name="challenge", - name="first_blood", - field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="first_bloods", to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name="challenge", - name="unlocks", - field=models.ManyToManyField(blank=True, related_name="unlocked_by", to="challenge.Challenge"), - ), - ] - - if settings.DATABASES.get("default", {}).get("ENGINE", "").endswith("postgresql"): - operations.append( - migrations.AddIndex( - model_name="solve", - index=django.contrib.postgres.indexes.BrinIndex(autosummarize=True, fields=["challenge"], name="challenge_s_challen_dd8715_brin"), - ) - ) - - operations.extend( - [ - migrations.AddConstraint( - model_name="solve", - constraint=models.UniqueConstraint(condition=models.Q(("correct", True), ("team__isnull", False)), fields=("team", "challenge"), name="unique_team_challenge_correct"), - ), - migrations.AddConstraint( - model_name="solve", - constraint=models.UniqueConstraint(condition=models.Q(correct=True), fields=("solved_by", "challenge"), name="unique_member_challenge_correct"), - ), - ] - ) diff --git a/src/challenge/migrations/0003_challengevote.py b/src/challenge/migrations/0003_challengevote.py deleted file mode 100644 index 2435df40..00000000 --- a/src/challenge/migrations/0003_challengevote.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-08 15:49 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('challenge', '0002_auto_20200808_1337'), - ] - - operations = [ - migrations.CreateModel( - name='ChallengeVote', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('positive', models.BooleanField()), - ('challenge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='challenge.Challenge')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/challenge/migrations/0018_challenge_tiebreaker.py b/src/challenge/migrations/0018_challenge_tiebreaker.py deleted file mode 100644 index 636d26ad..00000000 --- a/src/challenge/migrations/0018_challenge_tiebreaker.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.4 on 2021-07-19 15:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804'), - ] - - operations = [ - migrations.AddField( - model_name='challenge', - name='tiebreaker', - field=models.BooleanField(default=True), - ), - ] diff --git a/src/challenge/migrations/0019_score_tiebreaker.py b/src/challenge/migrations/0019_score_tiebreaker.py deleted file mode 100644 index f1bdb59d..00000000 --- a/src/challenge/migrations/0019_score_tiebreaker.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.4 on 2021-07-19 15:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0018_challenge_tiebreaker'), - ] - - operations = [ - migrations.AddField( - model_name='score', - name='tiebreaker', - field=models.BooleanField(default=True), - ), - ] diff --git a/src/challenge/migrations/0020_auto_20210729_2015.py b/src/challenge/migrations/0020_auto_20210729_2015.py deleted file mode 100644 index 92a8e873..00000000 --- a/src/challenge/migrations/0020_auto_20210729_2015.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.4 on 2021-07-29 20:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0019_score_tiebreaker'), - ] - - operations = [ - migrations.AlterField( - model_name='challenge', - name='tiebreaker', - field=models.BooleanField(default=True, help_text='Should the challenge be able to break ties?'), - ), - migrations.AlterField( - model_name='score', - name='tiebreaker', - field=models.BooleanField(default=True, help_text='Should the score be able to break ties?'), - ), - ] diff --git a/src/challenge/migrations/0021_challenge_maintenance.py b/src/challenge/migrations/0021_challenge_maintenance.py deleted file mode 100644 index db62d974..00000000 --- a/src/challenge/migrations/0021_challenge_maintenance.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.4 on 2021-07-31 17:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0020_auto_20210729_2015'), - ] - - operations = [ - migrations.AddField( - model_name='challenge', - name='maintenance', - field=models.BooleanField(default=False), - ), - ] diff --git a/src/admin/__init__.py b/src/challenges/__init__.py similarity index 100% rename from src/admin/__init__.py rename to src/challenges/__init__.py diff --git a/src/challenges/apps.py b/src/challenges/apps.py new file mode 100644 index 00000000..b32ea587 --- /dev/null +++ b/src/challenges/apps.py @@ -0,0 +1,15 @@ +"""App for managing challenges.""" + +from importlib import import_module + +from django.apps import AppConfig + + +class ChallengesConfig(AppConfig): + """The app config for the challenges app.""" + + name = "challenges" + + def ready(self): + """Import challenge signals when the app is ready.""" + import_module("challenges.signals", "challenges") diff --git a/src/challenges/logic/__init__.py b/src/challenges/logic/__init__.py new file mode 100644 index 00000000..8b524caf --- /dev/null +++ b/src/challenges/logic/__init__.py @@ -0,0 +1,44 @@ +"""A package of methods and classes containing logic specific to the challenges app.""" + +from typing import Optional + +from challenges import models + + +def get_file_path(file: "models.File", file_name: str) -> str: + """Given a file model and the relevant root filename, return the file path.""" + return f"{file.challenge.pk}/{file.md5}/{file_name}" + + +def evaluate_rpn(requirements: Optional[str], solves: list[int]) -> bool: + """ + Parse challenge requirements encoded in Reverse Polish Notation. + + Examples of RPN-encoded requirements include: + + >>> evaluate_rpn("1 2 OR", [9, 10]) # Challenges 1 or 2 are solved + False + >>> evaluate_rpn("3 4 AND", [3, 4]) # Challenges 3 and 4 are solved + True + """ + state = [] + + if not requirements: + return True + + for requirement in requirements.split(): + if requirement.isdigit(): + state.append(int(requirement) in solves) + elif requirement == "OR": + if len(state) >= 2: + a, b = state.pop(), state.pop() + state.append(a or b) + elif requirement == "AND": + if len(state) >= 2: + a, b = state.pop(), state.pop() + state.append(a and b) + + if not state: + return False + + return bool(state[0]) diff --git a/src/challenge/migrations/0001_initial.py b/src/challenges/migrations/0001_initial.py similarity index 100% rename from src/challenge/migrations/0001_initial.py rename to src/challenges/migrations/0001_initial.py diff --git a/src/challenges/migrations/0002_auto_20200808_1337.py b/src/challenges/migrations/0002_auto_20200808_1337.py new file mode 100644 index 00000000..e1059c40 --- /dev/null +++ b/src/challenges/migrations/0002_auto_20200808_1337.py @@ -0,0 +1,117 @@ +# Generated by Django 3.0.5 on 2020-08-08 13:37 + +import os + +import django.contrib.postgres.indexes +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("challenges", "0001_initial"), + ("teams", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="solve", + name="solved_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="solves", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="solve", + name="team", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, related_name="solves", to="team.Team" + ), + ), + migrations.AddField( + model_name="score", + name="team", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, related_name="scores", to="team.Team" + ), + ), + migrations.AddField( + model_name="score", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="scores", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="file", + name="challenge", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="file_set", to="challenge.Challenge" + ), + ), + migrations.AddField( + model_name="challenge", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="category_challenges", to="challenge.Category" + ), + ), + migrations.AddField( + model_name="challenge", + name="first_blood", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="first_bloods", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="challenge", + name="unlocks", + field=models.ManyToManyField(blank=True, related_name="unlocked_by", to="challenge.Challenge"), + ), + ] + + if settings.DATABASES.get("default", {}).get("ENGINE", "").endswith("postgresql"): + operations.append( + migrations.AddIndex( + model_name="solve", + index=django.contrib.postgres.indexes.BrinIndex( + autosummarize=True, fields=["challenge"], name="challenge_s_challen_dd8715_brin" + ), + ) + ) + + operations.extend( + [ + migrations.AddConstraint( + model_name="solve", + constraint=models.UniqueConstraint( + condition=models.Q(("correct", True), ("team__isnull", False)), + fields=("team", "challenge"), + name="unique_team_challenge_correct", + ), + ), + migrations.AddConstraint( + model_name="solve", + constraint=models.UniqueConstraint( + condition=models.Q(correct=True), + fields=("solved_by", "challenge"), + name="unique_member_challenge_correct", + ), + ), + ] + ) diff --git a/src/challenges/migrations/0003_challengevote.py b/src/challenges/migrations/0003_challengevote.py new file mode 100644 index 00000000..2afdd204 --- /dev/null +++ b/src/challenges/migrations/0003_challengevote.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.5 on 2020-08-08 15:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("challenges", "0002_auto_20200808_1337"), + ] + + operations = [ + migrations.CreateModel( + name="ChallengeVote", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("positive", models.BooleanField()), + ( + "challenge", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="challenges.Challenge"), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/challenge/migrations/0004_challenge_post_score_explanation.py b/src/challenges/migrations/0004_challenge_post_score_explanation.py similarity index 79% rename from src/challenge/migrations/0004_challenge_post_score_explanation.py rename to src/challenges/migrations/0004_challenge_post_score_explanation.py index f0dde2cb..67ada79a 100644 --- a/src/challenge/migrations/0004_challenge_post_score_explanation.py +++ b/src/challenges/migrations/0004_challenge_post_score_explanation.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ('challenge', '0003_challengevote'), + ('challenges', '0003_challengevote'), ] operations = [ migrations.AddField( - model_name='challenge', + model_name='challenges', name='post_score_explanation', field=models.TextField(blank=True), ), diff --git a/src/challenge/migrations/0005_challengefeedback.py b/src/challenges/migrations/0005_challengefeedback.py similarity index 79% rename from src/challenge/migrations/0005_challengefeedback.py rename to src/challenges/migrations/0005_challengefeedback.py index 1a6f15cc..be7946d3 100644 --- a/src/challenge/migrations/0005_challengefeedback.py +++ b/src/challenges/migrations/0005_challengefeedback.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('challenge', '0004_challenge_post_score_explanation'), + ('challenges', '0004_challenge_post_score_explanation'), ] operations = [ @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('feedback', models.TextField()), - ('challenge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='challenge.Challenge')), + ('challenges', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='challenge.Challenge')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), diff --git a/src/challenge/migrations/0006_tag.py b/src/challenges/migrations/0006_tag.py similarity index 78% rename from src/challenge/migrations/0006_tag.py rename to src/challenges/migrations/0006_tag.py index 9f34fc7d..5b8ede87 100644 --- a/src/challenge/migrations/0006_tag.py +++ b/src/challenges/migrations/0006_tag.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('challenge', '0005_challengefeedback'), + ('challenges', '0005_challengefeedback'), ] operations = [ @@ -18,7 +18,7 @@ class Migration(migrations.Migration): ('text', models.CharField(max_length=255)), ('type', models.CharField(max_length=255)), ('post_competition', models.BooleanField(default=False)), - ('challenge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='challenge.Challenge')), + ('challenges', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='challenge.Challenge')), ], ), ] diff --git a/src/challenge/migrations/0007_file_upload.py b/src/challenges/migrations/0007_file_upload.py similarity index 53% rename from src/challenge/migrations/0007_file_upload.py rename to src/challenges/migrations/0007_file_upload.py index fecbcc52..9cee9f35 100644 --- a/src/challenge/migrations/0007_file_upload.py +++ b/src/challenges/migrations/0007_file_upload.py @@ -1,21 +1,21 @@ -# Generated by Django 3.1.4 on 2020-12-23 16:22 - -from django.db import migrations, models - -import challenge.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0006_tag'), - ] - - operations = [ - migrations.AddField( - model_name='file', - name='upload', - field=models.FileField(default='', upload_to=challenge.models.get_file_name), - preserve_default=False, - ), - ] +# Generated by Django 3.1.4 on 2020-12-23 16:22 + +from django.db import migrations, models + +import challenges + + +class Migration(migrations.Migration): + + dependencies = [ + ('challenges', "0006_tag"), + ] + + operations = [ + migrations.AddField( + model_name="file", + name="upload", + field=models.FileField(default="", upload_to=challenges.logic.get_file_path), + preserve_default=False, + ), + ] diff --git a/src/challenge/migrations/0008_auto_20201223_1623.py b/src/challenges/migrations/0008_auto_20201223_1623.py similarity index 63% rename from src/challenge/migrations/0008_auto_20201223_1623.py rename to src/challenges/migrations/0008_auto_20201223_1623.py index 473fa3f6..52c3b6c6 100644 --- a/src/challenge/migrations/0008_auto_20201223_1623.py +++ b/src/challenges/migrations/0008_auto_20201223_1623.py @@ -1,20 +1,20 @@ -# Generated by Django 3.1.4 on 2020-12-23 16:23 - -from django.db import migrations, models - -import challenge.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0007_file_upload'), - ] - - operations = [ - migrations.AlterField( - model_name='file', - name='upload', - field=models.FileField(null=True, upload_to=challenge.models.get_file_name), - ), - ] +# Generated by Django 3.1.4 on 2020-12-23 16:23 + +from django.db import migrations, models + +import challenges + + +class Migration(migrations.Migration): + + dependencies = [ + ('challenges', "0007_file_upload"), + ] + + operations = [ + migrations.AlterField( + model_name="file", + name="upload", + field=models.FileField(null=True, upload_to=challenges.logic.get_file_path), + ), + ] diff --git a/src/challenge/migrations/0009_file_md5.py b/src/challenges/migrations/0009_file_md5.py similarity index 83% rename from src/challenge/migrations/0009_file_md5.py rename to src/challenges/migrations/0009_file_md5.py index 4186ebbc..5521d467 100644 --- a/src/challenge/migrations/0009_file_md5.py +++ b/src/challenges/migrations/0009_file_md5.py @@ -1,18 +1,18 @@ -# Generated by Django 3.1.4 on 2020-12-23 18:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0008_auto_20201223_1623'), - ] - - operations = [ - migrations.AddField( - model_name='file', - name='md5', - field=models.CharField(max_length=32, null=True), - ), - ] +# Generated by Django 3.1.4 on 2020-12-23 18:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('challenges', '0008_auto_20201223_1623'), + ] + + operations = [ + migrations.AddField( + model_name='file', + name='md5', + field=models.CharField(max_length=32, null=True), + ), + ] diff --git a/src/challenge/migrations/0010_auto_20201225_2135.py b/src/challenges/migrations/0010_auto_20201225_2135.py similarity index 82% rename from src/challenge/migrations/0010_auto_20201225_2135.py rename to src/challenges/migrations/0010_auto_20201225_2135.py index e3e04815..3eff0ebc 100644 --- a/src/challenge/migrations/0010_auto_20201225_2135.py +++ b/src/challenges/migrations/0010_auto_20201225_2135.py @@ -1,19 +1,19 @@ -# Generated by Django 3.1 on 2020-12-25 21:35 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0009_file_md5'), - ] - - operations = [ - migrations.AlterField( - model_name='challengevote', - name='challenge', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='challenge.challenge'), - ), - ] +# Generated by Django 3.1 on 2020-12-25 21:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('challenges', '0009_file_md5'), + ] + + operations = [ + migrations.AlterField( + model_name='challengevote', + name='challenges', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='challenge.challenge'), + ), + ] diff --git a/src/challenge/migrations/0011_auto_20201226_1341.py b/src/challenges/migrations/0011_auto_20201226_1341.py similarity index 82% rename from src/challenge/migrations/0011_auto_20201226_1341.py rename to src/challenges/migrations/0011_auto_20201226_1341.py index 1f6ada90..8269b0d2 100644 --- a/src/challenge/migrations/0011_auto_20201226_1341.py +++ b/src/challenges/migrations/0011_auto_20201226_1341.py @@ -1,33 +1,33 @@ -# Generated by Django 3.1 on 2020-12-26 13:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0010_auto_20201225_2135'), - ] - - operations = [ - migrations.AlterField( - model_name='category', - name='metadata', - field=models.JSONField(default=dict), - ), - migrations.AlterField( - model_name='challenge', - name='challenge_metadata', - field=models.JSONField(), - ), - migrations.AlterField( - model_name='challenge', - name='flag_metadata', - field=models.JSONField(), - ), - migrations.AlterField( - model_name='score', - name='metadata', - field=models.JSONField(default=dict), - ), - ] +# Generated by Django 3.1 on 2020-12-26 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('challenges', '0010_auto_20201225_2135'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='metadata', + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name='challenges', + name='challenge_metadata', + field=models.JSONField(), + ), + migrations.AlterField( + model_name='challenges', + name='flag_metadata', + field=models.JSONField(), + ), + migrations.AlterField( + model_name='score', + name='metadata', + field=models.JSONField(default=dict), + ), + ] diff --git a/src/challenge/migrations/0012_auto_20201226_1607.py b/src/challenges/migrations/0012_auto_20201226_1607.py similarity index 69% rename from src/challenge/migrations/0012_auto_20201226_1607.py rename to src/challenges/migrations/0012_auto_20201226_1607.py index 6c8ad57b..2dfbb97e 100644 --- a/src/challenge/migrations/0012_auto_20201226_1607.py +++ b/src/challenges/migrations/0012_auto_20201226_1607.py @@ -1,18 +1,18 @@ -# Generated by Django 3.1 on 2020-12-26 16:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('challenge', '0011_auto_20201226_1341'), - ] - - operations = [ - migrations.AlterField( - model_name='file', - name='size', - field=models.PositiveBigIntegerField(), - ), - ] +# Generated by Django 3.1 on 2020-12-26 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("challenges", "0011_auto_20201226_1341"), + ] + + operations = [ + migrations.AlterField( + model_name="file", + name="size", + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/src/challenge/migrations/0013_auto_20210130_0114.py b/src/challenges/migrations/0013_auto_20210130_0114.py similarity index 69% rename from src/challenge/migrations/0013_auto_20210130_0114.py rename to src/challenges/migrations/0013_auto_20210130_0114.py index 7d39f7b3..f659490a 100644 --- a/src/challenge/migrations/0013_auto_20210130_0114.py +++ b/src/challenges/migrations/0013_auto_20210130_0114.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('challenge', '0012_auto_20201226_1607'), + ("challenges", "0012_auto_20201226_1607"), ] operations = [ migrations.AddField( - model_name='challenge', - name='unlock_requirements', + model_name="challenge", + name="unlock_requirements", field=models.CharField(max_length=255, null=True), ), ] diff --git a/src/challenge/migrations/0014_migrate_legacy_unlocks.py b/src/challenges/migrations/0014_migrate_legacy_unlocks.py similarity index 80% rename from src/challenge/migrations/0014_migrate_legacy_unlocks.py rename to src/challenges/migrations/0014_migrate_legacy_unlocks.py index c8f60051..c025c156 100644 --- a/src/challenge/migrations/0014_migrate_legacy_unlocks.py +++ b/src/challenges/migrations/0014_migrate_legacy_unlocks.py @@ -6,9 +6,9 @@ def migrate_unlocks(apps, schema_editor): Challenge = apps.get_model("challenge", "Challenge") for chall in Challenge.objects.all(): - unlocked_by = Challenge.objects.filter(unlocks__id__exact=chall.id).all() + unlocked_by = Challenge.objects.filter(unlocks__id__exact=chall.pk).all() chall.unlock_requirements = ' '.join( - [str(i.id) for i in unlocked_by] + ["OR" for _ in range(len(unlocked_by)-1)] + [str(i.pk) for i in unlocked_by] + ["OR" for _ in range(len(unlocked_by)-1)] ) chall.save() @@ -16,7 +16,7 @@ def migrate_unlocks(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('challenge', '0013_auto_20210130_0114'), + ('challenges', '0013_auto_20210130_0114'), ] operations = [ diff --git a/src/challenge/migrations/0015_auto_20210131_1809.py b/src/challenges/migrations/0015_auto_20210131_1809.py similarity index 72% rename from src/challenge/migrations/0015_auto_20210131_1809.py rename to src/challenges/migrations/0015_auto_20210131_1809.py index 9af10db4..2ec97890 100644 --- a/src/challenge/migrations/0015_auto_20210131_1809.py +++ b/src/challenges/migrations/0015_auto_20210131_1809.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ('challenge', '0014_migrate_legacy_unlocks'), + ('challenges', '0014_migrate_legacy_unlocks'), ] operations = [ migrations.RemoveField( - model_name='challenge', + model_name='challenges', name='unlocks', ), ] diff --git a/src/challenge/migrations/0016_auto_20210327_2315.py b/src/challenges/migrations/0016_auto_20210327_2315.py similarity index 79% rename from src/challenge/migrations/0016_auto_20210327_2315.py rename to src/challenges/migrations/0016_auto_20210327_2315.py index 99b4849e..934cbfa0 100644 --- a/src/challenge/migrations/0016_auto_20210327_2315.py +++ b/src/challenges/migrations/0016_auto_20210327_2315.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ("challenge", "0015_auto_20210131_1809"), + ('challenges', "0015_auto_20210131_1809"), ] operations = [ migrations.AlterField( - model_name="challenge", + model_name='challenges', name="unlock_requirements", field=models.CharField(blank=True, max_length=255, null=True), ), diff --git a/src/challenge/migrations/0016_auto_20210408_1804.py b/src/challenges/migrations/0016_auto_20210408_1804.py similarity index 76% rename from src/challenge/migrations/0016_auto_20210408_1804.py rename to src/challenges/migrations/0016_auto_20210408_1804.py index b4344610..1656d741 100644 --- a/src/challenge/migrations/0016_auto_20210408_1804.py +++ b/src/challenges/migrations/0016_auto_20210408_1804.py @@ -6,16 +6,16 @@ class Migration(migrations.Migration): dependencies = [ - ('challenge', '0015_auto_20210131_1809'), + ('challenges', '0015_auto_20210131_1809'), ] operations = [ migrations.RemoveField( - model_name='challenge', + model_name='challenges', name='auto_unlock', ), migrations.AlterField( - model_name='challenge', + model_name='challenges', name='unlock_requirements', field=models.CharField(blank=True, max_length=255, null=True), ), diff --git a/src/challenge/migrations/0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804.py b/src/challenges/migrations/0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804.py similarity index 63% rename from src/challenge/migrations/0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804.py rename to src/challenges/migrations/0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804.py index 6b75c2f8..9f19c68d 100644 --- a/src/challenge/migrations/0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804.py +++ b/src/challenges/migrations/0017_merge_0016_auto_20210327_2315_0016_auto_20210408_1804.py @@ -6,8 +6,8 @@ class Migration(migrations.Migration): dependencies = [ - ('challenge', '0016_auto_20210327_2315'), - ('challenge', '0016_auto_20210408_1804'), + ('challenges', '0016_auto_20210327_2315'), + ('challenges', '0016_auto_20210408_1804'), ] operations = [ diff --git a/src/admin/migrations/__init__.py b/src/challenges/migrations/__init__.py similarity index 100% rename from src/admin/migrations/__init__.py rename to src/challenges/migrations/__init__.py diff --git a/src/challenge/models.py b/src/challenges/models.py similarity index 71% rename from src/challenge/models.py rename to src/challenges/models.py index e5d8d582..ff5f9456 100644 --- a/src/challenge/models.py +++ b/src/challenges/models.py @@ -1,7 +1,10 @@ +"""Database models used by the challenge app.""" + import time +from typing import Union from django.conf import settings -from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser from django.contrib.postgres.indexes import BrinIndex from django.db import models from django.db.models import ( @@ -17,19 +20,20 @@ ) from django.db.models.aggregates import Count from django.db.models.query import Prefetch -from django.db.models.signals import post_save -from django.dispatch import receiver from django.utils import timezone from django.utils.functional import cached_property from django_prometheus.models import ExportModelOperationsMixin +from challenges.logic import evaluate_rpn, get_file_path from config import config -from plugins import plugins +from core import plugins USING_POSTGRES = settings.DATABASES.get("default", {}).get("ENGINE", "").endswith("postgresql") class Category(ExportModelOperationsMixin("category"), models.Model): + """Represents a category containing 0 or more challenges.""" + name = models.CharField(max_length=36, unique=True) display_order = models.IntegerField() contained_type = models.CharField(max_length=36) @@ -39,8 +43,10 @@ class Category(ExportModelOperationsMixin("category"), models.Model): class Challenge(ExportModelOperationsMixin("challenge"), models.Model): + """Represents a challenge object.""" + name = models.CharField(max_length=36, unique=True) - category = models.ForeignKey(Category, on_delete=PROTECT, related_name="category_challenges") + category = models.ForeignKey("challenge.Category", on_delete=PROTECT, related_name="category_challenges") description = models.TextField() challenge_type = models.CharField(max_length=64) challenge_metadata = JSONField() @@ -53,7 +59,7 @@ class Challenge(ExportModelOperationsMixin("challenge"), models.Model): score = models.IntegerField() unlock_requirements = models.CharField(max_length=255, null=True, blank=True) first_blood = models.ForeignKey( - get_user_model(), + "teams.Member", related_name="first_bloods", on_delete=SET_NULL, null=True, @@ -68,15 +74,15 @@ def self_check(self): issues = [] if not self.score: - issues.append({"issue": "missing_points", "challenge": self.id}) + issues.append({"issue": "missing_points", "challenge": self.pk}) if not self.flag_type: - issues.append({"issue": "missing_flag_type", "challenge": self.id}) + issues.append({"issue": "missing_flag_type", "challenge": self.pk}) elif type(self.flag_metadata) != dict: - issues.append({"issue": "invalid_flag_data_type", "challenge": self.id}) + issues.append({"issue": "invalid_flag_data_type", "challenge": self.pk}) else: issues += [ - {"issue": "invalid_flag_data", "extra": issue, "challenge": self.id} + {"issue": "invalid_flag_data", "extra": issue, "challenge": self.pk} for issue in self.flag_plugin.self_check() ] @@ -84,59 +90,35 @@ def self_check(self): @cached_property def flag_plugin(self): - """Return the flag plugin responsible for validating flags sent to this challenge""" + """Return the flag plugin responsible for validating flags sent to this challenge.""" return plugins.plugins["flag"][self.flag_type](self) @cached_property def points_plugin(self): - """Return the points plugin responsible for granting points from this challenge""" + """Return the points plugin responsible for granting points from this challenge.""" return plugins.plugins["points"][self.points_type](self) - def is_unlocked(self, user, solves=None): - if user is None: - return False - if not user.is_authenticated: - return False - if not self.unlock_requirements: - return True - if user.team is None: + def is_unlocked_by(self, user: Union["teams.models.Member", AnonymousUser, None], solves=None) -> bool: + """Check if the provided user has unlocked this challenge.""" + if user is None or not user.is_authenticated or not user.team: return False - if solves is None: - solves = list(user.team.solves.filter(correct=True).values_list("challenge", flat=True)) - requirements = self.unlock_requirements - state = [] - if not requirements: - return True - for i in requirements.split(): - if i.isdigit(): - state.append(int(i) in solves) - elif i == "OR": - if len(state) >= 2: - a, b = state.pop(), state.pop() - state.append(a or b) - elif i == "AND": - if len(state) >= 2: - a, b = state.pop(), state.pop() - state.append(a and b) - if not state: - return False - return state[0] + return evaluate_rpn(self.unlock_requirements, solves or user.team.solved_challenges) - def is_solved(self, user, solves=None): - if not user.is_authenticated: - return False - if user.team is None: + def is_solved_by(self, user, solves=None) -> bool: + """Return True if the provided user has solved this challenge.""" + if not user.is_authenticated or user.team is None: return False - if solves is None: - solves = list(user.team.solves.filter(correct=True).values_list("challenge", flat=True)) - return self.id in solves + solves = solves or user.team.solved_challenges + return self.pk in solves def get_solve_count(self, solve_counter): - return solve_counter.get(self.id, 0) + """Return the solve count of this challenge.""" + return solve_counter.get(self.pk, 0) @classmethod def get_unlocked_annotated_queryset(cls, user): - if user.is_staff and user.should_deny_admin(): + """Get a queryset of all challenges, annotated with if they're unlocked and solved.""" + if user.is_staff and user.should_deny_admin: return Challenge.objects.none() if user.team is not None: challenges = Challenge.objects.annotate( @@ -189,25 +171,26 @@ def get_unlocked_annotated_queryset(cls, user): class ChallengeVote(ExportModelOperationsMixin("challenge_vote"), models.Model): - challenge = models.ForeignKey(Challenge, on_delete=CASCADE, related_name="votes") - user = models.ForeignKey(get_user_model(), on_delete=CASCADE) + """Represents a user's vote on a Challenge.""" + + challenge = models.ForeignKey("challenge.Challenge", on_delete=CASCADE, related_name="votes") + user = models.ForeignKey("teams.Member", on_delete=CASCADE) positive = models.BooleanField() class ChallengeFeedback(ExportModelOperationsMixin("challenge_feedback"), models.Model): - challenge = models.ForeignKey(Challenge, on_delete=CASCADE) - user = models.ForeignKey(get_user_model(), on_delete=CASCADE) - feedback = models.TextField() + """Represents a user's feedback on a Challenge.""" - -@receiver(post_save, sender=Challenge) -def on_challenge_update(sender, instance, created, **kwargs): - ... + challenge = models.ForeignKey("challenge.Challenge", on_delete=CASCADE) + user = models.ForeignKey("teams.Member", on_delete=CASCADE) + feedback = models.TextField() class Score(ExportModelOperationsMixin("score"), models.Model): + """Represents a score contributing to a team and/or user's points.""" + team = models.ForeignKey("team.Team", related_name="scores", on_delete=CASCADE, null=True) - user = models.ForeignKey(get_user_model(), related_name="scores", on_delete=SET_NULL, null=True) + user = models.ForeignKey("teams.Member", related_name="scores", on_delete=SET_NULL, null=True) reason = models.CharField(max_length=64) points = models.IntegerField() penalty = models.IntegerField(default=0) @@ -218,16 +201,20 @@ class Score(ExportModelOperationsMixin("score"), models.Model): class Solve(ExportModelOperationsMixin("solve"), models.Model): + """Represents a user and team's solve of a challenge.""" + team = models.ForeignKey("team.Team", related_name="solves", on_delete=CASCADE, null=True) - challenge = models.ForeignKey(Challenge, related_name="solves", on_delete=CASCADE) - solved_by = models.ForeignKey(get_user_model(), related_name="solves", on_delete=SET_NULL, null=True) + challenge = models.ForeignKey("challenge.Challenge", related_name="solves", on_delete=CASCADE) + solved_by = models.ForeignKey("teams.Member", related_name="solves", on_delete=SET_NULL, null=True) first_blood = models.BooleanField(default=False) correct = models.BooleanField(default=True) timestamp = models.DateTimeField(default=timezone.now) flag = models.TextField() - score = models.ForeignKey(Score, related_name="solve", on_delete=CASCADE, null=True) + score = models.ForeignKey("challenge.Score", related_name="solve", on_delete=CASCADE, null=True) class Meta: + """The constraints and indexes on the model.""" + constraints = [ UniqueConstraint( fields=["team", "challenge"], @@ -243,21 +230,21 @@ class Meta: indexes = [BrinIndex(fields=["challenge"], autosummarize=True)] if USING_POSTGRES else [] -def get_file_name(instance, filename): - return f"{instance.challenge.id}/{instance.md5}/{filename}" - - class File(ExportModelOperationsMixin("file"), models.Model): + """Represents a file attached to a challenge.""" + name = models.CharField(max_length=64) url = models.URLField() size = models.PositiveBigIntegerField() - upload = models.FileField(upload_to=get_file_name, null=True) - challenge = models.ForeignKey(Challenge, on_delete=CASCADE, related_name="file_set") + upload = models.FileField(upload_to=get_file_path, null=True) + challenge = models.ForeignKey("challenge.Challenge", on_delete=CASCADE, related_name="file_set") md5 = models.CharField(max_length=32, null=True) class Tag(ExportModelOperationsMixin("tag"), models.Model): - challenge = models.ForeignKey(Challenge, on_delete=CASCADE) + """Represents a tag on a challenge.""" + + challenge = models.ForeignKey("challenge.Challenge", on_delete=CASCADE) text = models.CharField(max_length=255) type = models.CharField(max_length=255) post_competition = models.BooleanField(default=False) diff --git a/src/challenge/permissions.py b/src/challenges/permissions.py similarity index 66% rename from src/challenge/permissions.py rename to src/challenges/permissions.py index 8b04cf26..4fd0a2c9 100644 --- a/src/challenge/permissions.py +++ b/src/challenges/permissions.py @@ -1,3 +1,5 @@ +"""Permissions for the challenge app.""" + import time from rest_framework import permissions @@ -6,8 +8,11 @@ class CompetitionOpen(permissions.BasePermission): + """Permission that checks if the competition is open.""" + def has_permission(self, request, view): - return (request.user.is_staff and not request.user.should_deny_admin()) or ( + """Return if the competition is open or the action should be allowed anyway.""" + return (request.user.is_staff and not request.user.should_deny_admin) or ( config.get("start_time") <= time.time() and (config.get("enable_view_challenges_after_competion") or time.time() <= config.get("end_time")) ) diff --git a/src/challenge/serializers.py b/src/challenges/serializers.py similarity index 76% rename from src/challenge/serializers.py rename to src/challenges/serializers.py index 1a7cef49..365d9754 100644 --- a/src/challenge/serializers.py +++ b/src/challenges/serializers.py @@ -1,7 +1,9 @@ +"""Serializers for the challenge app.""" + import serpy from rest_framework import serializers -from challenge.models import ( +from challenges.models import ( Category, Challenge, ChallengeFeedback, @@ -10,11 +12,12 @@ Solve, Tag, ) -from challenge.sql import get_negative_votes, get_positive_votes, get_solve_counts +from challenges.sql import get_negative_votes, get_positive_votes, get_solve_counts from hint.serializers import FastHintSerializer def setup_context(context): + """Add required information such as solve counts to a challenge serialization context.""" context.update( { "request": context["request"], @@ -38,36 +41,38 @@ class ForeignAttributeField(serpy.Field): """A :class:`Field` that gets a given attribute from a foreign object.""" def __init__(self, *args, attr_name="id", **kwargs): + """Construct the field and set the attr_name field.""" super(ForeignAttributeField, self).__init__(*args, **kwargs) self.attr_name = attr_name def to_value(self, value): + """Get the value of this field or None.""" if value: return getattr(value, self.attr_name) return None -class TestField(serpy.Field): - """A :class:`Field` that gets a given attribute from a foreign object.""" - - def to_value(self, value): - return value - - class DateTimeField(serpy.Field): """A :class:`Field` that transforms a datetime into ISO string.""" def to_value(self, value): + """Get the value in iso format.""" return value.isoformat() class FileSerializer(serializers.ModelSerializer): + """A serializer for files.""" + class Meta: + """The fields that should be serialized.""" + model = File fields = ["id", "name", "url", "size", "challenge", "md5"] class FastFileSerializer(serpy.Serializer): + """A serializer for files that uses serpy for compatibility with other serializers.""" + id = serpy.IntField() name = serpy.StrField() url = serpy.StrField() @@ -77,40 +82,52 @@ class FastFileSerializer(serpy.Serializer): class FastNestedTagSerializer(serpy.Serializer): + """A serializer for challenge tags.""" + text = serpy.StrField() type = serpy.StrField() class ChallengeSerializerMixin: + """Utility functions for challenge serializers.""" + def get_unlocked(self, instance): + """Return if the challenge is unlocked.""" if not getattr(instance, "unlocked", None): - return instance.is_unlocked(self.context["request"].user, solves=self.context.get("solves", None)) + return instance.is_unlocked_by(self.context["request"].user, solves=self.context.get("solves", None)) return instance.unlocked def get_solved(self, instance): + """Return if the challenge is solved.""" if not getattr(instance, "solved", None): - return instance.is_solved(self.context["request"].user, solves=self.context.get("solves", None)) + return instance.is_solved_by(self.context["request"].user, solves=self.context.get("solves", None)) return instance.solved def get_solve_count(self, instance): + """Return the solve count of the challenge.""" return instance.get_solve_count(self.context.get("solve_counter", None)) def get_unlock_time_surpassed(self, instance): + """Return if the challenge unlock time is passed, and the challenge can be shown.""" return instance.unlock_time_surpassed def get_votes(self, instance): + """Return the challenge votes.""" return { - "positive": self.context["votes_positive_counter"].get(instance.id, 0), - "negative": self.context["votes_negative_counter"].get(instance.id, 0), + "positive": self.context["votes_positive_counter"].get(instance.pk, 0), + "negative": self.context["votes_negative_counter"].get(instance.pk, 0), } def get_post_score_explanation(self, instance): + """Return the challenge explanation, or none if the explanation is not available to the user.""" if self.get_unlocked(instance): return instance.post_score_explanation return None class FastLockedChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): + """Serializer used to serialize locked challenges.""" + id = serpy.IntField() unlock_requirements = serpy.StrField() challenge_metadata = serpy.Field() @@ -119,12 +136,15 @@ class FastLockedChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): unlock_time_surpassed = serpy.MethodField() def serialize(self, instance): + """Serialize the challenge.""" serialized = self._serialize(instance, self._compiled_fields) serialized["challenge_metadata"].pop("cserv_name", None) return serialized class FastChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): + """The serpy serializer used to serialize challenges.""" + id = serpy.IntField() name = serpy.StrField() description = serpy.StrField() @@ -157,8 +177,9 @@ def __init__(self, *args, **kwargs) -> None: setup_context(self.context) def _serialize(self, instance, fields): + """Serialize a challenge.""" if ( - instance.is_unlocked(self.context["request"].user, solves=self.context.get("solves", None)) + instance.is_unlocked_by(self.context["request"].user, solves=self.context.get("solves", None)) and not instance.hidden and instance.unlock_time_surpassed ): @@ -167,6 +188,8 @@ def _serialize(self, instance, fields): class FastCategorySerializer(serpy.Serializer): + """The serpy serializer used to serialize categories.""" + id = serpy.IntField() name = serpy.StrField() display_order = serpy.StrField() @@ -176,36 +199,51 @@ class FastCategorySerializer(serpy.Serializer): challenges = serpy.MethodField() class Meta: + """The fields of the category to serialize.""" + model = Category fields = ["id", "name", "display_order", "contained_type", "description", "metadata", "challenges"] def __init__(self, *args, **kwargs): + """Construct the serializer and setup the context.""" super().__init__(*args, **kwargs) if "context" in kwargs: self.context = kwargs["context"] setup_context(self.context) def get_challenges(self, instance): + """Serialize the challenges of a category.""" return FastChallengeSerializer(instance.challenges, many=True, context=self.context).data class ChallengeFeedbackSerializer(serializers.ModelSerializer): + """Serializer used to serialize challenge feedback.""" + class Meta: + """The fields of the challenge feedback to serialize.""" + model = ChallengeFeedback fields = ["id", "challenge", "feedback", "user"] class CreateCategorySerializer(serializers.ModelSerializer): + """Serializer used to create a category.""" + class Meta: + """The fields of the challenge that should be serialized.""" + model = Category fields = ["id", "name", "contained_type", "description", "release_time", "metadata"] read_only_fields = ["id"] def create(self, validated_data): + """Create a challenge and save it in the database.""" return Category.objects.create(**validated_data, display_order=Category.objects.count()) class FastAdminChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): + """Serpy serializer used to serialize challenges for admins.""" + id = serpy.IntField() name = serpy.StrField() description = serpy.StrField() @@ -231,6 +269,7 @@ class FastAdminChallengeSerializer(ChallengeSerializerMixin, serpy.Serializer): tiebreaker = serpy.BoolField() def __init__(self, *args, **kwargs): + """Construct the serializer and setup the context.""" super(FastAdminChallengeSerializer, self).__init__(*args, **kwargs) if "context" in kwargs: self.context = kwargs["context"] @@ -238,20 +277,26 @@ def __init__(self, *args, **kwargs): setup_context(self.context) def serialize(self, instance): + """Serialize a single challenge.""" return super( FastAdminChallengeSerializer, FastAdminChallengeSerializer(instance, context=self.context) ).to_value(instance) def to_value(self, instance): + """Serialize a challenge or a list of challenges.""" if self.many: return [self.serialize(o) for o in instance] return self.serialize(instance) class CreateChallengeSerializer(serializers.ModelSerializer): + """Serializer used when an admin creates a challenge.""" + tags = serializers.ListField(write_only=True) class Meta: + """The fields of the challenge model that should me serialized.""" + model = Challenge fields = [ "id", @@ -275,6 +320,7 @@ class Meta: read_only_fields = ["id"] def create(self, validated_data): + """Create a challenge instance.""" tags = validated_data.pop("tags", []) challenge = Challenge.objects.create(**validated_data) for tag_data in tags: @@ -282,6 +328,7 @@ def create(self, validated_data): return challenge def update(self, instance, validated_data): + """Update a challenge instance.""" tags = validated_data.pop("tags", None) if tags: Tag.objects.filter(challenge=instance).delete() @@ -291,6 +338,8 @@ def update(self, instance, validated_data): class FastAdminCategorySerializer(serpy.Serializer): + """Serializer for Categories used when admins access the endpoint.""" + id = serpy.IntField() name = serpy.StrField() display_order = serpy.StrField() @@ -300,22 +349,30 @@ class FastAdminCategorySerializer(serpy.Serializer): challenges = serpy.MethodField() def __init__(self, *args, **kwargs): + """Construct the serializer and setup the context.""" super().__init__(*args, **kwargs) if "context" in kwargs: self.context = kwargs["context"] setup_context(self.context) def get_challenges(self, instance): + """Return a seralized list of all challenges for this category.""" return FastAdminChallengeSerializer(instance.challenges, many=True, context=self.context).data class AdminScoreSerializer(serializers.ModelSerializer): + """Serializer for Solve objects, used for creating or modifying solves.""" + class Meta: + """The fields of the score model that should me serialized.""" + model = Score fields = ["team", "user", "reason", "points", "penalty", "leaderboard", "timestamp", "metadata"] class SolveSerializer(serializers.ModelSerializer): + """Serializer for Solve objects.""" + team_name = serializers.ReadOnlyField(source="team.name") solved_by_name = serializers.ReadOnlyField(source="solved_by.username") challenge_name = serializers.ReadOnlyField(source="challenge.name") @@ -323,6 +380,8 @@ class SolveSerializer(serializers.ModelSerializer): scored = serializers.SerializerMethodField() class Meta: + """The fields of the solve model that should me serialized.""" + model = Solve fields = [ "id", @@ -339,22 +398,29 @@ class Meta: ] def to_representation(self, instance): + """Serialize the solve if it is correct, else return None.""" if instance.correct: return super(SolveSerializer, self).to_representation(instance) return None def get_points(self, instance): + """Return how many points the solve is worth after penalties.""" if instance.correct and instance.score is not None: return instance.score.points - instance.score.penalty return 0 def get_scored(self, instance): + """Return True if the solve should count towards a users displayed leaderboard points.""" if instance.correct and instance.score is not None: return instance.score.leaderboard return False class TagSerializer(serializers.ModelSerializer): + """Serializer for Tag objects.""" + class Meta: + """Which classes and fields should be serialized for each Tag.""" + model = Tag fields = ["id", "challenge", "text", "type", "post_competition"] diff --git a/src/challenge/signals.py b/src/challenges/signals.py similarity index 84% rename from src/challenge/signals.py rename to src/challenges/signals.py index c90e4a5e..2942b786 100644 --- a/src/challenge/signals.py +++ b/src/challenges/signals.py @@ -1,9 +1,11 @@ +"""Signal receivers for the challenge app.""" + from django.core.cache import caches from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from challenge.models import Category, Challenge, File, Tag -from challenge.views import get_cache_key +from challenges.models import Category, Challenge, File, Tag +from challenges.views import get_cache_key from hint.models import Hint, HintUse diff --git a/src/challenge/sql.py b/src/challenges/sql.py similarity index 88% rename from src/challenge/sql.py rename to src/challenges/sql.py index 002b86be..0d6b526b 100644 --- a/src/challenge/sql.py +++ b/src/challenges/sql.py @@ -1,3 +1,5 @@ +"""Raw SQL functions to optimise the challenge app.""" + from django.core.cache import caches from django.db import connection @@ -5,6 +7,7 @@ def get_solve_counts(): + """Get a dict of challenge id to solve count.""" cache = caches["default"] solve_counts = cache.get("solve_counts") if solve_counts is not None and config.get("enable_caching"): @@ -17,6 +20,7 @@ def get_solve_counts(): def get_incorrect_solve_counts(): + """Get a dict of challenge id to incorrect solve count.""" cache = caches["default"] solve_counts = cache.get("incorrect_solve_counts") if solve_counts is not None and config.get("enable_caching"): @@ -29,6 +33,7 @@ def get_incorrect_solve_counts(): def get_positive_votes(): + """Get a dict of challenge id to positive vote count.""" cache = caches["default"] positive_votes = cache.get("positive_votes") if positive_votes is not None and config.get("enable_caching"): @@ -43,6 +48,7 @@ def get_positive_votes(): def get_negative_votes(): + """Get a dict of challenge id to negative vote count.""" cache = caches["default"] negative_votes = cache.get("negative_votes") if negative_votes is not None and config.get("enable_caching"): diff --git a/src/announcements/__init__.py b/src/challenges/tests/__init__.py similarity index 100% rename from src/announcements/__init__.py rename to src/challenges/tests/__init__.py diff --git a/src/challenge/tests/mixins.py b/src/challenges/tests/mixins.py similarity index 90% rename from src/challenge/tests/mixins.py rename to src/challenges/tests/mixins.py index a7db9e9b..21df0d73 100644 --- a/src/challenge/tests/mixins.py +++ b/src/challenges/tests/mixins.py @@ -1,17 +1,16 @@ +"""Mixins for challenge tests.""" + from typing import Optional, Union -from challenge.models import Category, Challenge +from challenges.models import Category, Challenge from hint.models import Hint -from member.models import Member -from team.models import Team +from teams.models import Member, Team class ChallengeSetupMixin: - """ - Mixin to create dummy challenge objects for use in tests. + """Mixin to create dummy challenge objects for use in tests.""" - TODO: Deprecate in favour of Model factories and Faker(). - """ + # TODO: Deprecate in favour of Model factories and Faker(). def setUp(self) -> None: """Create dummy challenges and any relevant related models.""" @@ -37,7 +36,7 @@ def setUp(self) -> None: flag_metadata={"flag": "ractf{a}"}, author="dave", score=1000, - unlock_requirements=str(self.challenge2.id), + unlock_requirements=str(self.challenge2.pk), ) self.challenge3 = Challenge.objects.create( name="test3", diff --git a/src/challenge/tests/test_utils.py b/src/challenges/tests/test_utils.py similarity index 59% rename from src/challenge/tests/test_utils.py rename to src/challenges/tests/test_utils.py index 2cb0df3f..034e785e 100644 --- a/src/challenge/tests/test_utils.py +++ b/src/challenges/tests/test_utils.py @@ -1,21 +1,29 @@ +"""Unit tests for challenge utils.""" + from unittest import TestCase -from django.contrib.auth import get_user_model from rest_framework.test import APITestCase -from challenge.sql import get_negative_votes, get_positive_votes -from challenge.views import get_cache_key +from challenges.sql import get_negative_votes, get_positive_votes +from challenges.views import get_cache_key from config import config +from teams.models import Member class CacheKeyTestCase(TestCase): + """Tests for cache key generation.""" + def test_get_cache_key_no_team(self): - user = get_user_model()(username="cachekeytest", email="cachekeytest@example.com") + """Test the cache key generates correctly with no team.""" + user = Member(username="cachekeytest", email="cachekeytest@example.com") self.assertTrue(get_cache_key(user).endswith("no_team")) class SqlTestCase(APITestCase): + """Tests for the raw sql.""" + def test_get_positive_votes_cached(self): + """Test the positive votes are correctly cached.""" config.set("enable_caching", True) first = get_positive_votes() second = get_positive_votes() @@ -23,6 +31,7 @@ def test_get_positive_votes_cached(self): self.assertEqual(first, second) def test_get_negative_votes_cached(self): + """Test the negative votes are correctly cached.""" config.set("enable_caching", True) first = get_negative_votes() second = get_negative_votes() diff --git a/src/challenge/tests/test_views.py b/src/challenges/tests/test_views.py similarity index 75% rename from src/challenge/tests/test_views.py rename to src/challenges/tests/test_views.py index a29c6152..1e5cf670 100644 --- a/src/challenge/tests/test_views.py +++ b/src/challenges/tests/test_views.py @@ -1,3 +1,5 @@ +"""Unit tests for the challenge api endpoints.""" + import time from django.contrib.auth import get_user_model @@ -13,62 +15,71 @@ ) from rest_framework.test import APITestCase -from challenge.models import Solve -from challenge.tests.mixins import ChallengeSetupMixin +from challenges.models import Solve +from challenges.tests.mixins import ChallengeSetupMixin from config import config from hint.models import HintUse +from teams.models import Member class ChallengeTestCase(ChallengeSetupMixin, APITestCase): + """Tests for the challenge api routes.""" + def solve_challenge(self): + """Solve a challenge.""" self.client.force_authenticate(user=self.user) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } return self.client.post(reverse("submit-flag"), data) def test_challenge_solve(self): + """Test that a challenge can be solved.""" response = self.solve_challenge() self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["d"]["correct"], True) def test_challenge_solve_incorrect_flag(self): + """Test that a challenge cant be solved with an incorrect flag.""" self.client.force_authenticate(user=self.user) data = { "flag": "ractf{b}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["d"]["correct"], False) def test_challenge_double_solve(self): + """Test that a challenge cant be solved twice.""" self.solve_challenge() self.client.force_authenticate(user=self.user2) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_solve_challenge_not_unlocked(self): + """Test that a locked challenge cant be solved.""" self.client.force_authenticate(user=self.user) data = { "flag": "ractf{a}", - "challenge": self.challenge3.id, + "challenge": self.challenge3.pk, } response = self.client.post(reverse("submit-flag"), data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_solve_challenge_attempt_limit_reached(self): + """Test that a challenge cant be solved once its attempt limit is reached.""" self.client.force_authenticate(user=self.user) self.challenge2.challenge_metadata = {"attempt_limit": -1} self.challenge2.save() data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.challenge2.challenge_metadata = {} @@ -76,12 +87,13 @@ def test_solve_challenge_attempt_limit_reached(self): self.assertEqual(response.data["m"], "attempt_limit_reached") def test_solve_challenge_attempt_limit_not_reached(self): + """Test that a challenge with an attempt limit can be solved when the attempt limit isnt reached.""" self.client.force_authenticate(user=self.user) self.challenge2.challenge_metadata = {"attempt_limit": 5000} self.challenge2.save() data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.challenge2.challenge_metadata = {} @@ -89,69 +101,83 @@ def test_solve_challenge_attempt_limit_not_reached(self): self.assertNotEqual(response.data["m"], "attempt_limit_reached") def test_solve_challenge_with_explanation(self): + """Test that an explanation is sent when a challenge is solved.""" self.client.force_authenticate(user=self.user) self.challenge2.post_score_explanation = "test" self.challenge2.save() data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.assertTrue("explanation" in response.data["d"]) def test_challenge_unlocks(self): + """Test that a challenge can be unlocked.""" self.solve_challenge() - self.challenge1.unlock_requirements = str(self.challenge2.id) - self.assertTrue(self.challenge1.is_unlocked(get_user_model().objects.get(id=self.user.id))) + self.challenge1.unlock_requirements = str(self.challenge2.pk) + self.user.refresh_from_db() + self.assertTrue(self.challenge1.is_unlocked_by(self.user)) def test_challenge_unlocks_no_team(self): - user4 = get_user_model()(username="challenge-test-4", email="challenge-test-4@example.org") + """Test that challenges are locked until you have a team.""" + user4 = Member(username="challenge-test-4", email="challenge-test-4@example.org") user4.save() - self.assertFalse(self.challenge1.is_unlocked(user4)) + self.assertFalse(self.challenge1.is_unlocked_by(user4)) def test_challenge_unlocks_locked(self): - self.assertFalse(self.challenge1.is_unlocked(self.user)) + """That that challenges are correctly locked.""" + self.assertFalse(self.challenge1.is_unlocked_by(self.user)) def test_hint_scoring(self): + """Test hint penalties are correctly applied.""" HintUse(hint=self.hint3, team=self.team, user=self.user, challenge=self.challenge2).save() self.solve_challenge() response = self.client.get(reverse("team-self")) self.assertEqual(response.data["solves"][0]["points"], 900) def test_solve_first_blood(self): + """Test first blood is correctly applied to solves.""" self.solve_challenge() response = self.client.get(reverse("team-self")) self.assertEqual(response.data["solves"][0]["first_blood"], True) def test_solve_solved_by_name(self): + """Test the solved_by_name is supplied correctly.""" self.solve_challenge() response = self.client.get(reverse("team-self")) self.assertEqual(response.data["solves"][0]["solved_by_name"], "challenge-test") def test_solve_team_name(self): + """Test the team name is supplied correctly.""" self.solve_challenge() response = self.client.get(reverse("team-self")) self.assertEqual(response.data["solves"][0]["team_name"], "team") def test_normal_scoring(self): + """Test solves are scored correctly.""" self.solve_challenge() response = self.client.get(reverse("team-self")) self.assertEqual(response.data["solves"][0]["points"], 1000) - def test_is_solved(self): + def test_is_solved_by(self): + """Test challenges correctly report when they are solved.""" self.solve_challenge() - self.assertTrue(self.challenge2.is_solved(user=self.user)) + self.assertTrue(self.challenge2.is_solved_by(user=self.user)) def test_is_not_solved(self): - self.assertFalse(self.challenge1.is_solved(user=self.user)) + """Test challenges correctly report being unsolved.""" + self.assertFalse(self.challenge1.is_solved_by(user=self.user)) def test_submission_disabled(self): + """Test flags cannot be submitted when enable_flag_submission is false.""" config.set("enable_flag_submission", False) response = self.solve_challenge() config.set("enable_flag_submission", True) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_submission_malformed(self): + """Test a malformed flag submission is rejected.""" self.client.force_authenticate(user=self.user) data = { "flag": "ractf{a}", @@ -160,43 +186,49 @@ def test_submission_malformed(self): self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_challenge_score_same_team(self): + """Test a challenge cannot be solved by 2 people on the same team.""" self.client.force_authenticate(user=self.user) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } self.client.post(reverse("submit-flag"), data) self.client.force_authenticate(user=self.user2) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_challenge_score_not_first_blood(self): + """Test a solve is not incorrectly marked first blood.""" self.solve_challenge() self.client.force_authenticate(user=self.user3) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } response = self.client.post(reverse("submit-flag"), data) self.assertEqual(response.status_code, HTTP_200_OK) self.assertFalse(Solve.objects.get(team=self.team2, challenge=self.challenge2).first_blood) def test_challenge_solved_unauthed(self): - self.assertFalse(self.challenge2.is_solved(AnonymousUser())) + """Test is_solved returns False early for anonymous users.""" + self.assertFalse(self.challenge2.is_solved_by(AnonymousUser())) def test_challenge_unlocked_unauthed(self): - self.assertFalse(self.challenge2.is_unlocked(AnonymousUser())) + """Test is_unlocked returns False early for anonymous users.""" + self.assertFalse(self.challenge2.is_unlocked_by(AnonymousUser())) def test_challenge_solved_no_team(self): - user4 = get_user_model()(username="challenge-test-4", email="challenge-test-4@example.org") + """Test is_solved returns False early for users with no team.""" + user4 = Member(username="challenge-test-4", email="challenge-test-4@example.org") user4.save() - self.assertFalse(self.challenge2.is_solved(user4)) + self.assertFalse(self.challenge2.is_solved_by(user4)) def test_challenge_solve_non_tiebreak(self): + """ "Test solving a challenge that is not a tiebreaker does not update last_score.""" self.challenge2.tiebreaker = False self.challenge2.save() last_score_before = self.user.last_score @@ -205,28 +237,35 @@ def test_challenge_solve_non_tiebreak(self): class CategoryViewsetTestCase(ChallengeSetupMixin, APITestCase): + """Tests for the /challenge/categories/ endpoints.""" + def test_category_list_unauthenticated_permission(self): + """Test unauthenticated users cannot list categories.""" response = self.client.get(reverse("categories-list")) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_category_list_authenticated_permission(self): + """Test authenticated users can list categories.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("categories-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_category_list_unauthenticated_content(self): + """Test unauthenticated users do not get any content from the category list.""" response = self.client.get(reverse("categories-list")) self.assertFalse(response.data["s"]) self.assertEqual(response.data["m"], "not_authenticated") self.assertEqual(response.data["d"], "") def test_category_list_authenticated_content(self): + """Test authenticated users do get content from the category list.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("categories-list")) self.assertEqual(len(response.data["d"]), 1) self.assertEqual(len(response.data["d"][0]["challenges"]), 3) def test_category_list_challenge_redacting(self): + """Test the category list is correctly redacted.""" self.user.is_staff = False self.user.save() self.client.force_authenticate(self.user) @@ -235,6 +274,7 @@ def test_category_list_challenge_redacting(self): self.assertFalse("description" in self.find_challenge_entry(self.challenge1, data=response.data)) def test_category_list_challenge_redacting_admin(self): + """Test the category list is not redacted for admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -243,6 +283,7 @@ def test_category_list_challenge_redacting_admin(self): self.assertTrue("description" in self.find_challenge_entry(self.challenge3, data=response.data)) def test_category_list_challenge_redacting_admin_denied(self): + """Test the category list is redacted for admins that are being denied admin permissions.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -253,6 +294,7 @@ def test_category_list_challenge_redacting_admin_denied(self): self.assertEqual(response.data["d"], []) def test_category_list_challenge_unlocked_admin(self): + """Test the category list correctly sets unlocked for admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -260,6 +302,7 @@ def test_category_list_challenge_unlocked_admin(self): self.assertFalse(self.find_challenge_entry(self.challenge1, data=response.data).get("unlocked")) def test_category_create(self): + """Test categories can be created.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -274,6 +317,7 @@ def test_category_create(self): self.assertTrue(response.status_code, HTTP_200_OK) def test_category_create_unauthorized(self): + """Test unauthorized users cannot create categories.""" self.user.is_staff = False self.user.save() self.client.force_authenticate(self.user) @@ -288,6 +332,7 @@ def test_category_create_unauthorized(self): self.assertTrue(response.status_code, HTTP_403_FORBIDDEN) def test_category_list_content_cached(self): + """Test the category list is correctly cached.""" self.client.force_authenticate(self.user) config.set("enable_caching", True) uncached_response = self.client.get(reverse("categories-list")) @@ -296,6 +341,7 @@ def test_category_list_content_cached(self): self.assertEqual(uncached_response.data, cached_response.data) def test_category_list_content_preevent_cached(self): + """Test the preevent cache is served to users.""" self.client.force_authenticate(self.user) config.set("enable_preevent_cache", True) config.set("start_time", time.time() - 5) @@ -305,6 +351,7 @@ def test_category_list_content_preevent_cached(self): self.assertEqual(cached_response.data["d"], {"key": "value"}) def test_category_list_content_preevent_cached_admin(self): + """Test the preevent cache is not served to admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -316,32 +363,40 @@ def test_category_list_content_preevent_cached_admin(self): class ChallengeViewsetTestCase(ChallengeSetupMixin, APITestCase): + """Tests for the challenge viewset api endpoints.""" + def test_challenge_list_unauthenticated_permission(self): + """Test the endpoint cannot be accessed by unauthenticated users.""" response = self.client.get(reverse("challenges-list")) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_challenge_list_authenticated_permission(self): + """Test the list can be viewed by authenticated users.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("challenges-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_challenge_list_unauthenticated_content(self): + """Test unauthenticated users get no content from the endpoint.""" response = self.client.get(reverse("challenges-list")) self.assertFalse(response.data["s"]) self.assertEqual(response.data["m"], "not_authenticated") self.assertEqual(response.data["d"], "") def test_challenge_list_authenticated_content(self): + """Test the correct amount of challenges are listed.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("challenges-list")) self.assertEqual(len(response.data), 3) def test_challenge_list_challenge_redacting(self): + """Test challenges are correctly redacted.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("challenges-list")) self.assertFalse("description" in self.find_challenge_entry(self.challenge3, data=response.data)) def test_challenge_list_challenge_redacting_admin(self): + """Test challenges are not redacted for admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -349,42 +404,47 @@ def test_challenge_list_challenge_redacting_admin(self): self.assertTrue("description" in response.data[0]) def test_challenge_list_challenge_unlocked_admin(self): + """Test unlocked is correctly set for admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) response = self.client.get(reverse("challenges-list")) - # TODO: Don't depend on order self.assertFalse(self.find_challenge_entry(self.challenge1, data=response.data)["unlocked"]) def test_single_challenge_redacting(self): + """Test single challenges are correctly redacted.""" self.user.is_staff = False self.user.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("challenges-detail", kwargs={"pk": self.challenge1.id})) + response = self.client.get(reverse("challenges-detail", kwargs={"pk": self.challenge1.pk})) self.assertFalse("description" in response.data) def test_single_challenge_admin_redacting(self): + """Test single challenges are not redacted for admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("challenges-detail", kwargs={"pk": self.challenge1.id})) + response = self.client.get(reverse("challenges-detail", kwargs={"pk": self.challenge1.pk})) self.assertTrue("description" in response.data) def test_admin_unlocking(self): + """Test unlocked is correctly set on the detail view for admins.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("challenges-detail", kwargs={"pk": self.challenge1.id})) + response = self.client.get(reverse("challenges-detail", kwargs={"pk": self.challenge1.pk})) self.assertFalse(response.data["unlocked"]) def test_user_post_detail(self): + """Test non staff users cannot post to the challenge detail endpoint.""" self.user.is_staff = False self.user.save() self.client.force_authenticate(self.user) - response = self.client.post(reverse("challenges-detail", kwargs={"pk": self.challenge1.id})) + response = self.client.post(reverse("challenges-detail", kwargs={"pk": self.challenge1.pk})) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_user_post_list(self): + """Test non staff users cannot post to the challenge list endpoint.""" self.user.is_staff = False self.user.save() self.client.force_authenticate(self.user) @@ -392,6 +452,7 @@ def test_user_post_list(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_create_challenge(self): + """Test challenges can be created.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -399,7 +460,7 @@ def test_create_challenge(self): reverse("challenges-list"), data={ "name": "test4", - "category": self.category.id, + "category": self.category.pk, "description": "abc", "challenge_type": "test", "challenge_metadata": {}, @@ -415,6 +476,7 @@ def test_create_challenge(self): self.assertEqual(response.status_code, HTTP_201_CREATED) def test_create_challenge_unauthorized(self): + """Test unauthorized users cannot create challenges.""" self.user.is_staff = False self.user.save() self.client.force_authenticate(self.user) @@ -422,7 +484,7 @@ def test_create_challenge_unauthorized(self): reverse("challenges-list"), data={ "name": "test4", - "category": self.category.id, + "category": self.category.pk, "description": "abc", "challenge_type": "test", "challenge_metadata": {}, @@ -437,6 +499,7 @@ def test_create_challenge_unauthorized(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_create_challenge_metadata_saves(self): + """Test challenge_metadata is saved.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -456,7 +519,10 @@ def test_create_challenge_metadata_saves(self): class FlagCheckViewTestCase(ChallengeSetupMixin, APITestCase): + """Tests for the flag check endpoint.""" + def test_disable_flag_submission(self): + """Test flags cannot be checked with flag submission disabled.""" self.client.force_authenticate(self.user) config.set("enable_flag_submission", False) response = self.client.post(reverse("check-flag")) @@ -464,54 +530,59 @@ def test_disable_flag_submission(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_bad_request(self): + """Test malformed requests are rejected.""" self.client.force_authenticate(self.user) response = self.client.post(reverse("check-flag")) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_havent_solved_challenge(self): + """Test the endpoint rejects challenges that the user has not solved.""" self.client.force_authenticate(self.user) response = self.client.post( reverse("check-flag"), data={ - "challenge": self.challenge1.id, + "challenge": self.challenge1.pk, "flag": "a", }, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_incorrect_flag(self): + """Test the incorrect flag is rejected.""" self.client.force_authenticate(self.user) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } self.client.post(reverse("submit-flag"), data) response = self.client.post( reverse("check-flag"), data={ - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, "flag": "a", }, ) self.assertEqual(response.data["m"], "incorrect_flag") def test_correct_flag(self): + """Test the correct flag is accepted.""" self.client.force_authenticate(self.user) data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } self.client.post(reverse("submit-flag"), data) response = self.client.post(reverse("check-flag"), data) self.assertEqual(response.data["m"], "correct_flag") def test_post_score_explanation(self): + """Test the post score explanation is correctly included.""" self.client.force_authenticate(self.user) self.challenge2.post_score_explanation = "test" self.challenge2.save() data = { "flag": "ractf{a}", - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, } self.client.post(reverse("submit-flag"), data) response = self.client.post(reverse("check-flag"), data) diff --git a/src/challenge/urls.py b/src/challenges/urls.py similarity index 92% rename from src/challenge/urls.py rename to src/challenges/urls.py index eb4d0adf..ac5de72c 100644 --- a/src/challenge/urls.py +++ b/src/challenges/urls.py @@ -1,7 +1,9 @@ +"""URL routes for the challenge app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter -from challenge import views +from challenges import views router = DefaultRouter() router.register(r"categories", views.CategoryViewset, basename="categories") diff --git a/src/challenge/views.py b/src/challenges/views.py similarity index 84% rename from src/challenge/views.py rename to src/challenges/views.py index ffd65d84..4236e397 100644 --- a/src/challenge/views.py +++ b/src/challenges/views.py @@ -1,3 +1,5 @@ +"""Challenge related api endpoints.""" + import hashlib import time from contextlib import suppress @@ -11,6 +13,7 @@ from django.db.models import Case, Prefetch, Sum, Value, When from django.utils import timezone from rest_framework import permissions +from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.parsers import MultiPartParser from rest_framework.permissions import IsAdminUser, IsAuthenticated @@ -20,11 +23,7 @@ from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet -from backend.permissions import AdminOrReadOnly, IsBot, ReadOnlyBot -from backend.response import FormattedResponse -from backend.signals import flag_reject, flag_score, flag_submit -from backend.viewsets import AdminCreateModelViewSet -from challenge.models import ( +from challenges.models import ( Category, Challenge, ChallengeFeedback, @@ -34,8 +33,8 @@ Solve, Tag, ) -from challenge.permissions import CompetitionOpen -from challenge.serializers import ( +from challenges.permissions import CompetitionOpen +from challenges.serializers import ( AdminScoreSerializer, ChallengeFeedbackSerializer, CreateCategorySerializer, @@ -46,24 +45,29 @@ FastChallengeSerializer, FileSerializer, TagSerializer, - get_negative_votes, - get_positive_votes, - get_solve_counts, ) +from challenges.sql import get_negative_votes, get_positive_votes, get_solve_counts from config import config +from core.permissions import AdminOrReadOnly, IsBot, ReadOnlyBot +from core.response import FormattedResponse +from core.signals import flag_reject, flag_score, flag_submit +from core.viewsets import AdminCreateModelViewSet from hint.models import Hint, HintUse -from team.models import Team -from team.permissions import HasTeam +from teams.models import Team, Member +from teams.permissions import HasTeam def get_cache_key(user): + """Get the category viewset cache key for a given user.""" if user.team is None: return str(caches["default"].get("challenge_mod_index", 0)) + "categoryvs_no_team" else: - return str(caches["default"].get("challenge_mod_index", 0)) + "categoryvs_team_" + str(user.team.id) + return str(caches["default"].get("challenge_mod_index", 0)) + "categoryvs_team_" + str(user.team.pk) class CategoryViewset(AdminCreateModelViewSet): + """Viewset for Category api endpoints.""" + queryset = Category.objects.all() permission_classes = (CompetitionOpen & AdminOrReadOnly,) throttle_scope = "challenges" @@ -73,7 +77,8 @@ class CategoryViewset(AdminCreateModelViewSet): create_serializer_class = CreateCategorySerializer def get_queryset(self): - if self.request.user.is_staff and self.request.user.should_deny_admin(): + """Get the list of categories for this view.""" + if self.request.user.is_staff and self.request.user.should_deny_admin: return Category.objects.none() team = self.request.user.team challenges = ( @@ -116,6 +121,7 @@ def get_queryset(self): return qs def list(self, request, *args, **kwargs): + """Return the list of challenges, this will be cached if caching is enabled.""" cache = caches["default"] if ( config.get("enable_preevent_cache") @@ -147,6 +153,8 @@ def list(self, request, *args, **kwargs): class ChallengeViewset(AdminCreateModelViewSet): + """Viewset for Challenge API endpoints.""" + queryset = Challenge.objects.all() permission_classes = (CompetitionOpen & AdminOrReadOnly,) throttle_scope = "challenges" @@ -156,19 +164,103 @@ class ChallengeViewset(AdminCreateModelViewSet): create_serializer_class = CreateChallengeSerializer def get_queryset(self): + """Get the list of challenges.""" if self.request.method not in permissions.SAFE_METHODS: return self.queryset return Challenge.get_unlocked_annotated_queryset(self.request.user) + @action(methods=["POST"], detail=True, permission_classes=(CompetitionOpen & IsAuthenticated & HasTeam & ~IsBot,)) + def submit_flag(self, request, pk=None): + """Attempt to solve a challenge from a given flag.""" + if not config.get("enable_flag_submission") or ( + not config.get("enable_flag_submission_after_competition") and time.time() > config.get("end_time") + ): + return FormattedResponse(m="flag_submission_disabled", status=HTTP_403_FORBIDDEN) + + # This is done in an atomic block to avoid a user racing this endpoint to score the same flag multiple times. + with transaction.atomic(): + team = Team.objects.select_for_update().get(id=request.user.team.pk) + user = Member.objects.select_for_update().get(id=request.user.pk) + flag = request.data.get("flag") + if not flag: + return FormattedResponse(status=HTTP_400_BAD_REQUEST, m="No flag provided") + + challenge = self.get_object() + solve_set = Solve.objects.filter(challenge=challenge) + if solve_set.filter(team=team, correct=True).exists(): + return FormattedResponse(m="already_solved_challenge", status=HTTP_403_FORBIDDEN) + if not challenge.is_unlocked_by(user): + return FormattedResponse(m="challenge_not_unlocked", status=HTTP_403_FORBIDDEN) + + if challenge.challenge_metadata.get("attempt_limit"): + count = solve_set.filter(team=team).count() + if count > challenge.challenge_metadata["attempt_limit"]: + flag_reject.send( + sender=self.__class__, + user=user, + team=team, + challenge=challenge, + flag=flag, + reason="attempt_limit_reached", + ) + return FormattedResponse(d={"correct": False}, m="attempt_limit_reached") + + flag_submit.send(sender=self.__class__, user=user, team=team, challenge=challenge, flag=flag) + + if not challenge.flag_plugin.check(flag, user=user, team=team): + flag_reject.send( + sender=self.__class__, user=user, team=team, challenge=challenge, flag=flag, reason="incorrect_flag" + ) + challenge.points_plugin.register_incorrect_attempt(user, team, flag, solve_set) + return FormattedResponse(d={"correct": False}, m="incorrect_flag") + + solve = challenge.points_plugin.score(user, team, flag, solve_set) + if challenge.first_blood is None: + challenge.first_blood = user + challenge.save(update_fields=["first_blood"]) + hook = config.get("firstblood_webhook") + if hook and hook != "": + challenge_clean = challenge.name.replace("`", "").replace("@", "@\u200b") + team_clean = team.name.replace("`", "").replace("@", "@\u200b") + if "discord.com" in hook and not hook.endswith("/slack"): + hook += "/slack" + challenge_clean = challenge_clean.replace("@", "@\u200b") + team_clean = team_clean.replace("@", "@\u200b") + body = { + "username": "First Bloods", + "attachments": [ + { + "title": f":drop_of_blood: First Blood on `{challenge_clean}`!", + "text": f"By team `{team_clean}`", + "color": "#ff0000", + } + ], + } + + with suppress(requests.exceptions.RequestException): + requests.post(hook, json=body) + + user.save() + team.save() + flag_score.send(sender=self.__class__, user=user, team=team, challenge=challenge, flag=flag, solve=solve) + caches["default"].delete(get_cache_key(request.user)) + ret = {"correct": True} + if challenge.post_score_explanation: + ret["explanation"] = challenge.post_score_explanation + return FormattedResponse(d=ret, m="correct_flag") + class ScoresViewset(ModelViewSet): + """Viewset for managing Score objects.""" + queryset = Score.objects.all() permission_classes = (IsAdminUser,) serializer_class = AdminScoreSerializer def recalculate_scores(self, user, team): + """Recalculate the scores of a given user and/or team.""" if user: - user = get_object_or_404(get_user_model(), id=user) + user = get_object_or_404(Member, id=user) user.leaderboard_points = ( Score.objects.filter(user=user, leaderboard=True).aggregate(Sum("points"))["points__sum"] or 0 ) @@ -195,30 +287,37 @@ def recalculate_scores(self, user, team): team.save() def create(self, req, *args, **kwargs): + """Recalculate scores when a score is created.""" x = super().create(req, *args, **kwargs) self.recalculate_scores(req.data.get("user", None), req.data.get("team", None)) return x def update(self, req, *args, **kwargs): + """Recalculate scores when a score is updated.""" x = super().update(req, *args, **kwargs) self.recalculate_scores(req.data.get("user", None), req.data.get("team", None)) return x def partial_update(self, req, *args, **kwargs): + """Recalculate scores when a score is partially updated.""" x = super().partial_update(req, *args, **kwargs) self.recalculate_scores(req.data.get("user", None), req.data.get("team", None)) return x def destroy(self, req, *args, **kwargs): + """Recalculate scores when a score is destroyed.""" x = super().destroy(req, *args, **kwargs) self.recalculate_scores(req.data.get("user", None), req.data.get("team", None)) return x class ChallengeFeedbackView(APIView): + """View for submitting and reading challenge feedback.""" + permission_classes = (IsAuthenticated & HasTeam & ReadOnlyBot,) def get(self, request): + """Return the user's challenge feedback, unless the user is an admin, then return all the challenge feedback.""" challenge = get_object_or_404(Challenge, id=request.data.get("challenge")) feedback = ChallengeFeedback.objects.filter(challenge=challenge) if request.user.is_staff: @@ -226,6 +325,7 @@ def get(self, request): return FormattedResponse(ChallengeFeedbackSerializer(feedback.filter(user=request.user).first()).data) def post(self, request): + """Submit or modify challenge feedback.""" challenge = get_object_or_404(Challenge, id=request.data.get("challenge")) solve_set = Solve.objects.filter(challenge=challenge) @@ -241,9 +341,12 @@ def post(self, request): class ChallengeVoteView(APIView): + """View for submitting challenge votes.""" + permission_classes = (IsAuthenticated & HasTeam & ~IsBot,) def post(self, request): + """Vote or modify your vote on a challenge once it is solved.""" challenge = get_object_or_404(Challenge, id=request.data.get("challenge")) solve_set = Solve.objects.filter(challenge=challenge) @@ -259,99 +362,36 @@ def post(self, request): class FlagSubmitView(APIView): + """Submit a flag to solve a challenge.""" + permission_classes = (CompetitionOpen & IsAuthenticated & HasTeam & ~IsBot,) throttle_scope = "flag_submit" def post(self, request): - if not config.get("enable_flag_submission") or ( - not config.get("enable_flag_submission_after_competition") and time.time() > config.get("end_time") - ): - return FormattedResponse(m="flag_submission_disabled", status=HTTP_403_FORBIDDEN) - - with transaction.atomic(): - team = Team.objects.select_for_update().get(id=request.user.team.id) - user = get_user_model().objects.select_for_update().get(id=request.user.id) - flag = request.data.get("flag") - challenge_id = request.data.get("challenge") - if not flag or not challenge_id: - return FormattedResponse(status=HTTP_400_BAD_REQUEST, m="No flag or challenge ID provided") - - challenge = get_object_or_404(Challenge.objects.select_for_update(), id=challenge_id) - solve_set = Solve.objects.filter(challenge=challenge) - if solve_set.filter(team=team, correct=True).exists(): - return FormattedResponse(m="already_solved_challenge", status=HTTP_403_FORBIDDEN) - if not challenge.is_unlocked(user): - return FormattedResponse(m="challenge_not_unlocked", status=HTTP_403_FORBIDDEN) - - if challenge.challenge_metadata.get("attempt_limit"): - count = solve_set.filter(team=team).count() - if count > challenge.challenge_metadata["attempt_limit"]: - flag_reject.send( - sender=self.__class__, - user=user, - team=team, - challenge=challenge, - flag=flag, - reason="attempt_limit_reached", - ) - return FormattedResponse(d={"correct": False}, m="attempt_limit_reached") - - flag_submit.send(sender=self.__class__, user=user, team=team, challenge=challenge, flag=flag) - - if not challenge.flag_plugin.check(flag, user=user, team=team): - flag_reject.send( - sender=self.__class__, user=user, team=team, challenge=challenge, flag=flag, reason="incorrect_flag" - ) - challenge.points_plugin.register_incorrect_attempt(user, team, flag, solve_set) - return FormattedResponse(d={"correct": False}, m="incorrect_flag") + """Attempt to solve a challenge from a given flag.""" + if "flag" not in request.data or "challenge" not in request.data: + return FormattedResponse(status=HTTP_400_BAD_REQUEST, m="No flag provided") - solve = challenge.points_plugin.score(user, team, flag, solve_set) - if challenge.first_blood is None: - challenge.first_blood = user - challenge.save(update_fields=["first_blood"]) - hook = config.get("firstblood_webhook") - if hook and hook != "": - challenge_clean = challenge.name.replace("`", "").replace("@", "@\u200b") - team_clean = team.name.replace("`", "").replace("@", "@\u200b") - if "discord.com" in hook and not hook.endswith("/slack"): - hook += "/slack" - challenge_clean = challenge_clean.replace("@", "@\u200b") - team_clean = team_clean.replace("@", "@\u200b") - body = { - "username": "First Bloods", - "attachments": [ - { - "title": f":drop_of_blood: First Blood on `{challenge_clean}`!", - "text": f"By team `{team_clean}`", - "color": "#ff0000", - } - ], - } - - with suppress(requests.exceptions.RequestException): - requests.post(hook, json=body) - - user.save() - team.save() - flag_score.send(sender=self.__class__, user=user, team=team, challenge=challenge, flag=flag, solve=solve) - caches["default"].delete(get_cache_key(request.user)) - ret = {"correct": True} - if challenge.post_score_explanation: - ret["explanation"] = challenge.post_score_explanation - return FormattedResponse(d=ret, m="correct_flag") + viewset = ChallengeViewset() + viewset.request = request + viewset.get_object = lambda: Challenge.objects.get(pk=request.data["challenge"]) + return viewset.submit_flag(request) class FlagCheckView(APIView): + """Api endpoint to check a flag is correct, but not solve a challenge.""" + permission_classes = (CompetitionOpen & IsAuthenticated & HasTeam & ~IsBot,) throttle_scope = "flag_submit" def post(self, request): + """Return if the given flag is correct.""" if not config.get("enable_flag_submission") or ( not config.get("enable_flag_submission_after_competition") and time.time() > config.get("end_time") ): return FormattedResponse(m="flag_submission_disabled", status=HTTP_403_FORBIDDEN) - team = Team.objects.get(id=request.user.team.id) - user = get_user_model().objects.get(id=request.user.id) + team = Team.objects.get(id=request.user.team.pk) + user = Member.objects.get(id=request.user.pk) flag = request.data.get("flag") challenge_id = request.data.get("challenge") if not flag or not challenge_id: @@ -372,6 +412,8 @@ def post(self, request): class FileViewSet(ModelViewSet): + """Viewset for managing files.""" + queryset = File.objects.all() permission_classes = (IsAdminUser,) parser_classes = (MultiPartParser,) @@ -423,6 +465,8 @@ def destroy(self, request: Request, *args, **kwargs) -> Response: class TagViewSet(ModelViewSet): + """Viewset for managing tags on challenges.""" + queryset = Tag.objects.all() permission_classes = (IsAdminUser,) throttle_scope = "tag" diff --git a/src/config/apps.py b/src/config/apps.py index 0ee32189..9b0f381a 100644 --- a/src/config/apps.py +++ b/src/config/apps.py @@ -1,10 +1,15 @@ +"""App managing the backend config.""" + from django.apps import AppConfig from config import config class ConfigConfig(AppConfig): + """The app config for the config app.""" + name = "config" def ready(self) -> None: + """Load the config when django is ready.""" config.load() diff --git a/src/config/backends.py b/src/config/backends.py index 4b2cb029..a4ab042b 100644 --- a/src/config/backends.py +++ b/src/config/backends.py @@ -1,3 +1,5 @@ +"""Backends capable of storing a key-value config.""" + import abc import sys @@ -9,38 +11,51 @@ class ConfigBackend(abc.ABC): + """Abstract class for a config backend.""" + @abc.abstractmethod def get(self, key): + """Get a config value from a given key.""" pass @abc.abstractmethod def set(self, key, value): + """Set a config key to a given value.""" pass @abc.abstractmethod def get_all(self): + """Get all config keys and values.""" pass def load(self, defaults): + """Load config keys and values from the database.""" pass def save(self): + """Save the config to a config.""" pass class CachedBackend(ConfigBackend): + """A config backend using django's low level cache api.""" + @property def config_set(self) -> "QuerySet[Config]": + """Get a queryset of Config objects.""" return Config.objects def __init__(self): + """Construct the backend and setup the cache.""" self.cache = caches["default"] self.keys = set() def get(self, key): + """Get a value for a given config key.""" return self.cache.get(f"config_{key}") def set(self, key, value): + """Set a config key and save it to the database.""" db_config = self.config_set.filter(key="config").first() if db_config: db_config.value[key] = value @@ -49,16 +64,19 @@ def set(self, key, value): self.keys.add(f"config_{key}") def get_all(self): + """Get all config keys and values.""" config = {} for key in self.keys: config[key[7:]] = self.get(key[7:]) return config def set_if_not_exists(self, key, value): + """Set a config key if it doesn't exist.""" if self.cache.add("config_" + key, value, timeout=None): self.keys.add(f"config_{key}") def load(self, defaults): + """Load the config from the database.""" db_config = self.config_set.filter(key="config") config_exists, migrations_needed = False, False diff --git a/src/config/config.py b/src/config/config.py index ffae569e..4d177f06 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,3 +1,5 @@ +"""Utility functions for other apps to access config.""" + from pydoc import locate from django.conf import settings @@ -7,22 +9,27 @@ def load(): + """Load the config.""" backend.load(defaults=settings.DEFAULT_CONFIG) def get(key): + """Get a config value.""" return backend.get(key) def set(key, value): + """Set a config value.""" backend.set(key, value) def get_all(): + """Get all config values.""" return backend.get_all() def get_all_non_sensitive(): + """Get all non sensitive config values.""" sensitive = backend.get("sensitive_fields") config = backend.get_all() for field in sensitive: @@ -31,4 +38,5 @@ def get_all_non_sensitive(): def is_sensitive(key): + """Return True if a config key is sensitive.""" return key in backend.get("sensitive_fields") diff --git a/src/config/models.py b/src/config/models.py index 45942cbf..3d69852c 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -1,7 +1,11 @@ +"""Database models for the config app.""" + from django.db import models from django_prometheus.models import ExportModelOperationsMixin class Config(ExportModelOperationsMixin("config"), models.Model): + """Represents the persisted version of the backed config.""" + key = models.CharField(max_length=64, unique=True) value = models.JSONField() diff --git a/src/config/tests.py b/src/config/tests.py index 6658cd74..7044753e 100644 --- a/src/config/tests.py +++ b/src/config/tests.py @@ -1,3 +1,5 @@ +"""Tests for the config app.""" + from django.contrib.auth import get_user_model from rest_framework.reverse import reverse from rest_framework.status import ( @@ -9,43 +11,53 @@ from rest_framework.test import APITestCase from config import config +from teams.models import Member class ConfigTestCase(APITestCase): + """Tests for the config api routes.""" + def setUp(self): - user = get_user_model()(username="config-test", email="config-test@example.org") + """Set up some users for use in tests.""" + user = Member(username="config-test", email="config-test@example.org") user.is_staff = True user.save() self.staff_user = user - user2 = get_user_model()(username="config-test2", email="config-test2@example.org") + user2 = Member(username="config-test2", email="config-test2@example.org") user2.save() self.user = user2 def test_auth_unauthed(self): + """Check unauthenticated users can access the full config.""" response = self.client.get(reverse("config-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_auth_authed(self): + """Check authenticated users can access the full config.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("config-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_auth_authed_staff(self): + """Check authenticated admin users can access the full config.""" self.client.force_authenticate(self.staff_user) response = self.client.get(reverse("config-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_get_sensitive_not_staff(self): + """Check authenticated non-admin users cannot access sensitive config keys.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("config-pk", kwargs={"name": "enable_force_admin_2fa"})) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_get_sensitive_staff(self): + """Check authenticated admin users can access sensitive config keys.""" self.client.force_authenticate(self.staff_user) response = self.client.get(reverse("config-pk", kwargs={"name": "enable_force_admin_2fa"})) self.assertEqual(response.status_code, HTTP_200_OK) def test_post_authed(self): + """Test a non-admin user cannot create config keys.""" self.client.force_authenticate(self.user) response = self.client.post( reverse("config-pk", kwargs={"name": "test"}), data={"key": "test", "value": "test"}, format="json" @@ -53,6 +65,7 @@ def test_post_authed(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_create(self): + """Test a admin user can create config keys.""" self.client.force_authenticate(self.staff_user) response = self.client.post( reverse("config-pk", kwargs={"name": "test"}), data={"value": "test"}, format="json" @@ -60,30 +73,35 @@ def test_create(self): self.assertEqual(response.status_code, HTTP_201_CREATED) def test_update_post(self): + """Test an admin user can update config keys.""" self.client.force_authenticate(self.staff_user) self.client.post(reverse("config-pk", kwargs={"name": "test"}), data={"value": "test"}, format="json") self.client.post(reverse("config-pk", kwargs={"name": "test"}), data={"value": "test2"}, format="json") self.assertEqual(config.get("test"), "test2") def test_update_post_bad_request(self): + """Test a malformed config update is rejected.""" self.client.force_authenticate(self.staff_user) self.client.post(reverse("config-pk", kwargs={"name": "test"}), data={"value": "test"}, format="json") response = self.client.post(reverse("config-pk", kwargs={"name": "test"}), data={}, format="json") self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_update_patch(self): + """Test a config key can be modified with a patch request.""" self.client.force_authenticate(self.staff_user) self.client.post(reverse("config-pk", kwargs={"name": "test"}), data={"value": "test"}, format="json") self.client.patch(reverse("config-pk", kwargs={"name": "test"}), data={"value": "test2"}, format="json") self.assertEqual(config.get("test"), "test2") def test_update_patch_bad_request(self): + """Test a malformed patch request is rejected.""" self.client.force_authenticate(self.staff_user) self.client.post(reverse("config-pk", kwargs={"name": "test"}), data={"value": "test"}, format="json") response = self.client.patch(reverse("config-pk", kwargs={"name": "test"}), data={}, format="json") self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_update_patch_list(self): + """Test a patch request correctly appends to a list.""" self.client.force_authenticate(self.staff_user) config.set("testlist", ["test"]) self.client.patch(reverse("config-pk", kwargs={"name": "testlist"}), data={"value": "test"}, format="json") diff --git a/src/config/urls.py b/src/config/urls.py index f8153844..89471033 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -1,3 +1,5 @@ +"""URL routes for the config app.""" + from django.urls import path from config import views diff --git a/src/config/views.py b/src/config/views.py index 00652343..0e44e2a8 100644 --- a/src/config/views.py +++ b/src/config/views.py @@ -1,3 +1,5 @@ +"""API endpoints to manage the backend config.""" + from rest_framework.status import ( HTTP_201_CREATED, HTTP_204_NO_CONTENT, @@ -6,16 +8,19 @@ ) from rest_framework.views import APIView -from backend.permissions import AdminOrAnonymousReadOnly -from backend.response import FormattedResponse from config import config +from core.permissions import AdminOrAnonymousReadOnly +from core.response import FormattedResponse class ConfigView(APIView): + """APIView to handle config.""" + throttle_scope = "config" permission_classes = (AdminOrAnonymousReadOnly,) def get(self, request, name=None): + """Return the whole config, or a specific key if the user has permissions to see it.""" if name is None: if request.user.is_staff: return FormattedResponse(config.get_all()) @@ -25,12 +30,14 @@ def get(self, request, name=None): return FormattedResponse(status=HTTP_403_FORBIDDEN) def post(self, request, name): + """Create or update a config key.""" if "value" not in request.data: return FormattedResponse(status=HTTP_400_BAD_REQUEST) config.set(name, request.data.get("value")) return FormattedResponse(status=HTTP_201_CREATED) def patch(self, request, name): + """Create or update a config key.""" if "value" not in request.data: return FormattedResponse(status=HTTP_400_BAD_REQUEST) if config.get(name) is not None and isinstance(config.get(name), list): diff --git a/src/announcements/migrations/__init__.py b/src/core/__init__.py similarity index 100% rename from src/announcements/migrations/__init__.py rename to src/core/__init__.py diff --git a/src/core/apps.py b/src/core/apps.py new file mode 100644 index 00000000..35aa4bca --- /dev/null +++ b/src/core/apps.py @@ -0,0 +1,49 @@ +"""App config for the core app, abstract app config for apps that provide plugins and a self check.""" + +import abc +from pydoc import locate + +from django.apps import AppConfig +from django.conf import settings +from django.core.checks import ERROR, WARNING, CheckMessage, Tags, register + +from core import plugins + + +class CoreConfig(AppConfig): + """App config for the core app.""" + + name = "core" + + def ready(self) -> None: + """Load plugins when django is ready.""" + plugins.load_plugins(settings.INSTALLED_PLUGINS) + + +class PluginConfig(AppConfig, abc.ABC): + """Base class for app configs that provide plugins.""" + + def ready(self): + """Register plugin providers when django is ready.""" + from core import providers + + if hasattr(self, "provides") and isinstance(self.provides, list): # pragma: no cover + for provider in map(locate, self.provides): + providers.register_provider(provider.type, provider()) + + +@register(Tags.compatibility) +def check_settings(app_configs, **kwargs): # pragma: no cover + """Check that no required settings are missing.""" + errors = [] + for setting in settings.REQUIRED_SETTINGS: + if getattr(settings, setting, None) is None: + errors.append( + CheckMessage( + (WARNING if settings.DEBUG else ERROR), + f"Required setting {setting} was missing.", + hint="Did you forget to set an environment variable?", + id="ractf.E001", + ) + ) + return errors diff --git a/src/backend/asgi.py b/src/core/asgi.py similarity index 83% rename from src/backend/asgi.py rename to src/core/asgi.py index 19e4c28d..2f42a5eb 100644 --- a/src/backend/asgi.py +++ b/src/core/asgi.py @@ -11,7 +11,7 @@ import django from channels.routing import get_default_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.lint") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.lint") django.setup() diff --git a/src/backend/authentication.py b/src/core/authentication.py similarity index 57% rename from src/backend/authentication.py rename to src/core/authentication.py index b4a789d7..f41be83a 100644 --- a/src/backend/authentication.py +++ b/src/core/authentication.py @@ -1,3 +1,5 @@ +"""Token authentication for ractf.""" + from rest_framework import authentication from authentication.models import Token @@ -5,14 +7,23 @@ class RactfTokenAuthentication(authentication.TokenAuthentication): + """A subclass of DRF's token authentication to add extra features.""" + model = Token def authenticate(self, request): + """ + Return the user and their token if the user is authenticated. + + This will also handle maintenance mode being enabled, in which case only admins can be authenticated, bot users + will also not be able to authenticate if bots are disabled, and the sudo attribute is set on requests if the + current user is sudoed. + """ x = super(RactfTokenAuthentication, self).authenticate(request) if x is None: return None user, token = x - if user.is_staff and not user.should_deny_admin(): + if user.is_staff and not user.should_deny_admin: return user, token if config.get("enable_maintenance_mode"): return None diff --git a/src/backend/backends.py b/src/core/backends.py similarity index 66% rename from src/backend/backends.py rename to src/core/backends.py index ad7ca93c..ee019787 100644 --- a/src/backend/backends.py +++ b/src/core/backends.py @@ -1,15 +1,21 @@ -from django.contrib.auth import get_user_model +"""Authentication backends define by RACTF core.""" + from django.contrib.auth.backends import ModelBackend -UserModel = get_user_model() +from teams.models import Member + +UserModel = Member class EmailOrUsernameBackend(ModelBackend): - """ - Authenticates against settings.AUTH_USER_MODEL. - """ + """Authenticates against settings.AUTH_USER_MODEL.""" def authenticate(self, request, username=None, password=None, **kwargs): + """ + Return a user object if authentication is successful or None. + + This is a copy of django's default authentication backend, except it uses the @ character to switch to email. + """ try: if "@" in username: user = UserModel.objects.get(email=username) diff --git a/src/core/base.py b/src/core/base.py new file mode 100644 index 00000000..1041ed59 --- /dev/null +++ b/src/core/base.py @@ -0,0 +1,7 @@ +"""Base class for plugins.""" + + +class Plugin: + """Base class used to identify plugins when loading them.""" + + pass diff --git a/src/backend/exception_handler.py b/src/core/exception_handler.py similarity index 87% rename from src/backend/exception_handler.py rename to src/core/exception_handler.py index 47bf2b2f..e1e3b997 100644 --- a/src/backend/exception_handler.py +++ b/src/core/exception_handler.py @@ -1,3 +1,5 @@ +"""Global exception handler for RACTF core.""" + import traceback from typing import Optional @@ -9,13 +11,16 @@ from rest_framework.status import HTTP_500_INTERNAL_SERVER_ERROR from rest_framework.views import exception_handler -from backend.exceptions import FormattedException -from backend.response import FormattedResponse +from core.exceptions import FormattedException +from core.response import FormattedResponse def handle_exception(exc: Exception, context: dict) -> Optional[Response]: - """Handle exceptions, sending data to Sentry if errors are unhandled.""" + """ + Handle exceptions, sending data to Sentry if errors are unhandled. + Abandon hope all ye who enter here. + """ if "X-Reasonable" in context["request"].headers: return exception_handler(exc, context) diff --git a/src/backend/exceptions.py b/src/core/exceptions.py similarity index 60% rename from src/backend/exceptions.py rename to src/core/exceptions.py index 3edd0c43..c92e82ce 100644 --- a/src/backend/exceptions.py +++ b/src/core/exceptions.py @@ -1,9 +1,14 @@ +"""Exceptions defined by RACTF core.""" + from rest_framework.exceptions import APIException from rest_framework.status import HTTP_500_INTERNAL_SERVER_ERROR class FormattedException(APIException): + """Used to throw an exception that fits into *that* format, because who doesnt like throwing away useful info.""" + def __init__(self, d="", m="", status=HTTP_500_INTERNAL_SERVER_ERROR): + """Set the message and data attributes of a formatted response.""" super(FormattedException, self).__init__() self.status_code = status self.m = m diff --git a/src/backend/__init__.py b/src/core/flag/__init__.py similarity index 100% rename from src/backend/__init__.py rename to src/core/flag/__init__.py diff --git a/src/plugins/flag/base.py b/src/core/flag/base.py similarity index 61% rename from src/plugins/flag/base.py rename to src/core/flag/base.py index 993bf85c..75db5b3c 100644 --- a/src/plugins/flag/base.py +++ b/src/core/flag/base.py @@ -1,16 +1,22 @@ +"""Base class from which all flag plugins inherit.""" + import abc -from plugins.base import Plugin +from core.base import Plugin class FlagPlugin(Plugin, abc.ABC): + """Base class from which all flag plugins inherit.""" + plugin_type = "flag" def __init__(self, challenge): + """Set the challenge used by this plugin.""" self.challenge = challenge @abc.abstractmethod def check(self, flag, *args, **kwargs): + """Return True if a flag is valid.""" pass @abc.abstractmethod diff --git a/src/plugins/flag/hashed.py b/src/core/flag/hashed.py similarity index 65% rename from src/plugins/flag/hashed.py rename to src/core/flag/hashed.py index 896fa23c..50af0680 100644 --- a/src/plugins/flag/hashed.py +++ b/src/core/flag/hashed.py @@ -1,16 +1,21 @@ +"""Flag plugin for validating sha256 hashed flags.""" + import hashlib -from plugins.flag.base import FlagPlugin +from core.flag.base import FlagPlugin class HashedFlagPlugin(FlagPlugin): + """Flag plugin for validating sha256 hashed flags.""" + name = "hashed" def check(self, flag, *args, **kwargs): + """Return True if the hash of the input matches the stored sha256.""" return self.challenge.flag_metadata["flag"] == hashlib.sha256(flag.encode("utf-8")).hexdigest() def self_check(self): - """Ensure the set flag metadata has a 'flag' property of length 64""" + """Ensure the set flag metadata has a 'flag' property of length 64.""" if len(self.challenge.flag_metadata.get("flag", "")) == 64: return ["property 'flag' must be of length 64!"] return [] diff --git a/src/plugins/flag/lenient.py b/src/core/flag/lenient.py similarity index 73% rename from src/plugins/flag/lenient.py rename to src/core/flag/lenient.py index fdee42ad..5782b601 100644 --- a/src/plugins/flag/lenient.py +++ b/src/core/flag/lenient.py @@ -1,22 +1,28 @@ +"""Flag plugin for leniently validating flags.""" + import unicodedata from config import config -from plugins.flag.base import FlagPlugin +from core.flag.base import FlagPlugin def strip_accents(s): + """Remove the accents from characters in a flag.""" return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def lower(s): + """Make the flag lowercase.""" return s.lower() def strip_whitespace(s): + """Strip all whitespace from a flag.""" return "".join(s.split()) def fix_format(s): + """Correct the flag format.""" prefix = config.get("flag_prefix") return s if prefix + "{" in s else prefix + "{" + s + "}" @@ -30,9 +36,12 @@ def fix_format(s): class LenientFlagPlugin(FlagPlugin): + """Flag plugin for leniently validating flags.""" + name = "lenient" def check(self, flag, *args, **kwargs): + """Return True if the flag is valid after cleaning.""" flag_metadata = self.challenge.flag_metadata if "exclude_passes" not in flag_metadata: flag_metadata["exclude_passes"] = [] @@ -45,7 +54,7 @@ def check(self, flag, *args, **kwargs): return real_flag == flag def self_check(self): - """Ensure the set flag metadata has a 'flag' property""" + """Ensure the set flag metadata has a 'flag' property.""" if not self.challenge.flag_metadata.get("flag", ""): return ["property 'flag' must be set!"] return [] diff --git a/src/plugins/flag/long_text.py b/src/core/flag/long_text.py similarity index 64% rename from src/plugins/flag/long_text.py rename to src/core/flag/long_text.py index 30e4d18f..0a54dee3 100644 --- a/src/plugins/flag/long_text.py +++ b/src/core/flag/long_text.py @@ -1,22 +1,28 @@ +"""Long text flag plugin.""" + import string -from plugins.flag.base import FlagPlugin +from core.flag.base import FlagPlugin WHITELIST = string.ascii_lowercase + string.ascii_uppercase def clean(text): + """Remove all non printable characters from the flag.""" return "".join(i for i in text if i in WHITELIST).lower() class LongTextFlagPlugin(FlagPlugin): + """Long text flag plugin.""" + name = "long_text" def check(self, flag, *args, **kwargs): + """Return True if the flag is valid.""" return clean(self.challenge.flag_metadata["flag"]) == clean(flag) def self_check(self): - """Ensure the set flag metadata has a 'flag' property""" + """Ensure the set flag metadata has a 'flag' property.""" if not self.challenge.flag_metadata.get("flag", ""): return ["property 'flag' must be set!"] return [] diff --git a/src/plugins/flag/map.py b/src/core/flag/map.py similarity index 85% rename from src/plugins/flag/map.py rename to src/core/flag/map.py index 75461ab4..de5cb5aa 100644 --- a/src/plugins/flag/map.py +++ b/src/core/flag/map.py @@ -1,12 +1,17 @@ +"""Flag plugin to validate locations on a map.""" + import math -from plugins.flag.base import FlagPlugin +from core.flag.base import FlagPlugin class MapFlagPlugin(FlagPlugin): + """Flag plugin to validate locations on a map.""" + name = "map" def check(self, flag, *args, **kwargs): + """Return True if the given location is within the specified radius of the correct location.""" r = 6373.0 correct_latlon = self.challenge.flag_metadata["location"] @@ -22,7 +27,7 @@ def check(self, flag, *args, **kwargs): return self.challenge.flag_metadata["radius"] > distance def self_check(self): - """Ensure the set flag metadata has the required properties""" + """Ensure the set flag metadata has the required properties.""" issues = [] if not self.challenge.flag_metadata.get("radius", ""): diff --git a/src/plugins/flag/plaintext.py b/src/core/flag/plaintext.py similarity index 66% rename from src/plugins/flag/plaintext.py rename to src/core/flag/plaintext.py index 6b906501..d0479c4d 100644 --- a/src/plugins/flag/plaintext.py +++ b/src/core/flag/plaintext.py @@ -1,14 +1,20 @@ +"""Plaintext flag plugin.""" + from config import config -from plugins.flag.base import FlagPlugin +from core.flag.base import FlagPlugin class PlaintextFlagPlugin(FlagPlugin): + """Plaintext flag plugin.""" + name = "plaintext" def check(self, flag, *args, **kwargs): + """Return True if the flag matches the stored plaintext.""" return self.challenge.flag_metadata["flag"] == flag def self_check(self): + """Ensure the set flag metadata has a 'flag' property and the flag matches the format.""" if not self.challenge.flag_metadata.get("flag", ""): return ["property 'flag' must be set!"] diff --git a/src/plugins/flag/regex.py b/src/core/flag/regex.py similarity index 59% rename from src/plugins/flag/regex.py rename to src/core/flag/regex.py index ae7caa95..4a359923 100644 --- a/src/plugins/flag/regex.py +++ b/src/core/flag/regex.py @@ -1,16 +1,22 @@ +"""Regex based flag validation plugin.""" + import re -from plugins.flag.base import FlagPlugin +from core.flag.base import FlagPlugin class RegexFlagPlugin(FlagPlugin): + """Plugin to validate flags with regex.""" + name = "regex" def check(self, flag, *args, **kwargs): + """Return True if the entire flag matches a regex.""" regex = re.compile(self.challenge.flag_metadata["flag"]) return regex.fullmatch(flag) def self_check(self): + """Ensure the set flag metadata has a 'flag' property.""" if not self.challenge.flag_metadata.get("flag", ""): return ["property 'flag' must be set!"] return [] diff --git a/src/backend/mail.py b/src/core/mail.py similarity index 94% rename from src/backend/mail.py rename to src/core/mail.py index 0b33733e..eee338b8 100644 --- a/src/backend/mail.py +++ b/src/core/mail.py @@ -1,3 +1,5 @@ +"""Functions used for sending emails to users.""" + from importlib import import_module from django.conf import settings @@ -13,6 +15,7 @@ def send_email(send_to, subject_line, template_name, **template_details): + """Send an email to a user from a given template.""" if settings.MAIL["SEND"]: # pragma: no cover if settings.MAIL["SEND_MODE"] == "AWS": # pragma: no cover client.send_email( @@ -67,5 +70,6 @@ def send_email(send_to, subject_line, template_name, **template_details): smtp.sendmail(sender, send_to, data.as_string()) else: print( - f"Sending email '{subject_line}' to {send_to} using template {template_name} with details {template_details}" + f"Sending email '{subject_line}' to {send_to} using template {template_name}" + f" with details {template_details}" ) diff --git a/src/challenge/__init__.py b/src/core/management/__init__.py similarity index 100% rename from src/challenge/__init__.py rename to src/core/management/__init__.py diff --git a/src/challenge/migrations/__init__.py b/src/core/management/commands/__init__.py similarity index 100% rename from src/challenge/migrations/__init__.py rename to src/core/management/commands/__init__.py diff --git a/src/ractf/management/commands/copy_points.py b/src/core/management/commands/copy_points.py similarity index 70% rename from src/ractf/management/commands/copy_points.py rename to src/core/management/commands/copy_points.py index 025df69e..39cab72c 100644 --- a/src/ractf/management/commands/copy_points.py +++ b/src/core/management/commands/copy_points.py @@ -1,15 +1,20 @@ +"""Command to recalculate the leaderboard_points attribute.""" + import time from django.core.management import BaseCommand -from challenge.models import Score +from challenges.models import Score from config import config class Command(BaseCommand): + """Command to recalculate the leaderboard_points attribute.""" + help = "Removes all scores from the database" def handle(self, *args, **options): + """Iterate over every score in the database and add it to the user/team's leaderboard_points.""" if time.time() > config.get("end_time"): return for score in Score.objects.all(): diff --git a/src/ractf/management/commands/create_preevent_cache.py b/src/core/management/commands/create_preevent_cache.py similarity index 92% rename from src/ractf/management/commands/create_preevent_cache.py rename to src/core/management/commands/create_preevent_cache.py index 495bc5cb..2dcc0b7e 100644 --- a/src/ractf/management/commands/create_preevent_cache.py +++ b/src/core/management/commands/create_preevent_cache.py @@ -8,10 +8,10 @@ from django.utils import timezone from rest_framework.request import Request -from challenge import serializers -from challenge.models import Category, Challenge, File, Tag -from challenge.serializers import FastCategorySerializer -from challenge.sql import get_negative_votes, get_positive_votes, get_solve_counts +from challenges import serializers +from challenges.models import Category, Challenge, File, Tag +from challenges.serializers import FastCategorySerializer +from challenges.sql import get_negative_votes, get_positive_votes, get_solve_counts from config import config from hint.models import Hint diff --git a/src/ractf/management/commands/flush_db.py b/src/core/management/commands/flush_db.py similarity index 88% rename from src/ractf/management/commands/flush_db.py rename to src/core/management/commands/flush_db.py index cd79ff63..f7c1e075 100644 --- a/src/ractf/management/commands/flush_db.py +++ b/src/core/management/commands/flush_db.py @@ -1,3 +1,5 @@ +"""Command to reset the database.""" + import sys import psycopg2 @@ -7,9 +9,12 @@ class Command(BaseCommand): + """Command to reset the database.""" + help = "Resets the database to the default configuration" def handle(self, *args, **options): + """Drop the database, recreate it then run migrations.""" connection = psycopg2.connect( user=settings.DATABASES["default"]["USER"], password=settings.DATABASES["default"]["PASSWORD"], diff --git a/src/ractf/management/commands/getschema.py b/src/core/management/commands/getschema.py similarity index 93% rename from src/ractf/management/commands/getschema.py rename to src/core/management/commands/getschema.py index dd070660..738a3109 100644 --- a/src/ractf/management/commands/getschema.py +++ b/src/core/management/commands/getschema.py @@ -1,3 +1,5 @@ +"""Command to generate an openapi schema.""" + import os from io import StringIO @@ -6,6 +8,8 @@ class Command(BaseCommand): + """Command to generate an openapi schema.""" + help = "Generate a schema file and add relevant metadata." def handle(self, *args, **options): diff --git a/src/core/management/commands/make_admin.py b/src/core/management/commands/make_admin.py new file mode 100644 index 00000000..6d54a02d --- /dev/null +++ b/src/core/management/commands/make_admin.py @@ -0,0 +1,21 @@ +"""Command to forcefully remove make a user admin.""" + +from django.core.management import BaseCommand + +from teams.models import Member + + +class Command(BaseCommand): + """Command to forcefully remove make a user admin.""" + + help = "Make a user admin." + + def add_arguments(self, parser): + """Add the user id parameter to the parser.""" + parser.add_argument("user_id", type=int) + + def handle(self, *args, **options): + """Make a user admin.""" + user = Member.objects.get(pk=options["user_id"]) + user.is_staff = True + user.save() diff --git a/src/core/management/commands/make_not_admin.py b/src/core/management/commands/make_not_admin.py new file mode 100644 index 00000000..0fc850e7 --- /dev/null +++ b/src/core/management/commands/make_not_admin.py @@ -0,0 +1,23 @@ +"""Command to forcefully remove make a user not admin.""" + +from django.contrib.auth import get_user_model +from django.core.management import BaseCommand + +from teams.models import Member + + +class Command(BaseCommand): + """Command to forcefully remove make a user not admin.""" + + help = "Make a user not admin." + + def add_arguments(self, parser): + """Add the user id parameter to the parser.""" + parser.add_argument("user_id", type=int) + + def handle(self, *args, **options): + """Make a user not admin.""" + user = Member.objects.get(pk=options["user_id"]) + user.is_staff = False + user.is_superuser = False + user.save() diff --git a/src/ractf/management/commands/reset_scores.py b/src/core/management/commands/reset_scores.py similarity index 57% rename from src/ractf/management/commands/reset_scores.py rename to src/core/management/commands/reset_scores.py index 634ef8d9..1dfcf0ef 100644 --- a/src/ractf/management/commands/reset_scores.py +++ b/src/core/management/commands/reset_scores.py @@ -1,21 +1,30 @@ -from django.contrib.auth import get_user_model +"""Command to remove all scores from the database.""" + from django.core.management import BaseCommand -from challenge.models import Challenge, Score, Solve -from team.models import Team +from challenges.models import Challenge, Score, Solve +from teams.models import Team, Member class Command(BaseCommand): + """Command to remove all scores from the database.""" + help = "Removes all scores from the database" def handle(self, *args, **options): + """ + Reset all points. + + Reset all teams and users to 0 points, remove all scores and solves, + and remove first bloods from all challenges. + """ Solve.objects.all().delete() Score.objects.all().delete() for team in Team.objects.all(): team.points = 0 team.leaderboard_points = 0 team.save() - for user in get_user_model().objects.all(): + for user in Member.objects.all(): user.points = 0 user.leaderboard_points = 0 user.save() diff --git a/src/ractf/management/commands/transfer.py b/src/core/management/commands/transfer.py similarity index 60% rename from src/ractf/management/commands/transfer.py rename to src/core/management/commands/transfer.py index 9a2c848a..62b1a313 100644 --- a/src/ractf/management/commands/transfer.py +++ b/src/core/management/commands/transfer.py @@ -1,18 +1,24 @@ +"""Command to transfer the owner of a team.""" + from django.contrib.auth import get_user_model from django.core.management import BaseCommand -from team.models import Team +from teams.models import Team, Member class Command(BaseCommand): + """Command to transfer the owner of a team.""" + help = "Transfer team owner" def add_arguments(self, parser): + """Add arguments to the parser.""" parser.add_argument("user_id", type=int) parser.add_argument("team_id", type=int) def handle(self, *args, **options): - user = get_user_model().objects.get(pk=options["user_id"]) + """Switch the owner of a team.""" + user = Member.objects.get(pk=options["user_id"]) team = Team.objects.get(pk=options["team_id"]) team.owner = user team.save() diff --git a/src/ractf/management/commands/unteam.py b/src/core/management/commands/unteam.py similarity index 68% rename from src/ractf/management/commands/unteam.py rename to src/core/management/commands/unteam.py index 3bbc3222..85e0d37d 100644 --- a/src/ractf/management/commands/unteam.py +++ b/src/core/management/commands/unteam.py @@ -1,15 +1,23 @@ +"""Command to forcefully remove a user from a team.""" + from django.contrib.auth import get_user_model from django.core.management import BaseCommand +from teams.models import Member + class Command(BaseCommand): + """Command to forcefully remove a user from a team.""" + help = "Removes all scores from the database" def add_arguments(self, parser): + """Add the user id parameter to the parser.""" parser.add_argument("user_id", type=int) def handle(self, *args, **options): - user = get_user_model().objects.get(pk=options["user_id"]) + """Remove a user from a team.""" + user = Member.objects.get(pk=options["user_id"]) print("Choices:", user.team.members) if user.team.owner == user: x = input("This will delete the team, are you sure?") diff --git a/src/core/mixins.py b/src/core/mixins.py new file mode 100644 index 00000000..113d1316 --- /dev/null +++ b/src/core/mixins.py @@ -0,0 +1,9 @@ +"""Generic mixins used in RACTF core.""" + + +class IncorrectSolvesMixin: + """Mixin to add the incorrect_solves attribute to a serializer.""" + + def get_incorrect_solves(self, instance): + """Return the count of incorrect solves.""" + return instance.solves.filter(correct=False).count() diff --git a/src/backend/pagination.py b/src/core/pagination.py similarity index 86% rename from src/backend/pagination.py rename to src/core/pagination.py index b4b51685..57331180 100644 --- a/src/backend/pagination.py +++ b/src/core/pagination.py @@ -1,10 +1,12 @@ +"""Pagination classes used by core.""" + from collections import OrderedDict from typing import Optional from urllib import parse from rest_framework.pagination import LimitOffsetPagination -from backend.response import FormattedResponse +from core.response import FormattedResponse def prepend_api_prefix(url: Optional[str] = None) -> Optional[str]: @@ -23,6 +25,7 @@ def get_previous_link(self) -> Optional[str]: return prepend_api_prefix(super().get_previous_link()) def get_paginated_response(self, data): + """Return a paginated response with next and previous links rewritten.""" return FormattedResponse( OrderedDict( [ diff --git a/src/backend/permissions.py b/src/core/permissions.py similarity index 59% rename from src/backend/permissions.py rename to src/core/permissions.py index 3d05be55..f7d654e7 100644 --- a/src/backend/permissions.py +++ b/src/core/permissions.py @@ -1,39 +1,59 @@ +"""Permission classes used throughout RACTF Core.""" + from rest_framework import permissions class AdminOrReadOnlyVisible(permissions.BasePermission): + """Allow the user access to an object if they are admin, or using a safe method on a visible object.""" + def has_object_permission(self, request, view, obj): - if request.user.is_staff and not request.user.should_deny_admin(): + """Return True if the user can access this object.""" + if request.user.is_staff and not request.user.should_deny_admin: return True return request.user.is_authenticated and obj.is_visible and request.method in permissions.SAFE_METHODS class AdminOrReadOnly(permissions.BasePermission): + """Allow the user access to a view if they are admin, or using a safe method.""" + def has_permission(self, request, view): + """Return True if the user can access this view.""" if request.method not in permissions.SAFE_METHODS: - return request.user.is_staff and not request.user.should_deny_admin() + return request.user.is_staff and not request.user.should_deny_admin return request.user.is_authenticated class AdminOrAnonymousReadOnly(permissions.BasePermission): + """Allow a possible unauthenticated user access to a view if they are admin, or using a safe method.""" + def has_permission(self, request, view): + """Return True if the user can access this view.""" if request.method not in permissions.SAFE_METHODS: - return request.user.is_staff and not request.user.should_deny_admin() + return request.user.is_staff and not request.user.should_deny_admin return True class IsBot(permissions.BasePermission): + """Allow the user access to a view if they are a bot.""" + def has_permission(self, request, view): + """Return True if the user can access this view.""" return request.user.is_authenticated and request.user.is_bot class ReadOnlyBot(permissions.BasePermission): + """Allow the user read only access to a view if they are a bot.""" + def has_permission(self, request, view): + """Return True if the user can access this view.""" if request.user.is_authenticated and request.user.is_bot: return request.method in permissions.SAFE_METHODS return True class IsSudo(permissions.BasePermission): + """Allow the user access to a view if impersonating another user.""" + def has_permission(self, request, view): + """Return True if the user can access this view.""" return hasattr(request, "sudo") diff --git a/src/plugins/plugins.py b/src/core/plugins.py similarity index 84% rename from src/plugins/plugins.py rename to src/core/plugins.py index 2ebae56c..9c848ee2 100644 --- a/src/plugins/plugins.py +++ b/src/core/plugins.py @@ -1,9 +1,11 @@ +"""Plugin loader.""" + import inspect import logging from collections import defaultdict from pydoc import locate -from plugins.base import Plugin +from core.base import Plugin logger = logging.getLogger(__name__) @@ -12,6 +14,7 @@ def load_plugins(plugin_list): + """Load all plugins in scope of modules specified in the list.""" global plugins for plugin in plugin_list: for name, obj in inspect.getmembers(locate(plugin)): diff --git a/src/challenge/tests/__init__.py b/src/core/points/__init__.py similarity index 100% rename from src/challenge/tests/__init__.py rename to src/core/points/__init__.py diff --git a/src/plugins/points/base.py b/src/core/points/base.py similarity index 82% rename from src/plugins/points/base.py rename to src/core/points/base.py index b638ed25..df1483e9 100644 --- a/src/plugins/points/base.py +++ b/src/core/points/base.py @@ -1,30 +1,38 @@ +"""Base class for points plugins.""" + import abc import time from django.db.models import F, Sum from django.utils import timezone -from challenge.models import Score, Solve +from challenges.models import Score, Solve from config import config +from core.base import Plugin from hint.models import HintUse -from plugins.base import Plugin class PointsPlugin(Plugin, abc.ABC): + """Base class for points plugins.""" + plugin_type = "points" recalculate_type = "none" def __init__(self, challenge): + """Set the challenge the plugin is calculating points for.""" self.challenge = challenge @abc.abstractmethod def get_points(self, team, flag, solves, *args, **kwargs): + """Return the amount of points a solve is worth.""" pass def recalculate(self, teams, users, solves, *args, **kwargs): + """Recalculate the amount of points a solve is worth.""" pass def score(self, user, team, flag, solves, *args, **kwargs): + """Score a solve for a user/team.""" challenge = self.challenge points = self.get_points(team, flag, solves.count()) @@ -66,5 +74,6 @@ def score(self, user, team, flag, solves, *args, **kwargs): return solve def register_incorrect_attempt(self, user, team, flag, solves, *args, **kwargs): + """Register an incorrect solve for a team/user.""" if config.get("enable_track_incorrect_submissions"): Solve(team=team, solved_by=user, challenge=self.challenge, flag=flag, correct=False, score=None).save() diff --git a/src/plugins/points/basic.py b/src/core/points/basic.py similarity index 51% rename from src/plugins/points/basic.py rename to src/core/points/basic.py index 4212759e..be5e06d8 100644 --- a/src/plugins/points/basic.py +++ b/src/core/points/basic.py @@ -1,8 +1,13 @@ -from plugins.points.base import PointsPlugin +"""Basic points plugin.""" + +from core.points.base import PointsPlugin class BasicPointsPlugin(PointsPlugin): + """Basic points plugin.""" + name = "basic" def get_points(self, team, flag, solves, *args, **kwargs): + """Return the challenge's points value.""" return self.challenge.score diff --git a/src/plugins/points/decay.py b/src/core/points/decay.py similarity index 56% rename from src/plugins/points/decay.py rename to src/core/points/decay.py index 48eeee78..0f5772cf 100644 --- a/src/plugins/points/decay.py +++ b/src/core/points/decay.py @@ -1,26 +1,41 @@ +"""Decay points plugin.""" + from django.contrib.auth import get_user_model from django.db.models import F -import team -from challenge.models import Score -from plugins.points.base import PointsPlugin +import teams +from challenges.models import Score +from core.points.base import PointsPlugin +from teams.models import Member class DecayPointsPlugin(PointsPlugin): + """ + Decay points plugin. + + The amount of points decreases exponentially as more users solve the challenge. + """ + name = "decay" recalculate_type = "custom" def get_points(self, team, flag, solves, *args, **kwargs): + """Return the amount of points a solve is worth.""" challenge = self.challenge decay_constant = challenge.challenge_metadata["decay_constant"] min_points = challenge.challenge_metadata["min_points"] return int(round(min_points + ((challenge.score - min_points) * (decay_constant ** max(solves - 1, 0))))) def recalculate(self, teams, users, solves, *args, **kwargs): + """ + Recalculates the amount of points a solve is worth. + + This will be called on every solve. + """ challenge = self.challenge points = self.get_points(None, None, solves.count()) delta = self.get_points(None, None, solves.count() - 1) - points scores = Score.objects.filter(solve__in=solves) scores.update(points=points) - team.models.Team.objects.filter(solves__challenge=challenge).update(points=F("points") - delta) - get_user_model().objects.filter(solves__challenge=challenge).update(points=F("points") - delta) + teams.models.Team.objects.filter(solves__challenge=challenge).update(points=F("points") - delta) + Member.objects.filter(solves__challenge=challenge).update(points=F("points") - delta) diff --git a/src/plugins/providers.py b/src/core/providers.py similarity index 53% rename from src/plugins/providers.py rename to src/core/providers.py index c33f67e1..415f20ac 100644 --- a/src/plugins/providers.py +++ b/src/core/providers.py @@ -1,3 +1,9 @@ +""" +Module used for provider registration. + +A provider is a class that is capable of handling a request for a certain thing, such as logging in, registering, etc. +""" + import abc from collections import defaultdict @@ -7,12 +13,16 @@ def register_provider(provider_type, provider): + """Register a provider.""" providers[provider_type][provider.name] = provider def get_provider(provider_type): + """Get the selected provider for a provider type.""" return providers[provider_type][config.get(provider_type + "_provider")] class Provider(abc.ABC): + """Base class all providers inherit from.""" + pass diff --git a/src/backend/renderers.py b/src/core/renderers.py similarity index 86% rename from src/backend/renderers.py rename to src/core/renderers.py index 38035f13..22333168 100644 --- a/src/backend/renderers.py +++ b/src/core/renderers.py @@ -1,13 +1,20 @@ +"""Renderers used to render RACTF core responses.""" + from rest_framework.renderers import JSONRenderer class RACTFJSONRenderer(JSONRenderer): + """Subclass of JSONRenderer to add more features and shoehorn responses into frontend's api format.""" + + # TODO: Deprecate this + media_type = "application/json" format = "json" charset = "utf-8" render_style = "text" def render(self, data, accepted_media_type=None, renderer_context=None): + """Return a json response in the correct format.""" if ( renderer_context and renderer_context.get("request") diff --git a/src/backend/response.py b/src/core/response.py similarity index 61% rename from src/backend/response.py rename to src/core/response.py index 1d0b7eff..8f388098 100644 --- a/src/backend/response.py +++ b/src/core/response.py @@ -1,10 +1,17 @@ +"""Common response formats used in RACTF core.""" + from rest_framework.response import Response class FormattedResponse(Response): + """A subclass of Response to attempt to make frontend's data format more reasonable to work with.""" + + # TODO: Deprecate this. + def __init__( self, d="", m="", s=True, status=None, template_name=None, headers=None, exception=False, content_type=None ): + """Convert the success, data and message attributes to the data object.""" if status and status >= 400: s = False data = {"s": s, "m": m, "d": d} diff --git a/src/backend/settings/__init__.py b/src/core/settings/__init__.py similarity index 88% rename from src/backend/settings/__init__.py rename to src/core/settings/__init__.py index b9444fe4..921a5ccb 100644 --- a/src/backend/settings/__init__.py +++ b/src/core/settings/__init__.py @@ -14,7 +14,7 @@ DOMAIN = os.getenv("DOMAIN") DEBUG = bool(os.getenv("DEBUG")) FRONTEND_URL = os.getenv("FRONTEND_URL") -AUTH_USER_MODEL = "member.Member" +AUTH_USER_MODEL = "teams.Member" BASE_DIR = str(Path(__file__).parent.parent.parent.absolute()) SECRET_KEY = os.getenv("SECRET_KEY") @@ -44,7 +44,7 @@ AWS_S3_CUSTOM_DOMAIN = os.getenv("AWS_FILES_BUCKET_DOMAIN", AWS_STORAGE_BUCKET_NAME) PUBLIC_MEDIA_LOCATION = "challenge-files" MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_MEDIA_LOCATION}/" - DEFAULT_FILE_STORAGE = "backend.storages.PublicMediaStorage" + DEFAULT_FILE_STORAGE = "core.storages.PublicMediaStorage" else: MEDIA_URL = "/publicmedia/" MEDIA_ROOT = os.path.join(BASE_DIR, "publicmedia") @@ -91,21 +91,15 @@ } INSTALLED_APPS = [ - "admin.apps.AdminConfig", - "announcements.apps.AnnouncementsConfig", + "core.apps.CoreConfig", "authentication.apps.AuthConfig", - "challenge.apps.ChallengeConfig", + "challenges.apps.ChallengesConfig", "andromeda.apps.AndromedaConfig", "config.apps.ConfigConfig", - "experiments.apps.ExperimentsConfig", "hint.apps.HintConfig", "leaderboard.apps.LeaderboardConfig", - "member.apps.MemberConfig", "pages.apps.PagesConfig", - "plugins.apps.PluginsConfig", - "ractf.apps.RactfConfig", - "scorerecalculator.apps.ScorerecalculatorConfig", - "team.apps.TeamConfig", + "teams.apps.TeamsConfig", "sockets.apps.SocketsConfig", "stats.apps.StatsConfig", "rest_framework", @@ -146,7 +140,7 @@ SILKY_PYTHON_PROFILER = True -ROOT_URLCONF = "backend.urls" +ROOT_URLCONF = "core.urls" TEMPLATE_DIRS = [ os.path.join(BASE_DIR, "templates"), @@ -168,7 +162,7 @@ }, ] -WSGI_APPLICATION = "backend.wsgi.application" +WSGI_APPLICATION = "core.wsgi.application" ASGI_APPLICATION = "sockets.routing.application" CHANNEL_LAYERS = { @@ -211,7 +205,7 @@ PASSWORD_MINIMAL_STRENGTH = 3 -AUTHENTICATION_BACKENDS = ["backend.backends.EmailOrUsernameBackend"] +AUTHENTICATION_BACKENDS = ["core.backends.EmailOrUsernameBackend"] # Internationalization @@ -234,14 +228,14 @@ REST_FRAMEWORK = { "DEFAULT_RENDERER_CLASSES": ( - "backend.renderers.RACTFJSONRenderer", + "core.renderers.RACTFJSONRenderer", "rest_framework.renderers.JSONRenderer", ), - "EXCEPTION_HANDLER": "backend.exception_handler.handle_exception", + "EXCEPTION_HANDLER": "core.exception_handler.handle_exception", "DEFAULT_AUTHENTICATION_CLASSES": [ - "backend.authentication.RactfTokenAuthentication", + "core.authentication.RactfTokenAuthentication", ], - "DEFAULT_THROTTLE_CLASSES": ["backend.throttling.AdminBypassThrottle"], + "DEFAULT_THROTTLE_CLASSES": ["core.throttling.AdminBypassThrottle"], "DEFAULT_THROTTLE_RATES": { "login": "5/minute", "register": "10/minute", @@ -280,7 +274,7 @@ "polaris_view_hosts": "100/minute", "polaris_view_instances": "100/minute", }, - "DEFAULT_PAGINATION_CLASS": "backend.pagination.FormattedPagination", + "DEFAULT_PAGINATION_CLASS": "core.pagination.FormattedPagination", "PAGE_SIZE": 100, "NUM_PROXIES": int(os.getenv("NUM_PROXIES", 0)), } @@ -298,14 +292,14 @@ ANDROMEDA_SERVER_IP = os.getenv("ANDROMEDA_IP") # shown to participants INSTALLED_PLUGINS = [ - "plugins.flag.hashed", - "plugins.flag.plaintext", - "plugins.flag.regex", - "plugins.flag.lenient", - "plugins.flag.long_text", - "plugins.flag.map", - "plugins.points.basic", - "plugins.points.decay", + "core.flag.hashed", + "core.flag.plaintext", + "core.flag.regex", + "core.flag.lenient", + "core.flag.long_text", + "core.flag.map", + "core.points.basic", + "core.points.decay", ] CORS_ORIGIN_ALLOW_ALL = True @@ -344,9 +338,9 @@ "django.request": { "handlers": ["console"], "propagate": False, - "level": "DEBUG", + "level": os.getenv("DJANGO_LOG_LEVEL", "DEBUG"), }, - "core.handlers": {"level": "DEBUG", "handlers": ["console"]}, + "core.handlers": {"level": os.getenv("DJANGO_LOG_LEVEL", "DEBUG"), "handlers": ["console"]}, }, } diff --git a/src/backend/settings/lint.py b/src/core/settings/lint.py similarity index 95% rename from src/backend/settings/lint.py rename to src/core/settings/lint.py index fd9e5ca3..dab9e7d9 100644 --- a/src/backend/settings/lint.py +++ b/src/core/settings/lint.py @@ -1,3 +1,5 @@ +"""Settings for linting RACTF Core.""" + from . import * SECRET_KEY = "CorrectHorseBatteryStaple" diff --git a/src/backend/settings/local.py b/src/core/settings/local.py similarity index 54% rename from src/backend/settings/local.py rename to src/core/settings/local.py index 2af7a896..ca75b734 100644 --- a/src/backend/settings/local.py +++ b/src/core/settings/local.py @@ -1,3 +1,5 @@ +"""Settings for locally deploying RACTF Core.""" + from . import * DOMAIN = "localhost" diff --git a/src/backend/settings/production.py b/src/core/settings/production.py similarity index 97% rename from src/backend/settings/production.py rename to src/core/settings/production.py index 9bf4fda7..481d4d48 100644 --- a/src/backend/settings/production.py +++ b/src/core/settings/production.py @@ -1,4 +1,4 @@ -"""Settings for running RACTF backend locally.""" +"""Settings for running RACTF backend in production.""" # flake8: noqa diff --git a/src/backend/settings/staging.py b/src/core/settings/staging.py similarity index 96% rename from src/backend/settings/staging.py rename to src/core/settings/staging.py index 69d47f43..4a7e9b93 100644 --- a/src/backend/settings/staging.py +++ b/src/core/settings/staging.py @@ -1,4 +1,4 @@ -"""Settings for running RACTF backend locally.""" +"""Settings for running RACTF backend in staging.""" # flake8: noqa diff --git a/src/backend/settings/test.py b/src/core/settings/test.py similarity index 94% rename from src/backend/settings/test.py rename to src/core/settings/test.py index f086238a..f71ab707 100644 --- a/src/backend/settings/test.py +++ b/src/core/settings/test.py @@ -1,3 +1,5 @@ +"""Settings for testing RACTF Core.""" + from . import * SECRET_KEY = "CorrectHorseBatteryStaple" diff --git a/src/backend/signals.py b/src/core/signals.py similarity index 96% rename from src/backend/signals.py rename to src/core/signals.py index 2152019a..678d5939 100644 --- a/src/backend/signals.py +++ b/src/core/signals.py @@ -1,3 +1,5 @@ +"""Django signals used internally by RACTF core.""" + from django.dispatch import Signal flag_score = Signal(providing_args=["user", "team", "challenge", "flag", "solve"]) diff --git a/src/backend/storages.py b/src/core/storages.py similarity index 65% rename from src/backend/storages.py rename to src/core/storages.py index a2437fc4..8039bd36 100644 --- a/src/backend/storages.py +++ b/src/core/storages.py @@ -1,6 +1,10 @@ +"""Storages used in RACTF core.""" + from storages.backends.s3boto3 import S3Boto3Storage class PublicMediaStorage(S3Boto3Storage): + """S3 storage for challenge files.""" + location = "challenge-files" default_acl = None diff --git a/src/experiments/__init__.py b/src/core/tests/__init__.py similarity index 100% rename from src/experiments/__init__.py rename to src/core/tests/__init__.py diff --git a/src/core/tests/test_permissions.py b/src/core/tests/test_permissions.py new file mode 100644 index 00000000..08193ddf --- /dev/null +++ b/src/core/tests/test_permissions.py @@ -0,0 +1,177 @@ +"""Tests for permissions defined by the core app.""" + +from django.contrib.auth.models import AnonymousUser +from django.http import HttpRequest +from rest_framework.request import Request +from rest_framework.test import APITestCase + +from core.permissions import ( + AdminOrAnonymousReadOnly, + AdminOrReadOnly, + AdminOrReadOnlyVisible, + IsBot, + IsSudo, + ReadOnlyBot, +) +from teams.models import Member + + +class PermissionTestMixin: + """Mixin to add common functionality to permissions tests.""" + + def create_request(self, method: str) -> Request: + """Return a request with the specified method.""" + request = Request(HttpRequest()) + request.method = method + return request + + +class AdminOrReadOnlyVisibleTestCase(PermissionTestMixin, APITestCase): + """Tests for the AdminOrReadOnlyVisible permission.""" + + def test_admin_safe(self) -> None: + """An admin should be able to use a safe method.""" + request = self.create_request("GET") + request.user = Member(username="permission-test", email="permission-test@gmail.com", is_staff=True) + self.assertTrue(AdminOrReadOnlyVisible().has_object_permission(request, None, None)) + + def test_admin_unsafe(self) -> None: + """An admin should be able to use an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="permission-test", email="permission-test@gmail.com", is_staff=True) + self.assertTrue(AdminOrReadOnlyVisible().has_object_permission(request, None, None)) + + def test_not_admin_safe_visible(self) -> None: + """A non admin should be able to use a safe method.""" + request = self.create_request("GET") + request.user = Member(username="permission-test", email="permission-test@gmail.com") + obj = type("Object", (object,), {}) + obj.is_visible = True + self.assertTrue(AdminOrReadOnlyVisible().has_object_permission(request, None, obj)) + + def test_not_admin_unsafe_visible(self) -> None: + """A non admin should not be able to use an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="permission-test", email="permission-test@gmail.com") + obj = type("Object", (object,), {}) + obj.is_visible = True + self.assertFalse(AdminOrReadOnlyVisible().has_object_permission(request, None, obj)) + + def test_not_admin_safe_not_visible(self) -> None: + """A non admin should not be able to use a safe method on a non visible object.""" + request = self.create_request("GET") + request.user = Member(username="permission-test", email="permission-test@gmail.com") + obj = type("Object", (object,), {}) + obj.is_visible = False + self.assertFalse(AdminOrReadOnlyVisible().has_object_permission(request, None, obj)) + + def test_not_admin_unsafe_not_visible(self) -> None: + """A non admin should not be able to use an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="permission-test", email="permission-test@gmail.com") + obj = type("Object", (object,), {}) + obj.is_visible = False + self.assertFalse(AdminOrReadOnlyVisible().has_object_permission(request, None, obj)) + + +class AdminOrReadOnlyTestCase(PermissionTestMixin, APITestCase): + """Tests for the AdminOrReadOnly permission.""" + + def test_admin_safe(self) -> None: + """An admin should be able to access a view using a safe method.""" + request = self.create_request("GET") + request.user = Member(username="permission-test", email="permission-test@gmail.com", is_staff=True) + self.assertTrue(AdminOrReadOnly().has_permission(request, None)) + + def test_admin_unsafe(self) -> None: + """An admin should be able to access a view using an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="permission-test", email="permission-test@gmail.com", is_staff=True) + self.assertTrue(AdminOrReadOnly().has_permission(request, None)) + + def test_not_admin_safe(self) -> None: + """An non-admin should be able to access a view using a safe method.""" + request = self.create_request("GET") + request.user = Member(username="permission-test", email="permission-test@gmail.com") + self.assertTrue(AdminOrReadOnly().has_permission(request, None)) + + def test_not_admin_unsafe(self) -> None: + """An non-admin should not be able to access a view using an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="permission-test", email="permission-test@gmail.com") + self.assertFalse(AdminOrReadOnly().has_permission(request, None)) + + +class AdminOrAnonymousReadOnlyTestCase(PermissionTestMixin, APITestCase): + """Tests for the AdminOrAnonymousReadOnly permission.""" + + def test_admin_safe(self) -> None: + """An admin should be able to access a view using a safe method.""" + request = self.create_request("GET") + request.user = Member(username="permission-test", email="permission-test@gmail.com", is_staff=True) + self.assertTrue(AdminOrAnonymousReadOnly().has_permission(request, None)) + + def test_admin_unsafe(self) -> None: + """An admin should be able to access a view using an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="permission-test", email="permission-test@gmail.com", is_staff=True) + self.assertTrue(AdminOrAnonymousReadOnly().has_permission(request, None)) + + def test_anonymous_safe(self) -> None: + """An anonymous user should be able to access a view using a safe method.""" + request = self.create_request("GET") + request.user = AnonymousUser() + self.assertTrue(AdminOrAnonymousReadOnly().has_permission(request, None)) + + def test_anonymous_unsafe(self) -> None: + """An anonymous user should not be able to access a view using an unsafe method.""" + request = self.create_request("POST") + request.user = AnonymousUser() + self.assertFalse(AdminOrAnonymousReadOnly().has_permission(request, None)) + + +class IsBotTestCase(PermissionTestMixin, APITestCase): + """Tests for the IsBot permission.""" + + def test_is_bot(self) -> None: + """A bot should be able to access this view.""" + request = self.create_request("GET") + request.user = Member(username="bot-test", email="bot-test@gmail.com", is_bot=True) + self.assertTrue(IsBot().has_permission(request, None)) + + def test_normal_user(self) -> None: + """A normal user should not be able to access this view.""" + request = self.create_request("GET") + request.user = Member(username="bot-test", email="bot-test@gmail.com") + self.assertFalse(IsBot().has_permission(request, None)) + + +class ReadOnlyBotTestCase(PermissionTestMixin, APITestCase): + """Tests for the ReadOnlyBot permission.""" + + def test_is_bot_safe_method(self) -> None: + """A bot should be able to access this view with a safe method.""" + request = self.create_request("GET") + request.user = Member(username="bot-test", email="bot-test@gmail.com", is_bot=True) + self.assertTrue(ReadOnlyBot().has_permission(request, None)) + + def test_is_bot_unsafe_method(self) -> None: + """A bot should not be able to access this view with an unsafe method.""" + request = self.create_request("POST") + request.user = Member(username="bot-test", email="bot-test@gmail.com", is_bot=True) + self.assertFalse(ReadOnlyBot().has_permission(request, None)) + + +class IsSudoTestCase(PermissionTestMixin, APITestCase): + """Tests for the IsSudo permission.""" + + def test_is_sudo(self) -> None: + """A sudo user should be able to access this view.""" + request = self.create_request("GET") + request.sudo = True + self.assertTrue(IsSudo().has_permission(request, None)) + + def test_is_not_sudo(self) -> None: + """A non-sudo user should not be able to access this view.""" + request = self.create_request("POST") + self.assertFalse(IsSudo().has_permission(request, None)) diff --git a/src/plugins/tests.py b/src/core/tests/test_plugins.py similarity index 71% rename from src/plugins/tests.py rename to src/core/tests/test_plugins.py index 1fe602c6..92085289 100644 --- a/src/plugins/tests.py +++ b/src/core/tests/test_plugins.py @@ -1,21 +1,26 @@ +"""Tests for core's plugins and plugin system.""" + from django.contrib.auth import get_user_model from rest_framework.test import APITestCase -from challenge.models import Category, Challenge, Score, Solve -from challenge.tests.mixins import ChallengeSetupMixin +from challenges.models import Category, Challenge, Score, Solve +from challenges.tests.mixins import ChallengeSetupMixin from config import config -from plugins import plugins -from plugins.flag.hashed import HashedFlagPlugin -from plugins.flag.lenient import LenientFlagPlugin -from plugins.flag.plaintext import PlaintextFlagPlugin -from plugins.flag.regex import RegexFlagPlugin -from plugins.points.basic import BasicPointsPlugin -from plugins.points.decay import DecayPointsPlugin -from team.models import Team +from core import plugins +from core.flag.hashed import HashedFlagPlugin +from core.flag.lenient import LenientFlagPlugin +from core.flag.plaintext import PlaintextFlagPlugin +from core.flag.regex import RegexFlagPlugin +from core.points.basic import BasicPointsPlugin +from core.points.decay import DecayPointsPlugin +from teams.models import Team, Member class HashedFlagPluginTestCase(APITestCase): + """Tests for HashedFlagPlugin.""" + def setUp(self): + """Create a challenge and category for testing.""" category = Category(name="test", display_order=0, contained_type="test", description="") category.save() challenge = Challenge( @@ -34,14 +39,19 @@ def setUp(self): self.plugin = HashedFlagPlugin(self.challenge) def test_valid_flag(self): + """A valid flag returns True.""" self.assertTrue(self.plugin.check("ractf{a}")) def test_invalid_flag(self): + """An invalid flag returns False.""" self.assertFalse(self.plugin.check("ractf{b}")) class LenientFlagPluginTestCase(APITestCase): + """Test the LenientFlagPlugin.""" + def setUp(self): + """Create a category and challenge for testing.""" category = Category(name="test", display_order=0, contained_type="test", description="") category.save() challenge = Challenge( @@ -60,50 +70,64 @@ def setUp(self): self.plugin = LenientFlagPlugin(self.challenge) def test_valid_flag(self): + """A valid flag returns True.""" self.assertTrue(self.plugin.check("ractf{a}")) def test_valid_flag_accented(self): + """Accent characters are correctly replaced.""" self.assertTrue(self.plugin.check("ractf{à}")) def test_valid_flag_invalid_format(self): + """The flag format is correctly fixed.""" self.assertTrue(self.plugin.check("a")) def test_valid_flag_spaced(self): + """Whitespace is stripped correctly.""" self.assertTrue(self.plugin.check(" ractf{a} ")) def test_valid_flag_casing(self): + """The flag validation is case-insensitive.""" self.assertTrue(self.plugin.check("A")) def test_valid_flag_all(self): + """A flag with all fixable inconsistencies is validated correctly.""" self.assertTrue(self.plugin.check(" Á")) def test_valid_flag_accented_excluded(self): + """A flag with an accent character is not validated when accent fixing is disabled.""" self.challenge.flag_metadata["exclude_passes"].append("accent_insensitive") self.challenge.save() self.assertFalse(self.plugin.check("ractf{à}")) def test_valid_flag_invalid_format_excluded(self): + """A flag in the wrong format is rejected when the flag format is not being fixed.""" self.challenge.flag_metadata["exclude_passes"].append("format") self.challenge.save() self.assertFalse(self.plugin.check("a")) def test_valid_flag_spaced_excluded(self): + """A flag with incorrect spacing is rejected when whitespace is not being fixed.""" self.challenge.flag_metadata["exclude_passes"].append("whitespace_insensitive") self.challenge.save() self.assertFalse(self.plugin.check(" ractf{a} ")) def test_valid_flag_casing_excluded(self): + """A flag with incorrect casing is rejected when the validator is case sensitive.""" self.challenge.flag_metadata["exclude_passes"].append("case_insensitive") self.challenge.save() self.assertFalse(self.plugin.check("A")) def test_valid_flag_missing_metadata(self): + """The flag plugin defaults to fixing everything.""" self.challenge.flag_metadata.pop("exclude_passes") self.assertTrue(self.plugin.check("A")) class PlaintextFlagPluginTestCase(APITestCase): + """Tests for PlaintextFlagPlugin.""" + def setUp(self): + """Create a category and challenge for testing.""" category = Category(name="test", display_order=0, contained_type="test", description="") category.save() challenge = Challenge( @@ -122,14 +146,19 @@ def setUp(self): self.plugin = PlaintextFlagPlugin(self.challenge) def test_valid_flag(self): + """A valid flag returns True.""" self.assertTrue(self.plugin.check("ractf{a}")) def test_invalid_flag(self): + """An invalid flag returns False.""" self.assertFalse(self.plugin.check("ractf{b}")) class RegexFlagPluginTestCase(APITestCase): + """Tests for RegexFlagPlugin.""" + def setUp(self): + """Create a category and challenge for testing.""" category = Category(name="test", display_order=0, contained_type="test", description="") category.save() challenge = Challenge( @@ -148,17 +177,23 @@ def setUp(self): self.plugin = RegexFlagPlugin(self.challenge) def test_valid_flag(self): + """A valid flag returns True.""" self.assertTrue(self.plugin.check("ractf{a}")) def test_invalid_flag(self): + """An invalid flag returns False.""" self.assertFalse(self.plugin.check("ractf{b}")) def test_valid_flag_regex(self): + """A flag that matches the regex returns True.""" self.assertTrue(self.plugin.check("abcractf{a}abc")) class BasicPointsPluginTestCase(APITestCase): + """Tests for BasicPointsPlugin.""" + def setUp(self): + """Create a category and challenge for testing.""" category = Category(name="test", display_order=0, contained_type="test", description="") category.save() challenge = Challenge( @@ -177,11 +212,15 @@ def setUp(self): self.plugin = BasicPointsPlugin(challenge) def test_points(self): + """The points value of the challenge is returned.""" self.assertEqual(self.plugin.get_points(None, None, None), 1000) class DecayPointsPluginTestCase(ChallengeSetupMixin, APITestCase): + """Tests for DecayPointsPlugin.""" + def setUp(self): + """Set the variables for decaying points.""" super(DecayPointsPluginTestCase, self).setUp() self.challenge2.challenge_metadata = { "decay_constant": 0.5, @@ -190,18 +229,23 @@ def setUp(self): self.plugin = DecayPointsPlugin(self.challenge2) def test_base_points(self): + """The base points value is the points value of a challenge.""" self.assertEqual(self.plugin.get_points(None, None, 0), 1000) def test_min_points(self): + """After a high enough number of solves, the points value eventually reaches the minimum.""" self.assertEqual(self.plugin.get_points(None, None, 1000000000), 100) def test_first_solve_points(self): + """The first solve should be the base points value.""" self.assertEqual(self.plugin.get_points(None, None, 1), 1000) def test_decaying_points(self): + """The amount of points should decrease as the solve number gets higher.""" self.assertTrue(self.plugin.get_points(None, None, 1) > self.plugin.get_points(None, None, 5)) def test_recalculate(self): + """User scores are correctly reduced when scores are recalculated.""" points = self.plugin.get_points(None, None, 0) score = Score(team=self.team, reason="test", points=points) score.save() @@ -225,18 +269,21 @@ def test_recalculate(self): self.user3.save() self.plugin.recalculate( teams=Team.objects.filter(solves__challenge=self.challenge2), - users=get_user_model().objects.filter(solves__challenge=self.challenge2), + users=Member.objects.filter(solves__challenge=self.challenge2), solves=Solve.objects.filter(challenge=self.challenge2), ) - self.assertTrue(get_user_model().objects.get(id=self.user.id).points < points) + self.user.refresh_from_db() + self.assertTrue(self.user.points < points) def test_score(self): + """The score function correctly sets user and team points.""" config.set("enable_scoring", True) self.plugin.score(self.user, self.team, "", Solve.objects.filter(challenge=self.challenge2)) self.assertEqual(self.team.points, 1000) self.assertEqual(self.team.leaderboard_points, 1000) - def test_score_lb_disabled(self): + def test_score_leaderboard_disabled(self): + """The score function correctly sets user and team points with the leaderboard disabled.""" config.set("enable_scoring", False) self.plugin.score(self.user, self.team, "", Solve.objects.filter(challenge=self.challenge2)) self.assertEqual(self.team.points, 1000) @@ -244,15 +291,24 @@ def test_score_lb_disabled(self): class PluginLoaderTestCase(APITestCase): - def test_plugin_loader(self): + """Test the plugin loader.""" + + def test_plugin_loader_points(self): + """There are 2 points plugins the plugin loader should find.""" plugins.load_plugins(["plugins.tests"]) - # TODO: why is this loading 5 - # self.assertEqual(len(plugins.plugins['flag']), 4) self.assertEqual(len(plugins.plugins["points"]), 2) + def test_plugin_loader_flag(self): + """There are 6 flag plugins the plugin loader should find.""" + plugins.load_plugins(["plugins.tests"]) + self.assertEqual(len(plugins.plugins["flag"]), 6) + class BasePluginTest(ChallengeSetupMixin, APITestCase): + """Test BasePlugin.""" + def test_dont_track_incorrect_submissions(self): + """The database should not be interacted with if enable_track_incorrect_submissions is False.""" config.set("enable_track_incorrect_submissions", False) plugin = BasicPointsPlugin(self.challenge2) self.assertNumQueries(0, lambda: plugin.register_incorrect_attempt(self.user, self.team, "ractf{}", None)) diff --git a/src/core/tests/test_validators.py b/src/core/tests/test_validators.py new file mode 100644 index 00000000..a56c3282 --- /dev/null +++ b/src/core/tests/test_validators.py @@ -0,0 +1,18 @@ +"""Tests for validators used in core.""" + +from django.core.exceptions import ValidationError +from rest_framework.test import APITestCase + +from core.validators import printable_name + + +class PrintableNameValidatorTestCase(APITestCase): + """Tests for the printable name validator.""" + + def test_unprintable_name(self): + """An unprintable name should be rejected.""" + self.assertRaises(ValidationError, lambda: printable_name(b"\x00".decode("latin-1"))) + + def test_printable_name(self): + """A printable name should be accepted.""" + self.assertIsNone(printable_name("abc")) diff --git a/src/admin/tests.py b/src/core/tests/test_views.py similarity index 64% rename from src/admin/tests.py rename to src/core/tests/test_views.py index 543b2b83..0549cb93 100644 --- a/src/admin/tests.py +++ b/src/core/tests/test_views.py @@ -1,22 +1,34 @@ +"""Tests for the views in the core app.""" + import hashlib from django.urls import reverse +from rest_framework.status import HTTP_404_NOT_FOUND from rest_framework.test import APITestCase -from challenge.models import Category, Challenge -from member.models import Member +from challenges.models import Category, Challenge +from teams.models import Member + + +class CatchAllTestCase(APITestCase): + """Tests for the catchall view.""" + + def test_catchall_404s(self): + """The view should return 404.""" + response = self.client.get("/sdgodgsjds") + self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) class MissingPointsTestCase(APITestCase): + """Test for the missing points case of the selfcheck endpoint.""" + def setUp(self): + """Create a member for testing.""" self.user = Member.objects.create(username="test", is_superuser=True, is_staff=True) - - def test_missing_points(self): - category = Category(name="test", display_order=0, contained_type="test", description="") - category.save() - x = Challenge.objects.create( + self.category = Category.objects.create(name="test", display_order=0, contained_type="test", description="") + self.challenge = Challenge.objects.create( name="test1", - category=category, + category=self.category, description="a", challenge_type="basic", challenge_metadata={}, @@ -26,19 +38,26 @@ def test_missing_points(self): score=0, ) + def test_missing_points(self): + """A challenge with 0 points should flag a warning.""" self.client.force_authenticate(user=self.user) response = self.client.get(reverse("self-check")) self.assertEqual(response.data["d"][0]["issue"], "missing_points") - x.score = 5 - x.save() + def test_not_missing_points(self): + """A challenge with a non zero points value should not flag a warning.""" + self.challenge.score = 5 + self.challenge.save() response = self.client.get(reverse("self-check")) self.assertEqual(len(response.data["d"]), 0) class BadFlagConfigTestCase(APITestCase): + """Test the invalid flag case of the selfcheck endpoint.""" + def setUp(self): + """Create users and challenges to use in the tests.""" self.user = Member.objects.create(username="test", is_superuser=True, is_staff=True) self.category = Category(name="test", display_order=0, contained_type="test", description="") @@ -69,6 +88,7 @@ def setUp(self): self.create_challenge("long_text", {"flag": "ractf{flag}"}) def create_challenge(self, typ, metadata): + """Create a challenge.""" self.i += 1 Challenge.objects.create( name=f"{self.i}", @@ -83,7 +103,18 @@ def create_challenge(self, typ, metadata): ) def test_length(self): + """The endpoint should find 14 errors in the challenges.""" self.client.force_authenticate(user=self.user) response = self.client.get(reverse("self-check")) self.assertEqual(len(response.data["d"]), 14) + + +class ExperimentsTestCase(APITestCase): + """Test the experiments viewset.""" + + def test_experiments(self): + """Test the overrides are correctly sent.""" + with self.settings(EXPERIMENT_OVERRIDES={"test": True}): + response = self.client.get(reverse("experiments")) + self.assertEqual(response.data["d"]["test"], True) diff --git a/src/core/tests/utils.py b/src/core/tests/utils.py new file mode 100644 index 00000000..461b08b0 --- /dev/null +++ b/src/core/tests/utils.py @@ -0,0 +1,30 @@ +"""Test utilities for use in this app, or shared across apps in this project.""" + +from typing import Any, Callable +from unittest.mock import patch + +from config import config + +NO_OVERRIDE = object() + + +def patch_config(**config_options) -> Callable: + """Override config options inside a test via a custom decorator.""" + + def config_get_override(key: str) -> Any: + """ + Override the 'config.get' method with our specific changes. + + The dict.get call here defaults to an arbitrary object() + to allow overrides with values set to 'None'. + """ + override = config_options.get(key, NO_OVERRIDE) + if override is NO_OVERRIDE: + return config.backend.get(key) + return override + + def wrapper(function: Callable) -> Callable: + """Return our test case, patched with the custom config overrides.""" + return patch("config.config.get", side_effect=config_get_override)(function) + + return wrapper diff --git a/src/backend/throttling.py b/src/core/throttling.py similarity index 65% rename from src/backend/throttling.py rename to src/core/throttling.py index 87ebaf73..58eceaed 100644 --- a/src/backend/throttling.py +++ b/src/core/throttling.py @@ -1,11 +1,16 @@ +"""Custom throttling used in RACTF core.""" + from django.conf import settings from rest_framework import throttling class AdminBypassThrottle(throttling.ScopedRateThrottle): + """Subclass of DRF's ScopedRateThrottle to allow admins to bypass rate limits.""" + def allow_request(self, request, view): + """Return True if the user is admin or under the ratelimit.""" if not settings.RATELIMIT_ENABLE: return True - if request.user.is_staff and not request.user.should_deny_admin(): + if request.user.is_staff and not request.user.should_deny_admin: return True return super(AdminBypassThrottle, self).allow_request(request, view) diff --git a/src/core/types.py b/src/core/types.py new file mode 100644 index 00000000..b90bd9dd --- /dev/null +++ b/src/core/types.py @@ -0,0 +1,11 @@ +"""Type definitions for use throughout the app.""" + +from rest_framework.request import Request + +from teams.models import Member + + +class AuthenticatedRequest(Request): + """A request that is guaranteed to have an authenticated user.""" + + user: Member diff --git a/src/backend/urls.py b/src/core/urls.py similarity index 73% rename from src/backend/urls.py rename to src/core/urls.py index 2b92d131..b7492051 100644 --- a/src/backend/urls.py +++ b/src/core/urls.py @@ -1,4 +1,4 @@ -"""backend URL Configuration +"""backend URL Configuration. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.0/topics/http/urls/ @@ -17,23 +17,23 @@ from django.conf.urls.static import static from django.urls import include, path -from backend.views import CatchAllView +from core import views +from core.views import CatchAllView urlpatterns = [ - path("admin/", include("admin.urls")), - path("announcements/", include("announcements.urls")), + path("announcements/", include("sockets.urls")), path("auth/", include("authentication.urls")), - path("challenges/", include("challenge.urls")), + path("challenges/", include("challenges.urls")), path("challengeserver/", include("andromeda.urls")), path("config/", include("config.urls")), path("hints/", include("hint.urls")), path("leaderboard/", include("leaderboard.urls")), - path("member/", include("member.urls")), - path("scorerecalculator/", include("scorerecalculator.urls")), + path("member/", include("teams.urls.member")), path("stats/", include("stats.urls")), - path("team/", include("team.urls")), + path("team/", include("teams.urls.team")), path("pages/", include("pages.urls")), - path("experiments/", include("experiments.urls")), + path("self_check/", views.SelfCheckView.as_view(), name="self-check"), + path("experiments/", views.ExperimentView.as_view(), name="experiments"), ] urlpatterns = [ @@ -42,7 +42,7 @@ ] handler404 = CatchAllView.as_view() -handler500 = "backend.exception_handler.generic_error_response" +handler500 = "core.exception_handler.generic_error_response" if "silk" in settings.INSTALLED_APPS: urlpatterns += [path("silk/", include("silk.urls"))] diff --git a/src/backend/validators.py b/src/core/validators.py similarity index 77% rename from src/backend/validators.py rename to src/core/validators.py index 4ed7eae3..946ed188 100644 --- a/src/backend/validators.py +++ b/src/core/validators.py @@ -1,3 +1,5 @@ +"""Validators used in RACTF core.""" + from string import printable from django.core import validators @@ -19,13 +21,8 @@ def printable_name(value: str) -> None: @deconstructible class NameValidator(validators.RegexValidator): - regex = r"^[\w.+ -]+\Z" - message = _("Enter a valid name. This value may contain only letters, " "numbers, spaces, and ./+/-/_ characters.") - flags = 0 + """Ensure that usernames only contain letters, numbers, spaces and +-_.""" - -@deconstructible -class LenientNameValidator(validators.RegexValidator): - regex = r"^[]+\Z" + regex = r"^[\w.+ -]+\Z" message = _("Enter a valid name. This value may contain only letters, " "numbers, spaces, and ./+/-/_ characters.") flags = 0 diff --git a/src/core/views.py b/src/core/views.py new file mode 100644 index 00000000..67e5f7fb --- /dev/null +++ b/src/core/views.py @@ -0,0 +1,43 @@ +"""Misc views for RACTF core.""" + +from django.conf import settings +from django.shortcuts import render +from django.views.generic import TemplateView +from rest_framework.permissions import IsAdminUser +from rest_framework.views import APIView + +from challenges.models import Challenge +from core.response import FormattedResponse + + +class CatchAllView(TemplateView): + """A catchall 404 view.""" + + def get(self, request, *args, **kwargs): + """Return a 404 page.""" + return render(template_name="404.html", context={"link": settings.FRONTEND_URL}, request=request, status=404) + + +class SelfCheckView(APIView): + """API endpoint to run basic self checks on the challenges.""" + + permission_classes = [IsAdminUser] + + def get(self, request): + """Return any issues found with the challenges.""" + issues = [] + + for challenge in Challenge.objects.all(): + issues += challenge.self_check() + + return FormattedResponse(issues) + + +class ExperimentView(APIView): + """API endpoint to override experiments on RACTF shell.""" + + throttle_scope = "config" + + def get(self, request): + """Return the list of overriden experiments.""" + return FormattedResponse(settings.EXPERIMENT_OVERRIDES) diff --git a/src/backend/viewsets.py b/src/core/viewsets.py similarity index 67% rename from src/backend/viewsets.py rename to src/core/viewsets.py index 0c755639..571257ed 100644 --- a/src/backend/viewsets.py +++ b/src/core/viewsets.py @@ -1,16 +1,22 @@ +"""Abstractions to make common tasks with DRF viewsets easier.""" + from rest_framework import permissions from rest_framework.viewsets import ModelViewSet def is_exporting(request): + """Return True if the user is exporting data.""" return request.user.is_staff and (request.headers.get("exporting") or request.headers.get("x-exporting")) class AdminCreateModelViewSet(ModelViewSet): + """A subclass of ModelViewSet that uses a different serializer for admins and create requests.""" + def get_serializer_class(self): + """Return the appropriate serializer to handle a request.""" if self.request is None: return self.admin_serializer_class - if self.request.user.is_staff and not self.request.user.should_deny_admin(): + if self.request.user.is_staff and not self.request.user.should_deny_admin: if self.request.method in permissions.SAFE_METHODS: return self.admin_serializer_class return self.create_serializer_class @@ -18,22 +24,28 @@ def get_serializer_class(self): class AdminModelViewSet(ModelViewSet): + """A viewset that subclasses ModelViewSet but uses a different serializer for admins, and normal users.""" + def get_serializer_class(self): + """Return the appropriate serializer to handle a request.""" if self.request is None: return self.admin_serializer_class - if self.request.user.is_staff and not self.request.user.should_deny_admin(): + if self.request.user.is_staff and not self.request.user.should_deny_admin: return self.admin_serializer_class return self.serializer_class class AdminListModelViewSet(ModelViewSet): + """A subclass of ModelViewSet that uses a different serializer for admins, listings and admins listings.""" + def get_serializer_class(self): + """Return the appropriate serializer to handle a request.""" if self.request is None: return self.admin_serializer_class if self.action == "list" and not is_exporting(self.request): - if self.request.user.is_staff and not self.request.user.should_deny_admin(): + if self.request.user.is_staff and not self.request.user.should_deny_admin: return self.list_admin_serializer_class return self.list_serializer_class - if self.request.user.is_staff and not self.request.user.should_deny_admin(): + if self.request.user.is_staff and not self.request.user.should_deny_admin: return self.admin_serializer_class return self.serializer_class diff --git a/src/backend/wsgi.py b/src/core/wsgi.py similarity index 81% rename from src/backend/wsgi.py rename to src/core/wsgi.py index bf0853c2..34d2c95a 100644 --- a/src/backend/wsgi.py +++ b/src/core/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.lint") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.lint") application = get_wsgi_application() diff --git a/src/experiments/apps.py b/src/experiments/apps.py deleted file mode 100644 index 864b3dee..00000000 --- a/src/experiments/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ExperimentsConfig(AppConfig): - name = "experiments" diff --git a/src/experiments/models.py b/src/experiments/models.py deleted file mode 100644 index 061d2a59..00000000 --- a/src/experiments/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/src/experiments/tests.py b/src/experiments/tests.py deleted file mode 100644 index 52278c0c..00000000 --- a/src/experiments/tests.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import reverse -from rest_framework.test import APITestCase - - -class ExperimentsTestCase(APITestCase): - def test_experiments(self): - with self.settings(EXPERIMENT_OVERRIDES={"test": True}): - response = self.client.get(reverse("experiments")) - self.assertEqual(response.data["d"]["test"], True) diff --git a/src/experiments/urls.py b/src/experiments/urls.py deleted file mode 100644 index 6064cca1..00000000 --- a/src/experiments/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from experiments import views - -urlpatterns = [ - path("", views.ExperimentView.as_view(), name="experiments"), -] diff --git a/src/experiments/views.py b/src/experiments/views.py deleted file mode 100644 index e116f68f..00000000 --- a/src/experiments/views.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.conf import settings -from rest_framework.views import APIView - -from backend.response import FormattedResponse - - -class ExperimentView(APIView): - throttle_scope = "config" - - def get(self, request): - return FormattedResponse(settings.EXPERIMENT_OVERRIDES) diff --git a/src/gunicorn_config.py b/src/gunicorn_config.py index aa3e7ae0..a0d404c1 100644 --- a/src/gunicorn_config.py +++ b/src/gunicorn_config.py @@ -1,5 +1,12 @@ +""" +Configuration for our gunicorn workers. + +Primarily used for marking workers as unresponsive for Prometheus. +""" + from prometheus_client import multiprocess -def child_exit(server, worker): +def child_exit(_, worker): + """Mark this worker's process as dead once it exits.""" multiprocess.mark_process_dead(worker.pid) diff --git a/src/hint/apps.py b/src/hint/apps.py index 615851c2..c42e2c43 100644 --- a/src/hint/apps.py +++ b/src/hint/apps.py @@ -1,5 +1,9 @@ +"""App for managing hints.""" + from django.apps import AppConfig class HintConfig(AppConfig): + """The app config for the hints app.""" + name = "hint" diff --git a/src/hint/migrations/0001_initial.py b/src/hint/migrations/0001_initial.py index 858cd025..2b200585 100644 --- a/src/hint/migrations/0001_initial.py +++ b/src/hint/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('challenge', '0001_initial'), + ('challenges', '0001_initial'), ] operations = [ @@ -28,7 +28,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), - ('challenge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hints_used', to='challenge.Challenge')), + ('challenges', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hints_used', to='challenge.Challenge')), ('hint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uses', to='hint.Hint')), ], ), diff --git a/src/hint/migrations/0002_auto_20200808_1337.py b/src/hint/migrations/0002_auto_20200808_1337.py index dd91eb50..508d4721 100644 --- a/src/hint/migrations/0002_auto_20200808_1337.py +++ b/src/hint/migrations/0002_auto_20200808_1337.py @@ -12,14 +12,14 @@ class Migration(migrations.Migration): dependencies = [ ('hint', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('challenge', '0002_auto_20200808_1337'), - ('team', '0001_initial'), + ('challenges', '0002_auto_20200808_1337'), + ('teams', '0001_initial'), ] operations = [ migrations.AddField( model_name='hintuse', - name='team', + name='teams', field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hints_used', to='team.Team'), ), migrations.AddField( @@ -29,11 +29,11 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='hint', - name='challenge', + name='challenges', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hint_set', to='challenge.Challenge'), ), migrations.AlterUniqueTogether( name='hintuse', - unique_together={('hint', 'team')}, + unique_together={('hint', 'teams')}, ), ] diff --git a/src/hint/models.py b/src/hint/models.py index a9ec6605..18df8f16 100644 --- a/src/hint/models.py +++ b/src/hint/models.py @@ -1,28 +1,32 @@ -from django.contrib.auth import get_user_model +"""Models for the hint app.""" + from django.db import models from django.db.models import CASCADE, SET_NULL from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin -from challenge.models import Challenge -from team.models import Team - class Hint(ExportModelOperationsMixin("hint"), models.Model): + """Represents a hint for a challenge.""" + name = models.CharField(max_length=36) - challenge = models.ForeignKey(Challenge, related_name="hint_set", on_delete=CASCADE) + challenge = models.ForeignKey("challenge.Challenge", related_name="hint_set", on_delete=CASCADE) text = models.TextField() penalty = models.IntegerField() class HintUse(ExportModelOperationsMixin("hint_use"), models.Model): - hint = models.ForeignKey(Hint, related_name="uses", on_delete=CASCADE) - team = models.ForeignKey(Team, related_name="hints_used", on_delete=CASCADE, null=True) - user = models.ForeignKey(get_user_model(), related_name="hints_used", on_delete=SET_NULL, null=True) + """Represents a user/team redeeming a hint.""" + + hint = models.ForeignKey("hint.Hint", related_name="uses", on_delete=CASCADE) + team = models.ForeignKey("team.Team", related_name="hints_used", on_delete=CASCADE, null=True) + user = models.ForeignKey("teams.Member", related_name="hints_used", on_delete=SET_NULL, null=True) timestamp = models.DateTimeField(default=timezone.now) - challenge = models.ForeignKey(Challenge, related_name="hints_used", on_delete=CASCADE) + challenge = models.ForeignKey("challenge.Challenge", related_name="hints_used", on_delete=CASCADE) class Meta: + """The constraints on the model.""" + unique_together = ( "hint", "team", diff --git a/src/hint/permissions.py b/src/hint/permissions.py index 0125bca5..1a865f47 100644 --- a/src/hint/permissions.py +++ b/src/hint/permissions.py @@ -1,13 +1,19 @@ +"""Permissions for the hint app.""" + from rest_framework import permissions class HasUsedHint(permissions.BasePermission): + """Permission based on if a user/team has used a hint.""" + def has_object_permission(self, request, view, obj): - if request.user.is_staff and not request.user.should_deny_admin(): + """Return True if the user/team has used this hint.""" + if request.user.is_staff and not request.user.should_deny_admin: return True return request.method in permissions.SAFE_METHODS and request.user.team.hints_used.filter(hint=obj).exists() def has_permission(self, request, view): + """Return True if the user can use a specific http method to access hints.""" return request.method in permissions.SAFE_METHODS or ( - request.user.is_staff and not request.user.should_deny_admin() + request.user.is_staff and not request.user.should_deny_admin ) diff --git a/src/hint/serializers.py b/src/hint/serializers.py index e462db6b..d15efdb3 100644 --- a/src/hint/serializers.py +++ b/src/hint/serializers.py @@ -1,3 +1,5 @@ +"""Serializers for the hint app.""" + import serpy from rest_framework import serializers @@ -5,6 +7,7 @@ def is_used(context, instance): + """Return True if a user can view a hint.""" return ( context["request"].user.team is not None and context["request"].user.team.hints_used.filter(hint=instance).exists() @@ -12,14 +15,21 @@ def is_used(context, instance): class HintUseSerializer(serializers.ModelSerializer): + """Serializer for a hint use.""" + class Meta: + """The fields that should be serialized for a hint use.""" + model = HintUse fields = ["id", "hint", "team", "user", "timestamp"] class HintSerializerMixin: + """Common functions for hint-related serializers.""" + def get_text(self, instance): - if (self.context["request"].user.is_staff and not self.context["request"].user.should_deny_admin()) or is_used( + """Get the text of a hint or an empty string if the hint is locked.""" + if (self.context["request"].user.is_staff and not self.context["request"].user.should_deny_admin) or is_used( self.context, instance ): return instance.text @@ -27,19 +37,26 @@ def get_text(self, instance): return "" def get_used(self, instance): + """Return True if the hint is used.""" return is_used(self.context, instance) class HintSerializer(serializers.ModelSerializer, HintSerializerMixin): + """Serializer for the Hint model.""" + text = serializers.SerializerMethodField() used = serializers.SerializerMethodField() class Meta: + """The fields that should be serialized.""" + model = Hint fields = ["id", "name", "penalty", "challenge", "text", "used"] class FastHintSerializer(serpy.Serializer): + """Serpy serializer for hints.""" + id = serpy.IntField() name = serpy.StrField() penalty = serpy.IntField() @@ -48,22 +65,33 @@ class FastHintSerializer(serpy.Serializer): class CreateHintSerializer(serializers.ModelSerializer): + """Serializer for creating hints.""" + class Meta: + """The fields that should be serialized.""" + model = Hint fields = ["id", "name", "penalty", "challenge", "text"] read_only_fields = ["id"] class FullHintSerializer(serializers.ModelSerializer): + """Serializer for the full details of a hint.""" + used = serializers.SerializerMethodField() class Meta: + """The fields that should be serialized.""" + model = Hint fields = ["id", "name", "penalty", "challenge", "text", "used"] def get_used(self, instance): + """Return True if the hint is used.""" return is_used(self.context, instance) class UseHintSerializer(serializers.Serializer): + """Serializer for the HintUse model.""" + id = serializers.IntegerField() diff --git a/src/hint/tests.py b/src/hint/tests.py index 041f9c78..a45c8053 100644 --- a/src/hint/tests.py +++ b/src/hint/tests.py @@ -1,40 +1,50 @@ +"""Tests for the hint app.""" + from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_403_FORBIDDEN from rest_framework.test import APITestCase -from challenge.tests.mixins import ChallengeSetupMixin +from challenges.tests.mixins import ChallengeSetupMixin from hint.views import HintViewSet, UseHintView class HintTestCase(ChallengeSetupMixin, APITestCase): + """Tests for the hints.""" + def setUp(self): + """Remove ratelimits from endpoints.""" super().setUp() HintViewSet.throttle_scope = "" UseHintView.throttle_scope = "" def test_hint_view(self): + """Test a user cannot access a hint they haven't unlocked.""" self.client.force_authenticate(self.user) - response = self.client.get(reverse("hint-detail", kwargs={"pk": self.hint1.id})) + response = self.client.get(reverse("hint-detail", kwargs={"pk": self.hint1.pk})) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_hint_view_admin(self): + """Test an admin can access a hint they haven't unlocked.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("hint-detail", kwargs={"pk": self.hint1.id})) + response = self.client.get(reverse("hint-detail", kwargs={"pk": self.hint1.pk})) self.assertEqual(response.status_code, HTTP_200_OK) def test_hint_list(self): + """Test a user can view the hint list.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("hint-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_hint_list_redaction(self): + """Test a user cannot view the details of a hint they haven't unlocked.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("hint-list")) self.assertEqual(response.data[0]["text"], "") def test_hint_list_admin(self): + """Test an admin can access the list of hints.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -42,6 +52,7 @@ def test_hint_list_admin(self): self.assertEqual(response.status_code, HTTP_200_OK) def test_hint_list_redaction_admin(self): + """Test an admin can view details of hints they haven't unlocked.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -49,22 +60,25 @@ def test_hint_list_redaction_admin(self): self.assertTrue("text" in response.data[0]) def test_hint_post(self): + """Test a non-admin user cannot create a hint.""" self.client.force_authenticate(self.user) response = self.client.post( reverse("hint-list"), - data={"name": "test-hint", "penalty": 100, "challenge": self.challenge2.id}, + data={"name": "test-hint", "penalty": 100, "challenge": self.challenge2.pk}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_hint_detail_put(self): + """Test a non-admin user cannot modify a hint.""" self.client.force_authenticate(self.user) response = self.client.put( - reverse("hint-detail", kwargs={"pk": self.hint1.id}), + reverse("hint-detail", kwargs={"pk": self.hint1.pk}), data={"name": "test-hint"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_hint_post_admin(self): + """Test an admin user can create a hint.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) @@ -73,49 +87,55 @@ def test_hint_post_admin(self): data={ "name": "test-hint", "penalty": 100, - "challenge": self.challenge2.id, + "challenge": self.challenge2.pk, "text": "abc", }, ) self.assertEqual(response.status_code, HTTP_201_CREATED) def test_hint_detail_patch_admin(self): + """Test an admin user can modify a hint.""" self.user.is_staff = True self.user.save() self.client.force_authenticate(self.user) response = self.client.patch( - reverse("hint-detail", kwargs={"pk": self.hint3.id}), + reverse("hint-detail", kwargs={"pk": self.hint3.pk}), data={"name": "test-hint"}, ) self.assertEqual(response.status_code, HTTP_200_OK) def test_hint_detail_patch(self): + """Test a normal user cannot patch a hint.""" self.client.force_authenticate(self.user) response = self.client.patch( - reverse("hint-detail", kwargs={"pk": self.hint3.id}), + reverse("hint-detail", kwargs={"pk": self.hint3.pk}), data={"name": "test-hint"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_hint_use(self): + """Test a user can use a hint.""" self.client.force_authenticate(self.user) - response = self.client.post(reverse("hint-use"), data={"id": self.hint3.id}) + response = self.client.post(reverse("hint-use"), data={"id": self.hint3.pk}) self.assertEqual(response.status_code, HTTP_200_OK) def test_hint_use_read(self): + """Test a user can read a hint once they've used it.""" self.client.force_authenticate(self.user) - self.client.post(reverse("hint-use"), data={"id": self.hint3.id}) - response = self.client.get(reverse("hint-detail", kwargs={"pk": self.hint3.id})) + self.client.post(reverse("hint-use"), data={"id": self.hint3.pk}) + response = self.client.get(reverse("hint-detail", kwargs={"pk": self.hint3.pk})) self.assertEqual(response.status_code, HTTP_200_OK) self.assertNotEqual(response.data["text"], "") def test_hint_use_duplicate(self): + """Test a user cannot redeem a hint twice.""" self.client.force_authenticate(self.user) - self.client.post(reverse("hint-use"), data={"id": self.hint3.id}) - response = self.client.post(reverse("hint-use"), data={"id": self.hint3.id}) + self.client.post(reverse("hint-use"), data={"id": self.hint3.pk}) + response = self.client.post(reverse("hint-use"), data={"id": self.hint3.pk}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_hint_use_locked(self): + """Test a user cannot use a hint that is on a locked challenge.""" self.client.force_authenticate(self.user) - response = self.client.post(reverse("hint-use"), data={"id": self.hint1.id}) + response = self.client.post(reverse("hint-use"), data={"id": self.hint1.pk}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) diff --git a/src/hint/urls.py b/src/hint/urls.py index d7afa2de..ecb8cc51 100644 --- a/src/hint/urls.py +++ b/src/hint/urls.py @@ -1,3 +1,5 @@ +"""URL routes for the hint app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter diff --git a/src/hint/views.py b/src/hint/views.py index 0e9db09a..9b780a2b 100644 --- a/src/hint/views.py +++ b/src/hint/views.py @@ -1,15 +1,17 @@ +"""API routes for the hint app.""" + from django.core.cache import caches from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.status import HTTP_403_FORBIDDEN from rest_framework.views import APIView -from backend.permissions import IsBot -from backend.response import FormattedResponse -from backend.signals import use_hint -from backend.viewsets import AdminCreateModelViewSet -from challenge.permissions import CompetitionOpen -from challenge.views import get_cache_key +from challenges.permissions import CompetitionOpen +from challenges.views import get_cache_key +from core.permissions import IsBot +from core.response import FormattedResponse +from core.signals import use_hint +from core.viewsets import AdminCreateModelViewSet from hint.models import Hint, HintUse from hint.permissions import HasUsedHint from hint.serializers import ( @@ -18,10 +20,12 @@ HintSerializer, UseHintSerializer, ) -from team.permissions import HasTeam +from teams.permissions import HasTeam class HintViewSet(AdminCreateModelViewSet): + """Viewset for managing and viewing hints.""" + queryset = Hint.objects.all() permission_classes = (HasUsedHint,) throttle_scope = "hint" @@ -32,15 +36,18 @@ class HintViewSet(AdminCreateModelViewSet): class UseHintView(APIView): + """API endpoint for redeeming hints.""" + permission_classes = (CompetitionOpen & IsAuthenticated & HasTeam & ~IsBot,) throttle_scope = "use_hint" def post(self, request): + """Redeem a hint and return the content.""" serializer = UseHintSerializer(data=request.data) serializer.is_valid(raise_exception=True) hint_id = serializer.validated_data["id"] hint = get_object_or_404(Hint, id=hint_id) - if not hint.challenge.is_unlocked(request.user): + if not hint.challenge.is_unlocked_by(request.user): return FormattedResponse(m="challenge_not_unlocked", s=False, status=HTTP_403_FORBIDDEN) if HintUse.objects.filter(hint=hint, team=request.user.team).exists(): return FormattedResponse(m="hint_already_used", s=False, status=HTTP_403_FORBIDDEN) diff --git a/src/leaderboard/apps.py b/src/leaderboard/apps.py index 8df688da..40e100dc 100644 --- a/src/leaderboard/apps.py +++ b/src/leaderboard/apps.py @@ -1,5 +1,9 @@ +"""App to display various scoreboards.""" + from django.apps import AppConfig class LeaderboardConfig(AppConfig): + """App config for the leaderboard app.""" + name = "leaderboard" diff --git a/src/leaderboard/models.py b/src/leaderboard/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/leaderboard/serializers.py b/src/leaderboard/serializers.py index d4cf06c0..5b8183ac 100644 --- a/src/leaderboard/serializers.py +++ b/src/leaderboard/serializers.py @@ -1,14 +1,18 @@ -from django.contrib.auth import get_user_model +"""Serializers for the leaderboard app.""" + from rest_framework import serializers -from challenge.models import Score -from team.models import Team +from challenges.models import Score +from teams.models import Team, Member class CTFTimeSerializer(serializers.BaseSerializer): + """Serializer for getting a scoreboard in a CTFTime compatible format.""" + position: int = 0 def to_representation(self, instance): + """Serialize a team into its name and score.""" # TODO: Use SerializerFields for team, score and position. return {"team": instance.name, "score": instance.leaderboard_points, "pos": self.get_position(instance)} @@ -19,39 +23,60 @@ def get_position(self, _) -> int: class LeaderboardTeamScoreSerializer(serializers.ModelSerializer): + """Serializer for a point on the team score graph.""" + team_name = serializers.ReadOnlyField(source="team.name") class Meta: + """The fields to serialize.""" + model = Score fields = ["points", "timestamp", "team_name", "reason", "metadata"] class LeaderboardUserScoreSerializer(serializers.ModelSerializer): + """Serializer for a point on the user score graph.""" + user_name = serializers.ReadOnlyField(source="user.username") class Meta: + """The fields to serialize.""" + model = Score fields = ["points", "timestamp", "user_name", "reason", "metadata"] class TeamPointsSerializer(serializers.ModelSerializer): + """Serializer for the team leaderboard.""" + class Meta: + """The fields to serialize.""" + model = Team fields = ["name", "id", "leaderboard_points"] class UserPointsSerializer(serializers.ModelSerializer): + """Serializer for the user leaderboard.""" + class Meta: - model = get_user_model() + """The fields to serialize.""" + + model = Member fields = ["username", "id", "leaderboard_points"] class MatrixSerializer(serializers.ModelSerializer): + """Serializer for the matrix scoreboard.""" + solve_ids = serializers.SerializerMethodField() class Meta: + """The fields to serialize.""" + model = Team fields = ["id", "name", "leaderboard_points", "solve_ids"] def get_solve_ids(self, instance): + """Return the ids of every challenge a team has solved.""" return list(instance.solves.values_list("challenge", flat=True)) diff --git a/src/leaderboard/tests.py b/src/leaderboard/tests.py index 30d03322..c6d4814c 100644 --- a/src/leaderboard/tests.py +++ b/src/leaderboard/tests.py @@ -1,15 +1,17 @@ -from django.contrib.auth import get_user_model +"""Tests for the leaderboard app.""" + from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK from rest_framework.test import APITestCase -from challenge.models import Category, Challenge, Score, Solve +from challenges.models import Category, Challenge, Score, Solve from config import config from leaderboard.views import CTFTimeListView, GraphView, TeamListView, UserListView -from team.models import Team +from teams.models import Team, Member def populate(): + """Populate the database with some example data.""" category = Category(name="test", display_order=0, contained_type="test", description="") category.save() challenge = Challenge( @@ -26,7 +28,7 @@ def populate(): ) challenge.save() for i in range(15): - user = get_user_model()(username=f"scorelist-test{i}", email=f"scorelist-test{i}@example.org", is_visible=True) + user = Member(username=f"scorelist-test{i}", email=f"scorelist-test{i}@example.org", is_visible=True) user.save() team = Team(name=f"scorelist-test{i}", password=f"scorelist-test{i}", owner=user, is_visible=True) team.points = i * 100 @@ -42,19 +44,24 @@ def populate(): class ScoreListTestCase(APITestCase): + """Tests for the scorelist endpoint.""" + def setUp(self): + """Remove ratelimits from the graph view and create a user.""" GraphView.throttle_scope = "" - user = get_user_model()(username="scorelist-test", email="scorelist-test@example.org") + user = Member(username="scorelist-test", email="scorelist-test@example.org") user.save() self.user = user def test_unauthed_access(self): + """Test an unauthenticated user can access the leaderboard.""" config.set("enable_caching", False) response = self.client.get(reverse("leaderboard-graph")) config.set("enable_caching", True) self.assertEqual(response.status_code, HTTP_200_OK) def test_authed_access(self): + """Test an authenticated user can access the leaderboard.""" self.client.force_authenticate(self.user) config.set("enable_caching", False) response = self.client.get(reverse("leaderboard-graph")) @@ -62,6 +69,7 @@ def test_authed_access(self): self.assertEqual(response.status_code, HTTP_200_OK) def test_disabled_access(self): + """Test an unauthenticated user cannot access the leaderboard when its disabled.""" config.set("enable_caching", False) config.set("enable_scoreboard", False) response = self.client.get(reverse("leaderboard-graph")) @@ -70,6 +78,7 @@ def test_disabled_access(self): self.assertEqual(response.data["d"], {}) def test_format(self): + """Test the leaderboard is formatted correctly.""" config.set("enable_caching", False) response = self.client.get(reverse("leaderboard-graph")) config.set("enable_caching", True) @@ -77,6 +86,7 @@ def test_format(self): self.assertTrue("team" in response.data["d"]) def test_list_size(self): + """Test the leaderboard contains the right amount of users.""" config.set("enable_caching", False) populate() response = self.client.get(reverse("leaderboard-graph")) @@ -85,6 +95,7 @@ def test_list_size(self): self.assertEqual(len(response.data["d"]["team"]), 10) def test_list_sorting(self): + """Test the leaderboard is ordered correctly.""" config.set("enable_caching", False) populate() response = self.client.get(reverse("leaderboard-graph")) @@ -93,6 +104,7 @@ def test_list_sorting(self): self.assertEqual(response.data["d"]["team"][0]["points"], 1400) def test_user_only(self): + """Test the leaderboard only displays users when teams are disabled.""" populate() config.set("enable_teams", False) config.set("enable_caching", False) @@ -104,6 +116,7 @@ def test_user_only(self): self.assertNotIn("team", response.data["d"].keys()) def test_caching(self): + """Test the leaderboard is cached correctly.""" config.set("enable_caching", True) uncached_response = self.client.get(reverse("leaderboard-graph")) cached_response = self.client.get(reverse("leaderboard-graph")) @@ -112,33 +125,41 @@ def test_caching(self): class UserListTestCase(APITestCase): + """Tests for the user scoreboard.""" + def setUp(self): - user = get_user_model()(username="userlist-test", email="userlist-test@example.org") + """Remove the rate limit for the view.""" + user = Member(username="userlist-test", email="userlist-test@example.org") user.save() self.user = user UserListView.throttle_scope = None def test_unauthed(self): + """Test an unauthenticated user can access the view.""" response = self.client.get(reverse("leaderboard-user")) self.assertEqual(response.status_code, HTTP_200_OK) def test_authed(self): + """Test an authenticated user can access the view.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-user")) self.assertEqual(response.status_code, HTTP_200_OK) def test_disabled_access(self): + """Test the scoreboard cannot be accessed when enable_scoreboard is False.""" config.set("enable_scoreboard", False) response = self.client.get(reverse("leaderboard-user")) config.set("enable_scoreboard", True) self.assertEqual(response.data["d"], {}) def test_length(self): + """Test the length of the scoreboard is correct.""" populate() response = self.client.get(reverse("leaderboard-user")) self.assertEqual(len(response.data["d"]["results"]), 15) def test_order(self): + """Test the order of the scoreboard is correct.""" populate() response = self.client.get(reverse("leaderboard-user")) points = [x["leaderboard_points"] for x in response.data["d"]["results"]] @@ -146,33 +167,41 @@ def test_order(self): class TeamListTestCase(APITestCase): + """Tests for the team scoreboard.""" + def setUp(self): - user = get_user_model()(username="userlist-test", email="userlist-test@example.org") + """Remove the rate limit for the view.""" + user = Member(username="userlist-test", email="userlist-test@example.org") user.save() self.user = user TeamListView.throttle_scope = None def test_unauthed(self): + """Test an unauthenticated user can access the view.""" response = self.client.get(reverse("leaderboard-team")) self.assertEqual(response.status_code, HTTP_200_OK) def test_authed(self): + """Test an authenticated user can access the view.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-team")) self.assertEqual(response.status_code, HTTP_200_OK) def test_disabled_access(self): + """Test the scoreboard cannot be accessed when enable_scoreboard is False.""" config.set("enable_scoreboard", False) response = self.client.get(reverse("leaderboard-team")) config.set("enable_scoreboard", True) self.assertEqual(response.data["d"], {}) def test_length(self): + """Test the length of the scoreboard is correct.""" populate() response = self.client.get(reverse("leaderboard-team")) self.assertEqual(len(response.data["d"]["results"]), 15) def test_order(self): + """Test the order of the scoreboard is correct.""" populate() response = self.client.get(reverse("leaderboard-team")) points = [x["leaderboard_points"] for x in response.data["d"]["results"]] @@ -180,39 +209,48 @@ def test_order(self): class CTFTimeListTestCase(APITestCase): + """Test the CTFTime scoreboard integration.""" + def setUp(self): - user = get_user_model()(username="userlist-test", email="userlist-test@example.org") + """Remove the rate limit for the view.""" + user = Member(username="userlist-test", email="userlist-test@example.org") user.save() self.user = user CTFTimeListView.throttle_scope = None def test_unauthed(self): + """Test an unauthenticated user can access the view.""" response = self.client.get(reverse("leaderboard-ctftime")) self.assertEqual(response.status_code, HTTP_200_OK) def test_authed(self): + """Test an authenticated user can access the view.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-ctftime")) self.assertEqual(response.status_code, HTTP_200_OK) def test_disabled_access(self): + """Test the scoreboard cannot be accessed when enable_scoreboard is False.""" config.set("enable_scoreboard", False) response = self.client.get(reverse("leaderboard-ctftime")) config.set("enable_scoreboard", True) self.assertEqual(response.data, {}) def test_disabled_ctftime(self): + """Test the scoreboard cannot be accessed when enable_ctftime is False.""" config.set("enable_ctftime", False) response = self.client.get(reverse("leaderboard-ctftime")) config.set("enable_ctftime", True) self.assertEqual(response.data, {}) def test_length(self): + """Test the length of the scoreboard is correct.""" populate() response = self.client.get(reverse("leaderboard-ctftime")) self.assertEqual(len(response.data["standings"]), 15) def test_order(self): + """Test the order of the scoreboard is correct.""" populate() response = self.client.get(reverse("leaderboard-ctftime")) points = [x["score"] for x in response.data["standings"]] @@ -220,44 +258,54 @@ def test_order(self): class MatrixTestCase(APITestCase): + """Test the matrix scoreboard.""" + def setUp(self): - user = get_user_model()(username="matrix-test", email="matrix-test@example.org") + """Remove the rate limit for the view.""" + user = Member(username="matrix-test", email="matrix-test@example.org") user.save() self.user = user TeamListView.throttle_scope = None populate() def test_authenticated(self): + """Test an authenticated user can access the view.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-matrix-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_unauthenticated(self): + """Test an unauthenticated user can access the view.""" response = self.client.get(reverse("leaderboard-matrix-list")) self.assertEqual(response.status_code, HTTP_200_OK) def test_length(self): + """Test the length of the scoreboard is correct.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-matrix-list")) self.assertEqual(len(response.data["d"]["results"]), 15) def test_solves_present(self): + """Test solves are displayed.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-matrix-list")) self.assertEqual(len(response.data["d"]["results"][0]["solve_ids"]), 1) def test_solves_not_present(self): + """Test the only challenges included are ones the user has solved.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-matrix-list")) self.assertEqual(len(response.data["d"]["results"][1]["solve_ids"]), 0) def test_order(self): + """Test the order of the scoreboard is correct.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("leaderboard-matrix-list")) points = [x["leaderboard_points"] for x in response.data["d"]["results"]] self.assertEqual(points, sorted(points, reverse=True)) def test_disabled_scoreboard(self): + """Test the scoreboard cannot be accessed when enable_scoreboard is False.""" config.set("enable_scoreboard", False) response = self.client.get(reverse("leaderboard-matrix-list")) config.set("enable_scoreboard", True) diff --git a/src/leaderboard/urls.py b/src/leaderboard/urls.py index c0a985b4..c7abf3e5 100644 --- a/src/leaderboard/urls.py +++ b/src/leaderboard/urls.py @@ -1,3 +1,5 @@ +"""URL routes for the leaderboard app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter diff --git a/src/leaderboard/views.py b/src/leaderboard/views.py index a60c79a7..b7d340ac 100644 --- a/src/leaderboard/views.py +++ b/src/leaderboard/views.py @@ -1,16 +1,17 @@ +"""API routes for the leaderboard app.""" + import time -from django.contrib.auth import get_user_model from django.core.cache import caches from rest_framework.generics import ListAPIView -from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ReadOnlyModelViewSet -from backend.response import FormattedResponse -from challenge.models import Score +from challenges.models import Score from config import config +from core.response import FormattedResponse from leaderboard.serializers import ( CTFTimeSerializer, LeaderboardTeamScoreSerializer, @@ -19,10 +20,11 @@ TeamPointsSerializer, UserPointsSerializer, ) -from team.models import Team +from teams.models import Team, Member def should_hide_scoreboard(): + """Return True if the scoreboard should be hidden.""" return not config.get("enable_scoreboard") and ( config.get("hide_scoreboard_at") == -1 or config.get("hide_scoreboard_at") > time.time() @@ -31,11 +33,12 @@ def should_hide_scoreboard(): class CTFTimeListView(APIView): - renderer_classes = ( - JSONRenderer, - ) + """CTFTime scoreboard integration.""" + + renderer_classes = (JSONRenderer,) def get(self, request, *args, **kwargs): + """Get the scoreboard in a CTFTime compatible format.""" if should_hide_scoreboard() or not config.get("enable_ctftime"): return Response({}) teams = Team.objects.visible().ranked() @@ -43,9 +46,12 @@ def get(self, request, *args, **kwargs): class GraphView(APIView): + """API endpoint to display the leaderboard as a graph.""" + throttle_scope = "leaderboard" def get(self, request, *args, **kwargs): + """Return the points to plot on the graph.""" if should_hide_scoreboard(): return FormattedResponse({}) @@ -57,7 +63,7 @@ def get(self, request, *args, **kwargs): graph_members = config.get("graph_members") top_teams = Team.objects.visible().ranked()[:graph_members] top_users = ( - get_user_model() + Member .objects.filter(is_visible=True) .order_by("-leaderboard_points", "last_score")[:graph_members] ) @@ -84,33 +90,42 @@ def get(self, request, *args, **kwargs): class UserListView(ListAPIView): + """API endpoint to display the user scoreboard.""" + throttle_scope = "leaderboard" - queryset = get_user_model().objects.filter(is_visible=True).order_by("-leaderboard_points", "last_score") + queryset = Member.objects.filter(is_visible=True).order_by("-leaderboard_points", "last_score") serializer_class = UserPointsSerializer def list(self, request, *args, **kwargs): + """Return a list of users and how many points they have.""" if should_hide_scoreboard(): return FormattedResponse({}) return super(UserListView, self).list(request, *args, **kwargs) class TeamListView(ListAPIView): + """API endpoint to display the team scoreboard.""" + throttle_scope = "leaderboard" queryset = Team.objects.visible().ranked() serializer_class = TeamPointsSerializer def list(self, request, *args, **kwargs): + """Return a list of teams and how many points they have.""" if should_hide_scoreboard(): return FormattedResponse({}) return super(TeamListView, self).list(request, *args, **kwargs) class MatrixScoreboardView(ReadOnlyModelViewSet): + """API endpoint to display the matrix scoreboard.""" + throttle_scope = "leaderboard" queryset = Team.objects.visible().ranked().prefetch_solves() serializer_class = MatrixSerializer def list(self, request, *args, **kwargs): + """Return a list of teams and which challenges they have solved.""" if should_hide_scoreboard(): return FormattedResponse({}) return super(MatrixScoreboardView, self).list(request, *args, **kwargs) diff --git a/src/manage.py b/src/manage.py index 575157e1..071c41f3 100755 --- a/src/manage.py +++ b/src/manage.py @@ -5,7 +5,8 @@ def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings.lint") + """Launch the relevant manage.py command.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.lint") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/src/member/apps.py b/src/member/apps.py deleted file mode 100644 index 437cc8ff..00000000 --- a/src/member/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class MemberConfig(AppConfig): - name = "member" diff --git a/src/member/migrations/0001_initial.py b/src/member/migrations/0001_initial.py deleted file mode 100644 index 8a970926..00000000 --- a/src/member/migrations/0001_initial.py +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-08 13:37 - -import secrets - -import django.contrib.auth.models -import django.contrib.postgres.fields.citext -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.contrib.postgres.operations import CITextExtension -from django.db import migrations, models - -import backend.validators - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0011_update_proxy_permissions'), - ] - - operations = [ - CITextExtension(), - migrations.CreateModel( - name='Member', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('username', django.contrib.postgres.fields.citext.CICharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 36 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=36, unique=True, validators=[backend.validators.NameValidator()], verbose_name='username')), - ('email', models.EmailField(blank=True, max_length=254, unique=True, verbose_name='email address')), - ('totp_secret', models.CharField(max_length=16, null=True)), - ('totp_status', models.IntegerField(choices=[0, 1, 2], default=0)), - ('is_visible', models.BooleanField(default=False)), - ('bio', models.TextField(blank=True, max_length=400)), - ('discord', models.CharField(blank=True, max_length=36)), - ('discordid', models.CharField(blank=True, max_length=18)), - ('twitter', models.CharField(blank=True, max_length=36)), - ('reddit', models.CharField(blank=True, max_length=36)), - ('email_verified', models.BooleanField(default=False)), - ('email_token', models.CharField(default=secrets.token_hex, max_length=64)), - ('password_reset_token', models.CharField(default=secrets.token_hex, max_length=64)), - ('points', models.IntegerField(default=0)), - ('leaderboard_points', models.IntegerField(default=0)), - ('last_score', models.DateTimeField(default=django.utils.timezone.now)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name='UserIP', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ip', models.CharField(max_length=255)), - ('seen', models.IntegerField(default=1)), - ('last_seen', models.DateTimeField(default=django.utils.timezone.now)), - ('user_agent', models.CharField(max_length=255)), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/member/migrations/0002_auto_20200808_1337.py b/src/member/migrations/0002_auto_20200808_1337.py deleted file mode 100644 index f2011df4..00000000 --- a/src/member/migrations/0002_auto_20200808_1337.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-08 13:37 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('member', '0001_initial'), - ('auth', '0011_update_proxy_permissions'), - ('team', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='member', - name='team', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='team.Team'), - ), - migrations.AddField( - model_name='member', - name='user_permissions', - field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), - ), - ] diff --git a/src/member/migrations/0003_auto_20200808_1649.py b/src/member/migrations/0003_auto_20200808_1649.py deleted file mode 100644 index 531ec11c..00000000 --- a/src/member/migrations/0003_auto_20200808_1649.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-08 15:49 - -import django.contrib.postgres.fields.citext -from django.db import migrations - -import backend.validators - - -class Migration(migrations.Migration): - - dependencies = [ - ('member', '0002_auto_20200808_1337'), - ] - - operations = [ - migrations.AlterField( - model_name='member', - name='username', - field=django.contrib.postgres.fields.citext.CICharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 36 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=36, unique=True, validators=[backend.validators.printable_name], verbose_name='username'), - ), - ] diff --git a/src/member/migrations/0004_auto_20200810_1111.py b/src/member/migrations/0004_auto_20200810_1111.py deleted file mode 100644 index 2f9bee5f..00000000 --- a/src/member/migrations/0004_auto_20200810_1111.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-10 11:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('member', '0003_auto_20200808_1649'), - ] - - operations = [ - migrations.RemoveField( - model_name='member', - name='totp_secret', - ), - migrations.RemoveField( - model_name='member', - name='totp_status', - ), - migrations.AddField( - model_name='member', - name='state_actor', - field=models.BooleanField(default=False), - ), - ] diff --git a/src/member/migrations/0005_member_is_bot.py b/src/member/migrations/0005_member_is_bot.py deleted file mode 100644 index aa7e1c82..00000000 --- a/src/member/migrations/0005_member_is_bot.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-15 00:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('member', '0004_auto_20200810_1111'), - ] - - operations = [ - migrations.AddField( - model_name='member', - name='is_bot', - field=models.BooleanField(default=False), - ), - ] diff --git a/src/member/migrations/0006_member_is_verified.py b/src/member/migrations/0006_member_is_verified.py deleted file mode 100644 index d86f421a..00000000 --- a/src/member/migrations/0006_member_is_verified.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.5 on 2020-09-03 16:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('member', '0005_member_is_bot'), - ] - - operations = [ - migrations.AddField( - model_name='member', - name='is_verified', - field=models.BooleanField(default=False), - ), - ] diff --git a/src/member/migrations/0007_auto_20201226_1330.py b/src/member/migrations/0007_auto_20201226_1330.py deleted file mode 100644 index 0259d4f2..00000000 --- a/src/member/migrations/0007_auto_20201226_1330.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-12-26 13:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('member', '0006_member_is_verified'), - ] - - operations = [ - migrations.AlterField( - model_name='member', - name='first_name', - field=models.CharField(blank=True, max_length=150, verbose_name='first name'), - ), - ] diff --git a/src/member/migrations/0008_remove_member_password_reset_token.py b/src/member/migrations/0008_remove_member_password_reset_token.py deleted file mode 100644 index c783ceeb..00000000 --- a/src/member/migrations/0008_remove_member_password_reset_token.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.2.4 on 2021-06-09 01:38 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('member', '0007_auto_20201226_1330'), - ] - - operations = [ - migrations.RemoveField( - model_name='member', - name='password_reset_token', - ), - ] diff --git a/src/member/migrations/__init__.py b/src/member/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/member/models.py b/src/member/models.py deleted file mode 100644 index bf21848d..00000000 --- a/src/member/models.py +++ /dev/null @@ -1,95 +0,0 @@ -import secrets -import time -from enum import IntEnum - -from django.contrib.auth import get_user_model -from django.contrib.auth.models import AbstractUser -from django.contrib.postgres.fields import CICharField -from django.db import models -from django.db.models import SET_NULL -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django_prometheus.models import ExportModelOperationsMixin - -from backend.validators import printable_name -from config import config - - -class TOTPStatus(IntEnum): - DISABLED = 0 - VERIFYING = 1 - ENABLED = 2 - - -class Member(ExportModelOperationsMixin("member"), AbstractUser): - username_validator = printable_name - - username = CICharField( - _("username"), - max_length=36, - unique=True, - help_text=_("Required. 36 characters or fewer. Letters, digits and @/./+/-/_ only."), - validators=[username_validator], - error_messages={"unique": _("A user with that username already exists.")}, - ) - email = models.EmailField(_("email address"), blank=True, unique=True) - state_actor = models.BooleanField(default=False) - is_visible = models.BooleanField(default=False) - is_bot = models.BooleanField(default=False) - is_verified = models.BooleanField(default=False) - bio = models.TextField(blank=True, max_length=400) - discord = models.CharField(blank=True, max_length=36) - discordid = models.CharField(blank=True, max_length=18) - twitter = models.CharField(blank=True, max_length=36) - reddit = models.CharField(blank=True, max_length=36) - team = models.ForeignKey("team.Team", on_delete=SET_NULL, null=True, related_name="members") - email_verified = models.BooleanField(default=False) - email_token = models.CharField(max_length=64, default=secrets.token_hex) - points = models.IntegerField(default=0) - leaderboard_points = models.IntegerField(default=0) - last_score = models.DateTimeField(default=timezone.now) - - def __str__(self): - return self.username - - def can_login(self): - return self.is_staff or ( - config.get("enable_login") and (config.get("enable_prelogin") or config.get("start_time") <= time.time()) - ) - - def issue_token(self, owner=None): - from authentication.models import Token - - token = Token(user=self, owner=owner) - token.save() - return token.key - - def has_2fa(self): - return hasattr(self, "totp_device") and self.totp_device.verified - - def should_deny_admin(self): - return config.get("enable_force_admin_2fa") and not self.has_2fa() - - -class UserIP(ExportModelOperationsMixin("user_ip"), models.Model): - user = models.ForeignKey(get_user_model(), on_delete=SET_NULL, null=True) - ip = models.CharField(max_length=255) - seen = models.IntegerField(default=1) - last_seen = models.DateTimeField(default=timezone.now) - user_agent = models.CharField(max_length=255) - - @staticmethod - def hook(request): - if not request.user.is_authenticated: - return - ip = request.headers.get("x-forwarded-for", "0.0.0.0") - user_agent = request.headers.get("user-agent", "???")[:255] - qs = UserIP.objects.filter(user=request.user, ip=ip) - if qs.exists(): - user_ip = qs.first() - user_ip.seen += 1 - user_ip.last_seen = timezone.now() - user_ip.user_agent = user_agent - user_ip.save() - else: - UserIP(user=request.user, ip=ip, user_agent=user_agent).save() diff --git a/src/member/views.py b/src/member/views.py deleted file mode 100644 index 48811410..00000000 --- a/src/member/views.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import filters -from rest_framework.generics import RetrieveUpdateAPIView -from rest_framework.permissions import IsAdminUser, IsAuthenticated -from rest_framework.viewsets import ModelViewSet - -from backend.permissions import AdminOrReadOnlyVisible, ReadOnlyBot -from backend.viewsets import AdminListModelViewSet -from member.models import UserIP -from member.serializers import ( - AdminMemberSerializer, - ListMemberSerializer, - MemberSerializer, - SelfSerializer, - UserIPSerializer, -) - - -class SelfView(RetrieveUpdateAPIView): - serializer_class = SelfSerializer - permission_classes = (IsAuthenticated & ReadOnlyBot,) - throttle_scope = "self" - - def get_object(self): - UserIP.hook(self.request) - return ( - get_user_model() - .objects.prefetch_related( - "team", - "team__solves", - "team__solves__score", - "team__hints_used", - "team__solves__challenge", - "team__solves__solved_by", - "solves", - "solves__score", - "hints_used", - "solves__challenge", - "solves__team", - "solves__score__team", - ) - .distinct() - .get(id=self.request.user.id) - ) - - -class MemberViewSet(AdminListModelViewSet): - permission_classes = (AdminOrReadOnlyVisible,) - throttle_scope = "member" - serializer_class = MemberSerializer - admin_serializer_class = AdminMemberSerializer - list_serializer_class = ListMemberSerializer - list_admin_serializer_class = ListMemberSerializer - search_fields = ["username"] - ordering_fields = ["username", "team__name"] - filter_backends = [filters.SearchFilter, filters.OrderingFilter] - - def get_queryset(self): - if self.action != "list": - return get_user_model().objects.prefetch_related( - "team", - "team__solves", - "team__solves__score", - "team__hints_used", - "team__solves__challenge", - "team__solves__solved_by", - "solves", - "solves__score", - "hints_used", - "solves__challenge", - "solves__team", - "solves__score__team", - ) - if self.request.user.is_staff and not self.request.user.should_deny_admin(): - return get_user_model().objects.order_by("id").prefetch_related("team") - return get_user_model().objects.filter(is_visible=True).order_by("id").prefetch_related("team") - - -class UserIPViewSet(ModelViewSet): - queryset = UserIP.objects.all() - pagination_class = None - permission_classes = (IsAdminUser,) - serializer_class = UserIPSerializer diff --git a/src/pages/apps.py b/src/pages/apps.py index 344e0f0c..30899b4e 100644 --- a/src/pages/apps.py +++ b/src/pages/apps.py @@ -1,5 +1,9 @@ +"""Apps for displaying custom pages on the frontend.""" + from django.apps import AppConfig class PagesConfig(AppConfig): + """App config for the page app.""" + name = "pages" diff --git a/src/pages/models.py b/src/pages/models.py index bde2752b..4392adec 100644 --- a/src/pages/models.py +++ b/src/pages/models.py @@ -1,8 +1,12 @@ +"""Database models for the pages app.""" + from django.db import models from django_prometheus.models import ExportModelOperationsMixin class Page(ExportModelOperationsMixin("page"), models.Model): + """Represents a custom page that will be displayed on the frontend.""" + title = models.CharField(max_length=255) content = models.TextField() url = models.CharField(max_length=255, unique=True) diff --git a/src/pages/serializers.py b/src/pages/serializers.py index 143973ee..7011578b 100644 --- a/src/pages/serializers.py +++ b/src/pages/serializers.py @@ -1,9 +1,15 @@ +"""Serializers for the page views.""" + from rest_framework import serializers from pages.models import Page class PageSerializer(serializers.ModelSerializer): + """Serializer for pages.""" + class Meta: + """The fields to serialize.""" + model = Page fields = ["id", "url", "title", "content"] diff --git a/src/pages/tests.py b/src/pages/tests.py index a39b155a..9ff8ef3c 100644 --- a/src/pages/tests.py +++ b/src/pages/tests.py @@ -1 +1 @@ -# Create your tests here. +"""Tests for the pages app.""" diff --git a/src/pages/urls.py b/src/pages/urls.py index 93400892..9e9ce0ce 100644 --- a/src/pages/urls.py +++ b/src/pages/urls.py @@ -1,9 +1,11 @@ +"""URL routes for the pages app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter from pages import views router = DefaultRouter() -router.register(r"", views.TagViewSet, basename="pages") +router.register(r"", views.PageViewSet, basename="pages") urlpatterns = [path("", include(router.urls))] diff --git a/src/pages/views.py b/src/pages/views.py index 70832fbc..75580695 100644 --- a/src/pages/views.py +++ b/src/pages/views.py @@ -1,11 +1,15 @@ +"""Views for the page app.""" + from rest_framework.viewsets import ModelViewSet -from backend.permissions import AdminOrAnonymousReadOnly +from core.permissions import AdminOrAnonymousReadOnly from pages.models import Page from pages.serializers import PageSerializer -class TagViewSet(ModelViewSet): +class PageViewSet(ModelViewSet): + """Viewset for page objects.""" + queryset = Page.objects.all() permission_classes = (AdminOrAnonymousReadOnly,) throttle_scope = "pages" diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/plugins/apps.py b/src/plugins/apps.py deleted file mode 100644 index 09cc385e..00000000 --- a/src/plugins/apps.py +++ /dev/null @@ -1,17 +0,0 @@ -import abc -from pydoc import locate - -from django.apps import AppConfig - - -class PluginsConfig(AppConfig): - name = "plugins" - - -class PluginConfig(AppConfig, abc.ABC): - def ready(self): - from plugins import providers - - if hasattr(self, "provides") and isinstance(self.provides, list): # pragma: no cover - for provider in map(locate, self.provides): - providers.register_provider(provider.type, provider()) diff --git a/src/plugins/base.py b/src/plugins/base.py deleted file mode 100644 index 6b936ba4..00000000 --- a/src/plugins/base.py +++ /dev/null @@ -1,4 +0,0 @@ -class Plugin: - """Base class used to identify plugins when loading them""" - - pass diff --git a/src/plugins/flag/__init__.py b/src/plugins/flag/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/plugins/migrations/__init__.py b/src/plugins/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/plugins/models.py b/src/plugins/models.py deleted file mode 100644 index 6a40c35b..00000000 --- a/src/plugins/models.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.conf import settings - -from plugins import plugins - -plugins.load_plugins(settings.INSTALLED_PLUGINS) diff --git a/src/plugins/points/__init__.py b/src/plugins/points/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ractf/__init__.py b/src/ractf/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ractf/apps.py b/src/ractf/apps.py deleted file mode 100644 index 713bc363..00000000 --- a/src/ractf/apps.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.apps import AppConfig -from django.conf import settings -from django.core.checks import ERROR, WARNING, CheckMessage, register - - -class RactfConfig(AppConfig): - name = "ractf" - - -@register() -def check_settings(app_configs, **kwargs): # pragma: no cover - errors = [] - for setting in settings.REQUIRED_SETTINGS: - if getattr(settings, setting, None) is None: - errors.append( - CheckMessage( - (WARNING if settings.DEBUG else ERROR), - f"Required setting {setting} was missing.", - hint="Did you forget to set an environment variable?", - id="ractf.E001", - ) - ) - return errors diff --git a/src/ractf/management/__init__.py b/src/ractf/management/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ractf/management/commands/__init__.py b/src/ractf/management/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ractf/migrations/__init__.py b/src/ractf/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ractf/models.py b/src/ractf/models.py deleted file mode 100644 index 6b202199..00000000 --- a/src/ractf/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/src/ractf/tests.py b/src/ractf/tests.py deleted file mode 100644 index a39b155a..00000000 --- a/src/ractf/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/scorerecalculator/__init__.py b/src/scorerecalculator/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/scorerecalculator/apps.py b/src/scorerecalculator/apps.py deleted file mode 100644 index 290c8d03..00000000 --- a/src/scorerecalculator/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ScorerecalculatorConfig(AppConfig): - name = "scorerecalculator" diff --git a/src/scorerecalculator/migrations/__init__.py b/src/scorerecalculator/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/scorerecalculator/models.py b/src/scorerecalculator/models.py deleted file mode 100644 index 6b202199..00000000 --- a/src/scorerecalculator/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/src/scorerecalculator/tests.py b/src/scorerecalculator/tests.py deleted file mode 100644 index ad72a275..00000000 --- a/src/scorerecalculator/tests.py +++ /dev/null @@ -1,176 +0,0 @@ -import random - -from django.contrib.auth import get_user_model -from rest_framework.reverse import reverse -from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN -from rest_framework.test import APITestCase - -from challenge.models import Score -from team.models import Team - - -class RecalculateUserViewTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="recalculate-test", email="recalculate-test@example.org") - user.save() - admin_user = get_user_model()( - username="recalculate-test-admin", - email="recalculate-test-admin@example.org", - ) - admin_user.is_staff = True - admin_user.save() - team = Team(name="recalculate-team", owner=user, password="a") - team.save() - user.team = team - user.save() - self.user = user - self.admin_user = admin_user - self.team = team - - def test_unauthed(self): - response = self.client.post(reverse("recalculate-user", kwargs={"id": self.user.id})) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_authed(self): - self.client.force_authenticate(self.admin_user) - response = self.client.post(reverse("recalculate-user", kwargs={"id": self.user.id})) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_authed_not_admin(self): - self.client.force_authenticate(self.user) - response = self.client.post(reverse("recalculate-user", kwargs={"id": self.user.id})) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_recalculate(self): - total = 0 - for i in range(15): - points = random.randint(0, 100) - total += points - Score(team=self.team, user=self.user, reason="test", points=points).save() - Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() - self.client.force_authenticate(self.admin_user) - self.client.post(reverse("recalculate-user", kwargs={"id": self.user.id})) - self.assertEqual(get_user_model().objects.get(id=self.user.id).points, total + 100) - - def test_recalculate_leaderboard(self): - total = 0 - for i in range(15): - points = random.randint(0, 100) - total += points - Score(team=self.team, user=self.user, reason="test", points=points).save() - Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() - self.client.force_authenticate(self.admin_user) - self.client.post(reverse("recalculate-user", kwargs={"id": self.user.id})) - self.assertEqual(get_user_model().objects.get(id=self.user.id).leaderboard_points, total) - - -class RecalculateTeamViewTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="recalculate-test", email="recalculate-test@example.org") - user.save() - admin_user = get_user_model()( - username="recalculate-test-admin", - email="recalculate-test-admin@example.org", - ) - admin_user.is_staff = True - admin_user.save() - team = Team(name="recalculate-team", owner=user, password="a") - team.save() - user.team = team - user.save() - self.user = user - self.admin_user = admin_user - self.team = team - - def test_unauthed(self): - response = self.client.post(reverse("recalculate-team", kwargs={"id": self.team.id})) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_authed(self): - self.client.force_authenticate(self.admin_user) - response = self.client.post(reverse("recalculate-team", kwargs={"id": self.team.id})) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_authed_not_admin(self): - self.client.force_authenticate(self.user) - response = self.client.post(reverse("recalculate-team", kwargs={"id": self.team.id})) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_recalculate(self): - total = 0 - for i in range(15): - points = random.randint(0, 100) - total += points - Score(team=self.team, user=self.user, reason="test", points=points).save() - Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() - self.client.force_authenticate(self.admin_user) - self.client.post(reverse("recalculate-team", kwargs={"id": self.team.id})) - self.assertEqual(Team.objects.get(id=self.team.id).points, total + 100) - - def test_recalculate_leaderboard(self): - total = 0 - for i in range(15): - points = random.randint(0, 100) - total += points - Score(team=self.team, user=self.user, reason="test", points=points).save() - Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() - self.client.force_authenticate(self.admin_user) - self.client.post(reverse("recalculate-team", kwargs={"id": self.team.id})) - self.assertEqual(Team.objects.get(id=self.team.id).leaderboard_points, total) - - -class RecalculateAllViewTestCase(APITestCase): - def setUp(self): - user = get_user_model()(username="recalculate-test", email="recalculate-test@example.org") - user.save() - admin_user = get_user_model()( - username="recalculate-test-admin", - email="recalculate-test-admin@example.org", - ) - admin_user.is_staff = True - admin_user.save() - team = Team(name="recalculate-team", owner=user, password="a") - team.save() - user.team = team - user.save() - self.user = user - self.admin_user = admin_user - self.team = team - - def test_unauthed(self): - response = self.client.post(reverse("recalculate-all")) - self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) - - def test_authed(self): - self.client.force_authenticate(self.admin_user) - response = self.client.post(reverse("recalculate-all")) - self.assertEqual(response.status_code, HTTP_200_OK) - - def test_authed_not_admin(self): - self.client.force_authenticate(self.user) - response = self.client.post(reverse("recalculate-all")) - self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - - def test_recalculate(self): - total = 0 - for i in range(15): - points = random.randint(0, 100) - total += points - Score(team=self.team, user=self.user, reason="test", points=points).save() - Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() - self.client.force_authenticate(self.admin_user) - self.client.post(reverse("recalculate-all")) - self.assertEqual(Team.objects.get(id=self.team.id).points, total + 100) - self.assertEqual(get_user_model().objects.get(id=self.user.id).points, total + 100) - - def test_recalculate_leaderboard(self): - total = 0 - for i in range(15): - points = random.randint(0, 100) - total += points - Score(team=self.team, user=self.user, reason="test", points=points).save() - Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() - self.client.force_authenticate(self.admin_user) - self.client.post(reverse("recalculate-all")) - self.assertEqual(Team.objects.get(id=self.team.id).leaderboard_points, total) - self.assertEqual(get_user_model().objects.get(id=self.user.id).leaderboard_points, total) diff --git a/src/scorerecalculator/urls.py b/src/scorerecalculator/urls.py deleted file mode 100644 index 0741be61..00000000 --- a/src/scorerecalculator/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from scorerecalculator import views - -urlpatterns = [ - path("team//", views.RecalculateTeamView.as_view(), name="recalculate-team"), - path("user//", views.RecalculateUserView.as_view(), name="recalculate-user"), - path("", views.RecalculateAllView.as_view(), name="recalculate-all"), -] diff --git a/src/scorerecalculator/views.py b/src/scorerecalculator/views.py deleted file mode 100644 index d3aed346..00000000 --- a/src/scorerecalculator/views.py +++ /dev/null @@ -1,62 +0,0 @@ -from django.contrib.auth import get_user_model -from django.db import transaction -from django.shortcuts import get_object_or_404 -from rest_framework.permissions import IsAdminUser -from rest_framework.views import APIView - -from backend.response import FormattedResponse -from challenge.models import Score -from team.models import Team - - -def recalculate_team(team): - team.points = 0 - team.leaderboard_points = 0 - for user_unsafe in team.members.all(): - with transaction.atomic(): - user = get_user_model().objects.select_for_update().get(id=user_unsafe.id) - recalculate_user(user) - team.points += user.points - team.leaderboard_points += user.leaderboard_points - team.save() - - -def recalculate_user(user): - user.points = 0 - user.leaderboard_points = 0 - scores = Score.objects.filter(user=user) - for score in scores: - if score.leaderboard: - user.leaderboard_points += score.points - score.penalty - user.points += score.points - score.penalty - user.save() - - -class RecalculateTeamView(APIView): - permission_classes = (IsAdminUser,) - - def post(self, request, id): - with transaction.atomic(): - team = get_object_or_404(Team.objects.select_for_update(), id=id) - recalculate_team(team) - return FormattedResponse() - - -class RecalculateUserView(APIView): - permission_classes = (IsAdminUser,) - - def post(self, request, id): - with transaction.atomic(): - user = get_object_or_404(get_user_model().objects.select_for_update(), id=id) - recalculate_user(user) - return FormattedResponse() - - -class RecalculateAllView(APIView): - permission_classes = (IsAdminUser,) - - def post(self, request): - with transaction.atomic(): - for team in Team.objects.select_for_update().all(): - recalculate_team(team) - return FormattedResponse() diff --git a/src/sockets/apps.py b/src/sockets/apps.py index 9d648930..62142015 100644 --- a/src/sockets/apps.py +++ b/src/sockets/apps.py @@ -1,10 +1,15 @@ +"""Configuration options and startup hooks for the sockets app.""" + from importlib import import_module from django.apps import AppConfig class SocketsConfig(AppConfig): + """App config for sockets.""" + name = "sockets" def ready(self): + """Load all signals when django is ready.""" import_module("sockets.signals", "sockets") diff --git a/src/sockets/consumers.py b/src/sockets/consumers.py index 40e14e8e..e7b19889 100644 --- a/src/sockets/consumers.py +++ b/src/sockets/consumers.py @@ -1,3 +1,5 @@ +"""Websocket consumers used by the sockets app.""" + import json import prometheus_client @@ -6,29 +8,35 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from authentication.models import Token -from backend.signals import websocket_connect, websocket_disconnect +from core.signals import websocket_connect, websocket_disconnect class EventConsumer(AsyncJsonWebsocketConsumer): + """The main websocket consumer for RACTF core.""" + groups = ["event"] async def connect(self): + """Handle a websocket connection.""" await self.accept() await self.channel_layer.group_add("event", self.channel_name) await self.send_json({"event_code": 0, "message": "Websocket connected."}) websocket_connect.send(self.__class__, channel_layer=self.channel_layer) async def disconnect(self, close_code): + """Handle a websocket disconnection.""" websocket_disconnect.send(self.__class__, channel_layer=self.channel_layer) @staticmethod def get_team(token): + """Return a user's team.""" if not Token.objects.filter(key=token).exists(): return None user = Token.objects.get(key=token).user return user.team async def receive(self, text_data=None, bytes_data=None, **kwargs): + """Handle an incoming websocket message.""" try: data = json.loads(text_data) except json.decoder.JSONDecodeError: @@ -36,7 +44,7 @@ async def receive(self, text_data=None, bytes_data=None, **kwargs): if "token" in data: team = await sync_to_async(self.get_team)(data["token"]) if team is not None: - await self.channel_layer.group_add(f"team.{team.id}", self.channel_name) + await self.channel_layer.group_add(f"team.{team.pk}", self.channel_name) class PrometheusConsumer(AsyncHttpConsumer): @@ -49,5 +57,5 @@ async def handle(self, body: str) -> None: await self.send_response( 200, latest, - headers=[(b'Content-Type', prometheus_client.CONTENT_TYPE_LATEST.encode())], + headers=[(b"Content-Type", prometheus_client.CONTENT_TYPE_LATEST.encode())], ) diff --git a/src/sockets/migrations/0001_initial.py b/src/sockets/migrations/0001_initial.py new file mode 100644 index 00000000..8a495687 --- /dev/null +++ b/src/sockets/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-06-10 08:17 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Announcement", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=255)), + ("body", models.TextField()), + ("timestamp", models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + ] diff --git a/src/announcements/models.py b/src/sockets/models.py similarity index 75% rename from src/announcements/models.py rename to src/sockets/models.py index 0808b631..a80102d1 100644 --- a/src/announcements/models.py +++ b/src/sockets/models.py @@ -1,9 +1,13 @@ +"""Database models for the sockets app.""" + from django.db import models from django.utils import timezone from django_prometheus.models import ExportModelOperationsMixin class Announcement(ExportModelOperationsMixin("announcement"), models.Model): + """Represents an announcement show to users on the frontend.""" + title = models.CharField(max_length=255) body = models.TextField() timestamp = models.DateTimeField(default=timezone.now) diff --git a/src/sockets/routing.py b/src/sockets/routing.py index ef59625b..99a95eb9 100644 --- a/src/sockets/routing.py +++ b/src/sockets/routing.py @@ -1,10 +1,11 @@ +"""Routing for the sockets app, main class for running websockets.""" + from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from django.urls import path, re_path from sockets import consumers - application = ProtocolTypeRouter( { "http": AuthMiddlewareStack( @@ -22,6 +23,6 @@ re_path(r"^api/v2/ws/$", consumers.EventConsumer.as_asgi()), ] ) - ) + ), } ) diff --git a/src/announcements/serializers.py b/src/sockets/serializers.py similarity index 54% rename from src/announcements/serializers.py rename to src/sockets/serializers.py index ec938bcd..b07e42da 100644 --- a/src/announcements/serializers.py +++ b/src/sockets/serializers.py @@ -1,9 +1,15 @@ +"""Serializers used for the sockets app.""" + from rest_framework.serializers import ModelSerializer -from announcements.models import Announcement +from sockets.models import Announcement class AnnouncementSerializer(ModelSerializer): + """Serializer used for announcements.""" + class Meta: + """The fields to serialize.""" + model = Announcement fields = ["id", "body", "title", "timestamp"] diff --git a/src/sockets/signals.py b/src/sockets/signals.py index e11264df..2f1ae5a2 100644 --- a/src/sockets/signals.py +++ b/src/sockets/signals.py @@ -1,41 +1,51 @@ +"""Signal handlers for the sockets app.""" + from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.db.models.signals import post_save from django.dispatch import receiver -from announcements.models import Announcement -from announcements.serializers import AnnouncementSerializer -from backend.signals import flag_reject, flag_score, team_join, use_hint -from challenge.models import Challenge +from challenges.models import Challenge from config import config +from core.signals import flag_reject, flag_score, team_join, use_hint +from sockets.models import Announcement +from sockets.serializers import AnnouncementSerializer def get_team_channel(user): - return f"team.{user.team.id}" + """Return the channel key of a user's team.""" + return f"team.{user.team.pk}" def send(user, data): + """Send a websocket message to a specific user's team.""" channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)(get_team_channel(user), data) def broadcast(data): + """Send a websocket message to all users.""" channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)("event", data) @receiver(flag_score) def on_flag_score(user, team, challenge, flag, solve, **kwargs): + """Broadcast a flag being scored.""" + # TODO: Frontend depends on this being sent + # is there a way to either fix frontend or send this with less detail if solve broacast is off? + # if not config.get("enable_solve_broadcast"): + # return broadcast( { "type": "send_json", "event_code": 1, - "user": user.id, + "user": user.pk, "username": user.username, - "challenge_id": challenge.id, + "challenge_id": challenge.pk, "challenge_name": challenge.name, "challenge_score": solve.score.points, - "team": team.id, + "team": team.pk, "team_name": team.name, } ) @@ -43,16 +53,17 @@ def on_flag_score(user, team, challenge, flag, solve, **kwargs): @receiver(flag_reject) def on_flag_reject(user, team, challenge, flag, **kwargs): + """Tell a team about a flag being rejected.""" send( user, { "type": "send_json", "event_code": 2, - "user": user.id, + "user": user.pk, "username": user.username, - "challenge_id": challenge.id, + "challenge_id": challenge.pk, "challenge_name": challenge.name, - "team": team.id, + "team": team.pk, "team_name": team.name, }, ) @@ -60,14 +71,15 @@ def on_flag_reject(user, team, challenge, flag, **kwargs): @receiver(use_hint) def on_use_hint(user, team, hint, **kwargs): + """Tell a team about a hint being used.""" send( user, { "type": "send_json", "event_code": 3, - "user": user.id, + "user": user.pk, "username": user.username, - "team": team.id, + "team": team.pk, "team_name": team.name, "hint_name": hint.name, "hint_penalty": hint.penalty, @@ -79,14 +91,15 @@ def on_use_hint(user, team, hint, **kwargs): @receiver(team_join) def on_team_join(user, team, **kwargs): + """Tell a team about a new member.""" send( user, { "type": "send_json", "event_code": 4, - "user": user.id, + "user": user.pk, "username": user.username, - "team": team.id, + "team": team.pk, "team_name": team.name, }, ) @@ -94,6 +107,7 @@ def on_team_join(user, team, **kwargs): @receiver(post_save, sender=Announcement) def on_announcement_create(sender, instance, **kwargs): + """Broadcast a new announcement.""" data = AnnouncementSerializer(instance).data data["type"] = "send_json" data["event_code"] = 5 @@ -102,6 +116,7 @@ def on_announcement_create(sender, instance, **kwargs): @receiver(post_save, sender=Challenge) def on_challenge_edit(sender, instance, update_fields, **kwargs): + """Broadcast a challenge modification.""" if update_fields is not None and "first_blood" in update_fields: return broadcast({"type": "send_json", "event_code": 6, "challenge_id": instance.id}) diff --git a/src/announcements/urls.py b/src/sockets/urls.py similarity index 78% rename from src/announcements/urls.py rename to src/sockets/urls.py index f8739a64..efda280a 100644 --- a/src/announcements/urls.py +++ b/src/sockets/urls.py @@ -1,7 +1,9 @@ +"""URL routes for the sockets app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter -from announcements import views +from sockets import views router = DefaultRouter() router.register("", views.AnnouncementViewSet, basename="announcements") diff --git a/src/announcements/views.py b/src/sockets/views.py similarity index 54% rename from src/announcements/views.py rename to src/sockets/views.py index f2174bf8..36fbfbbc 100644 --- a/src/announcements/views.py +++ b/src/sockets/views.py @@ -1,11 +1,15 @@ +"""API endpoints for the sockets app.""" + from rest_framework.viewsets import ModelViewSet -from announcements.models import Announcement -from announcements.serializers import AnnouncementSerializer -from backend.permissions import AdminOrReadOnly +from core.permissions import AdminOrReadOnly +from sockets.models import Announcement +from sockets.serializers import AnnouncementSerializer class AnnouncementViewSet(ModelViewSet): + """Viewset for reading and managing announcements.""" + queryset = Announcement.objects.all() permission_classes = (AdminOrReadOnly,) throttle_scope = "announcement" diff --git a/src/stats/apps.py b/src/stats/apps.py index 3b2580c0..f6c470f2 100644 --- a/src/stats/apps.py +++ b/src/stats/apps.py @@ -1,26 +1,28 @@ +"""App for statistics api endpoints.""" import sys from importlib import import_module from django.apps import AppConfig -import challenge -import member -import team - class StatsConfig(AppConfig): + """App config for the stats app.""" + name = "stats" def ready(self): """Logic for adding extra prometheus statistics.""" - if "migrate" in sys.argv or "makemigrations" in sys.argv: # pragma: no cover # Don't run stats-related logic if we haven't migrated yet return signals = import_module("stats.signals", "stats") - Team, Solve, Member = team.models.Team, challenge.models.Solve, member.models.Member + challenges, teams = ( + import_module("challenges.models", "challenges"), + import_module("teams.models", "teams"), + ) + Team, Solve, Member = teams.Team, challenges.Solve, teams.Member signals.team_count.set(Team.objects.count()) signals.solve_count.set(Solve.objects.count()) diff --git a/src/stats/signals.py b/src/stats/signals.py index 137ac800..68515f36 100644 --- a/src/stats/signals.py +++ b/src/stats/signals.py @@ -1,18 +1,19 @@ +"""Signal handlers and prometheus gauges for the stats app.""" + from django.core.cache import cache from django.db.models.signals import post_delete from django.dispatch import receiver from prometheus_client import Gauge -from backend.signals import ( +from challenges.models import Solve +from core.signals import ( flag_score, register, team_create, websocket_connect, websocket_disconnect, ) -from challenge.models import Solve -from member.models import Member -from team.models import Team +from teams.models import Member, Team member_count = Gauge("member_count", "The number of members currently registered") team_count = Gauge("team_count", "The number of teams currently registered") @@ -38,26 +39,31 @@ @receiver(register) def on_member_create(sender, user, **kwargs): + """Increment the member_count gauge.""" cache.set("member_count", cache.get("member_count") + 1, timeout=None) @receiver(post_delete, sender=Member) def on_member_delete(sender, instance, **kwargs): + """Decrement the member_count gauge.""" cache.set("member_count", cache.get("member_count") - 1, timeout=None) @receiver(team_create) def on_team_create(sender, team, **kwargs): + """Increment the team_count gauge.""" cache.set("team_count", cache.get("team_count") + 1, timeout=None) @receiver(post_delete, sender=Team) def on_team_delete(sender, instance, **kwargs): + """Decrement the team_count gauge.""" cache.set("team_count", cache.get("team_count") - 1, timeout=None) @receiver(flag_score) def on_solve_create(sender, user, team, challenge, flag, solve, **kwargs): + """Increment the solve_count gauge, and if the solve is correct, the correct_solve_count gauge.""" cache.set("solve_count", cache.get("solve_count") + 1, timeout=None) if solve.correct: cache.set("correct_solve_count", cache.get("correct_solve_count") + 1, timeout=None) @@ -65,6 +71,7 @@ def on_solve_create(sender, user, team, challenge, flag, solve, **kwargs): @receiver(post_delete, sender=Solve) def on_solve_delete(sender, instance, **kwargs): + """Decrement the solve_count gauge, and if the solve is correct, the correct_solve_count gauge.""" cache.set("solve_count", cache.get("solve_count") - 1, timeout=None) if instance.correct: cache.set("correct_solve_count", cache.get("correct_solve_count") - 1, timeout=None) @@ -72,9 +79,11 @@ def on_solve_delete(sender, instance, **kwargs): @receiver(websocket_connect) def on_ws_connect(**kwargs): + """Increment the websocket connections gauge.""" connected_websocket_users.inc() @receiver(websocket_disconnect) def on_ws_disconnect(**kwargs): + """Decrement the websocket connections gauge.""" connected_websocket_users.dec() diff --git a/src/stats/tests.py b/src/stats/tests.py index c3a7f039..9d7a9090 100644 --- a/src/stats/tests.py +++ b/src/stats/tests.py @@ -1,20 +1,26 @@ +"""Tests for the stats app.""" + from django.contrib.auth import get_user_model from rest_framework.reverse import reverse from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN from rest_framework.test import APITestCase -from challenge.models import Category, Challenge, Solve +from challenges.models import Category, Challenge, Solve from config import config -from team.models import Team +from teams.models import Team, Member class CountdownTestCase(APITestCase): - def test_unauthed(self): + """Tests for the /stats/countdown/ api route.""" + + def test_unauthed(self) -> None: + """Test an unauthenticated user can GET the view.""" response = self.client.get(reverse("countdown")) self.assertEqual(response.status_code, HTTP_200_OK) - def test_authed(self): - user = get_user_model()(username="countdown-test", email="countdown-test@example.org") + def test_authed(self) -> None: + """Test an authenticated user can GET the view.""" + user = Member(username="countdown-test", email="countdown-test@example.org") user.save() self.client.force_authenticate(user) response = self.client.get(reverse("countdown")) @@ -22,19 +28,24 @@ def test_authed(self): class StatsTestCase(APITestCase): + """Tests for the /stats/stats/ api route.""" + def test_unauthed(self): + """Test an unauthenticated user can GET the view.""" response = self.client.get(reverse("stats")) self.assertEqual(response.status_code, HTTP_200_OK) def test_authed(self): - user = get_user_model()(username="stats-test", email="stats-test@example.org") + """Test an authenticated user can GET the view.""" + user = Member(username="stats-test", email="stats-test@example.org") user.save() self.client.force_authenticate(user) response = self.client.get(reverse("stats")) self.assertEqual(response.status_code, HTTP_200_OK) def test_team_average(self): - user = get_user_model()(username="stats-test", email="stats-test@example.org") + """Test the average team member calculation works.""" + user = Member(username="stats-test", email="stats-test@example.org") user.save() team = Team(name="stats-test", password="stats-test", owner=user) @@ -45,26 +56,32 @@ def test_team_average(self): class FullStatsTestCase(APITestCase): + """Tests the /stats/full/ endpoint.""" + def test_unauthed(self): + """Test an unauthenticated user cannot access the endpoint.""" response = self.client.get(reverse("full")) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_authed_non_privileged(self): - user = get_user_model()(username="stats-test", email="stats-test@example.org") + """Test an authenticated, but unprivileged, user cannot access the endpoint.""" + user = Member(username="stats-test", email="stats-test@example.org") user.save() self.client.force_authenticate(user) response = self.client.get(reverse("full")) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - def test_authed(self): - user = get_user_model()(username="stats-test", email="stats-test@example.org", is_superuser=True, is_staff=True) + def test_authed_privileged(self): + """Test an authenticated and privileged user can access the endpoint.""" + user = Member(username="stats-test", email="stats-test@example.org", is_superuser=True, is_staff=True) user.save() self.client.force_authenticate(user) response = self.client.get(reverse("full")) self.assertEqual(response.status_code, HTTP_200_OK) def test_team_point_distribution(self): - user = get_user_model()(username="stats-test", email="stats-test@example.org", is_superuser=True, is_staff=True) + """Test the team point distribution is correctly calculated.""" + user = Member(username="stats-test", email="stats-test@example.org", is_superuser=True, is_staff=True) user.save() team = Team(name="stats-test", password="stats-test", owner=user) @@ -83,7 +100,8 @@ def test_team_point_distribution(self): self.assertEqual(response.data["d"]["team_point_distribution"][5], 1) def test_challenge_data(self): - user = get_user_model()(username="stats-test", email="stats-test@example.org", is_superuser=True, is_staff=True) + """Test the solve statistics are correctly calculated.""" + user = Member(username="stats-test", email="stats-test@example.org", is_superuser=True, is_staff=True) user.save() category = Category.objects.create( @@ -108,17 +126,29 @@ def test_challenge_data(self): config.set("enable_caching", False) response = self.client.get(reverse("full")) config.set("enable_caching", True) - self.assertEqual(response.data["d"]["challenges"][chall.id]["incorrect"], 1) - self.assertEqual(response.data["d"]["challenges"][chall.id]["correct"], 1) + self.assertEqual(response.data["d"]["challenges"][chall.pk]["incorrect"], 1) + self.assertEqual(response.data["d"]["challenges"][chall.pk]["correct"], 1) class CommitTestCase(APITestCase): + """Tests the /stats/version/ endpoint.""" + def test_unauthed(self): + """Test an unauthenticated user cannot get the commit hash.""" response = self.client.get(reverse("version")) - self.assertEqual(response.status_code, HTTP_200_OK) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_authed(self): - user = get_user_model()(username="commit-test", email="commit-test@example.org") + """Test an authenticated but unprivileged user cannot get the commit hash.""" + user = Member(username="commit-test", email="commit-test@example.org") + user.save() + self.client.force_authenticate(user) + response = self.client.get(reverse("version")) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_authed_admin(self): + """Test a staff user can get the commit hash.""" + user = Member(username="commit-test2", email="commit-test2@example.org", is_staff=True) user.save() self.client.force_authenticate(user) response = self.client.get(reverse("version")) @@ -126,19 +156,24 @@ def test_authed(self): class PrometheusTestCase(APITestCase): + """Tests the /stats/prometheus endpoint.""" + def test_unauthed(self): + """Test an unauthenticated user cannot access the endpoint.""" response = self.client.get(reverse("prometheus")) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_authed(self): - user = get_user_model()(username="prometheus-test", email="prometheus-test@example.org") + """Test an authenticated user cannot access the endpoint.""" + user = Member(username="prometheus-test", email="prometheus-test@example.org") user.save() self.client.force_authenticate(user) response = self.client.get(reverse("prometheus")) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_authed_admin(self): - user = get_user_model()( + """Test an authenticated admin user can access the endpoint.""" + user = Member( username="prometheus-test-admin", email="prometheus-test-admin@example.org", is_staff=True, diff --git a/src/stats/urls.py b/src/stats/urls.py index 5e103fda..f44d7db3 100644 --- a/src/stats/urls.py +++ b/src/stats/urls.py @@ -1,3 +1,5 @@ +"""URL Routes for the stats app.""" + from django.urls import path from stats import views diff --git a/src/stats/views.py b/src/stats/views.py index a2c064b0..04bb5097 100644 --- a/src/stats/views.py +++ b/src/stats/views.py @@ -1,3 +1,5 @@ +"""Views for the stats app.""" + import os from datetime import datetime, timezone @@ -9,17 +11,17 @@ from rest_framework.permissions import IsAdminUser from rest_framework.views import APIView -from backend.response import FormattedResponse -from challenge.models import Score -from challenge.sql import get_incorrect_solve_counts, get_solve_counts +from challenges.models import Score +from challenges.sql import get_incorrect_solve_counts, get_solve_counts from config import config -from member.models import UserIP +from core.response import FormattedResponse from stats.signals import correct_solve_count, member_count, solve_count, team_count -from team.models import Team +from teams.models import Team, UserIP, Member @api_view(["GET"]) def countdown(request): + """View to show the countdown for when registration opens, the event starts, and the event ends.""" return FormattedResponse( { "countdown_timestamp": config.get("start_time"), @@ -32,7 +34,8 @@ def countdown(request): @api_view(["GET"]) def stats(request): - users = get_user_model().objects.count() + """View to display some stats about the event.""" + users = Member.objects.count() teams = Team.objects.count() if users > 0 and teams > 0: average = users / teams @@ -56,6 +59,7 @@ def stats(request): @api_view(["GET"]) @permission_classes([IsAdminUser]) def full(request): + """View to display full statistics to event admins.""" challenge_data = {} correct_solve_counts = get_solve_counts() incorrect_solve_counts = get_incorrect_solve_counts() @@ -76,8 +80,8 @@ def full(request): return FormattedResponse( { "users": { - "all": get_user_model().objects.count(), - "confirmed": get_user_model().objects.filter(email_verified=True).count(), + "all": Member.objects.count(), + "confirmed": Member.objects.filter(email_verified=True).count(), }, "teams": Team.objects.count(), "ips": UserIP.objects.count(), @@ -89,14 +93,19 @@ def full(request): @api_view(["GET"]) +@permission_classes((IsAdminUser,)) def version(request): + """Get the current commit hash.""" return FormattedResponse({"commit_hash": os.popen("git rev-parse HEAD").read().strip()}) class PrometheusMetricsView(APIView): + """API endpoints related to prometheus.""" + permission_classes = [IsAdminUser] def get(self, request, format=None): + """Get displayable statistics.""" member_count.set(cache.get("member_count")) team_count.set(cache.get("team_count")) solve_count.set(cache.get("solve_count")) diff --git a/src/team/__init__.py b/src/team/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/team/apps.py b/src/team/apps.py deleted file mode 100644 index 4a56f252..00000000 --- a/src/team/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class TeamConfig(AppConfig): - name = "team" diff --git a/src/team/migrations/0001_initial.py b/src/team/migrations/0001_initial.py deleted file mode 100644 index a79b9286..00000000 --- a/src/team/migrations/0001_initial.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 3.0.5 on 2020-08-08 13:37 - -import django.contrib.postgres.fields.citext -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - -import backend.validators - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Team', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', django.contrib.postgres.fields.citext.CICharField(max_length=36, unique=True, validators=[backend.validators.NameValidator()])), - ('is_visible', models.BooleanField(default=True)), - ('password', models.CharField(max_length=64)), - ('description', models.TextField(blank=True, max_length=400)), - ('points', models.IntegerField(default=0)), - ('leaderboard_points', models.IntegerField(default=0)), - ('last_score', models.DateTimeField(default=django.utils.timezone.now)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_team', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/team/migrations/__init__.py b/src/team/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/team/models.py b/src/team/models.py deleted file mode 100644 index f35796de..00000000 --- a/src/team/models.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.contrib.postgres.fields import CICharField -from django.db import models -from django.db.models import CASCADE, Prefetch -from django.utils import timezone -from django_prometheus.models import ExportModelOperationsMixin - -from backend.validators import printable_name -from challenge.models import Solve -from member.models import Member - - -class TeamQuerySet(models.QuerySet): - """Custom QuerySet for common operations used to filter Teams.""" - - def visible(self) -> "models.QuerySet[Team]": - """Return a QuerySet of teams that are visible.""" - return self.filter(is_visible=True) - - def ranked(self) -> "models.QuerySet[Team]": - """ - Return a QuerySet of teams ordered how they should be displayed on frontend. - - First by points, then by how long they've been at that amount of points. - """ - return self.order_by("-leaderboard_points", "last_score") - - def prefetch_solves(self) -> "models.QuerySet[Team]": - """Prefetch this team's correct solves.""" - return self.prefetch_related(Prefetch("solves", queryset=Solve.objects.filter(correct=True))) - - -class Team(ExportModelOperationsMixin("team"), models.Model): - """Represents a team of one or more Members.""" - - name = CICharField(max_length=36, unique=True, validators=[printable_name]) - is_visible = models.BooleanField(default=True) - password = models.CharField(max_length=64) - owner = models.ForeignKey(Member, on_delete=CASCADE, related_name="owned_team") - description = models.TextField(blank=True, max_length=400) - points = models.IntegerField(default=0) - leaderboard_points = models.IntegerField(default=0) - last_score = models.DateTimeField(default=timezone.now) - size_limit_exempt = models.BooleanField(default=False) - - objects = TeamQuerySet.as_manager() diff --git a/src/team/views.py b/src/team/views.py deleted file mode 100644 index 6a4f4bfc..00000000 --- a/src/team/views.py +++ /dev/null @@ -1,163 +0,0 @@ -from django.db.models import Count -from django.http import Http404 -from rest_framework import filters -from rest_framework.generics import ( - CreateAPIView, - RetrieveUpdateAPIView, - get_object_or_404, -) -from rest_framework.permissions import IsAuthenticated -from rest_framework.status import ( - HTTP_400_BAD_REQUEST, - HTTP_403_FORBIDDEN, - HTTP_404_NOT_FOUND, -) -from rest_framework.views import APIView - -from backend.exceptions import FormattedException -from backend.permissions import AdminOrReadOnlyVisible, ReadOnlyBot -from backend.response import FormattedResponse -from backend.signals import team_join, team_join_attempt, team_join_reject -from backend.viewsets import AdminListModelViewSet -from challenge.models import Solve -from config import config -from member.models import Member -from team.models import Team -from team.permissions import HasTeam, IsTeamOwnerOrReadOnly, TeamsEnabled -from team.serializers import ( - AdminTeamSerializer, - CreateTeamSerializer, - ListTeamSerializer, - SelfTeamSerializer, - TeamSerializer, -) - - -class SelfView(RetrieveUpdateAPIView): - serializer_class = SelfTeamSerializer - permission_classes = (IsAuthenticated & IsTeamOwnerOrReadOnly & ReadOnlyBot,) - throttle_scope = "self" - pagination_class = None - - def get_object(self): - if self.request.user.team is None: - raise Http404() - return ( - Team.objects.order_by("id") - .prefetch_related( - "solves", - "members", - "hints_used", - "solves__challenge", - "solves__score", - "solves__solved_by", - ) - .get(id=self.request.user.team.id) - ) - - -class TeamViewSet(AdminListModelViewSet): - permission_classes = (AdminOrReadOnlyVisible,) - throttle_scope = "team" - serializer_class = TeamSerializer - admin_serializer_class = AdminTeamSerializer - list_serializer_class = ListTeamSerializer - list_admin_serializer_class = ListTeamSerializer - search_fields = ["name"] - ordering_fields = ["name", "members_count"] - filter_backends = [filters.SearchFilter, filters.OrderingFilter] - - def get_queryset(self): - if self.action == "list": - if self.request.user.is_staff: - qs = Team.objects.order_by("id").prefetch_related("members") - else: - qs = Team.objects.filter(is_visible=True).order_by("id").prefetch_related("members") - elif self.request.user.is_staff and not self.request.user.should_deny_admin(): - qs = Team.objects.order_by("id").prefetch_related( - "solves", - "members", - "hints_used", - "solves__challenge", - "solves__score", - "solves__solved_by", - ) - else: - qs = ( - Team.objects.filter(is_visible=True) - .order_by("id") - .prefetch_related( - "solves", - "members", - "hints_used", - "solves__challenge", - "solves__score", - "solves__solved_by", - ) - ) - return qs.annotate(members_count=Count("members")) - - -class CreateTeamView(CreateAPIView): - serializer_class = CreateTeamSerializer - model = Team - permission_classes = (IsAuthenticated,) - throttle_scope = "team_create" - - def post(self, request, *args, **kwargs): - if request.user.team is not None: - return FormattedResponse(m="already_in_team", status=HTTP_403_FORBIDDEN) - return super(CreateTeamView, self).post(request, *args, **kwargs) - - -class JoinTeamView(APIView): - permission_classes = (IsAuthenticated & TeamsEnabled,) - throttle_scope = "team_join" - - def post(self, request): - if not config.get("enable_team_join"): - return FormattedResponse(m="join_disabled", status=HTTP_403_FORBIDDEN) - if request.user.team is not None: - return FormattedResponse(m="already_in_team", status=HTTP_403_FORBIDDEN) - name = request.data.get("name") - password = request.data.get("password") - team_join_attempt.send(sender=self.__class__, user=request.user, name=name) - if name and password: - try: - team = get_object_or_404(Team, name=name) - if team.password != password: - team_join_reject.send(sender=self.__class__, user=request.user, name=name) - raise FormattedException(m="invalid_team_password", status=HTTP_403_FORBIDDEN) - except Http404: - team_join_reject.send(sender=self.__class__, user=request.user, name=name) - raise FormattedException(m="invalid_team", status=HTTP_404_NOT_FOUND) - team_size = int(config.get("team_size")) - if not request.user.is_staff and not team.size_limit_exempt and 0 < team_size <= team.members.count(): - return FormattedResponse(m="team_full", status=HTTP_403_FORBIDDEN) - request.user.team = team - request.user.save() - team_join.send(sender=self.__class__, user=request.user, team=team) - return FormattedResponse() - return FormattedResponse(m="joined_team", status=HTTP_400_BAD_REQUEST) - - -class LeaveTeamView(APIView): - """ - Remove the authenticated user from a team. - """ - - permission_classes = (IsAuthenticated & HasTeam & TeamsEnabled,) - - def post(self, request): - if not config.get("enable_team_leave"): - return FormattedResponse(m="leave_disabled", status=HTTP_403_FORBIDDEN) - if Solve.objects.filter(solved_by=request.user).exists(): - return FormattedResponse(m="challenge_solved", status=HTTP_403_FORBIDDEN) - if request.user.team.owner == request.user: - if Member.objects.filter(team=request.user.team).count() > 1: - return FormattedResponse(m="cannot_leave_team_ownerless", status=HTTP_403_FORBIDDEN) - else: - request.user.team.delete() - request.user.team = None - request.user.save() - return FormattedResponse() diff --git a/src/experiments/migrations/__init__.py b/src/teams/__init__.py similarity index 100% rename from src/experiments/migrations/__init__.py rename to src/teams/__init__.py diff --git a/src/teams/apps.py b/src/teams/apps.py new file mode 100644 index 00000000..13d7110c --- /dev/null +++ b/src/teams/apps.py @@ -0,0 +1,9 @@ +"""App for managing teams.""" + +from django.apps import AppConfig + + +class TeamsConfig(AppConfig): + """App config for the teams app.""" + + name = "teams" diff --git a/src/teams/migrations/0001_initial.py b/src/teams/migrations/0001_initial.py new file mode 100644 index 00000000..d276cafc --- /dev/null +++ b/src/teams/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.5 on 2020-08-08 13:37 + +import django.contrib.postgres.fields.citext +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import core.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Team", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", django.contrib.postgres.fields.citext.CICharField(max_length=36, unique=True, validators=[core.validators.NameValidator()])), + ("is_visible", models.BooleanField(default=True)), + ("password", models.CharField(max_length=64)), + ("description", models.TextField(blank=True, max_length=400)), + ("points", models.IntegerField(default=0)), + ("leaderboard_points", models.IntegerField(default=0)), + ("last_score", models.DateTimeField(default=django.utils.timezone.now)), + ("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="owned_team", to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/team/migrations/0002_auto_20200808_1649.py b/src/teams/migrations/0002_auto_20200808_1649.py similarity index 62% rename from src/team/migrations/0002_auto_20200808_1649.py rename to src/teams/migrations/0002_auto_20200808_1649.py index 8f7519e0..590675e4 100644 --- a/src/team/migrations/0002_auto_20200808_1649.py +++ b/src/teams/migrations/0002_auto_20200808_1649.py @@ -3,19 +3,19 @@ import django.contrib.postgres.fields.citext from django.db import migrations -import backend.validators +import core.validators class Migration(migrations.Migration): dependencies = [ - ('team', '0001_initial'), + ('teams', "0001_initial"), ] operations = [ migrations.AlterField( - model_name='team', - name='name', - field=django.contrib.postgres.fields.citext.CICharField(max_length=36, unique=True, validators=[backend.validators.printable_name]), + model_name='teams', + name="name", + field=django.contrib.postgres.fields.citext.CICharField(max_length=36, unique=True, validators=[core.validators.printable_name]), ), ] diff --git a/src/team/migrations/0003_team_size_limit_exempt.py b/src/teams/migrations/0003_team_size_limit_exempt.py similarity index 80% rename from src/team/migrations/0003_team_size_limit_exempt.py rename to src/teams/migrations/0003_team_size_limit_exempt.py index 4fa58ee4..b95da835 100644 --- a/src/team/migrations/0003_team_size_limit_exempt.py +++ b/src/teams/migrations/0003_team_size_limit_exempt.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ('team', '0002_auto_20200808_1649'), + ('teams', '0002_auto_20200808_1649'), ] operations = [ migrations.AddField( - model_name='team', + model_name='teams', name='size_limit_exempt', field=models.BooleanField(default=False), ), diff --git a/src/member/__init__.py b/src/teams/migrations/__init__.py similarity index 100% rename from src/member/__init__.py rename to src/teams/migrations/__init__.py diff --git a/src/teams/models.py b/src/teams/models.py new file mode 100644 index 00000000..082304ee --- /dev/null +++ b/src/teams/models.py @@ -0,0 +1,177 @@ +"""Database models and querysets for the team app.""" + +import secrets +import time +from enum import IntEnum + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.fields import CICharField +from django.db import models, transaction +from django.db.models import SET_NULL +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_prometheus.models import ExportModelOperationsMixin + +from authentication.models import Token, TOTPDevice +from challenges.models import Challenge, Solve +from config import config +from core.validators import printable_name + + +class TeamQuerySet(models.QuerySet): + """Custom QuerySet for common operations used to filter Teams.""" + + def visible(self) -> "models.QuerySet[Team]": + """Return a QuerySet of teams that are visible.""" + return self.filter(is_visible=True) + + def ranked(self) -> "models.QuerySet[Team]": + """Return a QuerySet of teams ordered by how they should be displayed.""" + return self.order_by("-leaderboard_points", "last_score") + + def prefetch_solves(self) -> "models.QuerySet[Team]": + """Prefetch this team's correct solves.""" + return self.prefetch_related(models.Prefetch("solves", queryset=Solve.objects.filter(correct=True))) + + +class Team(ExportModelOperationsMixin("team"), models.Model): + """Represents a team of one or more Members.""" + + name = CICharField(max_length=36, unique=True, validators=[printable_name]) + is_visible = models.BooleanField(default=True) + password = models.CharField(max_length=64) + owner = models.ForeignKey("teams.Member", on_delete=models.CASCADE, related_name="owned_team") + description = models.TextField(blank=True, max_length=400) + points = models.IntegerField(default=0) + leaderboard_points = models.IntegerField(default=0) + last_score = models.DateTimeField(default=timezone.now) + size_limit_exempt = models.BooleanField(default=False) + + objects = TeamQuerySet.as_manager() + + @property + def solved_challenges(self) -> "models.QuerySet[Challenge]": + """Return the list of challenges the team has solved.""" + return self.solves.filter(correct=True).values_list("challenge", flat=True) + + def recalculate_score(self): + """Recalculate the score for this team and all its users and implicity save.""" + self.points = 0 + self.leaderboard_points = 0 + for user_unsafe in self.members.all(): + with transaction.atomic(): + user = Member.objects.select_for_update().get(id=user_unsafe.pk) + user.recalculate_score() + self.points += user.points + self.leaderboard_points += user.leaderboard_points + self.save() + + +class TOTPStatus(IntEnum): + """The status of a user's totp device.""" + + DISABLED = 0 + VERIFYING = 1 + ENABLED = 2 + + +class Member(ExportModelOperationsMixin("member"), AbstractUser): + """Represents a member.""" + + username_validator = printable_name + + username = CICharField( + _("username"), + max_length=36, + unique=True, + help_text=_("Required. 36 characters or fewer. Letters, digits and @/./+/-/_ only."), + validators=[username_validator], + error_messages={"unique": _("A user with that username already exists.")}, + ) + email = models.EmailField(_("email address"), blank=True, unique=True) + state_actor = models.BooleanField(default=False) + is_visible = models.BooleanField(default=False) + is_bot = models.BooleanField(default=False) + is_verified = models.BooleanField(default=False) + bio = models.TextField(blank=True, max_length=400) + discord = models.CharField(blank=True, max_length=36) + discordid = models.CharField(blank=True, max_length=18) + twitter = models.CharField(blank=True, max_length=36) + reddit = models.CharField(blank=True, max_length=36) + team = models.ForeignKey("team.Team", on_delete=SET_NULL, null=True, related_name="members") + email_verified = models.BooleanField(default=False) + email_token = models.CharField(max_length=64, default=secrets.token_hex) + points = models.IntegerField(default=0) + leaderboard_points = models.IntegerField(default=0) + last_score = models.DateTimeField(default=timezone.now) + + totp_device: TOTPDevice + + def __str__(self) -> str: + """Represent a member as a string, returns the username.""" + return self.username + + @property + def has_totp_device(self) -> bool: + """Check if the user has a TOTP device.""" + return hasattr(self, "totp_device") and self.totp_device is not None + + @property + def can_login(self) -> bool: + """Check if the user can currently login.""" + return self.is_staff or ( + config.get("enable_login") and (config.get("enable_prelogin") or config.get("start_time") <= time.time()) + ) + + def has_2fa(self) -> bool: + """Check if the user has 2fa enabled.""" + return self.has_totp_device and self.totp_device.verified + + @property + def should_deny_admin(self) -> bool: + """Check if the user should be explicitly denied admin perms.""" + return config.get("enable_force_admin_2fa") and not self.has_2fa() + + def issue_token(self, owner=None) -> str: + """Issue an authentication token for the user.""" + token = Token(user=self, owner=owner) + token.save() + return token.key + + def recalculate_score(self) -> None: + """Recalculate the score for this user and implicity save.""" + self.points = 0 + self.leaderboard_points = 0 + for score in self.scores.all(): + if score.leaderboard: + self.leaderboard_points += score.points - score.penalty + self.points += score.points - score.penalty + self.save() + + +class UserIP(ExportModelOperationsMixin("user_ip"), models.Model): + """Represents the ip and useragent a given user accessed the api from.""" + + user = models.ForeignKey("teams.Member", on_delete=SET_NULL, null=True) + ip = models.CharField(max_length=255) + seen = models.IntegerField(default=1) + last_seen = models.DateTimeField(default=timezone.now) + user_agent = models.CharField(max_length=255) + + @staticmethod + def hook(request): + """Store the ip and useragent used to make a request in the db.""" + if not request.user.is_authenticated: + return + ip = request.headers.get("x-forwarded-for", "0.0.0.0") + user_agent = request.headers.get("user-agent", "???") + qs = UserIP.objects.filter(user=request.user, ip=ip) + if qs.exists(): + user_ip = qs.first() + user_ip.seen += 1 + user_ip.last_seen = timezone.now() + user_ip.user_agent = user_agent + user_ip.save() + else: + UserIP(user=request.user, ip=ip, user_agent=user_agent).save() diff --git a/src/team/permissions.py b/src/teams/permissions.py similarity index 56% rename from src/team/permissions.py rename to src/teams/permissions.py index 892c03cc..000a9cff 100644 --- a/src/team/permissions.py +++ b/src/teams/permissions.py @@ -1,18 +1,29 @@ +"""Permissions related to teams.""" + from rest_framework import permissions from config import config class IsTeamOwnerOrReadOnly(permissions.BasePermission): + """Only allow read only access unless the user is the team owner.""" + def has_permission(self, request, view): + """Return True if the user is the owner of the team or the method is safe.""" return request.method in permissions.SAFE_METHODS or request.user.team.owner == request.user class HasTeam(permissions.BasePermission): + """Only allow access if the user has a team.""" + def has_permission(self, request, view): + """Return True if the user has a team.""" return request.user.team is not None class TeamsEnabled(permissions.BasePermission): + """Only allow access if teams are enabled.""" + def has_permission(self, request, view): + """Return the value of the enable_teams config key.""" return config.get("enable_teams") diff --git a/src/teams/serializers/__init__.py b/src/teams/serializers/__init__.py new file mode 100644 index 00000000..db4c6ae1 --- /dev/null +++ b/src/teams/serializers/__init__.py @@ -0,0 +1,45 @@ +"""Serializers used for teams and their members.""" + +from rest_framework import serializers + +from teams.models import Member, Team + + +class MinimalTeamSerializer(serializers.ModelSerializer): + """Serializer used for listing teams.""" + + class Meta: + """The fields to serialize.""" + + model = Team + fields = ["id", "is_visible", "name", "owner", "description"] + + +class MinimalMemberSerializer(serializers.ModelSerializer): + """Serializer for members that includes minimal detail.""" + + team_name = serializers.ReadOnlyField(source="team.name") + + class Meta: + """The fields to serialize.""" + + model = Member + fields = [ + "id", + "username", + "is_staff", + "bio", + "discord", + "discordid", + "twitter", + "reddit", + "team", + "points", + "is_visible", + "is_active", + "team_name", + "leaderboard_points", + "state_actor", + "date_joined", + "is_verified", + ] diff --git a/src/member/serializers.py b/src/teams/serializers/member.py similarity index 77% rename from src/member/serializers.py rename to src/teams/serializers/member.py index 7f02746b..cadea88c 100644 --- a/src/member/serializers.py +++ b/src/teams/serializers/member.py @@ -1,21 +1,27 @@ +"""Serializers for the members app.""" + import secrets -from django.contrib.auth import get_user_model from rest_framework import serializers -from backend.mixins import IncorrectSolvesMixin -from challenge.serializers import SolveSerializer +from challenges.serializers import SolveSerializer from config import config -from member.models import UserIP +from core.mixins import IncorrectSolvesMixin +from teams.models import UserIP, Member +from teams.serializers import MinimalTeamSerializer class MemberSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): + """Serializer for Member objects.""" + solves = SolveSerializer(many=True, read_only=True) team_name = serializers.ReadOnlyField(source="team.name") incorrect_solves = serializers.SerializerMethodField() class Meta: - model = get_user_model() + """The fields of the member to serialize.""" + + model = Member fields = [ "id", "username", @@ -40,20 +46,28 @@ class Meta: class ListMemberSerializer(serializers.ModelSerializer): + """Serializer for listing Member objects.""" + team_name = serializers.ReadOnlyField(source="team.name") class Meta: - model = get_user_model() + """The fields of the member to serialize.""" + + model = Member fields = ["id", "username", "team", "team_name"] class AdminMemberSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): + """Serializer used by admins for Member objects.""" + solves = SolveSerializer(many=True, read_only=True) team_name = serializers.ReadOnlyField(source="team.name") incorrect_solves = serializers.SerializerMethodField() class Meta: - model = get_user_model() + """The fields of the member to serialize.""" + + model = Member fields = [ "id", "username", @@ -79,34 +93,8 @@ class Meta: ] -class MinimalMemberSerializer(serializers.ModelSerializer): - team_name = serializers.ReadOnlyField(source="team.name") - - class Meta: - model = get_user_model() - fields = [ - "id", - "username", - "is_staff", - "bio", - "discord", - "discordid", - "twitter", - "reddit", - "team", - "points", - "is_visible", - "is_active", - "team_name", - "leaderboard_points", - "state_actor", - "date_joined", - "is_verified", - ] - - class SelfSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): - from team.serializers import MinimalTeamSerializer + """Serializer used for serializing the current user.""" solves = SolveSerializer(many=True, read_only=True) team = MinimalTeamSerializer(read_only=True) @@ -116,7 +104,9 @@ class SelfSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): has_2fa = serializers.BooleanField() class Meta: - model = get_user_model() + """The fields to serialize, and which fields should be read only.""" + + model = Member fields = [ "id", "username", @@ -149,11 +139,13 @@ class Meta: ] def validate_email(self, value): + """Update email verification token when a user's email is updated.""" self.instance.email_token = secrets.token_hex() self.instance.save() return value def update(self, instance, validated_data): + """Update a user's team's name to match their username if teams are disabled.""" if not config.get("enable_teams"): if instance.team: instance.team.name = validated_data.get("username", instance.username) @@ -162,6 +154,10 @@ def update(self, instance, validated_data): class UserIPSerializer(serializers.ModelSerializer): + """Serializer for UserIP objects.""" + class Meta: + """The fields to serialize.""" + model = UserIP fields = ["user", "ip", "seen", "last_seen", "user_agent"] diff --git a/src/team/serializers.py b/src/teams/serializers/team.py similarity index 75% rename from src/team/serializers.py rename to src/teams/serializers/team.py index d68fe532..45d4fffb 100644 --- a/src/team/serializers.py +++ b/src/teams/serializers/team.py @@ -1,18 +1,24 @@ +"""Serializers for team related api endpoints.""" + from rest_framework import serializers -from backend.mixins import IncorrectSolvesMixin -from backend.signals import team_create -from challenge.serializers import SolveSerializer -from member.serializers import MinimalMemberSerializer -from team.models import Team +from challenges.serializers import SolveSerializer +from core.mixins import IncorrectSolvesMixin +from core.signals import team_create +from teams.models import Team +from teams.serializers import MinimalMemberSerializer class SelfTeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): + """Serializer used for the current user's team.""" + members = MinimalMemberSerializer(many=True, read_only=True) solves = SolveSerializer(many=True, read_only=True) incorrect_solves = serializers.SerializerMethodField() class Meta: + """The fields to serialize.""" + model = Team fields = [ "id", @@ -31,11 +37,15 @@ class Meta: class TeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): + """Serializer used for other users' teams.""" + members = MinimalMemberSerializer(many=True, read_only=True) solves = SolveSerializer(many=True, read_only=True) incorrect_solves = serializers.SerializerMethodField() class Meta: + """Which fields to serialize.""" + model = Team fields = [ "id", @@ -51,23 +61,32 @@ class Meta: ] def get_incorrect_solves(self, instance): + """Get the amount of incorrect solves this team has.""" return instance.solves.filter(correct=False).count() class ListTeamSerializer(serializers.ModelSerializer): + """Team serializer with minimal information.""" + members = serializers.IntegerField(read_only=True, source="members_count") class Meta: + """The fields to serialize.""" + model = Team fields = ["id", "name", "members"] class AdminTeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): + """Serializer for admins viewing/modifying teams.""" + members = MinimalMemberSerializer(many=True, read_only=True) solves = SolveSerializer(many=True, read_only=True) incorrect_solves = serializers.SerializerMethodField() class Meta: + """The fields to serialize.""" + model = Team fields = [ "id", @@ -84,18 +103,27 @@ class Meta: class MinimalTeamSerializer(serializers.ModelSerializer): + """Serializer used for listing teams.""" + class Meta: + """The fields to serialize.""" + model = Team fields = ["id", "is_visible", "name", "owner", "description"] class CreateTeamSerializer(serializers.ModelSerializer): + """Serializer used for team creation.""" + class Meta: + """The fields to serialize.""" + model = Team fields = ["id", "is_visible", "name", "owner", "password"] read_only_fields = ["id", "is_visible", "owner"] def create(self, validated_data): + """Create the team and set the owner.""" name = validated_data["name"] password = validated_data["password"] team = Team.objects.create(name=name, password=password, owner=self.context["request"].user) diff --git a/src/member/tests.py b/src/teams/tests/member/test_members.py similarity index 63% rename from src/member/tests.py rename to src/teams/tests/member/test_members.py index 608dc9e7..b411f70d 100644 --- a/src/member/tests.py +++ b/src/teams/tests/member/test_members.py @@ -1,4 +1,5 @@ -from django.contrib.auth import get_user_model +"""Tests for the member app.""" + from django.contrib.auth.models import AnonymousUser from django.http import HttpRequest from rest_framework.request import Request @@ -11,86 +12,104 @@ ) from rest_framework.test import APITestCase -from config import config -from member.models import UserIP -from team.models import Team +from core.tests.utils import patch_config +from teams.models import Team, UserIP, Member class MemberTestCase(APITestCase): + """Tests for the member-self api endpoint.""" + def setUp(self): - user = get_user_model()(username="test-self", email="test-self@example.org") + """Create a user for use in unit tests.""" + user = Member(username="test-self", email="test-self@example.org") user.save() self.user = user def test_str(self): - user = get_user_model()(username="test-str", email="test-str@example.org") + """Test the string representation of a user.""" + user = Member(username="test-str", email="test-str@example.org") self.assertEqual(str(user), user.username) def test_self_status(self): + """Test member-self can be accessed by an authenticated user.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("member-self")) self.assertEqual(response.status_code, HTTP_200_OK) def test_self_status_unauth(self): + """Test member-self cannot be accessed by an unauthenticated user.""" response = self.client.get(reverse("member-self")) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_self_change_email(self): + """Test email can be changed.""" self.client.force_authenticate(self.user) response = self.client.put( reverse("member-self"), data={"email": "test-self2@example.org", "username": "test-self"} ) self.assertEqual(response.status_code, HTTP_200_OK) - self.assertEqual(get_user_model().objects.get(id=self.user.id).email, "test-self2@example.org") + self.user.refresh_from_db() + self.assertEqual(self.user.email, "test-self2@example.org") def test_self_change_email_invalid(self): + """Test email cannot be changed to an invalid email.""" self.client.force_authenticate(self.user) response = self.client.put(reverse("member-self"), data={"email": "test-self"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) def test_self_change_email_token_change(self): + """Test changing email changes email verification token.""" ev_token = self.user.email_token self.client.force_authenticate(self.user) self.client.put(reverse("member-self"), data={"email": "test-self3@example.org"}) - user = get_user_model().objects.get(id=self.user.id) - self.assertNotEqual(ev_token, user.email_token) + self.user.refresh_from_db() + self.assertNotEqual(ev_token, self.user.email_token) def test_self_get_email(self): + """Test a user can get their email.""" self.client.force_authenticate(self.user) response = self.client.get(reverse("member-self")) self.assertEqual(response.data["email"], "test-self@example.org") - def test_self_change_username_teams_disabled(self): + @patch_config(enable_teams=False) + def test_self_change_username_teams_disabled(self, config_get): + """Test changing username changed the name of the users team when teams are disabled.""" self.client.force_authenticate(self.user) team = Team(name="team", password="123", owner=self.user) team.save() self.user.team = team self.user.save() - config.set("enable_teams", False) self.client.put(reverse("member-self"), data={"username": "test-self2", "email": "test-self@example.org"}) - config.set("enable_teams", True) - self.assertEqual(Team.objects.get(id=team.id).name, "test-self2") + team.refresh_from_db() + self.assertEqual(team.name, "test-self2") + self.assertTrue(config_get.called_once) - def test_self_change_username_no_team(self): + @patch_config(enable_teams=False) + def test_self_change_username_no_team(self, config_get): + """Test changing username with teams disabled changes the username.""" self.client.force_authenticate(self.user) - config.set("enable_teams", False) self.client.put(reverse("member-self"), data={"username": "test-self2", "email": "test-self@example.org"}) - config.set("enable_teams", True) - self.assertEqual(get_user_model().objects.get(id=self.user.id).username, "test-self2") + self.user.refresh_from_db() + self.assertEqual(self.user.username, "test-self2") + self.assertTrue(config_get.called_once) class MemberViewSetTestCase(APITestCase): + """Tests for MemberViewset.""" + def setUp(self): - user = get_user_model()(username="test-member", email="test-member@example.org") + """Create a test user and a test admin user.""" + user = Member(username="test-member", email="test-member@example.org") user.save() self.user = user - user = get_user_model()(username="test-admin", email="test-admin@example.org") + user = Member(username="test-admin", email="test-admin@example.org") user.is_staff = True user.save() self.admin_user = user def test_visible_admin(self): - user = get_user_model()(username="test-member-invisible", email="test-member-invisible@example.org") + """Test admins can see all users.""" + user = Member(username="test-member-invisible", email="test-member-invisible@example.org") user.is_visible = False user.save() self.client.force_authenticate(self.admin_user) @@ -98,7 +117,8 @@ def test_visible_admin(self): self.assertEqual(len(response.data["d"]["results"]), 3) def test_visible_not_admin(self): - user = get_user_model()(username="test-member-invisible", email="test-member-invisible@example.org") + """Test non admins can only see visible users.""" + user = Member(username="test-member-invisible", email="test-member-invisible@example.org") user.is_visible = False user.save() self.client.force_authenticate(self.user) @@ -106,66 +126,77 @@ def test_visible_not_admin(self): self.assertEqual(len(response.data["d"]["results"]), 0) def test_visible_detail_admin(self): - user = get_user_model()(username="test-member-invisible", email="test-member-invisible@example.org") + """Test admins can view details of a not visible user.""" + user = Member(username="test-member-invisible", email="test-member-invisible@example.org") user.is_visible = False user.save() self.client.force_authenticate(self.admin_user) - response = self.client.get(reverse("member-detail", kwargs={"pk": user.id})) + response = self.client.get(reverse("member-detail", kwargs={"pk": user.pk})) self.assertEqual(response.status_code, HTTP_200_OK) def test_visible_detail_not_admin(self): - user = get_user_model()(username="test-member-invisible", email="test-member-invisible@example.org") + """Test non admins cannot view details of a not visible user.""" + user = Member(username="test-member-invisible", email="test-member-invisible@example.org") user.is_visible = False user.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("member-detail", kwargs={"pk": user.id})) + response = self.client.get(reverse("member-detail", kwargs={"pk": user.pk})) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_view_email_admin(self): + """Test admins can view user emails.""" self.client.force_authenticate(self.admin_user) - response = self.client.get(reverse("member-detail", kwargs={"pk": self.user.id})) + response = self.client.get(reverse("member-detail", kwargs={"pk": self.user.pk})) self.assertTrue("email" in response.data) def test_view_email_not_admin(self): + """Test non-admins cant view user emails.""" self.client.force_authenticate(self.user) - response = self.client.get(reverse("member-detail", kwargs={"pk": self.admin_user.id})) + response = self.client.get(reverse("member-detail", kwargs={"pk": self.admin_user.pk})) self.assertFalse("email" in response.data) def test_view_member(self): + """Test users can view visible members.""" self.admin_user.is_visible = True self.admin_user.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("member-detail", kwargs={"pk": self.admin_user.id})) + response = self.client.get(reverse("member-detail", kwargs={"pk": self.admin_user.pk})) self.assertEqual(response.status_code, HTTP_200_OK) def test_patch_member(self): + """Test non-admin users cant patch other users.""" self.admin_user.is_visible = True self.admin_user.save() self.client.force_authenticate(self.user) response = self.client.patch( - reverse("member-detail", kwargs={"pk": self.admin_user.id}), + reverse("member-detail", kwargs={"pk": self.admin_user.pk}), data={"username": "test"}, ) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_patch_member_admin(self): + """Test admins can patch other users.""" self.client.force_authenticate(self.admin_user) response = self.client.patch( - reverse("member-detail", kwargs={"pk": self.user.id}), + reverse("member-detail", kwargs={"pk": self.user.pk}), data={"username": "test"}, ) self.assertEqual(response.status_code, HTTP_200_OK) class UserIPTest(APITestCase): + """Tests for the UserIP model.""" + def test_not_authenticated(self): + """Test unauthenticated users do not get logged.""" request = Request(HttpRequest()) request.user = AnonymousUser() self.assertNumQueries(0, lambda: UserIP.hook(request)) def test_first_sight(self): + """Test authenticated users get logged.""" request = Request(HttpRequest()) - user = get_user_model()(username="test-userip", email="test-userip@example.org") + user = Member(username="test-userip", email="test-userip@example.org") user.save() request.user = user request.META["x-forward-for"] = "1.1.1.1" @@ -174,8 +205,9 @@ def test_first_sight(self): self.assertEqual(UserIP.objects.get(user=user).seen, 1) def test_second_sight(self): + """Test the seen attribute is correctly incrememented.""" request = Request(HttpRequest()) - user = get_user_model()(username="test-userip2", email="test-userip2@example.org") + user = Member(username="test-userip2", email="test-userip2@example.org") user.save() request.user = user request.META["x-forward-for"] = "1.1.1.1" diff --git a/src/team/tests.py b/src/teams/tests/team/test_teams.py similarity index 54% rename from src/team/tests.py rename to src/teams/tests/team/test_teams.py index 48649ee5..31b99dd5 100644 --- a/src/team/tests.py +++ b/src/teams/tests/team/test_teams.py @@ -1,3 +1,6 @@ +"""Unit tests for the teams app.""" +import random + from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework.status import ( @@ -10,20 +13,23 @@ ) from rest_framework.test import APITestCase -from challenge.models import Category, Challenge, Solve +from challenges.models import Category, Challenge, Score, Solve from config import config -from team.models import Team +from teams.models import Team, Member class TeamSetupMixin: + """Mixin to add a setup method to team tests.""" + def setUp(self): - self.user = get_user_model()(username="team-test", email="team-test@example.org", is_visible=True) + """Create some users and teams for testing.""" + self.user = Member(username="team-test", email="team-test@example.org", is_visible=True) self.user.save() self.team = Team(name="team-test", password="abc", description="", owner=self.user, is_visible=True) self.team.save() self.user.team = self.team self.user.save() - self.admin_user = get_user_model()( + self.admin_user = Member( username="team-test-admin", email="team-test-admin@example.org", is_visible=True ) self.admin_user.is_staff = True @@ -32,32 +38,40 @@ def setUp(self): class TeamSelfTestCase(TeamSetupMixin, APITestCase): + """Tests for the team-self endpoint.""" + def test_team_self(self): + """Test an authenticated user with a team can view the endpoint.""" self.client.force_authenticate(user=self.user) response = self.client.get(reverse("team-self")) self.assertEqual(response.status_code, HTTP_200_OK) def test_team_password(self): + """Test the user can read the team password.""" self.client.force_authenticate(user=self.user) response = self.client.get(reverse("team-self")) self.assertEqual(response.data["password"], "abc") def test_no_team(self): + """Test the endpoint 404s if the user has no team.""" self.client.force_authenticate(user=self.admin_user) response = self.client.get(reverse("team-self")) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_not_authed(self): + """Test the endpoint cannot be accessed by unauthorized users.""" response = self.client.get(reverse("team-self")) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_update(self): + """Test the owner of a team can change the team name.""" self.client.force_authenticate(user=self.user) response = self.client.patch(reverse("team-self"), data={"name": "name-change"}) self.assertEqual(response.status_code, HTTP_200_OK) self.assertEqual(response.data["name"], "name-change") def test_update_not_owner(self): + """Test a user who isnt the owner of their team cannot change the team name.""" self.admin_user.team = self.team self.admin_user.is_staff = False self.admin_user.save() @@ -66,6 +80,7 @@ def test_update_not_owner(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_team_leave_disabled(self): + """Leaving a team should be blocked if enable_team_leave is False.""" self.client.force_authenticate(user=self.user) config.set("enable_team_leave", False) response = self.client.post(reverse("team-leave")) @@ -73,6 +88,7 @@ def test_team_leave_disabled(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_team_leave_challenge_solved(self): + """Leaving a team should be blocked if the team has solved a challenge.""" config.set("enable_team_leave", True) self.client.force_authenticate(user=self.user) @@ -97,6 +113,7 @@ def test_team_leave_challenge_solved(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_team_leave_as_owner_with_members(self): + """Leaving as owner should be blocked if the team has other members.""" self.client.force_authenticate(user=self.user) self.admin_user.team = self.team @@ -109,6 +126,7 @@ def test_team_leave_as_owner_with_members(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_team_leave_as_owner_without_members(self): + """Leaving as owner with noone else in the team should delete the team.""" self.client.force_authenticate(user=self.user) config.set("enable_team_leave", True) @@ -118,10 +136,9 @@ def test_team_leave_as_owner_without_members(self): def test_team_leave_as_mortal(self) -> None: """Leaving as non-owner should leave the team without deletion.""" - # We create new regular user, and authenticate the request as a normal # member of the team (a non-owner). - new_user = get_user_model()( + new_user = Member( username="team-test-2", email="team-test-2@example.org", is_visible=True, @@ -138,44 +155,56 @@ def test_team_leave_as_mortal(self) -> None: class CreateTeamTestCase(TeamSetupMixin, APITestCase): + """Tests for creating a team.""" + def test_create_team(self): + """A user without a team should be able to create a team.""" self.client.force_authenticate(self.admin_user) response = self.client.post(reverse("team-create"), data={"name": "test-team", "password": "test"}) self.assertEqual(response.status_code, HTTP_201_CREATED) def test_create_team_in_team(self): + """A user with a team should not be able to create a team.""" self.client.force_authenticate(self.user) response = self.client.post(reverse("team-create"), data={"name": "test-team", "password": "test"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_create_team_not_authed(self): + """An unauthenticated user should not be able to create a team.""" response = self.client.post(reverse("team-create"), data={"name": "test-team", "password": "test"}) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_create_duplicate_team(self): + """A team should not be able to be created with a name that already exists.""" self.client.force_authenticate(self.admin_user) response = self.client.post(reverse("team-create"), data={"name": "team-test", "password": "test"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) class JoinTeamTestCase(TeamSetupMixin, APITestCase): + """Tests for joining a team.""" + def test_join_team(self): + """A user without a team should be able to join a team with the correct name and password.""" self.client.force_authenticate(self.admin_user) response = self.client.post(reverse("team-join"), data={"name": "team-test", "password": "abc"}) self.assertEqual(response.status_code, HTTP_200_OK) def test_join_team_incorrect_password(self): + """A user without a team should not be able to join a team with the correct name and incorrect password.""" self.client.force_authenticate(self.admin_user) response = self.client.post(reverse("team-join"), data={"name": "team-test", "password": "incorrect_pass"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_join_team_incorrect_name(self): + """A user without a team should not be able to join a team with the incorrect name and correct password.""" self.client.force_authenticate(self.admin_user) response = self.client.post(reverse("team-join"), data={"name": "incorrect-team-test", "password": "abc"}) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_join_team_full(self): - user2 = get_user_model()(username="team-test2", email="team-test2@example.org", is_visible=True) + """A user without a team should not be able to join a full team.""" + user2 = Member(username="team-test2", email="team-test2@example.org", is_visible=True) user2.save() self.client.force_authenticate(self.admin_user) config.set("team_size", 1) @@ -185,6 +214,7 @@ def test_join_team_full(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_join_team_disabled(self): + """A user without a team should not be able to join a team when team join is disabled.""" self.client.force_authenticate(self.admin_user) config.set("enable_team_join", False) response = self.client.post(reverse("team-join"), data={"name": "team-test", "password": "abc"}) @@ -192,28 +222,35 @@ def test_join_team_disabled(self): self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_join_team_duplicate(self): + """A user should not be able to join a team twice.""" self.client.force_authenticate(self.admin_user) self.client.post(reverse("team-join"), data={"name": "team-test", "password": "abc"}) response = self.client.post(reverse("team-join"), data={"name": "team-test", "password": "abc"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_join_team_not_authed(self): + """An unauthenticated user should not be able to join a team.""" response = self.client.post(reverse("team-join"), data={"name": "team-test", "password": "abc"}) self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) def test_join_team_team_owner(self): + """A user should not be able to join a team they own.""" self.client.force_authenticate(self.user) response = self.client.post(reverse("team-join"), data={"name": "team-test", "password": "abc"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_join_team_malformed(self): + """A malformed team join request should be rejected.""" self.client.force_authenticate(self.admin_user) response = self.client.post(reverse("team-join"), data={"name": "team-test"}) self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) class TeamViewsetTestCase(TeamSetupMixin, APITestCase): + """Tests for TeamViewset.""" + def test_visible_admin(self): + """All teams should be visible to admins.""" self.team.is_visible = False self.team.save() self.client.force_authenticate(self.admin_user) @@ -221,6 +258,7 @@ def test_visible_admin(self): self.assertEqual(len(response.data["d"]["results"]), 1) def test_visible_not_admin(self): + """Only teams where is_visible=True should be visible to admins.""" self.team.is_visible = False self.team.save() self.client.force_authenticate(self.user) @@ -229,42 +267,183 @@ def test_visible_not_admin(self): self.assertEqual(len(response.data["d"]["results"]), 0) def test_visible_detail_admin(self): + """An admin should be able to view the details of a team where is_visible=False.""" self.team.is_visible = False self.team.save() self.client.force_authenticate(self.admin_user) - response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.id})) + response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.pk})) self.assertEqual(response.status_code, HTTP_200_OK) def test_visible_detail_not_admin(self): + """A non admin should not be able to view the details of a team where is_visible=False.""" self.team.is_visible = False self.team.save() self.client.force_authenticate(self.user) - response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.id})) + response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.pk})) self.assertEqual(response.status_code, HTTP_404_NOT_FOUND) def test_view_password_admin(self): + """An admin should be able to view the password of any team.""" self.client.force_authenticate(self.admin_user) - response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.id})) + response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.pk})) self.assertTrue("password" in response.data) def test_view_password_not_admin(self): + """A non admin should not be able to view the password of any team.""" self.admin_user.is_staff = False self.admin_user.save() self.client.force_authenticate(self.admin_user) - response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.id})) + response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.pk})) self.assertFalse("password" in response.data) def test_view_team(self): + """A non admin should be able to view the details of a team where is_visible=True.""" self.client.force_authenticate(self.user) - response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.id})) + response = self.client.get(reverse("team-detail", kwargs={"pk": self.team.pk})) self.assertEqual(response.status_code, HTTP_200_OK) def test_patch_team(self): + """A normal user modifying a team should be rejected.""" self.client.force_authenticate(self.user) - response = self.client.patch(reverse("team-detail", kwargs={"pk": self.team.id}), data={"name": "test"}) + response = self.client.patch(reverse("team-detail", kwargs={"pk": self.team.pk}), data={"name": "test"}) self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_patch_team_admin(self): + """An admin should be able to modify other teams.""" self.client.force_authenticate(self.admin_user) - response = self.client.patch(reverse("team-detail", kwargs={"pk": self.team.id}), data={"name": "test"}) + response = self.client.patch(reverse("team-detail", kwargs={"pk": self.team.pk}), data={"name": "test"}) self.assertEqual(response.status_code, HTTP_200_OK) + + +class RecalculateTeamViewTestCase(APITestCase): + """Tests for recalculating the score of a team.""" + + def setUp(self): + """Create users and teams for testing.""" + user = Member(username="recalculate-test", email="recalculate-test@example.org") + user.save() + admin_user = Member( + username="recalculate-test-admin", + email="recalculate-test-admin@example.org", + ) + admin_user.is_staff = True + admin_user.save() + team = Team(name="recalculate-team", owner=user, password="a") + team.save() + user.team = team + user.save() + self.user = user + self.admin_user = admin_user + self.team = team + + def test_unauthed(self): + """An unauthenticated user should not be able to access this endpoint.""" + response = self.client.post(reverse("team-recalculate-score", kwargs={"pk": self.team.pk})) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + + def test_authed(self): + """An authenticated admin user should be able to access this endpoint.""" + self.client.force_authenticate(self.admin_user) + response = self.client.post(reverse("team-recalculate-score", kwargs={"pk": self.team.pk})) + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_authed_not_admin(self): + """An authenticated non-admin user should not be able to access this endpoint.""" + self.client.force_authenticate(self.user) + response = self.client.post(reverse("team-recalculate-score", kwargs={"pk": self.team.pk})) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_recalculate(self): + """The recalculation should be equal to the sum of all the teams scores.""" + total = 0 + for i in range(15): + points = random.randint(0, 100) + total += points + Score(team=self.team, user=self.user, reason="test", points=points).save() + Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() + self.client.force_authenticate(self.admin_user) + self.client.post(reverse("team-recalculate-score", kwargs={"pk": self.team.pk})) + self.team.refresh_from_db() + self.assertEqual(self.team.points, total + 100) + + def test_recalculate_leaderboard(self): + """Score objects where leaderboard=False should not be included in leaderboard_points.""" + total = 0 + for i in range(15): + points = random.randint(0, 100) + total += points + Score(team=self.team, user=self.user, reason="test", points=points).save() + Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() + self.client.force_authenticate(self.admin_user) + self.client.post(reverse("team-recalculate-score", kwargs={"pk": self.team.pk})) + self.team.refresh_from_db() + self.assertEqual(self.team.leaderboard_points, total) + + +class RecalculateAllViewTestCase(APITestCase): + """Tests for recalculating the scores of every team.""" + + def setUp(self): + """Create users and teams for testing.""" + user = Member(username="recalculate-test", email="recalculate-test@example.org") + user.save() + admin_user = Member( + username="recalculate-test-admin", + email="recalculate-test-admin@example.org", + ) + admin_user.is_staff = True + admin_user.save() + team = Team(name="recalculate-team", owner=user, password="a") + team.save() + user.team = team + user.save() + self.user = user + self.admin_user = admin_user + self.team = team + + def test_unauthed(self): + """An unauthenticated user should not be able to access this endpoint.""" + response = self.client.post(reverse("team-recalculate-all-scores")) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + + def test_authed(self): + """An authenticated admin user should be able to access this endpoint.""" + self.client.force_authenticate(self.admin_user) + response = self.client.post(reverse("team-recalculate-all-scores")) + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_authed_not_admin(self): + """An authenticated non-admin user should not be able to access this endpoint.""" + self.client.force_authenticate(self.user) + response = self.client.post(reverse("team-recalculate-all-scores")) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_recalculate(self): + """The recalculation should be equal to the sum of all the teams scores.""" + total = 0 + for i in range(15): + points = random.randint(0, 100) + total += points + Score(team=self.team, user=self.user, reason="test", points=points).save() + Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() + self.client.force_authenticate(self.admin_user) + self.client.post(reverse("team-recalculate-all-scores")) + self.team.refresh_from_db() + self.user.refresh_from_db() + self.assertEqual(self.team.points, total + 100) + self.assertEqual(self.user.points, total + 100) + + def test_recalculate_leaderboard(self): + """Score objects where leaderboard=False should not be included in leaderboard_points.""" + total = 0 + for i in range(15): + points = random.randint(0, 100) + total += points + Score(team=self.team, user=self.user, reason="test", points=points).save() + Score(team=self.team, user=self.user, reason="test", points=100, leaderboard=False).save() + self.client.force_authenticate(self.admin_user) + self.client.post(reverse("team-recalculate-all-scores")) + self.team.refresh_from_db() + self.user.refresh_from_db() + self.assertEqual(self.team.leaderboard_points, total) + self.assertEqual(self.user.leaderboard_points, total) diff --git a/src/member/urls.py b/src/teams/urls/member.py similarity index 85% rename from src/member/urls.py rename to src/teams/urls/member.py index 8735872a..36fe61fd 100644 --- a/src/member/urls.py +++ b/src/teams/urls/member.py @@ -1,7 +1,9 @@ +"""URL routes for the member app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter -from member import views +from teams import views router = DefaultRouter() router.register(r"", views.MemberViewSet, basename="member") diff --git a/src/team/urls.py b/src/teams/urls/team.py similarity index 77% rename from src/team/urls.py rename to src/teams/urls/team.py index d27e23b9..f8b345db 100644 --- a/src/team/urls.py +++ b/src/teams/urls/team.py @@ -1,13 +1,15 @@ +"""URL routes for the team app.""" + from django.urls import include, path from rest_framework.routers import DefaultRouter -from team import views +from teams import views router = DefaultRouter() router.register(r"", views.TeamViewSet, basename="team") urlpatterns = [ - path("self/", views.SelfView.as_view(), name="team-self"), + path("self/", views.SelfTeamView.as_view(), name="team-self"), path("create/", views.CreateTeamView.as_view(), name="team-create"), path("join/", views.JoinTeamView.as_view(), name="team-join"), path("leave/", views.LeaveTeamView.as_view(), name="team-leave"), diff --git a/src/teams/views.py b/src/teams/views.py new file mode 100644 index 00000000..2572bc7c --- /dev/null +++ b/src/teams/views.py @@ -0,0 +1,248 @@ +"""API endpoints for managing teams.""" + +from django.http import Http404 +from rest_framework import filters, status +from rest_framework.decorators import action +from rest_framework.generics import ( + CreateAPIView, + RetrieveUpdateAPIView, + get_object_or_404, +) +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet + +import teams.serializers.team as team_serializers +import teams.serializers.member as member_serializers +from challenges.models import Solve +from config import config +from core.exceptions import FormattedException +from core.permissions import AdminOrReadOnlyVisible, ReadOnlyBot +from core.response import FormattedResponse +from core.signals import team_join, team_join_attempt, team_join_reject +from core.viewsets import AdminListModelViewSet +from teams import serializers +from teams.models import Member, Team, UserIP +from teams.permissions import HasTeam, IsTeamOwnerOrReadOnly, TeamsEnabled + + +class SelfTeamView(RetrieveUpdateAPIView): + """A view to get the details or modify the current user's team.""" + + serializer_class = team_serializers.SelfTeamSerializer + permission_classes = (IsAuthenticated & IsTeamOwnerOrReadOnly & ReadOnlyBot,) + throttle_scope = "self" + pagination_class = None + + def get_object(self): + """Get the current user's team or 404.""" + if self.request.user.team is None: + raise Http404() + return ( + Team.objects.order_by("id") + .prefetch_related( + "solves", + "members", + "hints_used", + "solves__challenge", + "solves__score", + "solves__solved_by", + ) + .get(id=self.request.user.team.pk) + ) + + +class TeamViewSet(AdminListModelViewSet): + """View and modify other teams.""" + + permission_classes = (AdminOrReadOnlyVisible,) + throttle_scope = "team" + serializer_class = team_serializers.TeamSerializer + admin_serializer_class = team_serializers.AdminTeamSerializer + list_serializer_class = team_serializers.ListTeamSerializer + list_admin_serializer_class = team_serializers.ListTeamSerializer + search_fields = ["name"] + filter_backends = [filters.SearchFilter] + + def get_queryset(self): + """Get the queryset containing the relevant team(s) and details.""" + if self.action == "list": + if self.request.user.is_staff: + return Team.objects.order_by("id").prefetch_related("members") + return Team.objects.filter(is_visible=True).order_by("id").prefetch_related("members") + if self.request.user.is_staff and not self.request.user.should_deny_admin: + return Team.objects.order_by("id").prefetch_related( + "solves", + "members", + "hints_used", + "solves__challenge", + "solves__score", + "solves__solved_by", + ) + return ( + Team.objects.filter(is_visible=True) + .order_by("id") + .prefetch_related( + "solves", + "members", + "hints_used", + "solves__challenge", + "solves__score", + "solves__solved_by", + ) + ) + + @action(methods=["POST"], detail=True, permission_classes=[IsAdminUser]) + def recalculate_score(self, request, pk=None): + """Recalculate the score of a team and its members.""" + team = self.get_object() + team.recalculate_score() + return FormattedResponse(d={"points": team.points, "leaderboard_points": team.leaderboard_points}) + + @action(methods=["POST"], detail=False, permission_classes=[IsAdminUser]) + def recalculate_all_scores(self, request): + """Recalculate the scores of every team and their members.""" + teams = self.get_queryset() + for team in teams: + team.recalculate_score() + return FormattedResponse() + + +class CreateTeamView(CreateAPIView): + """View for creating a team.""" + + serializer_class = team_serializers.CreateTeamSerializer + model = Team + permission_classes = (IsAuthenticated & ~HasTeam,) + throttle_scope = "team_create" + + +class JoinTeamView(APIView): + """Endpoint for the user joining a team.""" + + permission_classes = (IsAuthenticated & ~HasTeam & TeamsEnabled,) + throttle_scope = "team_join" + + def post(self, request): + """Check if the user can join a team, and add them to it.""" + if not config.get("enable_team_join"): + return FormattedResponse(m="join_disabled", status=status.HTTP_403_FORBIDDEN) + name = request.data.get("name") + password = request.data.get("password") + team_join_attempt.send(sender=self.__class__, user=request.user, name=name) + if name and password: + try: + team = get_object_or_404(Team, name=name) + if team.password != password: + team_join_reject.send(sender=self.__class__, user=request.user, name=name) + raise FormattedException(m="invalid_team_password", status=status.HTTP_403_FORBIDDEN) + except Http404: + team_join_reject.send(sender=self.__class__, user=request.user, name=name) + raise FormattedException(m="invalid_team", status=status.HTTP_404_NOT_FOUND) + team_size = int(config.get("team_size")) + if not request.user.is_staff and not team.size_limit_exempt and 0 < team_size <= team.members.count(): + return FormattedResponse(m="team_full", status=status.HTTP_403_FORBIDDEN) + request.user.team = team + request.user.save() + team_join.send(sender=self.__class__, user=request.user, team=team) + return FormattedResponse() + return FormattedResponse(m="joined_team", status=status.HTTP_400_BAD_REQUEST) + + +class LeaveTeamView(APIView): + """ + Remove the authenticated user from a team. + + If the user is the owner of the team, they will be blocked from leaving if the team is not empty, + else the team is deleted. + """ + + permission_classes = (IsAuthenticated & HasTeam & TeamsEnabled,) + + def post(self, request): + """Leave the team and return if it was successful.""" + if not config.get("enable_team_leave"): + return FormattedResponse(m="leave_disabled", status=status.HTTP_403_FORBIDDEN) + if Solve.objects.filter(solved_by=request.user).exists(): + return FormattedResponse(m="challenge_solved", status=status.HTTP_403_FORBIDDEN) + if request.user.team.owner == request.user: + if Member.objects.filter(team=request.user.team).count() > 1: + return FormattedResponse(m="cannot_leave_team_ownerless", status=status.HTTP_403_FORBIDDEN) + else: + request.user.team.delete() + request.user.team = None + request.user.save() + return FormattedResponse() + + +class SelfView(RetrieveUpdateAPIView): + """API endpoints for viewing and updating the current user.""" + + serializer_class = member_serializers.SelfSerializer + permission_classes = (IsAuthenticated & ReadOnlyBot,) + throttle_scope = "self" + + def get_object(self): + """Get the current member with some prefetches.""" + UserIP.hook(self.request) + return ( + Member.objects.prefetch_related( + "team", + "team__solves", + "team__solves__score", + "team__hints_used", + "team__solves__challenge", + "team__solves__solved_by", + "solves", + "solves__score", + "hints_used", + "solves__challenge", + "solves__team", + "solves__score__team", + ) + .distinct() + .get(id=self.request.user.pk) + ) + + +class MemberViewSet(AdminListModelViewSet): + """Viewset for viewing and updating members.""" + + permission_classes = (AdminOrReadOnlyVisible,) + throttle_scope = "member" + serializer_class = member_serializers.MemberSerializer + admin_serializer_class = member_serializers.AdminMemberSerializer + list_serializer_class = member_serializers.ListMemberSerializer + list_admin_serializer_class = member_serializers.ListMemberSerializer + search_fields = ["username", "email"] + filter_backends = [filters.SearchFilter] + + def get_queryset(self): + """Return the queryset for the member or list of members.""" + if self.action != "list": + return Member.objects.prefetch_related( + "team", + "team__solves", + "team__solves__score", + "team__hints_used", + "team__solves__challenge", + "team__solves__solved_by", + "solves", + "solves__score", + "hints_used", + "solves__challenge", + "solves__team", + "solves__score__team", + ) + if self.request.user.is_staff and not self.request.user.should_deny_admin: + return Member.objects.order_by("id").prefetch_related("team") + return Member.objects.filter(is_visible=True).order_by("id").prefetch_related("team") + + +class UserIPViewSet(ModelViewSet): + """Viewset for managing UserIP objects.""" + + queryset = UserIP.objects.all() + pagination_class = None + permission_classes = (IsAdminUser,) + serializer_class = member_serializers.UserIPSerializer