Modernize app

This commit is contained in:
Gabriel Augendre 2023-03-25 20:01:14 +01:00
parent 37129d9405
commit 114ae437bc
40 changed files with 613 additions and 247 deletions

1
.gitignore vendored
View file

@ -276,3 +276,4 @@ dmypy.json
staticfiles/ staticfiles/
media/ media/
db.sqlite3 db.sqlite3
.direnv

View file

@ -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
View file

@ -0,0 +1 @@
python 3.11.2

90
constraints.txt Normal file
View 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

View file

@ -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
View 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
View 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
View 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
View 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

View file

@ -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"},

View file

@ -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")),

View file

@ -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",
), ),
), ),
( (

View file

@ -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;

View file

@ -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")

View file

@ -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)

View file

@ -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()}

View file

@ -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)

View file

@ -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

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [] dependencies = []

View file

@ -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"),
] ]

View file

@ -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,
), ),
), ),
] ]

View file

@ -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"),
] ]

View file

@ -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(

View file

@ -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"),
] ]

View file

@ -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"),
] ]

View file

@ -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"),
] ]

View file

@ -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"),
] ]

View file

@ -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"),
] ]

View file

@ -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"),
] ]

View file

@ -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"),
] ]

View file

@ -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

View file

@ -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()

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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"),

View file

@ -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)

View file

@ -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")

View file

@ -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
View file

@ -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):