From d7df0d0e5048de3dbdb070a2e2622bc16cb387a1 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Mon, 23 Mar 2026 12:17:50 +0100 Subject: [PATCH] fix: docker build --- Dockerfile | 30 ++++++++++------- config/settings/base.py | 32 +++++++++++++++++- nginx/nginx.conf | 44 ------------------------- pyproject.toml | 2 +- scripts/prebuild.sh | 4 +++ scripts/run.sh | 3 -- shop/models.py | 69 +++++++++++++++++++++++++++++++-------- shop/utils.py | 46 +++++++++++++------------- uv.lock | 36 ++++++++++---------- web/mixins.py | 4 ++- web/static/css/styles.css | 2 -- web/views/components.py | 23 +++++++++---- 12 files changed, 170 insertions(+), 125 deletions(-) delete mode 100644 nginx/nginx.conf create mode 100644 scripts/prebuild.sh diff --git a/Dockerfile b/Dockerfile index 9ba5acd..a77a6ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,26 @@ -FROM python:3.13-slim - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 +FROM python:3.13-alpine3.23 as builder ENV UV_SYSTEM_PYTHON=1 +COPY pyproject.toml . +RUN pip install uv && uv pip install -r pyproject.toml -RUN mkdir /code +FROM python:3.13-alpine3.23 + +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages WORKDIR /code -COPY pyproject.toml /code -RUN apt-get update && \ - apt-get install -y gcc git nginx gettext vim libpq-dev && \ - pip install uv && \ - uv pip install -r pyproject.toml && \ - apt-get autoremove -y +# Set user and group +ARG user=apps +ARG uid=1001 +ARG gid=1001 +RUN groupadd -g ${gid} ${user} +RUN useradd -u ${uid} -g ${user} -s /bin/sh -m ${user} -COPY . /code +RUN apk update && apk add gcc gettext vim libpq-dev +RUN chown -R ${user}:${user} /code +USER ${user} +COPY --chown=${user}:${user} . /code + +RUN ["sh", "./scripts/prebuild.sh"] CMD ["sh", "./scripts/run.sh"] diff --git a/config/settings/base.py b/config/settings/base.py index 4cc460d..bb2a41d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -103,7 +103,10 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = 'es' -LANGUAGES = (('en', _('Inglés')), ('es', _('Castellano'))) +LANGUAGES = ( + ('en', _('Inglés')), + ('es', _('Castellano')), +) TIME_ZONE = 'Europe/Madrid' @@ -143,3 +146,30 @@ EMAIL_USE_SSL = False CELERY_BROKER_URL = env.str('CELERY_BROKER_URL', default='memory://localhost:8000//') CELERY_TIME_ZONE = TIME_ZONE CELERY_ALWAYS_EAGER = DEBUG + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + }, + 'file': { + 'class': 'logging.FileHandler', + 'filename': 'error.log', + 'level': 'ERROR', + }, + }, + 'loggers': { + '': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'error': { + 'handlers': ['file'], + 'level': 'ERROR', + 'propagate': False + }, + }, +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index 023ba9e..0000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,44 +0,0 @@ -server { - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log notice; - - listen 80; - - # Static files - location /static { - alias /code/static; - } - - # Media files - location /media { - alias /code/media; - } - - # Reverse proxy - location / { - proxy_pass http://127.0.0.1:8000; - proxy_cache_bypass $http_upgrade; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - } - - # security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src * data: 'unsafe-eval' 'unsafe-inline'" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; - - # gzip - gzip on; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; -} diff --git a/pyproject.toml b/pyproject.toml index 4974a7b..98a13bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "boto3==1.34.105", - "celery==5.4.0", + "celery==5.6.2", "django==6.0.2", "django-celery-beat==2.9.0", "django-cors-headers==3.13.0", diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh new file mode 100644 index 0000000..f1f41ff --- /dev/null +++ b/scripts/prebuild.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +python manage.py collectstatic +python manage.py compilemessages diff --git a/scripts/run.sh b/scripts/run.sh index 2f6924f..fd00072 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -8,9 +8,6 @@ WORKER_NUM_PROCESSES=${WORKER_NUM_PROCESSES:=1} RUN_SERVER=${RUN_SERVER:="FALSE"} RUN_CELERY=${RUN_CELERY:="FALSE"} -python manage.py compilemessages -python manage.py build_tailwind_theme - if [ $RUN_SERVER = "TRUE" ]; then uvicorn --workers $ASGI_WORKERS --host $ASGI_HOST --port $ASGI_PORT config.asgi:application elif [ $RUN_CELERY = "TRUE" ]; then diff --git a/shop/models.py b/shop/models.py index 4206074..a1cb03a 100644 --- a/shop/models.py +++ b/shop/models.py @@ -41,7 +41,11 @@ class Tag(models.Model): class ProductCategory(models.Model): name = models.CharField(max_length=32) parent = models.ForeignKey( - 'shop.ProductCategory', on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('categoría padre') + 'shop.ProductCategory', + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('categoría padre'), ) promoted = models.BooleanField(default=False, verbose_name=_('promocionada')) hidden = models.BooleanField(default=False, verbose_name=_('oculta')) @@ -101,7 +105,11 @@ class ProductPrice(TimestampedModel): ) tax = models.ForeignKey('shop.Tax', on_delete=models.PROTECT, verbose_name=_('impuesto aplicable')) price_with_tax = models.DecimalField( - max_digits=11, decimal_places=2, blank=True, null=True, verbose_name=_('precio con impuestos') + max_digits=11, + decimal_places=2, + blank=True, + null=True, + verbose_name=_('precio con impuestos'), ) current = models.BooleanField(default=False, verbose_name=_('es el precio actual')) @@ -329,7 +337,9 @@ class Order(TimestampedModel): status = models.CharField(max_length=3, default=Statuses.STATUS_PENDING, verbose_name=_('estado')) shipping_status = models.CharField( - max_length=3, default=ShippingStatuses.STATUS_NOT_READY, verbose_name=_('estado de envío') + max_length=3, + default=ShippingStatuses.STATUS_NOT_READY, + verbose_name=_('estado de envío'), ) base_total = models.DecimalField( @@ -357,15 +367,25 @@ class Order(TimestampedModel): contact_phone = models.CharField(max_length=32, blank=True, verbose_name=_('teléfono de contacto')) amount_paid = models.DecimalField( - default=Decimal('0.00'), max_digits=11, decimal_places=2, verbose_name=_('cantidad pagada') + default=Decimal('0.00'), + max_digits=11, decimal_places=2, + verbose_name=_('cantidad pagada'), ) shipping_method = models.ForeignKey( - 'shop.ShippingMethod', blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('método de envío') + 'shop.ShippingMethod', + blank=True, + null=True, + on_delete=models.SET_NULL, + verbose_name=_('método de envío'), ) from_cart = models.ForeignKey( - 'shop.Cart', on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('carrito de origen de pedido') + 'shop.Cart', + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('carrito de origen de pedido'), ) def calculate_total_from_lines(self): @@ -407,7 +427,11 @@ class CustomerAddress(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, verbose_name=_('usuario')) vat_id = models.CharField( - max_length=16, blank=True, null=True, default='', verbose_name=_('número de identificación fiscal') + max_length=16, + blank=True, + null=True, + default='', + verbose_name=_('número de identificación fiscal'), ) full_name = models.CharField(max_length=128, blank=False, null=False, verbose_name=_('dirección')) address = models.CharField(max_length=128, blank=False, null=False, verbose_name=_('dirección')) @@ -415,12 +439,19 @@ class CustomerAddress(models.Model): address_zip = models.CharField(max_length=16, blank=False, null=False, verbose_name=_('código postal')) address_state = models.CharField(max_length=64, blank=False, null=False, verbose_name=_('provincia')) address_country = models.CharField( - max_length=64, default=settings.SHOP_COUNTRY, blank=False, null=False, verbose_name=_('país') + max_length=64, + default=settings.SHOP_COUNTRY, + blank=False, + null=False, + verbose_name=_('país'), ) address_phone = models.CharField(max_length=16, blank=True, null=True, default='', verbose_name=_('teléfono')) address_type = models.CharField( - max_length=4, choices=Types.choices, default=Types.SHIPPING, verbose_name=_('tipo de dirección') + max_length=4, + choices=Types.choices, + default=Types.SHIPPING, + verbose_name=_('tipo de dirección'), ) email = models.EmailField(blank=False, null=False, verbose_name=_('e-mail')) default = models.BooleanField(default=True, verbose_name=_('por defecto')) @@ -463,13 +494,19 @@ class Payment(models.Model): hash = models.UUIDField(verbose_name=_('Hash'), default=uuid4, primary_key=True) amount = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal('0'), verbose_name=_('Cantidad')) order = models.ForeignKey( - 'shop.Order', on_delete=models.PROTECT, verbose_name=_('pedido'), related_name='payments' + 'shop.Order', + on_delete=models.PROTECT, + verbose_name=_('pedido'), + related_name='payments', ) user = models.ForeignKey(User, blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('cliente')) creation_date = models.DateTimeField(auto_now_add=True, verbose_name=_('Fecha de creación')) metadata = models.JSONField(default=dict, verbose_name=_('Metadata')) method = models.CharField( - max_length=6, choices=MethodChoices.choices, default=MethodChoices.REDSYS, verbose_name=_('método de pago') + max_length=6, + choices=MethodChoices.choices, + default=MethodChoices.REDSYS, + verbose_name=_('método de pago'), ) class Meta: @@ -480,10 +517,16 @@ class Payment(models.Model): class ShopSettings(SingletonModel): debug = models.BooleanField(default=True, verbose_name=_('Modo depuración')) merchant_code = models.CharField( - max_length=9, blank=False, null=False, verbose_name=_('Identificación de comercio') + max_length=9, + blank=False, + null=False, + verbose_name=_('Identificación de comercio'), ) currency_code = models.CharField( - max_length=4, default='978', choices=CURRENCY_CODES, verbose_name=_('Código de moneda') + max_length=4, + default='978', + choices=CURRENCY_CODES, + verbose_name=_('Código de moneda'), ) # 978 == EURO terminal = models.CharField(max_length=8, verbose_name=_('Terminal')) shared_secret = models.CharField(max_length=100, verbose_name=_('Clave de Redsys')) diff --git a/shop/utils.py b/shop/utils.py index f1d27b7..17699c0 100644 --- a/shop/utils.py +++ b/shop/utils.py @@ -7,14 +7,13 @@ from decimal import Decimal import pyDes from django.contrib.auth import get_user_model from django.db import transaction -from django.db.models import Sum +from django.http import HttpRequest from django.utils import timezone from django.utils.text import gettext_lazy as _ from shop.exceptions import RedsysPaymentException, RedsysValidationException from shop.models import Cart, CartItem, Order, OrderLine, Payment, Product, ProductBatch, ProductPrice, ShippingMethod from shop.settings import ERROR_CODES -from shop.signals import clear_cart User = get_user_model() @@ -69,7 +68,7 @@ def create_order( def delete_product_batch(batch: ProductBatch): product = batch.product - product.stock = max(product.stock - batch.quantity, 0) + product.stock = max(product.stock - batch.quantity, Decimal('0.00')) product.save() batch.delete() @@ -77,20 +76,20 @@ def delete_product_batch(batch: ProductBatch): def create_order_from_cart( cart: Cart, shipping_method: ShippingMethod, - billing_address_full_name='', - billing_address_address='', - billing_address_town='', - billing_address_state='', - billing_address_country='', - billing_address_zip='', - shipping_address_full_name='', - shipping_address_address='', - shipping_address_town='', - shipping_address_state='', - shipping_address_country='', - shipping_address_zip='', - shipping_address_phone='', - email='', + billing_address_full_name: str = '', + billing_address_address: str = '', + billing_address_town: str = '', + billing_address_state: str = '', + billing_address_country: str = '', + billing_address_zip: str = '', + shipping_address_full_name: str = '', + shipping_address_address: str = '', + shipping_address_town: str = '', + shipping_address_state: str = '', + shipping_address_country: str = '', + shipping_address_zip: str = '', + shipping_address_phone: str = '', + email: str = '', ): order = Order.objects.create( billing_address=f'{billing_address_full_name} {billing_address_address}', @@ -155,7 +154,7 @@ def add_shipping_order_line(order, shipping_method): order.calculate_total_from_lines() -def compute_signature(salt, payload, key): +def compute_signature(salt: str, payload: str, key: str) -> bytes: """ :param salt: order number (Ds_Order or Ds_Merchant_Order) :param payload: Ds_MerchantParameters @@ -171,7 +170,7 @@ def compute_signature(salt, payload, key): return base64.b64encode(payload_hash) -def validate_payment_for_order(request, order: Order) -> Decimal: +def validate_payment_for_order(request: HttpRequest, order: Order) -> Decimal: """ example_response_data = { 'Ds_MerchantCode': '999008881', @@ -262,15 +261,18 @@ def update_order_payment_status(order: Order): order.save() -def delete_cart_items_from_order(order): +def delete_cart_items_from_order(order: Order): if order.status == Order.Statuses.STATUS_PAID and order.from_cart: CartItem.objects.filter(cart=order.from_cart).delete() -def add_payment_to_order(order: Order, amount): +def add_payment_to_order(order: Order, amount: Decimal): with transaction.atomic(): payment = Payment.objects.create( - amount=amount, order=order, user=order.user, method=Payment.MethodChoices.REDSYS + amount=amount, + order=order, + user=order.user, + method=Payment.MethodChoices.REDSYS, ) order.amount_paid += payment.amount diff --git a/uv.lock b/uv.lock index 7687968..adbc384 100644 --- a/uv.lock +++ b/uv.lock @@ -100,7 +100,7 @@ wheels = [ [[package]] name = "celery" -version = "5.4.0" +version = "5.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "billiard" }, @@ -110,12 +110,12 @@ dependencies = [ { name = "click-repl" }, { name = "kombu" }, { name = "python-dateutil" }, - { name = "tzdata" }, + { name = "tzlocal" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/9c/cf0bce2cc1c8971bf56629d8f180e4ca35612c7e79e6e432e785261a8be4/celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706", size = 1575692 } +sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802 } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/c4/6a4d3772e5407622feb93dd25c86ce3c0fee746fa822a777a627d56b4f2a/celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64", size = 425983 }, + { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502 }, ] [[package]] @@ -550,18 +550,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/04/da4d140d601609f1afd76c036be0b434165d701c1b9b660db895b4fa1d2f/django_watchman-1.3.0-py2.py3-none-any.whl", hash = "sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb", size = 20090 }, ] -[[package]] -name = "djangorestframework" -version = "3.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235 }, -] - [[package]] name = "executing" version = "2.2.1" @@ -975,7 +963,6 @@ dependencies = [ { name = "django-storages" }, { name = "django-unfold" }, { name = "django-watchman" }, - { name = "djangorestframework" }, { name = "gonk" }, { name = "ipython" }, { name = "pillow" }, @@ -1003,7 +990,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "boto3", specifier = "==1.34.105" }, - { name = "celery", specifier = "==5.4.0" }, + { name = "celery", specifier = "==5.6.2" }, { name = "dj-database-url", specifier = "==1.0.0" }, { name = "django", specifier = "==6.0.2" }, { name = "django-celery-beat", specifier = "==2.9.0" }, @@ -1016,7 +1003,6 @@ requires-dist = [ { name = "django-storages", specifier = "==1.13.2" }, { name = "django-unfold", specifier = "==0.42.0" }, { name = "django-watchman", specifier = "==1.3.0" }, - { name = "djangorestframework", specifier = "==3.15.2" }, { name = "gonk", specifier = "==0.6.1" }, { name = "ipython", specifier = "==8.16.1" }, { name = "pillow", specifier = "==11.0.0" }, @@ -1091,6 +1077,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 }, +] + [[package]] name = "urllib3" version = "2.6.3" diff --git a/web/mixins.py b/web/mixins.py index 1b5d072..9302755 100644 --- a/web/mixins.py +++ b/web/mixins.py @@ -43,4 +43,6 @@ class StylingMixin: field.widget.attrs.update({'class': ' '.join(self.classes)}) if self.placeholder_for_field.get(field_name): - field.widget.attrs.update({'placeholder': self.placeholder_for_field.get(field_name)}) + field.widget.attrs.update({ + 'placeholder': self.placeholder_for_field.get(field_name), + }) diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 97a4a5f..5a69679 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -52,8 +52,6 @@ --leading-tight: 1.25; --radius-md: 0.375rem; --radius-lg: 0.5rem; - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --animate-spin: spin 1s linear infinite; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); diff --git a/web/views/components.py b/web/views/components.py index 251e2fb..80fb7c4 100644 --- a/web/views/components.py +++ b/web/views/components.py @@ -10,7 +10,10 @@ from django.views.generic import TemplateView from config.mixins import HTMXFormComponent from shop.filters import ProductFilter -from shop.models import CartItem, CustomerAddress, Product, ProductPrice, ShippingMethod, WishlistedProduct +from shop.models import ( + CartItem, CustomerAddress, Product, ProductPrice, + ShippingMethod, WishlistedProduct, +) from users.forms.change_password import ChangePasswordForm from users.forms.info import UserInfoForm from web.forms import CustomerAddressForm @@ -140,7 +143,11 @@ class UserInfoFormComponentView(HTMXFormComponent): return reverse('web:user_info_component') def get_initial_values(self, instance): - return {'email': instance.email, 'first_name': instance.first_name, 'last_name': instance.last_name} + return { + 'email': instance.email, + 'first_name': instance.first_name, + 'last_name': instance.last_name + } class ChangePasswordFormComponentView(HTMXFormComponent): @@ -165,18 +172,20 @@ class ListCustomerAddressComponent(TemplateView): template_name = 'components/users/retrieve_update_address.html' def get_context_data(self, **kwargs): - return {'customer_addresses': CustomerAddress.objects.filter(user=self.request.user)} + return { + 'customer_addresses': CustomerAddress.objects.filter(user=self.request.user) + } def update_customer_address(request, pk): customer_address = get_object_or_404(CustomerAddress, pk=pk, user=request.user) form = CustomerAddressForm(request.POST, instance=customer_address) - if form.is_valid(): - form.save() - return HttpResponse(status=200, headers={'HX-Trigger': 'updated_addresses'}) + if not form.is_valid(): + return HttpResponse(form.errors, status=400) - return HttpResponse(form.errors, status=400) + form.save() + return HttpResponse(status=200, headers={'HX-Trigger': 'updated_addresses'}) list_products = ListProducts.as_view()