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/
media/
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:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.4.0
hooks:
- id: check-ast
- id: check-json
@ -20,52 +23,57 @@ repos:
- id: trailing-whitespace
args:
- --markdown-linebreak-ext=md
- repo: https://github.com/timothycrosley/isort
rev: 5.10.1
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
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.10.0
rev: 1.13.0
hooks:
- id: django-upgrade
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
rev: v1.5.2
rev: 3.0.6
hooks:
- id: djhtml
- repo: https://github.com/flakeheaven/flakeheaven
rev: 3.0.0
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.259
hooks:
- id: flakeheaven
additional_dependencies:
- flake8-annotations-complexity
- flake8-builtins
- flake8-bugbear
- flake8-comprehensions
- flake8-eradicate
- flake8-noqa
- flake8-pytest-style
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.0-alpha.0
rev: v3.0.0-alpha.6
hooks:
- id: prettier
types_or: [javascript, css]
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.24.0
rev: v8.36.0
hooks:
- id: eslint
args: [--fix]
types_or: [javascript, css]
additional_dependencies:
- eslint@^7.29.0
- eslint-config-prettier@^8.3.0
- eslint@8.36.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"
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"
###############################################################################
# pytest
###############################################################################
[tool.pytest.ini_options]
addopts = "--color=yes --driver Firefox"
minversion = "6.0"
@ -51,28 +9,46 @@ testpaths = [
"src",
]
[tool.flakeheaven]
max_complexity = 10
format = "grouped"
###############################################################################
# ruff
###############################################################################
[tool.flakeheaven.plugins]
"*" = [
"+*",
# long lines
"-E501",
# conflict with black on PEP8 interpretation
"-E203",
# deprecated rule: https://www.flake8rules.com/rules/W503.html
"-W503",
[tool.ruff]
src = ["src"]
target-version = "py311"
select = ["ALL"]
unfixable = ["T20", "RUF001", "RUF002", "RUF003"]
ignore = [
"ANN", # flake8-annotations
"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/*"]
flake8-bandit = ["+*", "-S101"] # Use of assert detected.
[tool.ruff.per-file-ignores]
"**/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]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
"src/purchase/management/commands/generate_dummy_baskets.py" = [
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes.
]
[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:
import kolo # noqa: F401
MIDDLEWARE = ["kolo.middleware.KoloMiddleware"] + MIDDLEWARE
MIDDLEWARE = ["kolo.middleware.KoloMiddleware", *MIDDLEWARE]
except ImportError:
# Don't add kolo if unavailable
pass
@ -139,14 +139,14 @@ CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "cache",
}
},
}
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DB_BASE_DIR = env("DB_BASE_DIR")
if not DB_BASE_DIR:
if not DB_BASE_DIR: # noqa: SIM108
# Protect against empty strings
DB_BASE_DIR = BASE_DIR
else:
@ -156,7 +156,7 @@ DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DB_BASE_DIR / "db.sqlite3",
}
},
}
INTERNAL_IPS = [
@ -169,7 +169,7 @@ INTERNAL_IPS = [
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.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:
https://docs.djangoproject.com/en/3.1/topics/http/urls/
@ -24,7 +24,8 @@ urlpatterns = [
path(
"robots.txt",
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")),

View file

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
@ -31,7 +30,9 @@ class Migration(migrations.Migration):
(
"last_login",
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",
models.CharField(
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.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
django.contrib.auth.validators.UnicodeUsernameValidator(),
],
verbose_name="username",
),
@ -60,19 +61,25 @@ class Migration(migrations.Migration):
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
blank=True,
max_length=150,
verbose_name="first name",
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
blank=True,
max_length=150,
verbose_name="last name",
),
),
(
"email",
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",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
default=django.utils.timezone.now,
verbose_name="date joined",
),
),
(

View file

@ -1,5 +1,5 @@
from django.shortcuts import redirect
def home(request):
def home(_request):
return redirect("purchase:new")

View file

@ -5,16 +5,17 @@ import sys
def main() -> None:
"""Run administrative tasks.""" # noqa: DAR401
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "checkout.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
msg = (
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
)
raise ImportError(msg) from exc
execute_from_command_line(sys.argv)

View file

@ -39,8 +39,8 @@ class BasketForm(forms.ModelForm):
label=product.name,
min_value=0,
initial=products.get(product, 0),
)
}
),
},
)
fields.append(BasketItemField(field_name, product=product))
self.helper.layout = Layout(
@ -51,7 +51,7 @@ class BasketForm(forms.ModelForm):
InlineRadios("payment_method"),
)
def save(self, commit=True):
def save(self):
instance: Basket = super().save(commit=True)
name: str
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):
help = "Clear all data" # noqa: A003
def handle(self, *args, **options):
def handle(self, *args, **options): # noqa: ARG002
self.delete(BasketItem)
self.delete(Basket)
self.delete(Product)

View file

@ -13,7 +13,7 @@ from purchase.models import Basket, BasketItem, PaymentMethod, Product
class Command(BaseCommand):
help = "Generates dummy baskets" # noqa: A003
def handle(self, *args, **options):
def handle(self, *args, **options): # noqa: ARG002
call_command("loaddata", ["payment_methods", "products"])
products = list(Product.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]
for _ in range(count):
method = None
if random.random() < 0.99:
if random.random() < 0.99: # noqa: PLR2004
method = random.choices(payment_methods, weights=methods_weights)[0]
basket = Basket.objects.create(payment_method=method)
items_in_basket = int(random.normalvariate(3, 2))
@ -45,7 +45,7 @@ class Command(BaseCommand):
items_in_basket = len(products)
if items_in_basket < 1:
items_in_basket = 1
selected_products = np.random.choice(
selected_products = np.random.Generator(
products,
size=items_in_basket,
replace=False,
@ -59,7 +59,7 @@ class Command(BaseCommand):
basket=basket,
quantity=random.randint(1, 3),
unit_price_cents=product.unit_price_cents,
)
),
)
BasketItem.objects.bulk_create(items)
return count

View file

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

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("purchase", "0001_initial"),
]

View file

@ -6,7 +6,6 @@ import purchase.models
class Migration(migrations.Migration):
dependencies = [
("purchase", "0002_alter_product_image"),
]
@ -16,7 +15,7 @@ class Migration(migrations.Migration):
model_name="product",
name="display_order",
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):
dependencies = [
("purchase", "0003_alter_product_display_order"),
]

View file

@ -7,7 +7,6 @@ import purchase.models
class Migration(migrations.Migration):
dependencies = [
("purchase", "0004_remove_basket_status"),
]
@ -128,7 +127,10 @@ class Migration(migrations.Migration):
model_name="product",
name="image",
field=models.ImageField(
blank=True, null=True, upload_to="", verbose_name="image"
blank=True,
null=True,
upload_to="",
verbose_name="image",
),
),
migrations.AlterField(
@ -140,7 +142,8 @@ class Migration(migrations.Migration):
model_name="product",
name="unit_price_cents",
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(

View file

@ -4,7 +4,7 @@ from django.db import migrations, models
def forwards(apps, schema_editor):
BasketItem = apps.get_model("purchase", "BasketItem")
BasketItem = apps.get_model("purchase", "BasketItem") # noqa: N806
items = (
BasketItem.objects.using(schema_editor.connection.alias)
.all()
@ -16,7 +16,6 @@ def forwards(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("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):
dependencies = [
("purchase", "0006_basketitem_unit_price_cents"),
]

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("purchase", "0007_alter_basketitem_unit_price_cents"),
]

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("purchase", "0008_basketitem_unique_product_per_basket"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("purchase", "0009_basketitemetag"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("purchase", "0010_rename_basketitemetag_cacheetag"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("purchase", "0011_rename_cacheetag_cache"),
]

View file

@ -27,10 +27,10 @@ class PaymentMethodQuerySet(models.QuerySet):
turnover=Coalesce(
Sum(
F("baskets__items__quantity")
* F("baskets__items__unit_price_cents")
* F("baskets__items__unit_price_cents"),
),
0,
)
),
)
def with_sold(self):
@ -71,7 +71,7 @@ class ProductQuerySet(models.QuerySet):
turnover=Coalesce(
Sum(F("basket_items__quantity") * F("basket_items__unit_price_cents")),
0,
)
),
)
def with_sold(self):
@ -87,10 +87,12 @@ class Product(Model):
name = models.CharField(max_length=250, unique=True, verbose_name=_("name"))
image = models.ImageField(null=True, blank=True, verbose_name=_("image"))
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(
default=default_product_display_order, verbose_name=_("display order")
default=default_product_display_order,
verbose_name=_("display order"),
)
objects = ProductManager.from_queryset(ProductQuerySet)()
@ -109,19 +111,21 @@ class Product(Model):
@property
def color_hue(self):
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):
super().save()
super().save(*args, **kwargs)
if not self.image:
return
with Image.open(self.image.path) as img:
img = ImageOps.exif_transpose(img)
with Image.open(self.image.path) as img_file:
img = ImageOps.exif_transpose(img_file)
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
img.thumbnail((width, height))
@ -142,8 +146,8 @@ class Product(Model):
bottom = width
img = img.crop((left, top, right, bottom))
if width > 300 and height > 300:
img.thumbnail((300, 300))
if width > image_max_size and height > image_max_size:
img.thumbnail((image_max_size, image_max_size))
img.save(self.image.path)
@ -151,7 +155,7 @@ class Product(Model):
class BasketQuerySet(models.QuerySet):
def priced(self) -> BasketQuerySet:
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:
@ -220,7 +224,7 @@ class BasketItem(Model):
verbose_name = _("basket item")
verbose_name_plural = _("basket items")
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()
def reports_etag(request):
def reports_etag(_request):
return str(Cache.get_solo().etag)
def reports_last_modified(request):
def reports_last_modified(_request):
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
Cache.get_solo().refresh()

View file

@ -5,6 +5,6 @@ register = template.Library()
@register.filter
def currency(value):
if isinstance(value, int) or isinstance(value, float):
if isinstance(value, int | float):
return f"{value/100:.2f}"
return value

View file

@ -21,7 +21,7 @@ class CashierFactory(factory.django.DjangoModelFactory):
is_staff = True
@factory.post_generation
def groups(self, create, extracted, **kwargs):
def groups(self, create, _extracted, **_kwargs):
if create:
self.groups.add(CashierGroupFactory())
@ -48,7 +48,7 @@ class BasketWithItemsFactory(factory.django.DjangoModelFactory):
payment_method = factory.Iterator(PaymentMethod.objects.all())
@factory.post_generation
def items(self, create, extracted, **kwargs):
def items(self, create, _extracted, **_kwargs):
if create:
products = Product.objects.order_by("?")
quantity = random.randint(1, len(products))
@ -68,7 +68,7 @@ class CashierGroupFactory(factory.django.DjangoModelFactory):
name = "Caissier"
@factory.post_generation
def permissions(self, create, extracted, **kwargs):
def permissions(self, create, _extracted, **_kwargs):
if create:
self.permissions.add(
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")
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)
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)
displayed_products = selenium.find_elements(By.CSS_SELECTOR, ".card.h-100")
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 (
product.name
== 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(
live_server: LiveServer, selenium: WebDriver, cashier: User, url: str = "/"
live_server: LiveServer,
selenium: WebDriver,
cashier: User,
url: str = "/",
) -> None:
# Go to page
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"):
basket_with_payment_method = BasketWithItemsFactory()
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"):
basket_no_payment_method = BasketWithItemsFactory(payment_method=None)
basket_no_payment_method = Basket.objects.priced().get(
pk=basket_no_payment_method.pk
pk=basket_no_payment_method.pk,
)
# Login
@ -268,7 +274,7 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
# Assert redirected to list view
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
@ -277,7 +283,9 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
# Assert redirected to edit view
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)

View file

@ -44,7 +44,9 @@ def update_basket(request: HttpRequest, pk: int) -> HttpResponse:
form = BasketForm(instance=basket)
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":
context = {"basket": basket}
return TemplateResponse(request, "purchase/basket_confirm_delete.html", context)
else:
basket.delete()
messages.success(request, _("Basket successfully deleted."))
return redirect("purchase:list")

View file

@ -2,7 +2,7 @@ import datetime
from io import StringIO
from zoneinfo import ZoneInfo
import matplotlib
import matplotlib as mpl
import numpy as np
from django.conf import settings
from django.contrib import messages
@ -27,7 +27,7 @@ from purchase.models import (
reports_last_modified,
)
matplotlib.use("SVG")
mpl.use("SVG")
@permission_required("purchase.view_basket")
@ -204,5 +204,4 @@ def get_image_from_fig(fig):
image_data = StringIO()
fig.savefig(image_data, format="svg")
image_data.seek(0)
img1 = image_data.getvalue()
return img1
return image_data.getvalue()

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
import requests
from invoke import Context, task
BASE_DIR = Path(__file__).parent.resolve(strict=True)
SRC_DIR = BASE_DIR / "src"
COMPOSE_BUILD_FILE = BASE_DIR / "docker-compose-build.yaml"
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
@ -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
def download_db(ctx: Context) -> None:
with ctx.cd(BASE_DIR):