mirror of
https://github.com/Crocmagnon/checkout.git
synced 2024-11-24 00:58:04 +01:00
Modernize app
This commit is contained in:
parent
37129d9405
commit
114ae437bc
40 changed files with 613 additions and 247 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -276,3 +276,4 @@ dmypy.json
|
||||||
staticfiles/
|
staticfiles/
|
||||||
media/
|
media/
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
.direnv
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
exclude: (\.min\.(js|css)(\.map)?$|/vendor/)
|
exclude: \.min\.(js|css)(\.map)?$|^\.idea/|/vendor/
|
||||||
|
ci:
|
||||||
|
skip: [pip-compile]
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.3.0
|
rev: v4.4.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-ast
|
- id: check-ast
|
||||||
- id: check-json
|
- id: check-json
|
||||||
|
@ -20,52 +23,57 @@ repos:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
args:
|
args:
|
||||||
- --markdown-linebreak-ext=md
|
- --markdown-linebreak-ext=md
|
||||||
- repo: https://github.com/timothycrosley/isort
|
- id: check-executables-have-shebangs
|
||||||
rev: 5.10.1
|
- id: check-shebang-scripts-are-executable
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 22.8.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
|
||||||
rev: v2.38.0
|
|
||||||
hooks:
|
|
||||||
- id: pyupgrade
|
|
||||||
args:
|
|
||||||
- --py310-plus
|
|
||||||
- repo: https://github.com/adamchainz/django-upgrade
|
- repo: https://github.com/adamchainz/django-upgrade
|
||||||
rev: 1.10.0
|
rev: 1.13.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: django-upgrade
|
- id: django-upgrade
|
||||||
args: [--target-version, "4.1"]
|
args: [--target-version, "4.1"]
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 23.1.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
args: [--target-version, py311]
|
||||||
- repo: https://github.com/rtts/djhtml
|
- repo: https://github.com/rtts/djhtml
|
||||||
rev: v1.5.2
|
rev: 3.0.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: djhtml
|
- id: djhtml
|
||||||
- repo: https://github.com/flakeheaven/flakeheaven
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
rev: 3.0.0
|
rev: v0.0.259
|
||||||
hooks:
|
hooks:
|
||||||
- id: flakeheaven
|
- id: ruff
|
||||||
additional_dependencies:
|
args: [--fix]
|
||||||
- flake8-annotations-complexity
|
|
||||||
- flake8-builtins
|
|
||||||
- flake8-bugbear
|
|
||||||
- flake8-comprehensions
|
|
||||||
- flake8-eradicate
|
|
||||||
- flake8-noqa
|
|
||||||
- flake8-pytest-style
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v3.0.0-alpha.0
|
rev: v3.0.0-alpha.6
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or: [javascript, css]
|
types_or: [javascript, css]
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||||
rev: v8.24.0
|
rev: v8.36.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- id: eslint
|
||||||
args: [--fix]
|
args: [--fix]
|
||||||
types_or: [javascript, css]
|
types_or: [javascript, css]
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- eslint@^7.29.0
|
- eslint@8.36.0
|
||||||
- eslint-config-prettier@^8.3.0
|
- eslint-config-prettier@8.5.0
|
||||||
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
|
rev: 0.9.2
|
||||||
|
hooks:
|
||||||
|
- id: pyproject-fmt
|
||||||
|
- repo: https://github.com/jazzband/pip-tools
|
||||||
|
rev: 6.12.3
|
||||||
|
hooks:
|
||||||
|
- id: pip-compile
|
||||||
|
name: pip-compile requirements.txt
|
||||||
|
args: [-q, --allow-unsafe, --resolver=backtracking, requirements.in]
|
||||||
|
files: ^requirements\.(in|txt)$
|
||||||
|
- id: pip-compile
|
||||||
|
name: pip-compile constraints.txt
|
||||||
|
args: [-q, --allow-unsafe, --resolver=backtracking, --strip-extras, --output-file=constraints.txt, requirements.in]
|
||||||
|
files: ^requirements\.in|constraints\.txt$
|
||||||
|
- id: pip-compile
|
||||||
|
name: pip-compile requirements-dev.txt
|
||||||
|
args: [-q, --allow-unsafe, --resolver=backtracking, requirements-dev.in]
|
||||||
|
files: ^requirements-dev\.(in|txt)$
|
||||||
|
|
1
.tool-versions
Normal file
1
.tool-versions
Normal file
|
@ -0,0 +1 @@
|
||||||
|
python 3.11.2
|
90
constraints.txt
Normal file
90
constraints.txt
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.11
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --allow-unsafe --output-file=constraints.txt --resolver=backtracking --strip-extras requirements.in
|
||||||
|
#
|
||||||
|
asgiref==3.6.0
|
||||||
|
# via django
|
||||||
|
certifi==2022.12.7
|
||||||
|
# via requests
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via requests
|
||||||
|
contourpy==1.0.7
|
||||||
|
# via matplotlib
|
||||||
|
crispy-bootstrap5==0.7
|
||||||
|
# via -r requirements.in
|
||||||
|
cycler==0.11.0
|
||||||
|
# via matplotlib
|
||||||
|
django==4.1.7
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# crispy-bootstrap5
|
||||||
|
# django-anymail
|
||||||
|
# django-crispy-forms
|
||||||
|
# django-csp
|
||||||
|
# django-extensions
|
||||||
|
# django-htmx
|
||||||
|
# django-solo
|
||||||
|
django-anymail==9.1
|
||||||
|
# via -r requirements.in
|
||||||
|
django-cleanup==7.0.0
|
||||||
|
# via -r requirements.in
|
||||||
|
django-crispy-forms==2.0
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# crispy-bootstrap5
|
||||||
|
django-csp==3.7
|
||||||
|
# via -r requirements.in
|
||||||
|
django-environ==0.10.0
|
||||||
|
# via -r requirements.in
|
||||||
|
django-extensions==3.2.1
|
||||||
|
# via -r requirements.in
|
||||||
|
django-htmx==1.14.0
|
||||||
|
# via -r requirements.in
|
||||||
|
django-solo==2.0.0
|
||||||
|
# via -r requirements.in
|
||||||
|
fonttools==4.39.2
|
||||||
|
# via matplotlib
|
||||||
|
freezegun==1.2.2
|
||||||
|
# via -r requirements.in
|
||||||
|
gunicorn==20.1.0
|
||||||
|
# via -r requirements.in
|
||||||
|
idna==3.4
|
||||||
|
# via requests
|
||||||
|
kiwisolver==1.4.4
|
||||||
|
# via matplotlib
|
||||||
|
matplotlib==3.7.1
|
||||||
|
# via -r requirements.in
|
||||||
|
numpy==1.24.2
|
||||||
|
# via
|
||||||
|
# contourpy
|
||||||
|
# matplotlib
|
||||||
|
packaging==23.0
|
||||||
|
# via matplotlib
|
||||||
|
pillow==9.4.0
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# matplotlib
|
||||||
|
pyparsing==3.0.9
|
||||||
|
# via matplotlib
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# freezegun
|
||||||
|
# matplotlib
|
||||||
|
requests==2.28.2
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# django-anymail
|
||||||
|
six==1.16.0
|
||||||
|
# via python-dateutil
|
||||||
|
sqlparse==0.4.3
|
||||||
|
# via django
|
||||||
|
urllib3==1.26.15
|
||||||
|
# via requests
|
||||||
|
whitenoise==6.4.0
|
||||||
|
# via -r requirements.in
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
setuptools==67.6.0
|
||||||
|
# via gunicorn
|
108
pyproject.toml
108
pyproject.toml
|
@ -1,48 +1,6 @@
|
||||||
[tool.poetry]
|
###############################################################################
|
||||||
name = "checkout"
|
# pytest
|
||||||
version = "0.1.0"
|
###############################################################################
|
||||||
description = ""
|
|
||||||
authors = ["Gabriel Augendre <gabriel@augendre.info>"]
|
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = "^3.10"
|
|
||||||
django = "^4.0"
|
|
||||||
django-anymail = {version = "^8.4", extras = ["mailgun"]}
|
|
||||||
django-cleanup = "^6.0"
|
|
||||||
whitenoise = {extras = ["brotli"], version = "^6.0"}
|
|
||||||
django-csp = "^3.7"
|
|
||||||
django-environ = "^0.9"
|
|
||||||
requests = "^2.27.1"
|
|
||||||
django-extensions = "^3.1.5"
|
|
||||||
bpython = "^0.23"
|
|
||||||
gunicorn = "^20.1.0"
|
|
||||||
Pillow = "^9.1.0"
|
|
||||||
django-crispy-forms = "^1.14.0"
|
|
||||||
crispy-bootstrap5 = "^0.6"
|
|
||||||
matplotlib = "^3.5.1"
|
|
||||||
freezegun = "^1.2.1"
|
|
||||||
django-htmx = "^1.12.2"
|
|
||||||
django-solo = "^2.0.0"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
|
||||||
pre-commit = "^2.7"
|
|
||||||
pytest = "^6.0"
|
|
||||||
pytest-django = "^4.5"
|
|
||||||
model-bakery = "^1.1"
|
|
||||||
pytest-cov = "^3.0"
|
|
||||||
poetry-deps-scanner = "^2.0"
|
|
||||||
invoke = "^1.7.0"
|
|
||||||
factory-boy = "^3.2.1"
|
|
||||||
pytest-selenium = "^4.0.0"
|
|
||||||
selenium = "^4.4.3"
|
|
||||||
kolo = "^2.0.3"
|
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
target-version = ['py310']
|
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "--color=yes --driver Firefox"
|
addopts = "--color=yes --driver Firefox"
|
||||||
minversion = "6.0"
|
minversion = "6.0"
|
||||||
|
@ -51,28 +9,46 @@ testpaths = [
|
||||||
"src",
|
"src",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.flakeheaven]
|
###############################################################################
|
||||||
max_complexity = 10
|
# ruff
|
||||||
format = "grouped"
|
###############################################################################
|
||||||
|
|
||||||
[tool.flakeheaven.plugins]
|
[tool.ruff]
|
||||||
"*" = [
|
src = ["src"]
|
||||||
"+*",
|
target-version = "py311"
|
||||||
# long lines
|
select = ["ALL"]
|
||||||
"-E501",
|
unfixable = ["T20", "RUF001", "RUF002", "RUF003"]
|
||||||
# conflict with black on PEP8 interpretation
|
|
||||||
"-E203",
|
ignore = [
|
||||||
# deprecated rule: https://www.flake8rules.com/rules/W503.html
|
"ANN", # flake8-annotations
|
||||||
"-W503",
|
"BLE", # flake8-blind-except
|
||||||
|
"TCH", # flake8-type-checking / TODO: revisit later ?
|
||||||
|
|
||||||
|
"E501", # long lines
|
||||||
|
"D1", # missing docstring
|
||||||
|
"TRY003", # Avoid specifying long messages outside the exception class
|
||||||
]
|
]
|
||||||
flake8-quotes = ["+*", "-Q000"] # found double quotes, conflict with black
|
|
||||||
flake8-commas = ["+*", "-C812"] # missing trailing comma, conflict with black
|
|
||||||
flake8-docstrings = ["+*", "-D1??"] # missing docstring
|
|
||||||
flake8-rst-docstrings = ["-*"]
|
|
||||||
|
|
||||||
[tool.flakeheaven.exceptions."**/tests/*"]
|
[tool.ruff.per-file-ignores]
|
||||||
flake8-bandit = ["+*", "-S101"] # Use of assert detected.
|
"**/tests/*" = [
|
||||||
|
"S101", # Use of assert detected.
|
||||||
|
"S105", # Possible hardcoded password.
|
||||||
|
"B011", # Do not call assert False since python -O removes these calls.
|
||||||
|
"ARG001", # Unused function argument (mostly fixtures)
|
||||||
|
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
|
||||||
|
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes.
|
||||||
|
]
|
||||||
|
# File {name} is part of an implicit namespace package. Add an `__init__.py`.
|
||||||
|
"tasks.py" = ["INP001"]
|
||||||
|
"src/conftest.py" = ["INP001"]
|
||||||
|
"src/manage.py" = ["INP001"]
|
||||||
|
|
||||||
[build-system]
|
"src/purchase/management/commands/generate_dummy_baskets.py" = [
|
||||||
requires = ["poetry-core>=1.0.0"]
|
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes.
|
||||||
build-backend = "poetry.core.masonry.api"
|
]
|
||||||
|
|
||||||
|
[tool.ruff.pydocstyle]
|
||||||
|
convention = "pep257"
|
||||||
|
|
||||||
|
[tool.ruff.mccabe]
|
||||||
|
max-complexity = 10
|
||||||
|
|
14
requirements-dev.in
Normal file
14
requirements-dev.in
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
-c constraints.txt
|
||||||
|
pre-commit>=2.7
|
||||||
|
pytest>=6.0
|
||||||
|
pytest-cov>=3.0.0
|
||||||
|
pytest-django>=4.5.0
|
||||||
|
pytest-selenium>=4.0.0
|
||||||
|
pre-commit>=2.7
|
||||||
|
model-bakery>=1.1
|
||||||
|
invoke>=2.0.0
|
||||||
|
factory-boy>=3.2.1
|
||||||
|
selenium>=4.4.3
|
||||||
|
black>=22.12.0
|
||||||
|
pip-tools>=6.0
|
||||||
|
ruff>=0.0.237
|
183
requirements-dev.txt
Normal file
183
requirements-dev.txt
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.11
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --allow-unsafe --resolver=backtracking requirements-dev.in
|
||||||
|
#
|
||||||
|
asgiref==3.6.0
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# django
|
||||||
|
async-generator==1.10
|
||||||
|
# via trio
|
||||||
|
attrs==22.2.0
|
||||||
|
# via
|
||||||
|
# outcome
|
||||||
|
# pytest
|
||||||
|
# trio
|
||||||
|
black==23.1.0
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
build==0.10.0
|
||||||
|
# via pip-tools
|
||||||
|
certifi==2022.12.7
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# requests
|
||||||
|
# selenium
|
||||||
|
cfgv==3.3.1
|
||||||
|
# via pre-commit
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# requests
|
||||||
|
click==8.1.3
|
||||||
|
# via
|
||||||
|
# black
|
||||||
|
# pip-tools
|
||||||
|
coverage[toml]==7.2.2
|
||||||
|
# via pytest-cov
|
||||||
|
distlib==0.3.6
|
||||||
|
# via virtualenv
|
||||||
|
django==4.1.7
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# model-bakery
|
||||||
|
exceptiongroup==1.1.1
|
||||||
|
# via trio-websocket
|
||||||
|
factory-boy==3.2.1
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
faker==18.3.1
|
||||||
|
# via factory-boy
|
||||||
|
filelock==3.10.4
|
||||||
|
# via virtualenv
|
||||||
|
h11==0.14.0
|
||||||
|
# via wsproto
|
||||||
|
identify==2.5.22
|
||||||
|
# via pre-commit
|
||||||
|
idna==3.4
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# requests
|
||||||
|
# trio
|
||||||
|
iniconfig==2.0.0
|
||||||
|
# via pytest
|
||||||
|
invoke==2.0.0
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
model-bakery==1.10.1
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
# via black
|
||||||
|
nodeenv==1.7.0
|
||||||
|
# via pre-commit
|
||||||
|
outcome==1.2.0
|
||||||
|
# via trio
|
||||||
|
packaging==23.0
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# black
|
||||||
|
# build
|
||||||
|
# pytest
|
||||||
|
pathspec==0.11.1
|
||||||
|
# via black
|
||||||
|
pip-tools==6.12.3
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
platformdirs==3.1.1
|
||||||
|
# via
|
||||||
|
# black
|
||||||
|
# virtualenv
|
||||||
|
pluggy==1.0.0
|
||||||
|
# via pytest
|
||||||
|
pre-commit==3.2.1
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
py==1.11.0
|
||||||
|
# via
|
||||||
|
# pytest
|
||||||
|
# pytest-html
|
||||||
|
pyproject-hooks==1.0.0
|
||||||
|
# via build
|
||||||
|
pysocks==1.7.1
|
||||||
|
# via urllib3
|
||||||
|
pytest==6.2.5
|
||||||
|
# via
|
||||||
|
# -r requirements-dev.in
|
||||||
|
# pytest-base-url
|
||||||
|
# pytest-cov
|
||||||
|
# pytest-django
|
||||||
|
# pytest-html
|
||||||
|
# pytest-metadata
|
||||||
|
# pytest-selenium
|
||||||
|
# pytest-variables
|
||||||
|
pytest-base-url==2.0.0
|
||||||
|
# via pytest-selenium
|
||||||
|
pytest-cov==4.0.0
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
pytest-django==4.5.2
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
pytest-html==3.2.0
|
||||||
|
# via pytest-selenium
|
||||||
|
pytest-metadata==2.0.4
|
||||||
|
# via pytest-html
|
||||||
|
pytest-selenium==4.0.0
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
pytest-variables==2.0.0
|
||||||
|
# via pytest-selenium
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# faker
|
||||||
|
pyyaml==6.0
|
||||||
|
# via pre-commit
|
||||||
|
requests==2.28.2
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# pytest-base-url
|
||||||
|
# pytest-selenium
|
||||||
|
ruff==0.0.259
|
||||||
|
# via -r requirements-dev.in
|
||||||
|
selenium==4.8.3
|
||||||
|
# via
|
||||||
|
# -r requirements-dev.in
|
||||||
|
# pytest-selenium
|
||||||
|
six==1.16.0
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# python-dateutil
|
||||||
|
# tenacity
|
||||||
|
sniffio==1.3.0
|
||||||
|
# via trio
|
||||||
|
sortedcontainers==2.4.0
|
||||||
|
# via trio
|
||||||
|
sqlparse==0.4.3
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# django
|
||||||
|
tenacity==6.3.1
|
||||||
|
# via pytest-selenium
|
||||||
|
toml==0.10.2
|
||||||
|
# via pytest
|
||||||
|
trio==0.22.0
|
||||||
|
# via
|
||||||
|
# selenium
|
||||||
|
# trio-websocket
|
||||||
|
trio-websocket==0.10.2
|
||||||
|
# via selenium
|
||||||
|
urllib3[socks]==1.26.15
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# requests
|
||||||
|
# selenium
|
||||||
|
virtualenv==20.21.0
|
||||||
|
# via pre-commit
|
||||||
|
wheel==0.40.0
|
||||||
|
# via pip-tools
|
||||||
|
wsproto==1.2.0
|
||||||
|
# via trio-websocket
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
pip==23.0.1
|
||||||
|
# via pip-tools
|
||||||
|
setuptools==67.6.0
|
||||||
|
# via
|
||||||
|
# -c constraints.txt
|
||||||
|
# nodeenv
|
||||||
|
# pip-tools
|
16
requirements.in
Normal file
16
requirements.in
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
django>=4.1,<5.0
|
||||||
|
django-anymail[mailgun]>=8.6
|
||||||
|
django-cleanup>=6.0
|
||||||
|
whitenoise>=6.2
|
||||||
|
django-csp>=3.7
|
||||||
|
django-environ>=0.9.0
|
||||||
|
requests>=2.28.1
|
||||||
|
django-extensions>=3.1.5
|
||||||
|
gunicorn>=20.1.0
|
||||||
|
Pillow>=9.3.0
|
||||||
|
django-crispy-forms>=1.14.0
|
||||||
|
crispy-bootstrap5>=0.6
|
||||||
|
matplotlib>=3.5.1
|
||||||
|
freezegun>=1.2.1
|
||||||
|
django-htmx>=1.12.2
|
||||||
|
django-solo>=2.0.0
|
90
requirements.txt
Normal file
90
requirements.txt
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.11
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --allow-unsafe --resolver=backtracking requirements.in
|
||||||
|
#
|
||||||
|
asgiref==3.6.0
|
||||||
|
# via django
|
||||||
|
certifi==2022.12.7
|
||||||
|
# via requests
|
||||||
|
charset-normalizer==3.1.0
|
||||||
|
# via requests
|
||||||
|
contourpy==1.0.7
|
||||||
|
# via matplotlib
|
||||||
|
crispy-bootstrap5==0.7
|
||||||
|
# via -r requirements.in
|
||||||
|
cycler==0.11.0
|
||||||
|
# via matplotlib
|
||||||
|
django==4.1.7
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# crispy-bootstrap5
|
||||||
|
# django-anymail
|
||||||
|
# django-crispy-forms
|
||||||
|
# django-csp
|
||||||
|
# django-extensions
|
||||||
|
# django-htmx
|
||||||
|
# django-solo
|
||||||
|
django-anymail[mailgun]==9.1
|
||||||
|
# via -r requirements.in
|
||||||
|
django-cleanup==7.0.0
|
||||||
|
# via -r requirements.in
|
||||||
|
django-crispy-forms==2.0
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# crispy-bootstrap5
|
||||||
|
django-csp==3.7
|
||||||
|
# via -r requirements.in
|
||||||
|
django-environ==0.10.0
|
||||||
|
# via -r requirements.in
|
||||||
|
django-extensions==3.2.1
|
||||||
|
# via -r requirements.in
|
||||||
|
django-htmx==1.14.0
|
||||||
|
# via -r requirements.in
|
||||||
|
django-solo==2.0.0
|
||||||
|
# via -r requirements.in
|
||||||
|
fonttools==4.39.2
|
||||||
|
# via matplotlib
|
||||||
|
freezegun==1.2.2
|
||||||
|
# via -r requirements.in
|
||||||
|
gunicorn==20.1.0
|
||||||
|
# via -r requirements.in
|
||||||
|
idna==3.4
|
||||||
|
# via requests
|
||||||
|
kiwisolver==1.4.4
|
||||||
|
# via matplotlib
|
||||||
|
matplotlib==3.7.1
|
||||||
|
# via -r requirements.in
|
||||||
|
numpy==1.24.2
|
||||||
|
# via
|
||||||
|
# contourpy
|
||||||
|
# matplotlib
|
||||||
|
packaging==23.0
|
||||||
|
# via matplotlib
|
||||||
|
pillow==9.4.0
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# matplotlib
|
||||||
|
pyparsing==3.0.9
|
||||||
|
# via matplotlib
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
# via
|
||||||
|
# freezegun
|
||||||
|
# matplotlib
|
||||||
|
requests==2.28.2
|
||||||
|
# via
|
||||||
|
# -r requirements.in
|
||||||
|
# django-anymail
|
||||||
|
six==1.16.0
|
||||||
|
# via python-dateutil
|
||||||
|
sqlparse==0.4.3
|
||||||
|
# via django
|
||||||
|
urllib3==1.26.15
|
||||||
|
# via requests
|
||||||
|
whitenoise==6.4.0
|
||||||
|
# via -r requirements.in
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
setuptools==67.6.0
|
||||||
|
# via gunicorn
|
|
@ -110,7 +110,7 @@ MIDDLEWARE = [
|
||||||
try:
|
try:
|
||||||
import kolo # noqa: F401
|
import kolo # noqa: F401
|
||||||
|
|
||||||
MIDDLEWARE = ["kolo.middleware.KoloMiddleware"] + MIDDLEWARE
|
MIDDLEWARE = ["kolo.middleware.KoloMiddleware", *MIDDLEWARE]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Don't add kolo if unavailable
|
# Don't add kolo if unavailable
|
||||||
pass
|
pass
|
||||||
|
@ -139,14 +139,14 @@ CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
|
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
|
||||||
"LOCATION": "cache",
|
"LOCATION": "cache",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||||
|
|
||||||
DB_BASE_DIR = env("DB_BASE_DIR")
|
DB_BASE_DIR = env("DB_BASE_DIR")
|
||||||
if not DB_BASE_DIR:
|
if not DB_BASE_DIR: # noqa: SIM108
|
||||||
# Protect against empty strings
|
# Protect against empty strings
|
||||||
DB_BASE_DIR = BASE_DIR
|
DB_BASE_DIR = BASE_DIR
|
||||||
else:
|
else:
|
||||||
|
@ -156,7 +156,7 @@ DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": DB_BASE_DIR / "db.sqlite3",
|
"NAME": DB_BASE_DIR / "db.sqlite3",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
|
@ -169,7 +169,7 @@ INTERNAL_IPS = [
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""checkout URL Configuration
|
"""checkout URL Configuration.
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
https://docs.djangoproject.com/en/3.1/topics/http/urls/
|
https://docs.djangoproject.com/en/3.1/topics/http/urls/
|
||||||
|
@ -24,7 +24,8 @@ urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"robots.txt",
|
"robots.txt",
|
||||||
TemplateView.as_view(
|
TemplateView.as_view(
|
||||||
template_name="common/robots.txt", content_type="text/plain"
|
template_name="common/robots.txt",
|
||||||
|
content_type="text/plain",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
path("", include("common.urls")),
|
path("", include("common.urls")),
|
||||||
|
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
@ -31,7 +30,9 @@ class Migration(migrations.Migration):
|
||||||
(
|
(
|
||||||
"last_login",
|
"last_login",
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
blank=True, null=True, verbose_name="last login"
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="last login",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -46,13 +47,13 @@ class Migration(migrations.Migration):
|
||||||
"username",
|
"username",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
error_messages={
|
error_messages={
|
||||||
"unique": "A user with that username already exists."
|
"unique": "A user with that username already exists.",
|
||||||
},
|
},
|
||||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
max_length=150,
|
max_length=150,
|
||||||
unique=True,
|
unique=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
django.contrib.auth.validators.UnicodeUsernameValidator(),
|
||||||
],
|
],
|
||||||
verbose_name="username",
|
verbose_name="username",
|
||||||
),
|
),
|
||||||
|
@ -60,19 +61,25 @@ class Migration(migrations.Migration):
|
||||||
(
|
(
|
||||||
"first_name",
|
"first_name",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
blank=True, max_length=150, verbose_name="first name"
|
blank=True,
|
||||||
|
max_length=150,
|
||||||
|
verbose_name="first name",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"last_name",
|
"last_name",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
blank=True, max_length=150, verbose_name="last name"
|
blank=True,
|
||||||
|
max_length=150,
|
||||||
|
verbose_name="last name",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"email",
|
"email",
|
||||||
models.EmailField(
|
models.EmailField(
|
||||||
blank=True, max_length=254, verbose_name="email address"
|
blank=True,
|
||||||
|
max_length=254,
|
||||||
|
verbose_name="email address",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -94,7 +101,8 @@ class Migration(migrations.Migration):
|
||||||
(
|
(
|
||||||
"date_joined",
|
"date_joined",
|
||||||
models.DateTimeField(
|
models.DateTimeField(
|
||||||
default=django.utils.timezone.now, verbose_name="date joined"
|
default=django.utils.timezone.now,
|
||||||
|
verbose_name="date joined",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Checkout</title>
|
<title>Checkout</title>
|
||||||
<link href="{% static "vendor/bootstrap-5.1.3-dist/css/bootstrap.min.css" %}"
|
<link href="{% static "vendor/bootstrap-5.1.3-dist/css/bootstrap.min.css" %}"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(_request):
|
||||||
return redirect("purchase:new")
|
return redirect("purchase:new")
|
||||||
|
|
|
@ -5,16 +5,17 @@ import sys
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Run administrative tasks.""" # noqa: DAR401
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "checkout.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "checkout.settings")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise ImportError(
|
msg = (
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
"available on your PYTHONPATH environment variable? Did you "
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
"forget to activate a virtual environment?"
|
"forget to activate a virtual environment?"
|
||||||
) from exc
|
)
|
||||||
|
raise ImportError(msg) from exc
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -39,8 +39,8 @@ class BasketForm(forms.ModelForm):
|
||||||
label=product.name,
|
label=product.name,
|
||||||
min_value=0,
|
min_value=0,
|
||||||
initial=products.get(product, 0),
|
initial=products.get(product, 0),
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
fields.append(BasketItemField(field_name, product=product))
|
fields.append(BasketItemField(field_name, product=product))
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
@ -51,7 +51,7 @@ class BasketForm(forms.ModelForm):
|
||||||
InlineRadios("payment_method"),
|
InlineRadios("payment_method"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self):
|
||||||
instance: Basket = super().save(commit=True)
|
instance: Basket = super().save(commit=True)
|
||||||
name: str
|
name: str
|
||||||
products = {product.id: product for product in Product.objects.all()}
|
products = {product.id: product for product in Product.objects.all()}
|
||||||
|
|
|
@ -6,7 +6,7 @@ from purchase.models import Basket, BasketItem, PaymentMethod, Product
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Clear all data" # noqa: A003
|
help = "Clear all data" # noqa: A003
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options): # noqa: ARG002
|
||||||
self.delete(BasketItem)
|
self.delete(BasketItem)
|
||||||
self.delete(Basket)
|
self.delete(Basket)
|
||||||
self.delete(Product)
|
self.delete(Product)
|
||||||
|
|
|
@ -13,7 +13,7 @@ from purchase.models import Basket, BasketItem, PaymentMethod, Product
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Generates dummy baskets" # noqa: A003
|
help = "Generates dummy baskets" # noqa: A003
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options): # noqa: ARG002
|
||||||
call_command("loaddata", ["payment_methods", "products"])
|
call_command("loaddata", ["payment_methods", "products"])
|
||||||
products = list(Product.objects.all())
|
products = list(Product.objects.all())
|
||||||
payment_methods = list(PaymentMethod.objects.all())
|
payment_methods = list(PaymentMethod.objects.all())
|
||||||
|
@ -37,7 +37,7 @@ class Command(BaseCommand):
|
||||||
products_weights = [1 / product.display_order for product in products]
|
products_weights = [1 / product.display_order for product in products]
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
method = None
|
method = None
|
||||||
if random.random() < 0.99:
|
if random.random() < 0.99: # noqa: PLR2004
|
||||||
method = random.choices(payment_methods, weights=methods_weights)[0]
|
method = random.choices(payment_methods, weights=methods_weights)[0]
|
||||||
basket = Basket.objects.create(payment_method=method)
|
basket = Basket.objects.create(payment_method=method)
|
||||||
items_in_basket = int(random.normalvariate(3, 2))
|
items_in_basket = int(random.normalvariate(3, 2))
|
||||||
|
@ -45,7 +45,7 @@ class Command(BaseCommand):
|
||||||
items_in_basket = len(products)
|
items_in_basket = len(products)
|
||||||
if items_in_basket < 1:
|
if items_in_basket < 1:
|
||||||
items_in_basket = 1
|
items_in_basket = 1
|
||||||
selected_products = np.random.choice(
|
selected_products = np.random.Generator(
|
||||||
products,
|
products,
|
||||||
size=items_in_basket,
|
size=items_in_basket,
|
||||||
replace=False,
|
replace=False,
|
||||||
|
@ -59,7 +59,7 @@ class Command(BaseCommand):
|
||||||
basket=basket,
|
basket=basket,
|
||||||
quantity=random.randint(1, 3),
|
quantity=random.randint(1, 3),
|
||||||
unit_price_cents=product.unit_price_cents,
|
unit_price_cents=product.unit_price_cents,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
BasketItem.objects.bulk_create(items)
|
BasketItem.objects.bulk_create(items)
|
||||||
return count
|
return count
|
||||||
|
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0001_initial"),
|
("purchase", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,7 +6,6 @@ import purchase.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0002_alter_product_image"),
|
("purchase", "0002_alter_product_image"),
|
||||||
]
|
]
|
||||||
|
@ -16,7 +15,7 @@ class Migration(migrations.Migration):
|
||||||
model_name="product",
|
model_name="product",
|
||||||
name="display_order",
|
name="display_order",
|
||||||
field=models.PositiveIntegerField(
|
field=models.PositiveIntegerField(
|
||||||
default=purchase.models.default_product_display_order
|
default=purchase.models.default_product_display_order,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0003_alter_product_display_order"),
|
("purchase", "0003_alter_product_display_order"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -7,7 +7,6 @@ import purchase.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0004_remove_basket_status"),
|
("purchase", "0004_remove_basket_status"),
|
||||||
]
|
]
|
||||||
|
@ -128,7 +127,10 @@ class Migration(migrations.Migration):
|
||||||
model_name="product",
|
model_name="product",
|
||||||
name="image",
|
name="image",
|
||||||
field=models.ImageField(
|
field=models.ImageField(
|
||||||
blank=True, null=True, upload_to="", verbose_name="image"
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to="",
|
||||||
|
verbose_name="image",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
|
@ -140,7 +142,8 @@ class Migration(migrations.Migration):
|
||||||
model_name="product",
|
model_name="product",
|
||||||
name="unit_price_cents",
|
name="unit_price_cents",
|
||||||
field=models.PositiveIntegerField(
|
field=models.PositiveIntegerField(
|
||||||
help_text="unit price in cents", verbose_name="unit price (cents)"
|
help_text="unit price in cents",
|
||||||
|
verbose_name="unit price (cents)",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
def forwards(apps, schema_editor):
|
def forwards(apps, schema_editor):
|
||||||
BasketItem = apps.get_model("purchase", "BasketItem")
|
BasketItem = apps.get_model("purchase", "BasketItem") # noqa: N806
|
||||||
items = (
|
items = (
|
||||||
BasketItem.objects.using(schema_editor.connection.alias)
|
BasketItem.objects.using(schema_editor.connection.alias)
|
||||||
.all()
|
.all()
|
||||||
|
@ -16,7 +16,6 @@ def forwards(apps, schema_editor):
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0005_alter_basket_options_alter_basketitem_options_and_more"),
|
("purchase", "0005_alter_basket_options_alter_basketitem_options_and_more"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0006_basketitem_unit_price_cents"),
|
("purchase", "0006_basketitem_unit_price_cents"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0007_alter_basketitem_unit_price_cents"),
|
("purchase", "0007_alter_basketitem_unit_price_cents"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0008_basketitem_unique_product_per_basket"),
|
("purchase", "0008_basketitem_unique_product_per_basket"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0009_basketitemetag"),
|
("purchase", "0009_basketitemetag"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0010_rename_basketitemetag_cacheetag"),
|
("purchase", "0010_rename_basketitemetag_cacheetag"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("purchase", "0011_rename_cacheetag_cache"),
|
("purchase", "0011_rename_cacheetag_cache"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -27,10 +27,10 @@ class PaymentMethodQuerySet(models.QuerySet):
|
||||||
turnover=Coalesce(
|
turnover=Coalesce(
|
||||||
Sum(
|
Sum(
|
||||||
F("baskets__items__quantity")
|
F("baskets__items__quantity")
|
||||||
* F("baskets__items__unit_price_cents")
|
* F("baskets__items__unit_price_cents"),
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def with_sold(self):
|
def with_sold(self):
|
||||||
|
@ -71,7 +71,7 @@ class ProductQuerySet(models.QuerySet):
|
||||||
turnover=Coalesce(
|
turnover=Coalesce(
|
||||||
Sum(F("basket_items__quantity") * F("basket_items__unit_price_cents")),
|
Sum(F("basket_items__quantity") * F("basket_items__unit_price_cents")),
|
||||||
0,
|
0,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def with_sold(self):
|
def with_sold(self):
|
||||||
|
@ -87,10 +87,12 @@ class Product(Model):
|
||||||
name = models.CharField(max_length=250, unique=True, verbose_name=_("name"))
|
name = models.CharField(max_length=250, unique=True, verbose_name=_("name"))
|
||||||
image = models.ImageField(null=True, blank=True, verbose_name=_("image"))
|
image = models.ImageField(null=True, blank=True, verbose_name=_("image"))
|
||||||
unit_price_cents = models.PositiveIntegerField(
|
unit_price_cents = models.PositiveIntegerField(
|
||||||
verbose_name=_("unit price (cents)"), help_text=_("unit price in cents")
|
verbose_name=_("unit price (cents)"),
|
||||||
|
help_text=_("unit price in cents"),
|
||||||
)
|
)
|
||||||
display_order = models.PositiveIntegerField(
|
display_order = models.PositiveIntegerField(
|
||||||
default=default_product_display_order, verbose_name=_("display order")
|
default=default_product_display_order,
|
||||||
|
verbose_name=_("display order"),
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ProductManager.from_queryset(ProductQuerySet)()
|
objects = ProductManager.from_queryset(ProductQuerySet)()
|
||||||
|
@ -109,19 +111,21 @@ class Product(Model):
|
||||||
@property
|
@property
|
||||||
def color_hue(self):
|
def color_hue(self):
|
||||||
return int(
|
return int(
|
||||||
hashlib.sha256(bytes(self.name, encoding="utf-8")).hexdigest()[:2], base=16
|
hashlib.sha256(bytes(self.name, encoding="utf-8")).hexdigest()[:2],
|
||||||
|
base=16,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
super().save()
|
super().save(*args, **kwargs)
|
||||||
if not self.image:
|
if not self.image:
|
||||||
return
|
return
|
||||||
with Image.open(self.image.path) as img:
|
with Image.open(self.image.path) as img_file:
|
||||||
img = ImageOps.exif_transpose(img)
|
img = ImageOps.exif_transpose(img_file)
|
||||||
|
|
||||||
width, height = img.size # Get dimensions
|
width, height = img.size # Get dimensions
|
||||||
|
|
||||||
if width > 300 and height > 300:
|
image_max_size = 300
|
||||||
|
if width > image_max_size and height > image_max_size:
|
||||||
# keep ratio but shrink down
|
# keep ratio but shrink down
|
||||||
img.thumbnail((width, height))
|
img.thumbnail((width, height))
|
||||||
|
|
||||||
|
@ -142,8 +146,8 @@ class Product(Model):
|
||||||
bottom = width
|
bottom = width
|
||||||
img = img.crop((left, top, right, bottom))
|
img = img.crop((left, top, right, bottom))
|
||||||
|
|
||||||
if width > 300 and height > 300:
|
if width > image_max_size and height > image_max_size:
|
||||||
img.thumbnail((300, 300))
|
img.thumbnail((image_max_size, image_max_size))
|
||||||
|
|
||||||
img.save(self.image.path)
|
img.save(self.image.path)
|
||||||
|
|
||||||
|
@ -151,7 +155,7 @@ class Product(Model):
|
||||||
class BasketQuerySet(models.QuerySet):
|
class BasketQuerySet(models.QuerySet):
|
||||||
def priced(self) -> BasketQuerySet:
|
def priced(self) -> BasketQuerySet:
|
||||||
return self.annotate(
|
return self.annotate(
|
||||||
price=Coalesce(Sum(F("items__quantity") * F("items__unit_price_cents")), 0)
|
price=Coalesce(Sum(F("items__quantity") * F("items__unit_price_cents")), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
def average_basket(self) -> float:
|
def average_basket(self) -> float:
|
||||||
|
@ -220,7 +224,7 @@ class BasketItem(Model):
|
||||||
verbose_name = _("basket item")
|
verbose_name = _("basket item")
|
||||||
verbose_name_plural = _("basket items")
|
verbose_name_plural = _("basket items")
|
||||||
constraints = [
|
constraints = [
|
||||||
UniqueConstraint("product", "basket", name="unique_product_per_basket")
|
UniqueConstraint("product", "basket", name="unique_product_per_basket"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -236,9 +240,9 @@ class Cache(SingletonModel):
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def reports_etag(request):
|
def reports_etag(_request):
|
||||||
return str(Cache.get_solo().etag)
|
return str(Cache.get_solo().etag)
|
||||||
|
|
||||||
|
|
||||||
def reports_last_modified(request):
|
def reports_last_modified(_request):
|
||||||
return Cache.get_solo().last_modified
|
return Cache.get_solo().last_modified
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
def basket_item_on_save(sender, **kwargs):
|
def basket_item_on_save(sender, **kwargs): # noqa: ARG001
|
||||||
from purchase.models import Cache
|
from purchase.models import Cache
|
||||||
|
|
||||||
Cache.get_solo().refresh()
|
Cache.get_solo().refresh()
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<img src="{{ product.image.url }}" class="card-img">
|
<img src="{{ product.image.url }}" class="card-img">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-img product-img-placeholder"
|
<div class="card-img product-img-placeholder"
|
||||||
style="background-color: hsl({{ product.color_hue }}, 60%, 80%)">
|
style="background-color: hsl({{ product.color_hue }}, 60%, 80%)">
|
||||||
<span>
|
<span>
|
||||||
{{ product.name|slice:"1" }}
|
{{ product.name|slice:"1" }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div hx-get="{% url url %}"
|
<div hx-get="{% url url %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<img class="htmx-indicator" src="{% static 'purchase/spinner.gif' %}" alt="Spinner">
|
<img class="htmx-indicator" src="{% static 'purchase/spinner.gif' %}" alt="Spinner">
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,6 +5,6 @@ register = template.Library()
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def currency(value):
|
def currency(value):
|
||||||
if isinstance(value, int) or isinstance(value, float):
|
if isinstance(value, int | float):
|
||||||
return f"{value/100:.2f}€"
|
return f"{value/100:.2f}€"
|
||||||
return value
|
return value
|
||||||
|
|
|
@ -21,7 +21,7 @@ class CashierFactory(factory.django.DjangoModelFactory):
|
||||||
is_staff = True
|
is_staff = True
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def groups(self, create, extracted, **kwargs):
|
def groups(self, create, _extracted, **_kwargs):
|
||||||
if create:
|
if create:
|
||||||
self.groups.add(CashierGroupFactory())
|
self.groups.add(CashierGroupFactory())
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ class BasketWithItemsFactory(factory.django.DjangoModelFactory):
|
||||||
payment_method = factory.Iterator(PaymentMethod.objects.all())
|
payment_method = factory.Iterator(PaymentMethod.objects.all())
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def items(self, create, extracted, **kwargs):
|
def items(self, create, _extracted, **_kwargs):
|
||||||
if create:
|
if create:
|
||||||
products = Product.objects.order_by("?")
|
products = Product.objects.order_by("?")
|
||||||
quantity = random.randint(1, len(products))
|
quantity = random.randint(1, len(products))
|
||||||
|
@ -68,7 +68,7 @@ class CashierGroupFactory(factory.django.DjangoModelFactory):
|
||||||
name = "Caissier"
|
name = "Caissier"
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def permissions(self, create, extracted, **kwargs):
|
def permissions(self, create, _extracted, **_kwargs):
|
||||||
if create:
|
if create:
|
||||||
self.permissions.add(
|
self.permissions.add(
|
||||||
Permission.objects.get(codename="add_basket"),
|
Permission.objects.get(codename="add_basket"),
|
||||||
|
|
|
@ -20,7 +20,10 @@ from purchase.tests.factories import (
|
||||||
|
|
||||||
|
|
||||||
@freezegun.freeze_time("2022-09-24 19:01:00+0200")
|
@freezegun.freeze_time("2022-09-24 19:01:00+0200")
|
||||||
def test_cashier_create_and_update_basket(live_server: LiveServer, selenium: WebDriver):
|
def test_cashier_create_and_update_basket( # noqa: PLR0915
|
||||||
|
live_server: LiveServer,
|
||||||
|
selenium: WebDriver,
|
||||||
|
):
|
||||||
wait = WebDriverWait(selenium, 10)
|
wait = WebDriverWait(selenium, 10)
|
||||||
assert Basket.objects.count() == 0
|
assert Basket.objects.count() == 0
|
||||||
|
|
||||||
|
@ -44,7 +47,7 @@ def test_cashier_create_and_update_basket(live_server: LiveServer, selenium: Web
|
||||||
wait.until(lambda driver: driver.current_url == redirect_url)
|
wait.until(lambda driver: driver.current_url == redirect_url)
|
||||||
displayed_products = selenium.find_elements(By.CSS_SELECTOR, ".card.h-100")
|
displayed_products = selenium.find_elements(By.CSS_SELECTOR, ".card.h-100")
|
||||||
assert len(displayed_products) == len(products)
|
assert len(displayed_products) == len(products)
|
||||||
for product, displayed_product in zip(products, displayed_products):
|
for product, displayed_product in zip(products, displayed_products, strict=True):
|
||||||
assert (
|
assert (
|
||||||
product.name
|
product.name
|
||||||
== displayed_product.find_element(By.CLASS_NAME, "card-title").text
|
== displayed_product.find_element(By.CLASS_NAME, "card-title").text
|
||||||
|
@ -185,7 +188,10 @@ def test_cashier_create_and_update_basket(live_server: LiveServer, selenium: Web
|
||||||
|
|
||||||
|
|
||||||
def login(
|
def login(
|
||||||
live_server: LiveServer, selenium: WebDriver, cashier: User, url: str = "/"
|
live_server: LiveServer,
|
||||||
|
selenium: WebDriver,
|
||||||
|
cashier: User,
|
||||||
|
url: str = "/",
|
||||||
) -> None:
|
) -> None:
|
||||||
# Go to page
|
# Go to page
|
||||||
url = live_url(live_server, url)
|
url = live_url(live_server, url)
|
||||||
|
@ -215,12 +221,12 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
|
||||||
with freezegun.freeze_time("2022-09-24 19:01:00+0200"):
|
with freezegun.freeze_time("2022-09-24 19:01:00+0200"):
|
||||||
basket_with_payment_method = BasketWithItemsFactory()
|
basket_with_payment_method = BasketWithItemsFactory()
|
||||||
basket_with_payment_method = Basket.objects.priced().get(
|
basket_with_payment_method = Basket.objects.priced().get(
|
||||||
pk=basket_with_payment_method.pk
|
pk=basket_with_payment_method.pk,
|
||||||
)
|
)
|
||||||
with freezegun.freeze_time("2022-09-24 19:02:00+0200"):
|
with freezegun.freeze_time("2022-09-24 19:02:00+0200"):
|
||||||
basket_no_payment_method = BasketWithItemsFactory(payment_method=None)
|
basket_no_payment_method = BasketWithItemsFactory(payment_method=None)
|
||||||
basket_no_payment_method = Basket.objects.priced().get(
|
basket_no_payment_method = Basket.objects.priced().get(
|
||||||
pk=basket_no_payment_method.pk
|
pk=basket_no_payment_method.pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Login
|
# Login
|
||||||
|
@ -268,7 +274,7 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
|
||||||
|
|
||||||
# Assert redirected to list view
|
# Assert redirected to list view
|
||||||
wait.until(
|
wait.until(
|
||||||
lambda driver: driver.current_url == live_reverse(live_server, "purchase:list")
|
lambda driver: driver.current_url == live_reverse(live_server, "purchase:list"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Click on edit on remaining basket
|
# Click on edit on remaining basket
|
||||||
|
@ -277,7 +283,9 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
|
||||||
|
|
||||||
# Assert redirected to edit view
|
# Assert redirected to edit view
|
||||||
redirect_url = live_reverse(
|
redirect_url = live_reverse(
|
||||||
live_server, "purchase:update", pk=basket_no_payment_method.pk
|
live_server,
|
||||||
|
"purchase:update",
|
||||||
|
pk=basket_no_payment_method.pk,
|
||||||
)
|
)
|
||||||
wait.until(lambda driver: driver.current_url == redirect_url)
|
wait.until(lambda driver: driver.current_url == redirect_url)
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,9 @@ def update_basket(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
form = BasketForm(instance=basket)
|
form = BasketForm(instance=basket)
|
||||||
|
|
||||||
return TemplateResponse(
|
return TemplateResponse(
|
||||||
request, "purchase/basket_form.html", {"form": form, "basket": basket}
|
request,
|
||||||
|
"purchase/basket_form.html",
|
||||||
|
{"form": form, "basket": basket},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -62,7 +64,6 @@ def delete_basket(request: HttpRequest, pk: int) -> HttpResponse:
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = {"basket": basket}
|
context = {"basket": basket}
|
||||||
return TemplateResponse(request, "purchase/basket_confirm_delete.html", context)
|
return TemplateResponse(request, "purchase/basket_confirm_delete.html", context)
|
||||||
else:
|
basket.delete()
|
||||||
basket.delete()
|
messages.success(request, _("Basket successfully deleted."))
|
||||||
messages.success(request, _("Basket successfully deleted."))
|
return redirect("purchase:list")
|
||||||
return redirect("purchase:list")
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import datetime
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib as mpl
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
@ -27,7 +27,7 @@ from purchase.models import (
|
||||||
reports_last_modified,
|
reports_last_modified,
|
||||||
)
|
)
|
||||||
|
|
||||||
matplotlib.use("SVG")
|
mpl.use("SVG")
|
||||||
|
|
||||||
|
|
||||||
@permission_required("purchase.view_basket")
|
@permission_required("purchase.view_basket")
|
||||||
|
@ -204,5 +204,4 @@ def get_image_from_fig(fig):
|
||||||
image_data = StringIO()
|
image_data = StringIO()
|
||||||
fig.savefig(image_data, format="svg")
|
fig.savefig(image_data, format="svg")
|
||||||
image_data.seek(0)
|
image_data.seek(0)
|
||||||
img1 = image_data.getvalue()
|
return image_data.getvalue()
|
||||||
return img1
|
|
||||||
|
|
101
tasks.py
101
tasks.py
|
@ -1,23 +1,48 @@
|
||||||
"""
|
|
||||||
Invoke management tasks for the project.
|
|
||||||
|
|
||||||
The current implementation with type annotations is not compatible
|
|
||||||
with invoke 1.6.0 and requires manual patching.
|
|
||||||
|
|
||||||
See https://github.com/pyinvoke/invoke/pull/458/files
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
|
||||||
from invoke import Context, task
|
from invoke import Context, task
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.resolve(strict=True)
|
BASE_DIR = Path(__file__).parent.resolve(strict=True)
|
||||||
SRC_DIR = BASE_DIR / "src"
|
SRC_DIR = BASE_DIR / "src"
|
||||||
COMPOSE_BUILD_FILE = BASE_DIR / "docker-compose-build.yaml"
|
COMPOSE_BUILD_FILE = BASE_DIR / "docker-compose-build.yaml"
|
||||||
COMPOSE_BUILD_ENV = {"COMPOSE_FILE": COMPOSE_BUILD_FILE}
|
COMPOSE_BUILD_ENV = {"COMPOSE_FILE": COMPOSE_BUILD_FILE}
|
||||||
|
TEST_ENV = {"ENV_FILE": BASE_DIR / "envs" / "test-envs.env"}
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def update_dependencies(ctx: Context, *, sync: bool = True):
|
||||||
|
return compile_dependencies(ctx, update=True, sync=sync)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def compile_dependencies(ctx: Context, *, update: bool = False, sync: bool = False):
|
||||||
|
common_args = "-q --allow-unsafe --resolver=backtracking"
|
||||||
|
if update:
|
||||||
|
common_args += " --upgrade"
|
||||||
|
with ctx.cd(BASE_DIR):
|
||||||
|
ctx.run(
|
||||||
|
f"pip-compile {common_args} requirements.in",
|
||||||
|
pty=True,
|
||||||
|
echo=True,
|
||||||
|
)
|
||||||
|
ctx.run(
|
||||||
|
f"pip-compile {common_args} --strip-extras -o constraints.txt requirements.in",
|
||||||
|
pty=True,
|
||||||
|
echo=True,
|
||||||
|
)
|
||||||
|
ctx.run(
|
||||||
|
f"pip-compile {common_args} requirements-dev.in",
|
||||||
|
pty=True,
|
||||||
|
echo=True,
|
||||||
|
)
|
||||||
|
if sync:
|
||||||
|
sync_dependencies(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def sync_dependencies(ctx: Context):
|
||||||
|
with ctx.cd(BASE_DIR):
|
||||||
|
ctx.run("pip-sync requirements.txt requirements-dev.txt", pty=True, echo=True)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
|
@ -49,58 +74,6 @@ def test_cov(ctx: Context) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def pre_commit(ctx: Context) -> None:
|
|
||||||
with ctx.cd(BASE_DIR):
|
|
||||||
ctx.run("pre-commit run --all-files", pty=True)
|
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[pre_commit, test_cov])
|
|
||||||
def check(ctx: Context) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def build(ctx: Context) -> None:
|
|
||||||
with ctx.cd(BASE_DIR):
|
|
||||||
ctx.run(
|
|
||||||
"docker-compose build django", pty=True, echo=True, env=COMPOSE_BUILD_ENV
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def publish(ctx: Context) -> None:
|
|
||||||
with ctx.cd(BASE_DIR):
|
|
||||||
ctx.run(
|
|
||||||
"docker-compose push django", pty=True, echo=True, env=COMPOSE_BUILD_ENV
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def deploy(ctx: Context) -> None:
|
|
||||||
ctx.run("ssh ubuntu /mnt/data/checkout/update", pty=True, echo=True)
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
|
||||||
def check_alive(ctx: Context) -> None:
|
|
||||||
exception = None
|
|
||||||
for _ in range(5):
|
|
||||||
try:
|
|
||||||
res = requests.get("https://checkout.augendre.info")
|
|
||||||
res.raise_for_status()
|
|
||||||
print("Server is up & running")
|
|
||||||
return
|
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
time.sleep(2)
|
|
||||||
exception = e
|
|
||||||
raise RuntimeError("Failed to reach the server") from exception
|
|
||||||
|
|
||||||
|
|
||||||
@task(pre=[check, build, publish, deploy], post=[check_alive])
|
|
||||||
def beam(ctx: Context) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def download_db(ctx: Context) -> None:
|
def download_db(ctx: Context) -> None:
|
||||||
with ctx.cd(BASE_DIR):
|
with ctx.cd(BASE_DIR):
|
||||||
|
|
Loading…
Reference in a new issue