Rewrite shortener with django and a db instead of DNS
This commit is contained in:
parent
72eb378b43
commit
bb756e9339
38 changed files with 690 additions and 533 deletions
15
.envrc.dist
15
.envrc.dist
|
@ -1,12 +1,3 @@
|
|||
export FLASK_APP=shortener.main
|
||||
export FLASK_ENV=development
|
||||
export BASE_DOMAIN=g4b.ovh
|
||||
export PROVIDER=ovh
|
||||
export OVH_ENDPOINT=ovh-eu
|
||||
export OVH_APPLICATION_KEY=
|
||||
export OVH_APPLICATION_SECRET=
|
||||
export OVH_CONSUMER_KEY=
|
||||
export PORT=5000
|
||||
export REDIS_HOST=localhost
|
||||
export REDIS_PORT=6379
|
||||
export REDIS_DB=0
|
||||
export DEBUG=false
|
||||
export SECRET_KEY=changeme
|
||||
export ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
|
20
Dockerfile
20
Dockerfile
|
@ -35,21 +35,11 @@ ENV PYTHONPATH $PYTHONPATH:/app
|
|||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY shortener ./shortener/
|
||||
COPY contrib/run ./run
|
||||
COPY src ./src/
|
||||
COPY --from=git /version /app/.version
|
||||
|
||||
ENV FLASK_APP shortener.main
|
||||
ENV FLASK_ENV production
|
||||
ENV BASE_DOMAIN g4b.ovh
|
||||
ENV PROVIDER ovh
|
||||
ENV OVH_ENDPOINT ovh-eu
|
||||
ENV OVH_APPLICATION_KEY ""
|
||||
ENV OVH_APPLICATION_SECRET ""
|
||||
ENV OVH_CONSUMER_KEY ""
|
||||
ENV REDIS_HOST redis
|
||||
ENV REDIS_PORT 6379
|
||||
ENV REDIS_DB 0
|
||||
HEALTHCHECK --start-period=30s CMD python -c "import requests; requests.get('http://localhost:8000', timeout=2)"
|
||||
|
||||
HEALTHCHECK --start-period=30s CMD python -c "import requests; requests.get('http://localhost:5000', timeout=2)"
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "shortener.wsgi:app"]
|
||||
WORKDIR /app/src
|
||||
CMD ["/app/run"]
|
||||
|
|
6
contrib/run
Executable file
6
contrib/run
Executable file
|
@ -0,0 +1,6 @@
|
|||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
./manage.py migrate
|
||||
./manage.py collectstatic --clear --noinput
|
||||
gunicorn --bind 0.0.0.0:8000 shortener.wsgi:application
|
|
@ -5,9 +5,12 @@ services:
|
|||
image: rg.fr-par.scw.cloud/crocmagnon/shortener
|
||||
build: .
|
||||
env_file: shortener.env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:////db/db.sqlite3
|
||||
ports:
|
||||
- 5000:5000
|
||||
depends_on:
|
||||
- redis
|
||||
redis:
|
||||
image: redis:6
|
||||
- 8000:8000
|
||||
volumes:
|
||||
- shortener_data:/db
|
||||
|
||||
volumes:
|
||||
shortener_data:
|
||||
|
|
379
poetry.lock
generated
379
poetry.lock
generated
|
@ -6,11 +6,22 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.3.4"
|
||||
description = "ASGI specs, helper code, and adapters"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
||||
|
||||
[[package]]
|
||||
name = "atomicwrites"
|
||||
version = "1.4.0"
|
||||
description = "Atomic file writes."
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
|
@ -18,7 +29,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|||
name = "attrs"
|
||||
version = "21.2.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
|
@ -28,9 +39,17 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
|||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
|
||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.0.9"
|
||||
description = "Python bindings for the Brotli compression library"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2020.12.5"
|
||||
version = "2021.5.30"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
category = "main"
|
||||
optional = false
|
||||
|
@ -52,17 +71,6 @@ category = "main"
|
|||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.0.1"
|
||||
description = "Composable command line interface toolkit"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.4"
|
||||
|
@ -80,19 +88,29 @@ optional = false
|
|||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.1.0"
|
||||
description = "DNS toolkit"
|
||||
name = "django"
|
||||
version = "3.2.4"
|
||||
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
asgiref = ">=3.3.2,<4"
|
||||
pytz = "*"
|
||||
sqlparse = ">=0.2.2"
|
||||
|
||||
[package.extras]
|
||||
dnssec = ["cryptography (>=2.6)"]
|
||||
doh = ["requests", "requests-toolbelt"]
|
||||
idna = ["idna (>=2.1)"]
|
||||
curio = ["curio (>=1.2)", "sniffio (>=1.1)"]
|
||||
trio = ["trio (>=0.14.0)", "sniffio (>=1.1)"]
|
||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
||||
bcrypt = ["bcrypt"]
|
||||
|
||||
[[package]]
|
||||
name = "django-environ"
|
||||
version = "0.4.5"
|
||||
description = "Django-environ allows you to utilize 12factor inspired environment variables to configure your Django application."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
|
@ -102,24 +120,6 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "2.0.1"
|
||||
description = "A simple framework for building complex web applications."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.1.2"
|
||||
itsdangerous = ">=2.0"
|
||||
Jinja2 = ">=3.0"
|
||||
Werkzeug = ">=2.0"
|
||||
|
||||
[package.extras]
|
||||
async = ["asgiref (>=3.2)"]
|
||||
dotenv = ["python-dotenv"]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "20.1.0"
|
||||
|
@ -136,7 +136,7 @@ tornado = ["tornado (>=0.2)"]
|
|||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.2.6"
|
||||
version = "2.2.9"
|
||||
description = "File identification library for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
|
@ -154,42 +154,23 @@ optional = false
|
|||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.0.1"
|
||||
description = "Safely pass data to untrusted environments and back."
|
||||
name = "iniconfig"
|
||||
version = "1.1.1"
|
||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.0.1"
|
||||
description = "A very fast and expressive template engine."
|
||||
name = "model-bakery"
|
||||
version = "1.3.1"
|
||||
description = "Smart object creation facility for Django."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
MarkupSafe = ">=2.0"
|
||||
|
||||
[package.extras]
|
||||
i18n = ["Babel (>=2.7)"]
|
||||
|
||||
[[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]]
|
||||
name = "more-itertools"
|
||||
version = "8.8.0"
|
||||
description = "More routines for operating on iterables, beyond itertools"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
django = ">=2.2"
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
|
@ -199,23 +180,11 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "ovh"
|
||||
version = "0.5.0"
|
||||
description = "\"Official OVH.com API wrapper\""
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage (==3.7.1)", "mock (==1.0.1)", "nose (==1.3.3)", "yanc (==0.2.4)", "Sphinx (==1.2.2)", "coveralls (==0.4.4)", "setuptools (>=30.3.0)", "wheel"]
|
||||
test = ["coverage (==3.7.1)", "mock (==1.0.1)", "nose (==1.3.3)", "yanc (==0.2.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "20.9"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
|
@ -226,7 +195,7 @@ pyparsing = ">=2.0.2"
|
|||
name = "pluggy"
|
||||
version = "0.13.1"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
|
@ -253,7 +222,7 @@ virtualenv = ">=20.0.8"
|
|||
name = "py"
|
||||
version = "1.10.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
|
@ -261,31 +230,53 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|||
name = "pyparsing"
|
||||
version = "2.4.7"
|
||||
description = "Python parsing module"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "5.4.3"
|
||||
version = "6.2.4"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
||||
attrs = ">=19.2.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<1.0.0a1"
|
||||
py = ">=1.8.2"
|
||||
toml = "*"
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.4.0"
|
||||
description = "A Django plugin for pytest."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
||||
attrs = ">=17.4.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
more-itertools = ">=4.0.0"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<1.0"
|
||||
py = ">=1.5.0"
|
||||
wcwidth = "*"
|
||||
pytest = ">=5.4.0"
|
||||
|
||||
[package.extras]
|
||||
checkqa-mypy = ["mypy (==v0.761)"]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
|
||||
docs = ["sphinx", "sphinx-rtd-theme"]
|
||||
testing = ["django", "django-configurations (>=2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2021.1"
|
||||
description = "World timezone definitions, modern and historical"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
|
@ -295,17 +286,6 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
|
||||
|
||||
[[package]]
|
||||
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.*"
|
||||
|
||||
[package.extras]
|
||||
hiredis = ["hiredis (>=0.1.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.25.1"
|
||||
|
@ -332,11 +312,19 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.4.1"
|
||||
description = "A non-validating SQL parser."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
description = "Python Library for Tom's Obvious, Minimal Language"
|
||||
category = "dev"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
|
@ -372,34 +360,33 @@ docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sp
|
|||
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)"]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.5"
|
||||
description = "Measures the displayed width of unicode strings in a terminal"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "2.0.1"
|
||||
description = "The comprehensive WSGI web application library."
|
||||
name = "whitenoise"
|
||||
version = "5.2.0"
|
||||
description = "Radically simplified static file serving for WSGI applications"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.5, <4"
|
||||
|
||||
[package.dependencies]
|
||||
Brotli = {version = "*", optional = true, markers = "extra == \"brotli\""}
|
||||
|
||||
[package.extras]
|
||||
watchdog = ["watchdog"]
|
||||
brotli = ["brotli"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "524e8d0aa500e21953ad6bb630504c3455d1dadaf2b310b22b52a233496221a7"
|
||||
content-hash = "0e2349ff77825e1aefa5d3f709364571f9a745ebc7bfba5414b17432067660eb"
|
||||
|
||||
[metadata.files]
|
||||
appdirs = [
|
||||
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
|
||||
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
|
||||
]
|
||||
asgiref = [
|
||||
{file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
|
||||
{file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
|
||||
]
|
||||
atomicwrites = [
|
||||
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
|
||||
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
|
||||
|
@ -408,9 +395,43 @@ attrs = [
|
|||
{file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
|
||||
{file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
|
||||
]
|
||||
brotli = [
|
||||
{file = "Brotli-1.0.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70"},
|
||||
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b"},
|
||||
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6"},
|
||||
{file = "Brotli-1.0.9-cp27-cp27m-win32.whl", hash = "sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa"},
|
||||
{file = "Brotli-1.0.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452"},
|
||||
{file = "Brotli-1.0.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7"},
|
||||
{file = "Brotli-1.0.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4"},
|
||||
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296"},
|
||||
{file = "Brotli-1.0.9-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430"},
|
||||
{file = "Brotli-1.0.9-cp35-cp35m-win32.whl", hash = "sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1"},
|
||||
{file = "Brotli-1.0.9-cp35-cp35m-win_amd64.whl", hash = "sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea"},
|
||||
{file = "Brotli-1.0.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f"},
|
||||
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4"},
|
||||
{file = "Brotli-1.0.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a"},
|
||||
{file = "Brotli-1.0.9-cp36-cp36m-win32.whl", hash = "sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14"},
|
||||
{file = "Brotli-1.0.9-cp36-cp36m-win_amd64.whl", hash = "sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c"},
|
||||
{file = "Brotli-1.0.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126"},
|
||||
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d"},
|
||||
{file = "Brotli-1.0.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12"},
|
||||
{file = "Brotli-1.0.9-cp37-cp37m-win32.whl", hash = "sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"},
|
||||
{file = "Brotli-1.0.9-cp37-cp37m-win_amd64.whl", hash = "sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5"},
|
||||
{file = "Brotli-1.0.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8"},
|
||||
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb"},
|
||||
{file = "Brotli-1.0.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26"},
|
||||
{file = "Brotli-1.0.9-cp38-cp38-win32.whl", hash = "sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429"},
|
||||
{file = "Brotli-1.0.9-cp38-cp38-win_amd64.whl", hash = "sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f"},
|
||||
{file = "Brotli-1.0.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cb1e18167792d7d21e21365d7650b72d5081ed476123ff7b8cac7f45189c0c7"},
|
||||
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:16d528a45c2e1909c2798f27f7bf0a3feec1dc9e50948e738b961618e38b6a7b"},
|
||||
{file = "Brotli-1.0.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:56d027eace784738457437df7331965473f2c0da2c70e1a1f6fdbae5402e0389"},
|
||||
{file = "Brotli-1.0.9-cp39-cp39-win32.whl", hash = "sha256:cfc391f4429ee0a9370aa93d812a52e1fee0f37a81861f4fdd1f4fb28e8547c3"},
|
||||
{file = "Brotli-1.0.9-cp39-cp39-win_amd64.whl", hash = "sha256:854c33dad5ba0fbd6ab69185fec8dab89e13cda6b7d191ba111987df74f38761"},
|
||||
{file = "Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438"},
|
||||
]
|
||||
certifi = [
|
||||
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
|
||||
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
|
||||
{file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
|
||||
{file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
|
||||
]
|
||||
cfgv = [
|
||||
{file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"},
|
||||
|
@ -420,10 +441,6 @@ chardet = [
|
|||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
||||
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
|
||||
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
|
||||
]
|
||||
colorama = [
|
||||
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
||||
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
||||
|
@ -432,86 +449,42 @@ distlib = [
|
|||
{file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"},
|
||||
{file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"},
|
||||
]
|
||||
dnspython = [
|
||||
{file = "dnspython-2.1.0-py3-none-any.whl", hash = "sha256:95d12f6ef0317118d2a1a6fc49aac65ffec7eb8087474158f42f26a639135216"},
|
||||
{file = "dnspython-2.1.0.zip", hash = "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4"},
|
||||
django = [
|
||||
{file = "Django-3.2.4-py3-none-any.whl", hash = "sha256:ea735cbbbb3b2fba6d4da4784a0043d84c67c92f1fdf15ad6db69900e792c10f"},
|
||||
{file = "Django-3.2.4.tar.gz", hash = "sha256:66c9d8db8cc6fe938a28b7887c1596e42d522e27618562517cc8929eb7e7f296"},
|
||||
]
|
||||
django-environ = [
|
||||
{file = "django-environ-0.4.5.tar.gz", hash = "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde"},
|
||||
{file = "django_environ-0.4.5-py2.py3-none-any.whl", hash = "sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4"},
|
||||
]
|
||||
filelock = [
|
||||
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
|
||||
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
|
||||
]
|
||||
flask = [
|
||||
{file = "Flask-2.0.1-py3-none-any.whl", hash = "sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9"},
|
||||
{file = "Flask-2.0.1.tar.gz", hash = "sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55"},
|
||||
]
|
||||
gunicorn = [
|
||||
{file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
|
||||
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
|
||||
]
|
||||
identify = [
|
||||
{file = "identify-2.2.6-py2.py3-none-any.whl", hash = "sha256:1560bb645b93d5c05c3535c72a4f4884133006423d02c692ac6862a45eb0d521"},
|
||||
{file = "identify-2.2.6.tar.gz", hash = "sha256:01ebbc7af37043806216c7550539210cde4f82451983eb8735a02b3b9d013e40"},
|
||||
{file = "identify-2.2.9-py2.py3-none-any.whl", hash = "sha256:96c57d493184daecc7299acdeef0ad7771c18a59931ea927942df393688fe849"},
|
||||
{file = "identify-2.2.9.tar.gz", hash = "sha256:3a8493cf49cfe4b28d50865e38f942c11be07a7b0aab8e715073e17f145caacc"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||
]
|
||||
itsdangerous = [
|
||||
{file = "itsdangerous-2.0.1-py3-none-any.whl", hash = "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c"},
|
||||
{file = "itsdangerous-2.0.1.tar.gz", hash = "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"},
|
||||
iniconfig = [
|
||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
]
|
||||
jinja2 = [
|
||||
{file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"},
|
||||
{file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"},
|
||||
]
|
||||
markupsafe = [
|
||||
{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-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"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"},
|
||||
{file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"},
|
||||
{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-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_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-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"},
|
||||
{file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"},
|
||||
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"},
|
||||
{file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"},
|
||||
{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-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"},
|
||||
]
|
||||
more-itertools = [
|
||||
{file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"},
|
||||
{file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"},
|
||||
model-bakery = [
|
||||
{file = "model_bakery-1.3.1-py2.py3-none-any.whl", hash = "sha256:3bd63ac4135a6918f1b7bb3da31cd9c44283d99c37dc6315c13d061026d6ab2d"},
|
||||
{file = "model_bakery-1.3.1.tar.gz", hash = "sha256:713b2d32319e033b221714b5c3a3e1c88e0c40c84481b21955c44979801e5e03"},
|
||||
]
|
||||
nodeenv = [
|
||||
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
|
||||
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
|
||||
]
|
||||
ovh = [
|
||||
{file = "ovh-0.5.0-py2.py3-none-any.whl", hash = "sha256:03c7a5e7a62e7bc09b899f7692c694360be7db93ebe44428d6605fccda244692"},
|
||||
{file = "ovh-0.5.0.tar.gz", hash = "sha256:f74d190c4bff0953d76124cb8ed319a8a999138720e42957f0db481ef4746ae8"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
|
||||
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
|
||||
|
@ -533,8 +506,16 @@ pyparsing = [
|
|||
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||
]
|
||||
pytest = [
|
||||
{file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"},
|
||||
{file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"},
|
||||
{file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"},
|
||||
{file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
|
||||
]
|
||||
pytest-django = [
|
||||
{file = "pytest-django-4.4.0.tar.gz", hash = "sha256:b5171e3798bf7e3fc5ea7072fe87324db67a4dd9f1192b037fed4cc3c1b7f455"},
|
||||
{file = "pytest_django-4.4.0-py3-none-any.whl", hash = "sha256:65783e78382456528bd9d79a35843adde9e6a47347b20464eb2c885cb0f1f606"},
|
||||
]
|
||||
pytz = [
|
||||
{file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"},
|
||||
{file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"},
|
||||
]
|
||||
pyyaml = [
|
||||
{file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
|
||||
|
@ -559,10 +540,6 @@ pyyaml = [
|
|||
{file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
|
||||
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
|
||||
]
|
||||
redis = [
|
||||
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
|
||||
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
|
||||
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
|
||||
|
@ -571,6 +548,10 @@ six = [
|
|||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||
]
|
||||
sqlparse = [
|
||||
{file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"},
|
||||
{file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"},
|
||||
]
|
||||
toml = [
|
||||
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
|
||||
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
|
||||
|
@ -583,11 +564,7 @@ virtualenv = [
|
|||
{file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"},
|
||||
{file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"},
|
||||
]
|
||||
wcwidth = [
|
||||
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
|
||||
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
|
||||
]
|
||||
werkzeug = [
|
||||
{file = "Werkzeug-2.0.1-py3-none-any.whl", hash = "sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8"},
|
||||
{file = "Werkzeug-2.0.1.tar.gz", hash = "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42"},
|
||||
whitenoise = [
|
||||
{file = "whitenoise-5.2.0-py2.py3-none-any.whl", hash = "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d"},
|
||||
{file = "whitenoise-5.2.0.tar.gz", hash = "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7"},
|
||||
]
|
||||
|
|
|
@ -6,15 +6,16 @@ authors = ["Gabriel Augendre <gabriel@augendre.info>"]
|
|||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
dnspython = "^2.1.0"
|
||||
Flask = "^2.0.0"
|
||||
ovh = "^0.5.0"
|
||||
redis = "^3.5.3"
|
||||
requests = "^2.25.1"
|
||||
gunicorn = "^20.1.0"
|
||||
Django = "^3.2.4"
|
||||
model-bakery = "^1.3.1"
|
||||
pytest-django = "^4.4.0"
|
||||
django-environ = "^0.4.5"
|
||||
whitenoise = {extras = ["brotli"], version = "^5.2.0"}
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^5.2"
|
||||
pytest = "^6.2"
|
||||
pre-commit = "^2.13.0"
|
||||
|
||||
[build-system]
|
||||
|
@ -26,3 +27,20 @@ target-version = ['py38']
|
|||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--color=yes"
|
||||
minversion = "6.0"
|
||||
DJANGO_SETTINGS_MODULE = "shortener.settings"
|
||||
junit_family = "xunit1"
|
||||
norecursedirs = [
|
||||
".git",
|
||||
".pytest_cache",
|
||||
]
|
||||
testpaths = [
|
||||
"src",
|
||||
]
|
||||
python_files = [
|
||||
"test_*.py",
|
||||
"tests.py",
|
||||
]
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
__version__ = "0.1.0"
|
|
@ -1,6 +0,0 @@
|
|||
from shortener.data_structures import UserInput
|
||||
|
||||
|
||||
class BaseApiConnector:
|
||||
def add_record(self, user_input: UserInput) -> None:
|
||||
raise NotImplementedError("Subclasses must implement add_record")
|
|
@ -1,38 +0,0 @@
|
|||
import os
|
||||
|
||||
import ovh
|
||||
from ovh import HTTPError, InvalidResponse
|
||||
|
||||
from shortener.api_connectors.base_api_connector import BaseApiConnector
|
||||
from shortener.data_structures import UserInput
|
||||
from shortener.exceptions import ApiException
|
||||
|
||||
|
||||
class OvhApi(BaseApiConnector):
|
||||
def __init__(self):
|
||||
endpoint = os.environ["OVH_ENDPOINT"]
|
||||
application_key = os.environ["OVH_APPLICATION_KEY"]
|
||||
application_secret = os.environ["OVH_APPLICATION_SECRET"]
|
||||
consumer_key = os.environ["OVH_CONSUMER_KEY"]
|
||||
self._base_domain = os.environ["BASE_DOMAIN"]
|
||||
self._client = ovh.Client(
|
||||
endpoint=endpoint,
|
||||
application_key=application_key,
|
||||
application_secret=application_secret,
|
||||
consumer_key=consumer_key,
|
||||
)
|
||||
|
||||
def add_record(self, user_input: UserInput) -> None:
|
||||
try:
|
||||
self._add_record(user_input)
|
||||
except (HTTPError, InvalidResponse) as e:
|
||||
raise ApiException(message=str(e))
|
||||
|
||||
def _add_record(self, user_input: UserInput) -> None:
|
||||
self._client.post(
|
||||
f"/domain/zone/{self._base_domain}/record",
|
||||
fieldType="TXT",
|
||||
subDomain=user_input.short_code,
|
||||
target=user_input.url,
|
||||
)
|
||||
self._client.post(f"/domain/zone/{self._base_domain}/refresh")
|
|
@ -1,8 +0,0 @@
|
|||
import os
|
||||
|
||||
import redis
|
||||
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
|
||||
REDIS_DB = int(os.getenv("REDIS_DB", 0))
|
||||
cache = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB)
|
|
@ -1,13 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserInput:
|
||||
short_code: str
|
||||
url: str
|
||||
|
||||
@property
|
||||
def short_url(self):
|
||||
return f"{request.host_url}{self.short_code}"
|
|
@ -1,13 +0,0 @@
|
|||
class ApiException(Exception):
|
||||
def __init__(self, message="Unknown server error", code=500):
|
||||
self.message = message
|
||||
self.code = code
|
||||
|
||||
|
||||
class BadRequestException(ApiException):
|
||||
def __init__(self, message="Bad request", code=400):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class ShortCodeNotFoundException(Exception):
|
||||
pass
|
|
@ -1,79 +0,0 @@
|
|||
import os
|
||||
import re
|
||||
from typing import Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import request
|
||||
|
||||
from shortener.api_connectors.base_api_connector import BaseApiConnector
|
||||
from shortener.api_connectors.ovh.ovh_api import OvhApi
|
||||
from shortener.cache import cache
|
||||
from shortener.data_structures import UserInput
|
||||
from shortener.exceptions import BadRequestException, ShortCodeNotFoundException
|
||||
from shortener.resolver import resolver
|
||||
|
||||
SUPPORTED_PROVIDERS: Dict[str, type] = {"ovh": OvhApi}
|
||||
PROVIDER_NAME = os.environ["PROVIDER"].lower()
|
||||
PROVIDER_CLASS = SUPPORTED_PROVIDERS[PROVIDER_NAME]
|
||||
COOLDOWN = "cooldown"
|
||||
|
||||
|
||||
def handle_shorten():
|
||||
user_input: UserInput = _validate_user_input()
|
||||
cooldown = cache.get(user_input.short_code)
|
||||
if cooldown:
|
||||
raise BadRequestException(
|
||||
message=f"Please wait before submitting again for {user_input.short_code}."
|
||||
)
|
||||
provider: BaseApiConnector = _get_provider()
|
||||
provider.add_record(user_input)
|
||||
cache.set(user_input.short_code, COOLDOWN, ex=90)
|
||||
return {"status": "ok", "url": user_input.short_url}
|
||||
|
||||
|
||||
def _validate_user_input():
|
||||
user_input = request.json
|
||||
if not user_input:
|
||||
user_input = request.form
|
||||
url = user_input.get("url")
|
||||
if not url:
|
||||
raise BadRequestException(message="`url` is required")
|
||||
parsed_url = urlparse(url)
|
||||
if not all([parsed_url.scheme, parsed_url.netloc]):
|
||||
raise BadRequestException(
|
||||
message="`url` doesn't seem well formatted. "
|
||||
"Please include at least protocol and domain name. "
|
||||
"E.g. 'https://google.com' instead of 'google.com'."
|
||||
)
|
||||
|
||||
short_code = user_input.get("short_code")
|
||||
if not short_code:
|
||||
raise BadRequestException(message="`short_code` is required")
|
||||
short_code = str(short_code)
|
||||
if len(short_code) < 4:
|
||||
raise BadRequestException(
|
||||
message="`short_code` must be longer than 4 characters."
|
||||
)
|
||||
reg = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||||
if not reg.match(short_code):
|
||||
raise BadRequestException(
|
||||
message="`short_code` must only contain non accentuated letters, "
|
||||
"digits, dashes (-) and underscores (_)."
|
||||
)
|
||||
if _short_code_exists(short_code):
|
||||
raise BadRequestException(message=f"`{short_code}` is already taken")
|
||||
|
||||
user_input = UserInput(short_code=short_code, url=url)
|
||||
return user_input
|
||||
|
||||
|
||||
def _get_provider() -> BaseApiConnector:
|
||||
return PROVIDER_CLASS()
|
||||
|
||||
|
||||
def _short_code_exists(short_code):
|
||||
try:
|
||||
resolver.resolve_shortcode(short_code)
|
||||
return True
|
||||
except ShortCodeNotFoundException:
|
||||
return False
|
|
@ -1,43 +0,0 @@
|
|||
import os
|
||||
|
||||
from flask import Flask, redirect, render_template, request
|
||||
|
||||
from shortener.exceptions import ApiException, ShortCodeNotFoundException
|
||||
from shortener.handlers import handle_shorten
|
||||
from shortener.resolver import resolver
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return render_template("welcome.html")
|
||||
|
||||
|
||||
@app.route("/shorten", methods=["POST"])
|
||||
def shorten_url():
|
||||
try:
|
||||
result = handle_shorten()
|
||||
code = 200
|
||||
except ApiException as e:
|
||||
result = {"message": e.message, "status": "error"}
|
||||
code = e.code
|
||||
is_api = bool(request.json)
|
||||
if is_api:
|
||||
return result, code
|
||||
|
||||
return render_template("result.html", **result), code
|
||||
|
||||
|
||||
@app.route("/<string:shortcode>")
|
||||
def expand_url(shortcode: str):
|
||||
try:
|
||||
redirect_url = resolver.resolve_shortcode(shortcode)
|
||||
except ShortCodeNotFoundException:
|
||||
return render_template("not_found.html")
|
||||
return redirect(redirect_url, code=302)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
app.run(host="0.0.0.0", port=port)
|
|
@ -1,25 +0,0 @@
|
|||
import os
|
||||
|
||||
from dns.name import from_text
|
||||
from dns.resolver import Resolver
|
||||
|
||||
from shortener.exceptions import ShortCodeNotFoundException
|
||||
|
||||
BASE_DOMAIN = os.getenv("BASE_DOMAIN")
|
||||
|
||||
|
||||
class ShortenerResolver(Resolver):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.search = [from_text(BASE_DOMAIN + ".")]
|
||||
|
||||
def resolve_shortcode(self, shortcode):
|
||||
try:
|
||||
answer = self.resolve(shortcode, "TXT", search=True, lifetime=10)
|
||||
redirect_url = list(answer)[0].strings[0].decode("utf-8")
|
||||
return redirect_url
|
||||
except:
|
||||
raise ShortCodeNotFoundException()
|
||||
|
||||
|
||||
resolver = ShortenerResolver()
|
|
@ -1,11 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>URL shortener</title>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -1,7 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>URL not found</h1>
|
||||
<p>If you recently created the redirect, please wait a couple of minutes for DNS to propagate.</p>
|
||||
<p>If you've been sent this URL, please contact the sender. It's likely there's an issue with the URL you received.</p>
|
||||
{% endblock %}
|
|
@ -1,22 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Result</h1>
|
||||
<p class="status-{{ status }}">Status: {{ status }}</p>
|
||||
{% if message %}
|
||||
<p>{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if url %}
|
||||
<p>
|
||||
Your short URL is: <a href="{{ url }}">{{ url }}</a>.<br>
|
||||
Please wait a couple of minutes for DNS to propagate before testing and
|
||||
sharing this URL.
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a onclick="window.history.back(); return false;"
|
||||
href="{{ url_for("home") }}">
|
||||
Go back
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
|
@ -1,20 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Shorten urls</h1>
|
||||
<p>Welcome to the URL shortener.</p>
|
||||
<form action="{{ url_for("shorten_url") }}" method="post">
|
||||
<p>
|
||||
<label for="short_code">Short code</label>
|
||||
<input type="text" name="short_code" id="short_code">
|
||||
</p>
|
||||
<p>
|
||||
<label for="url">Url to shorten</label>
|
||||
<input type="text" name="url" id="url">
|
||||
</p>
|
||||
<p>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,4 +0,0 @@
|
|||
from shortener.main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
22
src/manage.py
Executable file
22
src/manage.py
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
14
src/redirect/admin.py
Normal file
14
src/redirect/admin.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from redirect.models import Redirect
|
||||
|
||||
from .models import RedirectUser
|
||||
|
||||
|
||||
@admin.register(Redirect)
|
||||
class RedirectAdmin(admin.ModelAdmin):
|
||||
list_display = ["short_code", "target_url"]
|
||||
|
||||
|
||||
admin.site.register(RedirectUser, UserAdmin)
|
6
src/redirect/apps.py
Normal file
6
src/redirect/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RedirectConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "redirect"
|
148
src/redirect/migrations/0001_initial.py
Normal file
148
src/redirect/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,148 @@
|
|||
# Generated by Django 3.2.4 on 2021-06-07 14:01
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Redirect",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("short_code", models.CharField(max_length=250, unique=True)),
|
||||
("target_url", models.URLField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RedirectUser",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
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",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
14
src/redirect/models.py
Normal file
14
src/redirect/models.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Redirect(models.Model):
|
||||
short_code = models.CharField(max_length=250, blank=False, null=False, unique=True)
|
||||
target_url = models.URLField()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.short_code} => {self.target_url}"
|
||||
|
||||
|
||||
class RedirectUser(AbstractUser):
|
||||
pass
|
16
src/redirect/tests/test_models.py
Normal file
16
src/redirect/tests/test_models.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from model_bakery import baker
|
||||
|
||||
|
||||
class RedirectModelTestCase(TestCase):
|
||||
def test_has_short_code(self):
|
||||
baker.make("Redirect", short_code="potain3")
|
||||
|
||||
def test_short_code_is_unique(self):
|
||||
baker.make("Redirect", short_code="potain3")
|
||||
with self.assertRaises(IntegrityError):
|
||||
baker.make("Redirect", short_code="potain3")
|
||||
|
||||
def test_has_target_url(self):
|
||||
baker.make("Redirect", target_url="https://static.augendre.info/potain3")
|
32
src/redirect/tests/test_views.py
Normal file
32
src/redirect/tests/test_views.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from redirect.models import Redirect
|
||||
|
||||
|
||||
class RedirectViewTestCase(TestCase):
|
||||
def test_redirect_to_target_url(self):
|
||||
short_code = "potain3"
|
||||
target_url = "https://static.augendre.info/potain3/"
|
||||
Redirect.objects.create(short_code=short_code, target_url=target_url)
|
||||
url = reverse("redirect", kwargs={"short_code": short_code})
|
||||
res = self.client.get(url)
|
||||
assert res.status_code == 302
|
||||
assert res.url == target_url
|
||||
|
||||
def test_non_existent_short_code(self):
|
||||
short_code = "potain3"
|
||||
target_url = "https://static.augendre.info/potain3/"
|
||||
Redirect.objects.create(short_code=short_code, target_url=target_url)
|
||||
url = reverse("redirect", kwargs={"short_code": short_code + "4"})
|
||||
res = self.client.get(url)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
class HomeViewTestCase(TestCase):
|
||||
def test_home_redirects_to_admin(self):
|
||||
url = reverse("home")
|
||||
res = self.client.get(url)
|
||||
assert res.status_code == 302
|
||||
assert res.url == reverse("admin:index")
|
8
src/redirect/urls.py
Normal file
8
src/redirect/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
from redirect.views import home, redirect_view
|
||||
|
||||
urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
path("<str:short_code>", redirect_view, name="redirect"),
|
||||
]
|
13
src/redirect/views.py
Normal file
13
src/redirect/views.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import redirect as django_redirect
|
||||
|
||||
from redirect.models import Redirect
|
||||
|
||||
|
||||
def redirect_view(request, short_code):
|
||||
redirect = get_object_or_404(Redirect, short_code=short_code)
|
||||
return django_redirect(redirect.target_url)
|
||||
|
||||
|
||||
def home(request):
|
||||
return django_redirect("admin:index")
|
0
src/shortener/__init__.py
Normal file
0
src/shortener/__init__.py
Normal file
16
src/shortener/asgi.py
Normal file
16
src/shortener/asgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for shortener project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
|
||||
|
||||
application = get_asgi_application()
|
140
src/shortener/settings.py
Normal file
140
src/shortener/settings.py
Normal file
|
@ -0,0 +1,140 @@
|
|||
"""
|
||||
Django settings for shortener project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 3.2.4.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/3.2/ref/settings/
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
import environ
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
env = environ.Env(
|
||||
DEBUG=(bool, False),
|
||||
SECRET_KEY=(
|
||||
str,
|
||||
"django-insecure-rk#sto!9js$j6=l+z=@g*(h8if!r(%#u(f(=k6n79d^md0b^=&",
|
||||
),
|
||||
ALLOWED_HOSTS=(list, []),
|
||||
)
|
||||
env_file = os.getenv("ENV_FILE")
|
||||
if env_file:
|
||||
environ.Env.read_env(env_file)
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env("DEBUG")
|
||||
|
||||
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"redirect",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "shortener.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "shortener.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {"default": env.db(default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}")}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "Europe/Paris"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
AUTH_USER_MODEL = "redirect.RedirectUser"
|
22
src/shortener/urls.py
Normal file
22
src/shortener/urls.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
"""shortener URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/3.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("redirect.urls")),
|
||||
]
|
16
src/shortener/wsgi.py
Normal file
16
src/shortener/wsgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for shortener project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shortener.settings")
|
||||
|
||||
application = get_wsgi_application()
|
|
@ -1,5 +0,0 @@
|
|||
from shortener import __version__
|
||||
|
||||
|
||||
def test_version():
|
||||
assert __version__ == "0.1.0"
|
Loading…
Reference in a new issue