From 8bf933a4691be0fb5911276bf0fdc731dddeb7fb Mon Sep 17 00:00:00 2001 From: mtyton Date: Sun, 18 Jun 2023 16:18:00 +0200 Subject: [PATCH 01/30] documents are being sent properly, store models has changed a bit, added a lot of tests --- artel/artel/settings/base.py | 1 + artel/artel/settings/dev.py | 8 +- artel/docker-compose.yml | 5 + artel/requirements.txt | 3 +- artel/store/admin.py | 9 +- artel/store/cart.py | 89 +++++--- artel/store/forms.py | 11 +- .../0004_customerdata_order_orderproduct.py | 53 ----- ...nttemplate_order_paymentmethod_and_more.py | 129 +++++++++++ .../0005_documenttemplate_orderdocument.py | 41 ---- .../migrations/0005_order_order_number.py | 17 ++ ...ttemplate_created_at_orderdocument_sent.py | 22 -- artel/store/models.py | 216 +++++++++++++----- artel/store/serializers.py | 16 +- artel/store/static/js/cart.js | 4 +- artel/store/templates/store/cart.html | 11 +- .../store/templates/store/order_confirm.html | 16 +- .../store/partials/summary_cart_item.html | 2 +- artel/store/tests/factories.py | 61 ++++- artel/store/tests/test_cart.py | 120 ++++++++++ artel/store/tests/test_models.py | 120 ++++++++-- artel/store/utils.py | 43 +++- artel/store/views.py | 45 ++-- 23 files changed, 741 insertions(+), 301 deletions(-) delete mode 100644 artel/store/migrations/0004_customerdata_order_orderproduct.py create mode 100644 artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py delete mode 100644 artel/store/migrations/0005_documenttemplate_orderdocument.py create mode 100644 artel/store/migrations/0005_order_order_number.py delete mode 100644 artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py create mode 100644 artel/store/tests/test_cart.py diff --git a/artel/artel/settings/base.py b/artel/artel/settings/base.py index 3576ecb..8e68022 100644 --- a/artel/artel/settings/base.py +++ b/artel/artel/settings/base.py @@ -184,4 +184,5 @@ EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.gmail.com') EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", '') EMAIL_PORT = os.environ.get('EMAIL_PORT', 587) +EMAIL_USE_TLS = True DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'mtyton@tepewu.pl') diff --git a/artel/artel/settings/dev.py b/artel/artel/settings/dev.py index 410b60f..b29e619 100644 --- a/artel/artel/settings/dev.py +++ b/artel/artel/settings/dev.py @@ -9,7 +9,13 @@ SECRET_KEY = "django-insecure-s7hlfa-#n7-v0#&-0ko3(efe+@^d@ie1_1-633e&jb1rh$)j1p # SECURITY WARNING: define the correct hosts in production! ALLOWED_HOSTS = ["*"] -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = "smtp-server" +EMAIL_HOST_USER = None +EMAIL_HOST_PASSWORD = None +EMAIL_PORT = 1025 +EMAIL_USE_TLS = False +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'mtyton@tepewu.pl') try: diff --git a/artel/docker-compose.yml b/artel/docker-compose.yml index 8f4da6c..ddbc229 100644 --- a/artel/docker-compose.yml +++ b/artel/docker-compose.yml @@ -1,5 +1,10 @@ version: "3.8" services: + smtp-server: + image: mailhog/mailhog + ports: + - "1025:1025" + - "8025:8025" comfy: build: dockerfile: Dockerfile.local diff --git a/artel/requirements.txt b/artel/requirements.txt index 89d53de..5a81340 100644 --- a/artel/requirements.txt +++ b/artel/requirements.txt @@ -7,4 +7,5 @@ djangorestframework==3.14.0 phonenumbers==8.13.13 django-phonenumber-field==7.1.0 factory-boy==3.2.1 -pdfkit==1.0.0 \ No newline at end of file +pdfkit==1.0.0 +num2words==0.5.12 \ No newline at end of file diff --git a/artel/store/admin.py b/artel/store/admin.py index afd0798..29ff9a6 100644 --- a/artel/store/admin.py +++ b/artel/store/admin.py @@ -35,10 +35,14 @@ class ProductAdmin(ModelAdmin): list_display = ("title", "price") +class PaymentMethodAdmin(ModelAdmin): + model = models.PaymentMethod + list_display = ("name", "active") + class DocumentTemplateAdmin(ModelAdmin): model = models.DocumentTemplate - list_display = ("name", "doc_type") + list_display = ("name", ) class StoreAdminGroup(ModelAdminGroup): @@ -51,7 +55,8 @@ class StoreAdminGroup(ModelAdminGroup): ProductCategoryParamAdmin, ProductTemplateAdmin, ProductAdmin, - DocumentTemplateAdmin + DocumentTemplateAdmin, + PaymentMethodAdmin ) diff --git a/artel/store/cart.py b/artel/store/cart.py index 814f879..6eb3782 100644 --- a/artel/store/cart.py +++ b/artel/store/cart.py @@ -2,23 +2,23 @@ from abc import ( ABC, abstractmethod ) -from typing import List +from typing import ( + List, + Any +) from dataclasses import dataclass from django.http.request import HttpRequest from django.conf import settings -from store.models import Product - - -@dataclass -class CartItem: - product: Product - quantity: int +from store.models import ( + Product, + ProductAuthor +) class BaseCart(ABC): - def validate_item_id(self, item_id): + def validate_and_get_product(self, item_id): return Product.objects.get(id=item_id) @abstractmethod @@ -43,57 +43,76 @@ class SessionCart(BaseCart): def __init__(self, request: HttpRequest) -> None: super().__init__() self.session = request.session - if not self.session.get(settings.CART_SESSION_ID): - self.session[settings.CART_SESSION_ID] = {} + self._cart = self.session.get(settings.CART_SESSION_ID, None) + if not self._cart: + self._cart = {} + self.session[settings.CART_SESSION_ID] = self._cart + + def save_cart(self): + self.session[settings.CART_SESSION_ID] = self._cart + self.session.modified = True def add_item(self, item_id: int, quantity: int) -> None: # TODO - add logging - self.validate_item_id(item_id) + product = self.validate_and_get_product(item_id) + author = product.author quantity = int(quantity) item_id = int(item_id) - if not self.session[settings.CART_SESSION_ID].get(str(item_id)): - self.session[settings.CART_SESSION_ID][item_id] = quantity - self.session.modified = True + if not self._cart.get(str(author.id)): + self._cart[str(author.id)] = {str(item_id): quantity} + self.save_cart() + elif not self._cart[str(author.id)].get(str(item_id)): + self._cart[str(author.id)].update({str(item_id): quantity}) + self.save_cart() else: - self.update_item_quantity(item_id, quantity) + new_quantity = self._cart[str(author.id)][str(item_id)] + quantity + self.update_item_quantity(item_id, new_quantity) def remove_item(self, item_id: int) -> None: - self.validate_item_id(item_id) + product = self.validate_and_get_product(item_id) + author = product.author try: - self.session[settings.CART_SESSION_ID].pop(item_id) - self.session.modified = True + self._cart[str(author.id)].pop(str(item_id)) + self.save_cart() except KeyError: # TODO - add logging ... def update_item_quantity(self, item_id: int, new_quantity: int) -> None: - self.validate_item_id(item_id) + product = self.validate_and_get_product(item_id) + author = product.author if new_quantity < 1: self.remove_item(item_id) return - try: - self.session[settings.CART_SESSION_ID][str(item_id)] = new_quantity - self.session.modified = True - except KeyError: - # TODO - add logging + if not self._cart.get(str(author.id)): self.add_item(item_id, new_quantity) + return + self._cart[str(author.id)][str(product.id)] = new_quantity + self.save_cart() - def get_items(self) -> List[CartItem]: - _items = [] - for item_id, quantity in self.session[settings.CART_SESSION_ID].items(): - _items.append(CartItem(quantity=quantity, product=Product.objects.get(id=item_id))) - return _items + def get_items(self) -> List[dict[str, dict|str]]: + items: List[dict[str, dict|str]] = [] + for author_id, cart_items in self._cart.items(): + author = ProductAuthor.objects.get(id=int(author_id)) + products = [] + for item_id, quantity in cart_items.items(): + product=Product.objects.get(id=int(item_id)) + products.append({"product": product, "quantity": quantity}) + items.append({"author": author, "products": products}) + return items @property def total_price(self): total = 0 - for item in self.get_items(): - total += item.product.price * int(item.quantity) + for _, cart_items in self._cart.items(): + for item_id, quantity in cart_items.items(): + product = Product.objects.get(id=int(item_id)) + total += product.price * quantity return total def is_empty(self) -> bool: - return not bool(self.session[settings.CART_SESSION_ID].items()) + return not bool(self._cart.items()) def clear(self) -> None: - self.session[settings.CART_SESSION_ID] = {} - self.session.modified = True + self._cart = {} + self.save_cart() diff --git a/artel/store/forms.py b/artel/store/forms.py index 71c148e..74fe4bb 100644 --- a/artel/store/forms.py +++ b/artel/store/forms.py @@ -2,18 +2,9 @@ from django import forms from phonenumber_field.formfields import PhoneNumberField # from phonenumber_field.widgets import PhoneNumberPrefixWidget -from store.models import ( - CustomerData, -) -class CustomerDataForm(forms.ModelForm): - class Meta: - model = CustomerData - fields = [ - "name", "surname", "email", "phone", - "street", "city", "zip_code" - ] +class CustomerDataForm(forms.Form): name = forms.CharField( max_length=255, label="Imię", widget=forms.TextInput(attrs={"class": "form-control"}) diff --git a/artel/store/migrations/0004_customerdata_order_orderproduct.py b/artel/store/migrations/0004_customerdata_order_orderproduct.py deleted file mode 100644 index 8aad27e..0000000 --- a/artel/store/migrations/0004_customerdata_order_orderproduct.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-01 19:00 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion -import phonenumber_field.modelfields - - -class Migration(migrations.Migration): - dependencies = [ - ("store", "0003_product_info_product_name_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="CustomerData", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255)), - ("surname", models.CharField(max_length=255)), - ("email", models.EmailField(max_length=254)), - ("phone", phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), - ("street", models.CharField(max_length=255)), - ("city", models.CharField(max_length=255)), - ("zip_code", models.CharField(max_length=120)), - ("country", models.CharField(max_length=120)), - ], - ), - migrations.CreateModel( - name="Order", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("sent", models.BooleanField(default=False)), - ("customer", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.customerdata")), - ], - ), - migrations.CreateModel( - name="OrderProduct", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("quantity", models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])), - ( - "order", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="products", to="store.order" - ), - ), - ("product", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.product")), - ], - ), - ] diff --git a/artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py b/artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py new file mode 100644 index 0000000..7fc6650 --- /dev/null +++ b/artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 4.1.9 on 2023-06-16 15:33 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0003_product_info_product_name_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentTemplate", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("file", models.FileField(upload_to="documents")), + ( + "doc_type", + models.CharField( + choices=[("agreement", "Agreement"), ("receipt", "Receipt")], max_length=255, unique=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, null=True)), + ], + ), + migrations.CreateModel( + name="Order", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("sent", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="PaymentMethod", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("active", models.BooleanField(default=True)), + ], + ), + migrations.AddField( + model_name="productauthor", + name="city", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="productauthor", + name="country", + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name="productauthor", + name="display_name", + field=models.CharField(blank=True, max_length=255, unique=True), + ), + migrations.AddField( + model_name="productauthor", + name="email", + field=models.EmailField(blank=True, max_length=254), + ), + migrations.AddField( + model_name="productauthor", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None), + ), + migrations.AddField( + model_name="productauthor", + name="street", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="productauthor", + name="surname", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="productauthor", + name="zip_code", + field=models.CharField(blank=True, max_length=120), + ), + migrations.AlterField( + model_name="productauthor", + name="name", + field=models.CharField(blank=True, max_length=255), + ), + migrations.CreateModel( + name="OrderProduct", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("quantity", models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="products", to="store.order" + ), + ), + ("product", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.product")), + ], + ), + migrations.CreateModel( + name="OrderDocument", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sent", models.BooleanField(default=False)), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="documents", to="store.order" + ), + ), + ( + "template", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.documenttemplate"), + ), + ], + ), + migrations.AddField( + model_name="order", + name="payment_method", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.paymentmethod"), + ), + ] diff --git a/artel/store/migrations/0005_documenttemplate_orderdocument.py b/artel/store/migrations/0005_documenttemplate_orderdocument.py deleted file mode 100644 index deb0a75..0000000 --- a/artel/store/migrations/0005_documenttemplate_orderdocument.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-04 09:38 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("store", "0004_customerdata_order_orderproduct"), - ] - - operations = [ - migrations.CreateModel( - name="DocumentTemplate", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255)), - ("file", models.FileField(upload_to="documents")), - ( - "doc_type", - models.CharField(choices=[("agreement", "Agreement"), ("receipt", "Receipt")], max_length=255), - ), - ], - ), - migrations.CreateModel( - name="OrderDocument", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "order", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="documents", to="store.order" - ), - ), - ( - "template", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.documenttemplate"), - ), - ], - ), - ] diff --git a/artel/store/migrations/0005_order_order_number.py b/artel/store/migrations/0005_order_order_number.py new file mode 100644 index 0000000..eb07ce3 --- /dev/null +++ b/artel/store/migrations/0005_order_order_number.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.9 on 2023-06-18 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0004_documenttemplate_order_paymentmethod_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="order_number", + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py b/artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py deleted file mode 100644 index 8b2de2d..0000000 --- a/artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-07 23:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("store", "0005_documenttemplate_orderdocument"), - ] - - operations = [ - migrations.AddField( - model_name="documenttemplate", - name="created_at", - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name="orderdocument", - name="sent", - field=models.BooleanField(default=False), - ), - ] diff --git a/artel/store/models.py b/artel/store/models.py index 720311f..a8c1af2 100644 --- a/artel/store/models.py +++ b/artel/store/models.py @@ -1,4 +1,8 @@ import pdfkit +import datetime + +from decimal import Decimal +from typing import Any from django.db import models from django.core.paginator import ( Paginator, @@ -21,18 +25,42 @@ from wagtail.models import Page from wagtail import fields as wagtail_fields from taggit.managers import TaggableManager from phonenumber_field.modelfields import PhoneNumberField +from num2words import num2words from store.utils import ( - send_mail + notify_user_about_order, + notify_manufacturer_about_order ) -class ProductAuthor(models.Model): - name = models.CharField(max_length=255) - # TODO - add author contact data +class PersonalData(models.Model): + + class Meta: + abstract = True + + name = models.CharField(max_length=255, blank=True) + surname = models.CharField(max_length=255, blank=True) + email = models.EmailField(blank=True) + phone = PhoneNumberField(blank=True) + street = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=255, blank=True) + zip_code = models.CharField(max_length=120, blank=True) + country = models.CharField(max_length=120, blank=True) + + @property + def full_name(self): + return f"{self.name} {self.surname}" + + @property + def full_address(self): + return f"{self.street}, {self.zip_code} {self.city}, {self.country}" + + +class ProductAuthor(PersonalData): + display_name = models.CharField(max_length=255, unique=True, blank=True) def __str__(self): - return self.name + return self.display_name class ProductCategory(ClusterableModel): @@ -111,7 +139,6 @@ class Product(ClusterableModel): @property def main_image(self): images = self.template.images.all() - print(images) if images: return images.first().image @@ -119,6 +146,10 @@ class Product(ClusterableModel): def tags(self): return self.template.tags.all() + @property + def author(self): + return self.template.author + @property def description(self): return self.info or self.template.description @@ -166,33 +197,22 @@ class ProductListPage(Page): FieldPanel("tags") ] -class CustomerData(models.Model): - name = models.CharField(max_length=255) - surname = models.CharField(max_length=255) - email = models.EmailField() - phone = PhoneNumberField() - street = models.CharField(max_length=255) - city = models.CharField(max_length=255) - zip_code = models.CharField(max_length=120) - country = models.CharField(max_length=120) - - @property - def full_name(self): - return f"{self.name} {self.surname}" - - @property - def full_address(self): - return f"{self.street}, {self.zip_code} {self.city}, {self.country}" - class OrderProductManager(models.Manager): - def create_from_cart(self, cart, order): - for item in cart.get_items(): - self.create( - product=item.product, + def create_from_cart(self, items: dict[str, Product|int], order: models.Model): + pks = [] + for item in items: + if item["quantity"] < 1: + # TODO - logging + continue + + pk = self.create( + product=item["product"], order=order, - quantity=item.quantity - ) + quantity=item["quantity"] + ).pk + pks.append(pk) + return self.filter(pk__in=pks) class OrderProduct(models.Model): @@ -204,41 +224,107 @@ class OrderProduct(models.Model): class OrderManager(models.Manager): - def create_from_cart(self, cart, customer_data): - order = self.create(customer=customer_data) - OrderProduct.objects.create_from_cart(cart, order) - # create proper documents - # NOTE - this is temporary - # agreement_template = DocumentTemplate.objects.filter( - # doc_type=DocumentTypeChoices.AGREEMENT - # ).order_by("-created_at").first() - # receipt_template = DocumentTemplate.objects.filter( - # doc_type=DocumentTypeChoices.RECEIPT - # ).order_by("-created_at").first() - # agreement = OrderDocument.objects.create( - # order=order, - # template=agreement_template - # ) - # receipt = OrderDocument.objects.create( - # order=order, - # template=receipt_template - # ) - #send_mail(agreement) - #send_mail(receipt) - return order + + def _get_order_number(self, author: ProductAuthor): + number_of_prev_orders = OrderProduct.objects.filter( + product__template__author=author + ).values("order").distinct().count() + number_of_prev_orders += 1 + year = datetime.datetime.now().year + return f"{author.id}/{number_of_prev_orders:06}/{year}" + + def create_from_cart( + self, cart_items: list[dict[str, str|dict]], + payment_method: models.Model| None, + customer_data: dict[str, Any] + ) -> models.QuerySet: + # split cart + orders_pks = [] + + payment_method = payment_method or PaymentMethod.objects.first() + agreement_template = DocumentTemplate.objects.get(doc_type=DocumentTypeChoices.AGREEMENT) + receipt_template = DocumentTemplate.objects.get(doc_type=DocumentTypeChoices.RECEIPT) + + for item in cart_items: + author = item["author"] + author_products = item["products"] + + order = self.create( + payment_method=payment_method, + order_number=self._get_order_number(author) + ) + OrderProduct.objects.create_from_cart(author_products, order) + orders_pks.append(order.pk) + agreement = OrderDocument.objects.create( + order=order, + template=agreement_template + ) + receipt = OrderDocument.objects.create( + order=order, + template=receipt_template + ) + extra_document_kwargs = { + "customer_data": customer_data + } + default_kwargs ={ + "docs": [ + agreement.generate_document(extra_document_kwargs), + receipt.generate_document(extra_document_kwargs) + ], + "order_number": order.order_number + } + user_kwargs = { + "customer_email": customer_data["email"], + } + user_kwargs.update(default_kwargs) + user_notified = notify_user_about_order(**user_kwargs) + manufacturer_kwargs = { + "manufacturer_email": author.email, + } + manufacturer_kwargs.update(default_kwargs) + manufacturer_notified = notify_manufacturer_about_order(**manufacturer_kwargs) + sent = user_notified and manufacturer_notified + agreement.sent = sent + receipt.sent = sent + agreement.save() + receipt.save() + return Order.objects.filter(pk__in=orders_pks) + + +class PaymentMethod(models.Model): + name = models.CharField(max_length=255) + + description = models.TextField(blank=True) + active = models.BooleanField(default=True) class Order(models.Model): - customer = models.ForeignKey(CustomerData, on_delete=models.CASCADE) + payment_method = models.ForeignKey(PaymentMethod, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) sent = models.BooleanField(default=False) - + order_number = models.CharField(max_length=255, null=True) + objects = OrderManager() @property - def order_number(self) -> str: - return f"{self.id:06}/{self.created_at.year}" + def manufacturer(self) -> str: + return self.products.first().product.author + + @property + def total_price(self) -> Decimal: + return sum( + [order_product.product.price * order_product.quantity + for order_product in self.products.all()] + ) + + @property + def total_price_words(self) -> str: + return num2words(self.total_price, lang="pl", to="currency", currency="PLN") + + @property + def payment_date(self) -> datetime.date: + return self.created_at.date() + datetime.timedelta(days=7) class DocumentTypeChoices(models.TextChoices): @@ -249,7 +335,8 @@ class DocumentTypeChoices(models.TextChoices): class DocumentTemplate(models.Model): name = models.CharField(max_length=255) file = models.FileField(upload_to="documents") - doc_type = models.CharField(max_length=255, choices=DocumentTypeChoices.choices) + # there may be only one document of each type + doc_type = models.CharField(max_length=255, choices=DocumentTypeChoices.choices, unique=True) created_at = models.DateTimeField(auto_now_add=True, null=True) def __str__(self): @@ -264,16 +351,19 @@ class OrderDocument(models.Model): def get_document_context(self): _context = { "order": self.order, - "customer": self.order.customer, - "products": self.order.products.all(), + "author": self.order.manufacturer, + "order_products": self.order.products.all(), + "payment_data": self.order.payment_method, } return Context(_context) - @property - def document(self): - with open(self.template.file.path, "rb") as f: + def generate_document(self, extra_context: dict = None): + extra_context = extra_context or {} + context = self.get_document_context() + context.update(extra_context) + + with open(self.template.file.path, "r", encoding="utf-8") as f: content = f.read() template = Template(content) - context = self.get_document_context() content = template.render(context) return pdfkit.from_string(content, False) diff --git a/artel/store/serializers.py b/artel/store/serializers.py index 584bb49..682a68f 100644 --- a/artel/store/serializers.py +++ b/artel/store/serializers.py @@ -1,6 +1,9 @@ from rest_framework import serializers -from store.models import Product +from store.models import ( + Product, + ProductAuthor +) class TagSerializer(serializers.Serializer): @@ -21,6 +24,17 @@ class CartProductSerializer(serializers.Serializer): quantity = serializers.IntegerField() +class ProductAuthorSerializer(serializers.Serializer): + class Meta: + model = ProductAuthor + fields = ["display_name"] + + +class CartSerializer(serializers.Serializer): + author = ProductAuthorSerializer() + products = CartProductSerializer(many=True) + + class CartProductAddSerializer(serializers.Serializer): product_id = serializers.IntegerField() diff --git a/artel/store/static/js/cart.js b/artel/store/static/js/cart.js index 34cf520..0d40000 100644 --- a/artel/store/static/js/cart.js +++ b/artel/store/static/js/cart.js @@ -133,7 +133,9 @@ $(document).on('click', '.add-to-cart-button', function(event) { data: formData, // Use the serialized form data headers: { 'X-CSRFToken': csrfToken }, dataType: 'json', - success: location.reload(), + success: function(data) { + setTimeout(location.reload(), 500) + }, processData: false, // Prevent jQuery from processing the data contentType: false, // Let the browser set the content type }); diff --git a/artel/store/templates/store/cart.html b/artel/store/templates/store/cart.html index 5ecf9b3..4fb1df7 100644 --- a/artel/store/templates/store/cart.html +++ b/artel/store/templates/store/cart.html @@ -9,15 +9,20 @@

Koszyk

- {% for item in cart.get_items %} - {% include 'store/partials/cart_item.html' %} + {% for group in cart.get_items %} + {% if group.products %} +

Wykonawca: {{group.author.display_name}}

+ {% for item in group.products %} + {% include 'store/partials/cart_item.html' %} + {% endfor %} + {% endif %} {% endfor %}
-
Do zapłaty: {{cart.total_price}}
+
W sumie do zapłaty: {{cart.total_price}}
Dalej diff --git a/artel/store/templates/store/order_confirm.html b/artel/store/templates/store/order_confirm.html index 4c1ee87..75d9436 100644 --- a/artel/store/templates/store/order_confirm.html +++ b/artel/store/templates/store/order_confirm.html @@ -14,7 +14,7 @@

Imię i Nazwisko

-

{{customer_data.full_name}}

+

{{customer_data.name}} {{customer_data.surname}}


@@ -41,7 +41,10 @@

Adres

-

{{customer_data.full_address}}

+

+ {{customer_data.city}}, {{customer_data.zip_code}}
+ {{customer_data.street}} +

@@ -54,8 +57,13 @@

Zamówione przedmioty

- {% for item in cart.get_items %} - {% include 'store/partials/summary_cart_item.html' %} + {% for group in cart.get_items %} + {% if group.products %} +

Wykonawca: {{group.author.display_name}}

+ {% for item in group.products %} + {% include 'store/partials/summary_cart_item.html' %} + {% endfor %} + {% endif %} {% endfor %}
diff --git a/artel/store/templates/store/partials/summary_cart_item.html b/artel/store/templates/store/partials/summary_cart_item.html index cb45c4a..662af8a 100644 --- a/artel/store/templates/store/partials/summary_cart_item.html +++ b/artel/store/templates/store/partials/summary_cart_item.html @@ -1,6 +1,6 @@ {% load static %} -
+
diff --git a/artel/store/tests/factories.py b/artel/store/tests/factories.py index 0f6cb2c..a7568f3 100644 --- a/artel/store/tests/factories.py +++ b/artel/store/tests/factories.py @@ -1,16 +1,17 @@ from factory import ( Faker, - SubFactory + SubFactory, + Factory ) from factory.django import ( FileField, - DjangoModelFactory + DjangoModelFactory, ) -class CustomerDataFactory(DjangoModelFactory): +class ProductAuthorFactory(DjangoModelFactory): class Meta: - model = 'store.CustomerData' + model = 'store.ProductAuthor' name = Faker('name') surname = Faker('name') @@ -20,13 +21,61 @@ class CustomerDataFactory(DjangoModelFactory): city = Faker('city') zip_code = Faker('postcode') country = Faker('country') + display_name = Faker('name') + + +class ProductCategoryFactory(DjangoModelFactory): + class Meta: + model = 'store.ProductCategory' + + name = Faker('name') + + +class ProductCategoryParamFactory(DjangoModelFactory): + class Meta: + model = 'store.ProductCategoryParam' + + key = Faker('name') + category = SubFactory(ProductCategoryFactory) + param_type = 'str' + + +class ProductTemplateFactory(DjangoModelFactory): + class Meta: + model = 'store.ProductTemplate' + + title = Faker('name') + description = Faker('text') + code = Faker('name') + author = SubFactory(ProductAuthorFactory) + category = SubFactory(ProductCategoryFactory) + + +class ProductFactory(DjangoModelFactory): + class Meta: + model = 'store.Product' + + name = Faker('name') + info = Faker('text') + price = Faker('pydecimal', left_digits=5, right_digits=2, positive=True) + available = Faker('boolean') + template = SubFactory(ProductTemplateFactory) + + +class PaymentMethodFactory(DjangoModelFactory): + class Meta: + model = 'store.PaymentMethod' + + name = Faker('name') + description = Faker('text') + active = Faker('boolean') class OrderFactory(DjangoModelFactory): class Meta: model = 'store.Order' - customer = SubFactory(CustomerDataFactory) + payment_method = SubFactory(PaymentMethodFactory) created_at = Faker('date_time') updated_at = Faker('date_time') sent = Faker('boolean') @@ -38,4 +87,4 @@ class DocumentTemplateFactory(DjangoModelFactory): name = Faker('name') file = FileField(filename="doc.odt") - doc_type = "AGREEMENT" + doc_type = "agreement" diff --git a/artel/store/tests/test_cart.py b/artel/store/tests/test_cart.py new file mode 100644 index 0000000..5704e43 --- /dev/null +++ b/artel/store/tests/test_cart.py @@ -0,0 +1,120 @@ +from rest_framework.test import APITestCase +from django.urls import reverse +from django.conf import settings + +from store.tests import factories + + + +class SessionCartTestCase(APITestCase): + + def setUp(self): + super().setUp() + self.product = factories.ProductFactory(price=100) + self.second_product = factories.ProductFactory(price=200) + + def test_add_item_simple_success(self): + self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.product.id, "quantity": 1}, + ) + self.assertEqual( + self.client.session[settings.CART_SESSION_ID][str(self.product.author.id)][str(self.product.id)], + 1 + ) + + def test_add_item_complex_success(self): + self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.product.id, "quantity": 1}, + ) + self.assertEqual( + self.client.session[settings.CART_SESSION_ID][str(self.product.author.id)][str(self.product.id)], + 1 + ) + self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.product.id, "quantity": 1}, + ) + self.assertEqual( + self.client.session[settings.CART_SESSION_ID][str(self.product.author.id)][str(self.product.id)], + 2 + ) + self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.second_product.id, "quantity": 5}, + ) + final_dict = { + str(self.product.author.id): { + str(self.product.id): 2, + } + } + final_dict.update({ + str(self.second_product.author.id): { + str(self.second_product.id): 5, + } + }) + self.assertDictEqual( + self.client.session[settings.CART_SESSION_ID], final_dict + ) + + def test_add_item_invalid_product_id(self): + response = self.client.post( + reverse("cart-action-add-product"), + {"product_id": 999, "quantity": 1}, + ) + self.assertEqual(response.status_code, 400) + + def test_add_item_invalid_quantity(self): + response = self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.product.id, "quantity": "invalid"}, + ) + self.assertEqual(response.status_code, 400) + + def test_remove_item_success(self): + self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.product.id, "quantity": 1}, + ) + self.assertEqual( + self.client.session[settings.CART_SESSION_ID][str(self.product.author.id)][str(self.product.id)], + 1 + ) + self.client.post( + reverse("cart-action-remove-product"), + {"product_id": self.product.id}, + ) + self.assertEqual(self.client.session[settings.CART_SESSION_ID], {str(self.product.author.id): {}}) + + def test_remove_item_invalid_product_id(self): + response = self.client.post( + reverse("cart-action-remove-product"), + {"product_id": 999}, + ) + self.assertEqual(response.status_code, 400) + + def test_update_item_quantity_success(self): + self.client.post( + reverse("cart-action-add-product"), + {"product_id": self.product.id, "quantity": 1}, + ) + self.assertEqual( + self.client.session[settings.CART_SESSION_ID][str(self.product.author.id)][str(self.product.id)], + 1 + ) + self.client.put( + reverse("cart-action-update-product", kwargs={"pk": self.product.id}), + {"quantity": 5}, + ) + self.assertEqual( + self.client.session[settings.CART_SESSION_ID][str(self.product.author.id)][str(self.product.id)], + 5 + ) + + def test_update_item_quantity_invalid_product_id(self): + response = self.client.put( + reverse("cart-action-update-product", kwargs={"pk": 2137}), + {"quantity": 5}, + ) + self.assertEqual(response.status_code, 404) diff --git a/artel/store/tests/test_models.py b/artel/store/tests/test_models.py index 162facf..55aa5d8 100644 --- a/artel/store/tests/test_models.py +++ b/artel/store/tests/test_models.py @@ -1,5 +1,6 @@ from django.test import TestCase from django.urls import reverse +from django.core import mail from store.tests import factories from store import models as store_models @@ -9,33 +10,102 @@ from store import models as store_models # https://factoryboy.readthedocs.io/en/stable/ # TODO - test have to rewritten - I'll do it tommorow -class OrderDocumentTestCase(TestCase): + +class OrderProductTestCase(TestCase): def setUp(self): super().setUp() + self.author = factories.ProductAuthorFactory() self.order = factories.OrderFactory() - self.document_template = factories.DocumentTemplateFactory(file__data="test") - - def test_generate_document_success(self): - order_doc = store_models.OrderDocument.objects.create( - order=self.order, - template=self.document_template - ) - document = order_doc.document - self.assertIsInstance(document, bytes) - - def test_get_document_context_success(self): - order_doc = store_models.OrderDocument.objects.create( - order=self.order, - template=self.document_template - ) - context = order_doc.get_document_context() - self.assertIsInstance(context, store_models.Context) - self.assertEqual(context["order"].id, self.order.id) - self.assertEqual(context["customer"].id, self.order.customer.id) - self.assertEqual(context["products"].count(), 0) + self.product = factories.ProductFactory(template__author=self.author, price=100) + self.second_product = factories.ProductFactory(template__author=self.author, price=200) - def test_send_order_document_mail_success(self): - ... - def test_send_order_document_mail_failure_wrong_email(self): - ... + def test_create_from_cart_single_product_success(self): + products = store_models.OrderProduct.objects.create_from_cart( + items=[{"product": self.product, "quantity": 1}], + order=self.order + ) + self.assertEqual(products.count(), 1) + + def test_create_from_cart_multiple_products_success(self): + products = store_models.OrderProduct.objects.create_from_cart( + items=[ + {"product": self.product, "quantity": 1}, + {"product": self.second_product, "quantity": 1} + ], + order=self.order + ) + self.assertEqual(products.count(), 2) + + def test_create_from_cart_wrong_quanitity_failure(self): + products = store_models.OrderProduct.objects.create_from_cart( + items=[{"product": self.product, "quantity": -123}], + order=self.order + ) + self.assertEqual(products.count(), 0) + + + def test_create_from_cart_empty_data_failure(self): + products = store_models.OrderProduct.objects.create_from_cart( + items=[], + order=self.order + ) + self.assertEqual(products.count(), 0) + + +class OrderTestCase(TestCase): + def setUp(self) -> None: + super().setUp() + self.author = factories.ProductAuthorFactory() + self.second_author = factories.ProductAuthorFactory() + self.customer_data = { + "first_name": "Jan", + "last_name": "Kowalski", + "email": "jan.kowalski@tepewu.pl", + "phone": "", + "address": "", + "postal_code": "", + "city": "", + "country": "", + + } + self.payment_method = factories.PaymentMethodFactory() + factories.DocumentTemplateFactory() + factories.DocumentTemplateFactory(doc_type="receipt") + + def test_create_from_cart_success_single_author(self): + product = factories.ProductFactory(template__author=self.author, price=100) + cart_items = [{ + "author": self.author, + "products": [{"product": product, "quantity": 1}] + }] + orders = store_models.Order.objects.create_from_cart( + cart_items=cart_items, + customer_data=self.customer_data, + payment_method=self.payment_method + ) + self.assertEqual(orders.count(), 1) + self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[0].subject, f"Zamówienie {orders[0].order_number}") + + + def test_create_from_cart_success_multpile_authors(self): + product = factories.ProductFactory(template__author=self.second_author, price=100) + cart_items = [ + { + "author": self.author, + "products": [{"product": product, "quantity": 1}] + }, { + "author": self.second_author, + "products": [{"product": product, "quantity": 1}] + } + ] + orders = store_models.Order.objects.create_from_cart( + cart_items=cart_items, + customer_data=self.customer_data, + payment_method=self.payment_method + ) + self.assertEqual(orders.count(), 2) + self.assertEqual(len(mail.outbox), 4) + self.assertEqual(mail.outbox[0].subject, f"Zamówienie {orders[0].order_number}") + self.assertEqual(mail.outbox[2].subject, f"Zamówienie {orders[1].order_number}") diff --git a/artel/store/utils.py b/artel/store/utils.py index 9a9c7bd..4ffe0d2 100644 --- a/artel/store/utils.py +++ b/artel/store/utils.py @@ -1,18 +1,39 @@ +from typing import Any from django.core.mail import EmailMessage from django.conf import settings +from django.db.models import QuerySet -# TODO - add celery task for sending not sent earlier -def send_mail(order_doc): - order = order_doc.order +def send_mail( + to: list[str], docs: Any, order_number: str, + subject: str, body: str + ): message = EmailMessage( - subject=f"Zamówienie {order.order_number}", - body="Dokumenty dla Twojego zamówienia", + subject=subject, + body=body, from_email=settings.DEFAULT_FROM_EMAIL, - to=[order.customer.email] + to=to ) - message.attach(f"{order.order_number}.pdf", order_doc.document, "application/pdf") - sent = bool(message.send()) - order_doc.sent = sent - order_doc.save() - return sent + for doc in docs: + message.attach(f"{order_number}.pdf", doc, "application/pdf") + return bool(message.send()) + + +def notify_user_about_order(customer_email, docs, order_number): + return send_mail( + to=[customer_email], + docs=docs, + order_number=order_number, + subject=f"Zamówienie {order_number}", + body="Dokumenty dla Twojego zamówienia" + ) + + +def notify_manufacturer_about_order(manufacturer_email, docs, order_number): + return send_mail( + to=[manufacturer_email], + docs=docs, + order_number=order_number, + subject=f"Złożono zamówienie {order_number}", + body="Dokumenty dla złożonego zamówienia" + ) \ No newline at end of file diff --git a/artel/store/views.py b/artel/store/views.py index d92fb1d..4b349ca 100644 --- a/artel/store/views.py +++ b/artel/store/views.py @@ -13,16 +13,13 @@ from rest_framework.response import Response from store.cart import SessionCart from store.serializers import ( - CartProductSerializer, + CartSerializer, CartProductAddSerializer ) from store.forms import CustomerDataForm from store.models import ( - CustomerData, Order, - OrderProduct, - OrderDocument, - DocumentTemplate + Product ) @@ -43,12 +40,13 @@ class CartView(TemplateView): class CartActionView(ViewSet): + # TODO - test this, currently not in use @action(detail=False, methods=["get"], url_path="list-products") def list_products(self, request): # get cart items cart = SessionCart(self.request) items = cart.get_items() - serializer = CartProductSerializer(instance=items, many=True) + serializer = CartSerializer(instance=items, many=True) return Response(serializer.data) @action(detail=False, methods=["post"]) @@ -58,27 +56,32 @@ class CartActionView(ViewSet): if not serializer.is_valid(): return Response(serializer.errors, status=400) serializer.save(cart) - items = cart.get_items() - serializer = CartProductSerializer(instance=items, many=True) + serializer = CartSerializer(instance=items, many=True) return Response(serializer.data, status=201) @action(detail=False, methods=["post"]) def remove_product(self, request): cart = SessionCart(self.request) product_id = request.POST.get("product_id") - cart.remove_item(product_id) + try: + cart.remove_item(product_id) + except Product.DoesNotExist: + return Response({"error": "Product does not exist"}, status=400) items = cart.get_items() - serializer = CartProductSerializer(instance=items, many=True) + serializer = CartSerializer(instance=items, many=True) return Response(serializer.data, status=201) @action(detail=True, methods=["put"]) def update_product(self, request, pk): cart = SessionCart(self.request) - cart.update_item_quantity(pk, int(request.data["quantity"])) + try: + cart.update_item_quantity(pk, int(request.data["quantity"])) + except Product.DoesNotExist: + return Response({"error": "Product does not exist"}, status=404) items = cart.get_items() - serializer = CartProductSerializer(instance=items, many=True) + serializer = CartSerializer(instance=items, many=True) return Response(serializer.data, status=201) @@ -104,13 +107,12 @@ class OrderView(View): return HttpResponseRedirect(reverse("cart")) form = CustomerDataForm(request.POST) if not form.is_valid(): - print(form.errors) context = self.get_context_data() context["form"] = form return render(request, self.template_name, context) - customer_data = form.save() - request.session["customer_data_id"] = customer_data.id - # TODO - add this page + customer_data = form.data + # TODO - add encryption + request.session["customer_data"] = customer_data return HttpResponseRedirect(reverse("order-confirm")) @@ -118,7 +120,7 @@ class OrderConfirmView(View): template_name = "store/order_confirm.html" def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - customer_data = CustomerData.objects.get(id=self.request.session["customer_data_id"]) + customer_data = self.request.session["customer_data"] return { "cart": SessionCart(self.request), "customer_data": customer_data @@ -132,12 +134,13 @@ class OrderConfirmView(View): return render(request, self.template_name, self.get_context_data()) def post(self, request): - customer_data = CustomerData.objects.get(id=self.request.session["customer_data_id"]) + customer_data = request.session["customer_data"] cart = SessionCart(self.request) - order = Order.objects.create_from_cart( - cart, customer_data + Order.objects.create_from_cart( + cart.get_items(), + None, customer_data ) - self.request.session.pop("customer_data_id") + request.session.pop("customer_data") cart.clear() # TODO - messages return HttpResponseRedirect(reverse("cart")) From aabab2d67d82e433afc63ec7ff4bb2ba69038050 Mon Sep 17 00:00:00 2001 From: mtyton Date: Sun, 18 Jun 2023 20:58:04 +0200 Subject: [PATCH 02/30] added toast messages --- artel/artel/settings/base.py | 13 +++++++++++++ artel/artel/static/js/artel.js | 5 +++++ artel/artel/templates/base.html | 7 ++++++- artel/store/views.py | 8 +++++--- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/artel/artel/settings/base.py b/artel/artel/settings/base.py index 8e68022..66d3794 100644 --- a/artel/artel/settings/base.py +++ b/artel/artel/settings/base.py @@ -172,6 +172,19 @@ WAGTAILSEARCH_BACKENDS = { # e.g. in notification emails. Don't include '/admin' or a trailing slash WAGTAILADMIN_BASE_URL = "https://artel.tepewu.pl" +# Messages +from django.contrib import messages + +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" +MESSAGE_TAGS = { + messages.DEBUG: "debug", + messages.INFO: "info", + messages.SUCCESS: "success", + messages.WARNING: "warning", + messages.ERROR: "danger", +} + + # STORE SETTINGS PRODUCTS_PER_PAGE = 6 diff --git a/artel/artel/static/js/artel.js b/artel/artel/static/js/artel.js index e69de29..ffe2a1f 100644 --- a/artel/artel/static/js/artel.js +++ b/artel/artel/static/js/artel.js @@ -0,0 +1,5 @@ + +// close all alerts after 2 seconds +$('.alert').fadeTo(2000, 500).slideUp(500, function(){ + $("#success-alert").slideUp(500); +}); diff --git a/artel/artel/templates/base.html b/artel/artel/templates/base.html index 9b18cdf..086f284 100644 --- a/artel/artel/templates/base.html +++ b/artel/artel/templates/base.html @@ -41,16 +41,21 @@ {% main_menu max_levels=3 template="menu/custom_main_menu.html" %}
+ {% for message in messages %} + + {% endfor %} {% block content %}{% endblock %}
{# Global javascript #} - + {% block extra_js %} {# Override this in templates to add extra javascript #} diff --git a/artel/store/views.py b/artel/store/views.py index 4b349ca..384e43b 100644 --- a/artel/store/views.py +++ b/artel/store/views.py @@ -7,6 +7,7 @@ from django.views.generic import ( from django.shortcuts import render from django.urls import reverse from django.http import HttpResponseRedirect +from django.contrib import messages from rest_framework.viewsets import ViewSet from rest_framework.decorators import action from rest_framework.response import Response @@ -96,15 +97,16 @@ class OrderView(View): def get(self, request, *args, **kwargs): cart = SessionCart(self.request) if cart.is_empty(): - # TODO - messages + messages.error(request, "Twój koszyk jest pusty") return HttpResponseRedirect(reverse("cart")) return render(request, self.template_name, self.get_context_data()) def post(self, request, *args, **kwargs): - # TODO - messages cart = SessionCart(self.request) if cart.is_empty(): + messages.error(request, "Twój koszyk jest pusty") return HttpResponseRedirect(reverse("cart")) + form = CustomerDataForm(request.POST) if not form.is_valid(): context = self.get_context_data() @@ -142,5 +144,5 @@ class OrderConfirmView(View): ) request.session.pop("customer_data") cart.clear() - # TODO - messages + messages.success(request, "Zamówienie zostało złożone, sprawdź swój email.") return HttpResponseRedirect(reverse("cart")) From 70991fc2ea65ddb9b8295a37a4210b39b96275df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Tyto=C5=84?= Date: Mon, 19 Jun 2023 18:51:25 +0000 Subject: [PATCH 03/30] small docker-compsoe fixes --- artel/docker-compose-prod.yml | 2 ++ artel/docker-compose.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/artel/docker-compose-prod.yml b/artel/docker-compose-prod.yml index 23a0c08..237b033 100644 --- a/artel/docker-compose-prod.yml +++ b/artel/docker-compose-prod.yml @@ -20,6 +20,7 @@ services: build: dockerfile: Dockerfile context: ./ + user: "${UID}:${GID}" restart: always ports: - "8001:8000" @@ -38,6 +39,7 @@ services: web: image: nginx + restart: always volumes: - ../nginx/conf.d/:/etc/nginx/conf.d/ - ./static/:/opt/services/comfy/static diff --git a/artel/docker-compose.yml b/artel/docker-compose.yml index ddbc229..280cffa 100644 --- a/artel/docker-compose.yml +++ b/artel/docker-compose.yml @@ -9,6 +9,7 @@ services: build: dockerfile: Dockerfile.local context: ./ + user: "${UID}:${GID}" ports: - "8000:8000" volumes: From 1326b13948304f9bced9b2e75016b79dfd140f96 Mon Sep 17 00:00:00 2001 From: mtyton Date: Thu, 22 Jun 2023 10:41:42 +0200 Subject: [PATCH 04/30] [WIP] working on mailing templates --- artel/artel/settings/base.py | 1 + artel/mailings/__init__.py | 0 artel/mailings/admin.py | 3 + artel/mailings/apps.py | 6 ++ artel/mailings/migrations/0001_initial.py | 35 +++++++++ artel/mailings/migrations/__init__.py | 0 artel/mailings/models.py | 94 +++++++++++++++++++++++ artel/mailings/tests.py | 64 +++++++++++++++ artel/mailings/views.py | 3 + artel/requirements.txt | 2 +- 10 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 artel/mailings/__init__.py create mode 100644 artel/mailings/admin.py create mode 100644 artel/mailings/apps.py create mode 100644 artel/mailings/migrations/0001_initial.py create mode 100644 artel/mailings/migrations/__init__.py create mode 100644 artel/mailings/models.py create mode 100644 artel/mailings/tests.py create mode 100644 artel/mailings/views.py diff --git a/artel/artel/settings/base.py b/artel/artel/settings/base.py index 66d3794..0b62259 100644 --- a/artel/artel/settings/base.py +++ b/artel/artel/settings/base.py @@ -26,6 +26,7 @@ BASE_DIR = os.path.dirname(PROJECT_DIR) INSTALLED_APPS = [ "home", "store", + "mailings", "blog", "search", "wagtail.contrib.forms", diff --git a/artel/mailings/__init__.py b/artel/mailings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artel/mailings/admin.py b/artel/mailings/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/artel/mailings/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/artel/mailings/apps.py b/artel/mailings/apps.py new file mode 100644 index 0000000..c2ec5b6 --- /dev/null +++ b/artel/mailings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MailingsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'mailings' diff --git a/artel/mailings/migrations/0001_initial.py b/artel/mailings/migrations/0001_initial.py new file mode 100644 index 0000000..3b6fdfc --- /dev/null +++ b/artel/mailings/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.9 on 2023-06-21 18:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="MailTemplate", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("template_name", models.CharField(max_length=255, unique=True)), + ("template", models.FileField(upload_to="mail_templates")), + ("subject", models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name="OutgoingEmail", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sender", models.EmailField(max_length=254)), + ("recipient", models.EmailField(max_length=254)), + ("sent", models.BooleanField(default=False)), + ( + "template", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="mailings.mailtemplate"), + ), + ], + ), + ] diff --git a/artel/mailings/migrations/__init__.py b/artel/mailings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artel/mailings/models.py b/artel/mailings/models.py new file mode 100644 index 0000000..dd5d119 --- /dev/null +++ b/artel/mailings/models.py @@ -0,0 +1,94 @@ +from typing import Any +from dataclasses import dataclass + +from django.db import models +from django.db import transaction +from django.template import ( + Template, + Context +) +from django.core.mail import EmailMessage +from django.conf import settings + + +@dataclass +class Attachment: + name: str + content: Any + contenttype: str + + +def send_mail( + to: list[str], + attachments: list[Attachment], + subject: str, + body: str, + sender_email: str = settings.DEFAULT_FROM_EMAIL + ): + message = EmailMessage( + subject=subject, + body=body, + from_email=sender_email, + to=to + ) + for attachment in attachments: + message.attach(attachment.name, attachment.content, attachment.contenttype) + return bool(message.send()) + + +class MailTemplate(models.Model): + template_name = models.CharField(max_length=255, unique=True) + + template = models.FileField( + upload_to="mail_templates" + ) + subject = models.CharField(max_length=255) + + def delete(self, *args, **kwargs): + # delete file + super().delete(*args, **kwargs) + + @transaction.on_commit + def remove_template_file(self): + self.template.delete() + + def load_and_process_template(self, context: dict|Context): + if not self.template: + raise FileNotFoundError("Template file is missing") + if isinstance(context, dict): + context = Context(context) + with open(self.template.path, "r", encoding="utf-8") as f: + content = f.read() + template = Template(content) + return template.render(context) + + +class OutgoingEmailManager(models.Manager): + + def send( + self, template_name: str, + recipient: str, context: dict | Context, + sender:str, attachments: list[Attachment] = None + ): + template = MailTemplate.objects.get(template_name=template_name) + outgoing_email = self.create(template=template, recipient=recipient) + attachments = attachments or [] + # send email + sent = send_mail( + to=[recipient], sender_email=sender, + subject=template.subject, content=template.load_and_process_template(context), + attachments=attachments + ) + outgoing_email.sent = sent + outgoing_email.save() + return outgoing_email + + +class OutgoingEmail(models.Model): + template = models.ForeignKey(MailTemplate, on_delete=models.CASCADE) + sender = models.EmailField() + recipient = models.EmailField() + + sent = models.BooleanField(default=False) + + objects = OutgoingEmailManager() diff --git a/artel/mailings/tests.py b/artel/mailings/tests.py new file mode 100644 index 0000000..02bbbc3 --- /dev/null +++ b/artel/mailings/tests.py @@ -0,0 +1,64 @@ +from django.test import TestCase +from django.core.files.uploadedfile import SimpleUploadedFile + +from mailings.models import ( + MailTemplate, + OutgoingEmail, +) + + +class TestMailTemplate(TestCase): + + def setUp(self) -> None: + super().setUp() + self.mail_template = MailTemplate.objects.create( + template_name="test_template", + template=SimpleUploadedFile( + "test_template.html", b"{{test_var}}" + ), + subject="Test subject", + ) + + def test_load_and_process_template_success(self): + content = self.mail_template.load_and_process_template({"test_var": "test"}) + self.assertEqual(content, "test") + + def test_load_and_process_template_missing_var_failure(self): + content = self.mail_template.load_and_process_template({}) + self.assertEqual(content, "") + + def test_load_and_preprocess_template_no_template_file(self): + self.mail_template.template.delete() + self.mail_template.template = None + self.mail_template.save() + with self.assertRaises(FileNotFoundError): + self.mail_template.load_and_process_template({}) + + +class TestOutgoingEmail(TestCase): + + def setUp(self) -> None: + super().setUp() + self.mail_template = MailTemplate.objects.create( + template_name="test_template", + template=SimpleUploadedFile( + "test_template.html", b"{{test_var}}" + ), + subject="Test subject", + ) + + def test_send_success(self): + mail = OutgoingEmail.objects.send( + template_name="test_template", + recipient="test@stardust.io", context={}, + sender="sklep-test@stardust.io" + ) + self.assertEqual(mail.sent, True) + # TODO outbox + + def test_send_missing_template_failure(self): + with self.assertRaises(MailTemplate.DoesNotExist): + OutgoingEmail.objects.send( + template_name="missing_template", + recipient="", sender="", context={}\ + ) diff --git a/artel/mailings/views.py b/artel/mailings/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/artel/mailings/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/artel/requirements.txt b/artel/requirements.txt index 5a81340..7b1f252 100644 --- a/artel/requirements.txt +++ b/artel/requirements.txt @@ -1,7 +1,7 @@ Django>=4.1,<4.2 wagtail>=4.2,<4.3 wagtailmenus>=3.1.5,<=3.1.7 -psycopg2>=2.9.5,<=2.9.6 +psycopg2-binary>=2.9.5,<=2.9.6 dj-database-url<=2.0.0 djangorestframework==3.14.0 phonenumbers==8.13.13 From e800d44e23a3b4e4c483e20bc793fd1ebe3c43e0 Mon Sep 17 00:00:00 2001 From: mtyton Date: Thu, 22 Jun 2023 23:44:22 +0200 Subject: [PATCH 05/30] maling system is now enabled for orders --- artel/artel/settings/base.py | 2 +- artel/mailings/admin.py | 76 ++++++++++++++- artel/mailings/migrations/0001_initial.py | 4 +- artel/mailings/models.py | 16 ++-- artel/mailings/tests/__init__.py | 0 artel/mailings/tests/factories.py | 10 ++ .../{tests.py => tests/test_models.py} | 20 ++-- .../0006_remove_orderdocument_sent.py | 16 ++++ artel/store/models.py | 93 ++++++++++++------- artel/store/tests/test_models.py | 27 ++++-- 10 files changed, 202 insertions(+), 62 deletions(-) create mode 100644 artel/mailings/tests/__init__.py create mode 100644 artel/mailings/tests/factories.py rename artel/mailings/{tests.py => tests/test_models.py} (81%) create mode 100644 artel/store/migrations/0006_remove_orderdocument_sent.py diff --git a/artel/artel/settings/base.py b/artel/artel/settings/base.py index 0b62259..755a912 100644 --- a/artel/artel/settings/base.py +++ b/artel/artel/settings/base.py @@ -199,4 +199,4 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", '') EMAIL_PORT = os.environ.get('EMAIL_PORT', 587) EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'mtyton@tepewu.pl') +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'artel-sklep@tepewu.pl') diff --git a/artel/mailings/admin.py b/artel/mailings/admin.py index 8c38f3f..4440840 100644 --- a/artel/mailings/admin.py +++ b/artel/mailings/admin.py @@ -1,3 +1,75 @@ -from django.contrib import admin +from django.forms import fields -# Register your models here. +from wagtail.contrib.modeladmin.options import ( + ModelAdmin, + ModelAdminGroup, + modeladmin_register +) + +from mailings import models + + +class MailTemplateAdmin(ModelAdmin): + model = models.MailTemplate + menu_label = "Mail templates" + menu_icon = 'mail' + menu_order = 100 + add_to_settings_menu = False + exclude_from_explorer = False + list_display = ( + "template_name", + ) + search_fields = ( + "template_name", + ) + list_filter = ( + "template_name", + ) + form_fields = ( + "template_name", + "template", + ) + + +class OutgoingMailAdmin(ModelAdmin): + model = models.OutgoingEmail + menu_label = "Outgoing mails" + menu_icon = 'mail' + menu_order = 100 + add_to_settings_menu = False + exclude_from_explorer = False + list_display = ( + "subject", + "to", + "sent", + ) + search_fields = ( + "subject", + "to", + ) + list_filter = ( + "subject", + "sender", + "recipient", + "template__template_name", + "sent", + ) + readonly_fields = ( + "subject", + "sender", + "recipient", + "sent" + ) + + +class MailingGroup(ModelAdminGroup): + menu_label = "Mailings" + menu_icon = 'mail' + menu_order = 200 + items = ( + MailTemplateAdmin, + OutgoingMailAdmin + ) + + +modeladmin_register(MailingGroup) diff --git a/artel/mailings/migrations/0001_initial.py b/artel/mailings/migrations/0001_initial.py index 3b6fdfc..a3de859 100644 --- a/artel/mailings/migrations/0001_initial.py +++ b/artel/mailings/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.9 on 2023-06-21 18:32 +# Generated by Django 4.1.9 on 2023-06-22 14:09 from django.db import migrations, models import django.db.models.deletion @@ -16,13 +16,13 @@ class Migration(migrations.Migration): ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("template_name", models.CharField(max_length=255, unique=True)), ("template", models.FileField(upload_to="mail_templates")), - ("subject", models.CharField(max_length=255)), ], ), migrations.CreateModel( name="OutgoingEmail", fields=[ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("subject", models.CharField(max_length=255)), ("sender", models.EmailField(max_length=254)), ("recipient", models.EmailField(max_length=254)), ("sent", models.BooleanField(default=False)), diff --git a/artel/mailings/models.py b/artel/mailings/models.py index dd5d119..8612435 100644 --- a/artel/mailings/models.py +++ b/artel/mailings/models.py @@ -22,15 +22,16 @@ def send_mail( to: list[str], attachments: list[Attachment], subject: str, - body: str, + content: str, sender_email: str = settings.DEFAULT_FROM_EMAIL ): message = EmailMessage( subject=subject, - body=body, + body=content, from_email=sender_email, to=to ) + message.content_subtype = 'html' for attachment in attachments: message.attach(attachment.name, attachment.content, attachment.contenttype) return bool(message.send()) @@ -42,7 +43,6 @@ class MailTemplate(models.Model): template = models.FileField( upload_to="mail_templates" ) - subject = models.CharField(max_length=255) def delete(self, *args, **kwargs): # delete file @@ -66,17 +66,20 @@ class MailTemplate(models.Model): class OutgoingEmailManager(models.Manager): def send( - self, template_name: str, + self, template_name: str, subject: str, recipient: str, context: dict | Context, sender:str, attachments: list[Attachment] = None ): template = MailTemplate.objects.get(template_name=template_name) - outgoing_email = self.create(template=template, recipient=recipient) + outgoing_email = self.create( + template=template, recipient=recipient, subject=subject, + sender=sender + ) attachments = attachments or [] # send email sent = send_mail( to=[recipient], sender_email=sender, - subject=template.subject, content=template.load_and_process_template(context), + subject=subject, content=template.load_and_process_template(context), attachments=attachments ) outgoing_email.sent = sent @@ -85,6 +88,7 @@ class OutgoingEmailManager(models.Manager): class OutgoingEmail(models.Model): + subject = models.CharField(max_length=255) template = models.ForeignKey(MailTemplate, on_delete=models.CASCADE) sender = models.EmailField() recipient = models.EmailField() diff --git a/artel/mailings/tests/__init__.py b/artel/mailings/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artel/mailings/tests/factories.py b/artel/mailings/tests/factories.py new file mode 100644 index 0000000..14212c9 --- /dev/null +++ b/artel/mailings/tests/factories.py @@ -0,0 +1,10 @@ +from factory.django import DjangoModelFactory +from factory import Faker + + +class MailTemplateFactory(DjangoModelFactory): + class Meta: + model = "mailings.MailTemplate" + + template_name = Faker("name") + template = Faker("file_name", extension="html") diff --git a/artel/mailings/tests.py b/artel/mailings/tests/test_models.py similarity index 81% rename from artel/mailings/tests.py rename to artel/mailings/tests/test_models.py index 02bbbc3..3d98f03 100644 --- a/artel/mailings/tests.py +++ b/artel/mailings/tests/test_models.py @@ -1,5 +1,6 @@ from django.test import TestCase from django.core.files.uploadedfile import SimpleUploadedFile +from django.core import mail from mailings.models import ( MailTemplate, @@ -15,8 +16,7 @@ class TestMailTemplate(TestCase): template_name="test_template", template=SimpleUploadedFile( "test_template.html", b"{{test_var}}" - ), - subject="Test subject", + ) ) def test_load_and_process_template_success(self): @@ -43,22 +43,24 @@ class TestOutgoingEmail(TestCase): template_name="test_template", template=SimpleUploadedFile( "test_template.html", b"{{test_var}}" - ), - subject="Test subject", + ) ) def test_send_success(self): - mail = OutgoingEmail.objects.send( + email = OutgoingEmail.objects.send( template_name="test_template", recipient="test@stardust.io", context={}, - sender="sklep-test@stardust.io" + sender="sklep-test@stardust.io", + subject="Test subject" ) - self.assertEqual(mail.sent, True) - # TODO outbox + self.assertEqual(email.sent, True) + self.assertEqual(mail.outbox[0].subject, "Test subject") def test_send_missing_template_failure(self): with self.assertRaises(MailTemplate.DoesNotExist): OutgoingEmail.objects.send( template_name="missing_template", - recipient="", sender="", context={}\ + recipient="", sender="", context={}, + subject="Test subject" ) + self.assertEqual(len(mail.outbox), 0) diff --git a/artel/store/migrations/0006_remove_orderdocument_sent.py b/artel/store/migrations/0006_remove_orderdocument_sent.py new file mode 100644 index 0000000..65b8771 --- /dev/null +++ b/artel/store/migrations/0006_remove_orderdocument_sent.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.9 on 2023-06-22 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0005_order_order_number"), + ] + + operations = [ + migrations.RemoveField( + model_name="orderdocument", + name="sent", + ), + ] diff --git a/artel/store/models.py b/artel/store/models.py index a8c1af2..1f29204 100644 --- a/artel/store/models.py +++ b/artel/store/models.py @@ -31,6 +31,10 @@ from store.utils import ( notify_user_about_order, notify_manufacturer_about_order ) +from mailings.models import ( + OutgoingEmail, + Attachment +) class PersonalData(models.Model): @@ -233,6 +237,44 @@ class OrderManager(models.Manager): year = datetime.datetime.now().year return f"{author.id}/{number_of_prev_orders:06}/{year}" + def _send_notifications( + self, order: models.Model, author: ProductAuthor, + customer_data: dict[str, Any], docs: list[models.Model] + ): + # for user + attachments = [ + Attachment( + content=doc.generate_document({"customer_data": customer_data}), + contenttype="application/pdf", + name=f"{doc.template.doc_type}_{order.order_number}.pdf" + ) for doc in docs + ] + mail_subject = f"Wygenerowano umowę numer {order.order_number} z dnia {order.created_at.strftime('%d.%m.%Y')}" + user_mail = OutgoingEmail.objects.send( + recipient=customer_data["email"], + subject=mail_subject, + context = { + "docs": docs, + "order_number": order.order_number, + "customer_email": customer_data["email"], + }, sender=settings.DEFAULT_FROM_EMAIL, + template_name="order_created_user", + attachments=attachments + ) + # for author + author_mail = OutgoingEmail.objects.send( + recipient=author.email, + subject=mail_subject, + context = { + "docs": docs, + "order_number": order.order_number, + "manufacturer_email": author.email, + }, sender=settings.DEFAULT_FROM_EMAIL, + template_name="order_created_author", + attachments=attachments + ) + return user_mail is not None and author_mail is not None + def create_from_cart( self, cart_items: list[dict[str, str|dict]], payment_method: models.Model| None, @@ -242,8 +284,9 @@ class OrderManager(models.Manager): orders_pks = [] payment_method = payment_method or PaymentMethod.objects.first() - agreement_template = DocumentTemplate.objects.get(doc_type=DocumentTypeChoices.AGREEMENT) - receipt_template = DocumentTemplate.objects.get(doc_type=DocumentTypeChoices.RECEIPT) + doc_templates = DocumentTemplate.objects.filter( + doc_type__in=[DocumentTypeChoices.AGREEMENT, DocumentTypeChoices.RECEIPT] + ) for item in cart_items: author = item["author"] @@ -255,39 +298,18 @@ class OrderManager(models.Manager): ) OrderProduct.objects.create_from_cart(author_products, order) orders_pks.append(order.pk) - agreement = OrderDocument.objects.create( - order=order, - template=agreement_template - ) - receipt = OrderDocument.objects.create( - order=order, - template=receipt_template - ) - extra_document_kwargs = { - "customer_data": customer_data - } - default_kwargs ={ - "docs": [ - agreement.generate_document(extra_document_kwargs), - receipt.generate_document(extra_document_kwargs) - ], - "order_number": order.order_number - } - user_kwargs = { - "customer_email": customer_data["email"], - } - user_kwargs.update(default_kwargs) - user_notified = notify_user_about_order(**user_kwargs) - manufacturer_kwargs = { - "manufacturer_email": author.email, - } - manufacturer_kwargs.update(default_kwargs) - manufacturer_notified = notify_manufacturer_about_order(**manufacturer_kwargs) - sent = user_notified and manufacturer_notified - agreement.sent = sent - receipt.sent = sent - agreement.save() - receipt.save() + docs = [] + for template in doc_templates: + doc = OrderDocument.objects.create( + order=order, template=template + ) + docs.append(doc) + sent = self._send_notifications(order, author, customer_data, docs) + + if not sent: + # TODO - store data temporarily + raise Exception("Error while sending emails") + return Order.objects.filter(pk__in=orders_pks) @@ -346,7 +368,6 @@ class DocumentTemplate(models.Model): class OrderDocument(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="documents") template = models.ForeignKey(DocumentTemplate, on_delete=models.CASCADE) - sent = models.BooleanField(default=False) def get_document_context(self): _context = { diff --git a/artel/store/tests/test_models.py b/artel/store/tests/test_models.py index 55aa5d8..e614333 100644 --- a/artel/store/tests/test_models.py +++ b/artel/store/tests/test_models.py @@ -1,9 +1,12 @@ + +from unittest.mock import patch from django.test import TestCase from django.urls import reverse from django.core import mail from store.tests import factories from store import models as store_models +from mailings.tests.factories import MailTemplateFactory # TODO - this is fine for now, but we'll want to use factoryboy for this: @@ -72,8 +75,11 @@ class OrderTestCase(TestCase): self.payment_method = factories.PaymentMethodFactory() factories.DocumentTemplateFactory() factories.DocumentTemplateFactory(doc_type="receipt") + MailTemplateFactory(template_name="order_created_user") + MailTemplateFactory(template_name="order_created_author") - def test_create_from_cart_success_single_author(self): + @patch("mailings.models.MailTemplate.load_and_process_template", return_value="test") + def test_create_from_cart_success_single_author(self, mocked_load): product = factories.ProductFactory(template__author=self.author, price=100) cart_items = [{ "author": self.author, @@ -86,10 +92,13 @@ class OrderTestCase(TestCase): ) self.assertEqual(orders.count(), 1) self.assertEqual(len(mail.outbox), 2) - self.assertEqual(mail.outbox[0].subject, f"Zamówienie {orders[0].order_number}") + self.assertEqual( + mail.outbox[0].subject, + f"Wygenerowano umowę numer {orders[0].order_number} z dnia {orders[0].created_at.strftime('%d.%m.%Y')}" + ) - - def test_create_from_cart_success_multpile_authors(self): + @patch("mailings.models.MailTemplate.load_and_process_template", return_value="test") + def test_create_from_cart_success_multpile_authors(self, mocked_load): product = factories.ProductFactory(template__author=self.second_author, price=100) cart_items = [ { @@ -107,5 +116,11 @@ class OrderTestCase(TestCase): ) self.assertEqual(orders.count(), 2) self.assertEqual(len(mail.outbox), 4) - self.assertEqual(mail.outbox[0].subject, f"Zamówienie {orders[0].order_number}") - self.assertEqual(mail.outbox[2].subject, f"Zamówienie {orders[1].order_number}") + self.assertEqual( + mail.outbox[0].subject, + f"Wygenerowano umowę numer {orders[0].order_number} z dnia {orders[0].created_at.strftime('%d.%m.%Y')}" + ) + self.assertEqual( + mail.outbox[2].subject, + f"Wygenerowano umowę numer {orders[1].order_number} z dnia {orders[1].created_at.strftime('%d.%m.%Y')}" + ) From e51d8f9e589857567cf9ca269f45fd1658cbe190 Mon Sep 17 00:00:00 2001 From: mtyton Date: Thu, 22 Jun 2023 23:56:13 +0200 Subject: [PATCH 06/30] small menu name changes --- artel/store/admin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/artel/store/admin.py b/artel/store/admin.py index 29ff9a6..fe0eeef 100644 --- a/artel/store/admin.py +++ b/artel/store/admin.py @@ -26,11 +26,13 @@ class ProductCategoryParamAdmin(ModelAdmin): class ProductTemplateAdmin(ModelAdmin): + menu_label = "Product design" model = models.ProductTemplate list_display = ("title", "code") class ProductAdmin(ModelAdmin): + menu_label = "Product variant" model = models.Product list_display = ("title", "price") From e99a07f45df1e2a40fa449872443e1082c21fc9d Mon Sep 17 00:00:00 2001 From: mtyton Date: Sun, 25 Jun 2023 09:02:47 +0200 Subject: [PATCH 07/30] removed pyc files --- artel/home/__pycache__/__init__.cpython-311.pyc | Bin 173 -> 0 bytes artel/home/__pycache__/models.cpython-311.pyc | Bin 830 -> 0 bytes .../__pycache__/0001_initial.cpython-311.pyc | Bin 1022 -> 0 bytes .../0002_create_homepage.cpython-311.pyc | Bin 2554 -> 0 bytes .../0003_homepage_body.cpython-311.pyc | Bin 861 -> 0 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 184 -> 0 bytes .../search/__pycache__/__init__.cpython-311.pyc | Bin 175 -> 0 bytes artel/search/__pycache__/views.cpython-311.pyc | Bin 1774 -> 0 bytes 8 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 artel/home/__pycache__/__init__.cpython-311.pyc delete mode 100644 artel/home/__pycache__/models.cpython-311.pyc delete mode 100644 artel/home/migrations/__pycache__/0001_initial.cpython-311.pyc delete mode 100644 artel/home/migrations/__pycache__/0002_create_homepage.cpython-311.pyc delete mode 100644 artel/home/migrations/__pycache__/0003_homepage_body.cpython-311.pyc delete mode 100644 artel/home/migrations/__pycache__/__init__.cpython-311.pyc delete mode 100644 artel/search/__pycache__/__init__.cpython-311.pyc delete mode 100644 artel/search/__pycache__/views.cpython-311.pyc diff --git a/artel/home/__pycache__/__init__.cpython-311.pyc b/artel/home/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 08bc4555db9b4fef38561b8f61bd9a46e61a095c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 173 zcmZ3^%ge<81POYsDIoeWh=2h`DC095kTIPhg&~+hlhJP_LlF~@{~09t%RxUQKQ~oB zx1_QpKTkiPvLquvFQ6ztD>b>KSid|my(BR+CqA*LBsE7LLV}gY$7kkcmc+;F6;%G> ju*uC&Da}c>D`Ev22(q%6A4q&)W@KdizyKqPn1Nyd6%Q&7 diff --git a/artel/home/__pycache__/models.cpython-311.pyc b/artel/home/__pycache__/models.cpython-311.pyc deleted file mode 100644 index 9a58c37c2ffa4ba91192fc885f877c4d0103029d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 830 zcmZuuJ8u&~5T3o~+J_^E2nY+wEtZkpr9vqXF9|8KLJD-v>FiznVDB!RHv&@`iNYma zDkQ2P6n=~&xv;K^L`CPwRH>M~2jT>?vvc1(ZugtnueDkgP(D9@%<2T-hjI!rbFeu; zZ~_b%5fBiU5QR0N1-h%JSQkcMx@L+EVFk8p6VTu(F!LiYi|eO4z!~mLT*~Yh(4;?= z+a!qSaD80lknnfy z6x%w(or!C5Yit)DR#T>zD+c5}rd^vENYPB_vnb$-)gh8Gd%_Q2Cq^$~)BR>8p`I7| z0r$M5>Ur42u~7J$=e;@hMV5)x6qDM)s6Ra9?_}CdtY#i7mdRE>3MCJvcceO;#1;m> z!3Hf{?RAEDcPEfj8HJthR1TxC8%JZ_my^z0e<1yl@cdYE(aCI9p3bs;G>s`9l9~#F z$wM@=KWpVQ=DSO$@r5m%*16MKI<48Gi=8{moqH>~J+FS+J*V|0t*>?MzI6%OEo-)~ zR(zv4RP|qBV?P{3ZPts`<0e}#G4R#o|sV9Mf}*n|uBk@}3PFfnvs zWTSsTMOBqQrUOIdsZytIhhSpr^Cdy8%JcbmzvrLr_wK#>S}eK=N z>ne9?K}s-(c3^Zcvsu=|%(-$eGoZm78bone9uh7nXLFn>q@DjrJCb^SI}%)#o6VO^ z(xV+tn4kxeNKg7)*>9qN@4%|EQ>`b(-p0w~UnlEYty#*6LIPjTC0F?#6+D#0_rfDp zpvN*IZNaJJ3?$(NRP@Oa?<*(f!lS--!j&N-Wz+pw3fh)6{BHyuxt39xkOo{?2OjrX zta4FrSn=#Wjd{FPbCpeq-a?3S34uNw`yyo zaY9#36}Lma7XfVOSSq)+y1TaeYF)WekB=Jt4c+SM-fuS!p+b#7_GJ_{cKY%#3U@@* zUKn@hwCJqeYl_xeJ&GzI-n#Ag7f4R%4RR{CcFZIDfb+A~|IRAW5d65)z@WIIK&(Dq*vF1bodPwZJ0}z4xocb230bv~RBWB!vBRlf2$qIh9`#@xYOGoBb)Qi)YmN7obR9Bn~}E{gEn< zov$P=c!>+e>-A{-@yw%{@{yC3=qWFHs(5*ymiU3k4<0>Ow<__J7f%(hG*lAS9^Rv>3D@scyyvnCmISFJL!#{sf}K&j$W+y43@rWgvi(>=@pU3 zl-CF_u1pWFpKe4*@1PeyR*O$m;}i9s{(AqBdh9^`;E8%{pxzU&_a*9mhZ}vQ_xLNK z^}=8PATroNq%`zgE#LayXLHx1cgwA38+<3bQX*b@KP4vqzm)j?^c7-sE4(|&d_V$g zCn)r?B}`$Vt*#H5+fOj+tc|C}hOZZ~@%v$S0VHKRf%-uNJkfR(Td92%^z`?-dHLy>BBH}jBT0iqQ$2A45pP=2Z0Q(--{f=VHqy05HRHZ|uLr?FO z=}?7Edvv-+&sOQ#3O(o1b7guCPGh*&qXRWMQl%rMx#ypk=}3iM^5~@+y;`MLEA+ZY zua{eoeG)jNp|l0bqioLrJRzLSNesW((xNmMVlp6=2@fx0)O_JxT;06~V56}Rq;M*< z?^>Vhq%5KxNn7+yO?`Go_mdwFG8sf3cR_SxzST3ijGHmc*(i2O?tSdGo)-GvY_W z-!Xd%I(;||igOX5*#1F)wNs6doKT9_;67_(p!X}q8>kQe7V*@h>-tlxqRx2gOw+7* zt#~b%9VebmhK0tKjohM@V)H(`HIF+QCg^+MqDjQs_);ok+v0ceCINqRv> r%e$Y3GA#WdHAn}3DMh3k(A0rPx+SGS-iF^JUhnuT?0CITuTSbf4TDH$ diff --git a/artel/home/migrations/__pycache__/0003_homepage_body.cpython-311.pyc b/artel/home/migrations/__pycache__/0003_homepage_body.cpython-311.pyc deleted file mode 100644 index d1c00e5fd755cc27008df025a96a90d0e1521d52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 861 zcmZuvy>HV%6o2Qh*j9tUEeaJKsKnyMj_6jQ3I;$zP*o5ZBBYa(I})dk9c)*SEdv81 zNJw3oicmZ7rvwy<=vF2+S1C&;?)(uI!MnSkfA8+SckjLP=X%`)F2A?#kq-#qr%)iL}-9Rk9#XQsxN&SkX_qkI?neoW+ukm?jWF?uVDP@zzc+}&9hOvh;yx_jfa zABB5yG@#uyam#Sni&Gl71s3L2ClZ%;U~5@A7WF!#aV*{;2k`*Oa}JZ`$TTqQOqSNP z$)n5!W8$i(;@5CEi75_r=fb>KSid|my(BR+CqA*LBsE7LLPC^hrWYlaWaj4;>&M4u t=4F<|$LkeT{^GF7%}*)KNwq6t1)2@Ay_g?Jd|+l|Wcb>KSid|my(BR+CqA*LBsE7LLKdec7A0rs$H!;pWtPOp o>lIY~;;_lhPbtkwwJTx;8VRzrm>)=dU}j`w{J;PsikN|70Dh1v-2eap diff --git a/artel/search/__pycache__/views.cpython-311.pyc b/artel/search/__pycache__/views.cpython-311.pyc deleted file mode 100644 index b93aac2771d60d898e4d003be5dd51af76b79aed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1774 zcma)6TWcFf6rR0GYuS<-$3^Sfa+)+aaxDoH+EDCNnA)@{!8mPRq8C^74!W=xb#@$A zWdnZbgBk*X60j+O>dAHSOaFuW1FRs3STF=iAN;1^7EJomGrPK2at$4g&YU^>otf`k zc7GWe8A33=N-b;u2nhYbHl4w@m6IhzUkBO^*rD*1; zMG~AFy^oEuo}>5+Y?m$5#$Xef3#`(;4NNNQT$?9b0`P}8(1{N0w~>QN%<7HWSzN~m zF==QQu=|~8(If7Ec=4Jr;Ke%}$x^X&k^RCm8yVbk-7UBc_sc16AT5WBLEC#*_tAg5_-g(7uD(=st( zokT)Ru)ZUVq^p=4#v0>g6B+gi*$og3SKE|?8OVc2n8ql(3PAl=&}Q%}-guKq7c2v( z4O*d=nO?2Xf@Q7}%j1>a%;jmWsITP+#d_M8KFRdMBHpx9^=w(XnUB&TKt8-H1+CCGb?jPe&e){1AB( zslxBi!2_z{zh(5+_)co?YCW24M3Y;0TD&CPZmL&*j{g+j6YFZSp(d;0YWVah^iB(L z()&HpL2-3;rlHO-6mSRuSx&m$6CIe+xG`T>7aHnN3fj;6$a+oUhTAnw8J321+i%3Nj?ph}=NYT0SK43E z38;vSgLo5M0M`v2e@xzmEqf(w0s;c&xF-7S@Z#A-9~}12S7_?6e>TzdVgGE&aSk$c JUXUwr?Y|u4g7g3Y From 51c4ef96235d1a9787f77c4946c986565d567ade Mon Sep 17 00:00:00 2001 From: mtyton Date: Sun, 2 Jul 2023 15:51:28 +0200 Subject: [PATCH 08/30] prepared some basic model modification for configurator implementation --- ...ename_productimage_producttemplateimage.py | 16 +++ ...o_producttemplateimage_is_main_and_more.py | 47 ++++++++ ...ryparam_order_productcategoryparamvalue.py | 32 ++++++ .../migrations/0010_auto_20230630_1611.py | 29 +++++ ...aram_delete_templateparamvalue_and_more.py | 42 +++++++ artel/store/models.py | 104 ++++++++++++++---- artel/store/static/js/product_configurator.js | 0 .../templates/store/configure_product.html | 51 +++++++++ .../store/partials/product_card.html | 19 +--- artel/store/tests/factories.py | 17 ++- artel/store/tests/test_models.py | 58 +++++++++- artel/store/tests/test_validators.py | 42 +++++++ artel/store/urls.py | 1 + artel/store/validators.py | 20 ++++ artel/store/views.py | 18 ++- 15 files changed, 455 insertions(+), 41 deletions(-) create mode 100644 artel/store/migrations/0007_rename_productimage_producttemplateimage.py create mode 100644 artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py create mode 100644 artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py create mode 100644 artel/store/migrations/0010_auto_20230630_1611.py create mode 100644 artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py create mode 100644 artel/store/static/js/product_configurator.js create mode 100644 artel/store/templates/store/configure_product.html create mode 100644 artel/store/tests/test_validators.py create mode 100644 artel/store/validators.py diff --git a/artel/store/migrations/0007_rename_productimage_producttemplateimage.py b/artel/store/migrations/0007_rename_productimage_producttemplateimage.py new file mode 100644 index 0000000..e6bc809 --- /dev/null +++ b/artel/store/migrations/0007_rename_productimage_producttemplateimage.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.9 on 2023-06-25 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0006_remove_orderdocument_sent"), + ] + + operations = [ + migrations.RenameModel( + old_name="ProductImage", + new_name="ProductTemplateImage", + ), + ] diff --git a/artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py b/artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py new file mode 100644 index 0000000..fdc2854 --- /dev/null +++ b/artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.1.9 on 2023-06-25 19:24 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0007_rename_productimage_producttemplateimage"), + ] + + operations = [ + migrations.RemoveField( + model_name="product", + name="info", + ), + migrations.AddField( + model_name="producttemplateimage", + name="is_main", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="producttemplateimage", + name="template", + field=modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="template_images", to="store.producttemplate" + ), + ), + migrations.CreateModel( + name="ProductImage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("image", models.ImageField(upload_to="")), + ("is_main", models.BooleanField(default=False)), + ( + "product", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="product_images", to="store.product" + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py b/artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py new file mode 100644 index 0000000..eb86508 --- /dev/null +++ b/artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py @@ -0,0 +1,32 @@ +# Generated by Django 4.1.9 on 2023-06-30 16:11 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0008_remove_product_info_producttemplateimage_is_main_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProductCategoryParamValue", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("value", models.CharField(max_length=255)), + ( + "param", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="param_values", + to="store.productcategoryparam", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/artel/store/migrations/0010_auto_20230630_1611.py b/artel/store/migrations/0010_auto_20230630_1611.py new file mode 100644 index 0000000..c4938f9 --- /dev/null +++ b/artel/store/migrations/0010_auto_20230630_1611.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.9 on 2023-06-30 16:11 + +from django.db import migrations + + +def copy_old_data(apps, schema_editor): + TemplateParamValue = apps.get_model("store", "TemplateParamValue") + ProductCategoryParamValue = apps.get_model("store", "ProductCategoryParamValue") + + for param_value in TemplateParamValue.objects.all(): + ProductCategoryParamValue.objects.create( + param=param_value.param, + value=param_value.value + ) + + +def remove_new_data(apps, schema_editor): + ProductCategoryParamValue = apps.get_model("store", "ProductCategoryParamValue") + ProductCategoryParamValue.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0009_productcategoryparam_order_productcategoryparamvalue"), + ] + + operations = [ + migrations.RunPython(copy_old_data, remove_new_data), + ] diff --git a/artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py b/artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py new file mode 100644 index 0000000..1bceea9 --- /dev/null +++ b/artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.9 on 2023-07-02 09:34 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0010_auto_20230630_1611"), + ] + + operations = [ + migrations.CreateModel( + name="ProductParam", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "param_value", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="store.productcategoryparamvalue" + ), + ), + ( + "product", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="product_params", to="store.product" + ), + ), + ], + ), + migrations.DeleteModel( + name="TemplateParamValue", + ), + migrations.AddField( + model_name="product", + name="params", + field=models.ManyToManyField( + blank=True, through="store.ProductParam", to="store.productcategoryparamvalue" + ), + ), + ] diff --git a/artel/store/models.py b/artel/store/models.py index 1f29204..948ca51 100644 --- a/artel/store/models.py +++ b/artel/store/models.py @@ -1,5 +1,6 @@ import pdfkit import datetime +import builtins from decimal import Decimal from typing import Any @@ -14,6 +15,7 @@ from django.template import ( Template, Context ) +from django.core.exceptions import ValidationError from modelcluster.models import ClusterableModel from modelcluster.fields import ParentalKey @@ -27,14 +29,19 @@ from taggit.managers import TaggableManager from phonenumber_field.modelfields import PhoneNumberField from num2words import num2words -from store.utils import ( - notify_user_about_order, - notify_manufacturer_about_order -) from mailings.models import ( OutgoingEmail, Attachment ) +from store.validators import ProductParamDuplicateValidator + + +class BaseImageModel(models.Model): + image = models.ImageField() + is_main = models.BooleanField(default=False) + + class Meta: + abstract = True class PersonalData(models.Model): @@ -92,6 +99,28 @@ class ProductCategoryParam(ClusterableModel): def __str__(self): return self.key + + panels = [ + FieldPanel("category"), + FieldPanel("key"), + FieldPanel("param_type"), + InlinePanel("param_values") + ] + + +class ProductCategoryParamValue(ClusterableModel): + param = ParentalKey(ProductCategoryParam, on_delete=models.CASCADE, related_name="param_values") + value = models.CharField(max_length=255) + + def get_value(self): + try: + func = getattr(builtins, self.param.param_type) + return func(self.value) + except ValueError: + return + + def __str__(self): + return f"{self.param.key}: {self.value}" class ProductTemplate(ClusterableModel): @@ -106,45 +135,58 @@ class ProductTemplate(ClusterableModel): def __str__(self): return self.title + @property + def main_image(self): + try: + return self.template_images.get(is_main=True) + except ProductImage.DoesNotExist: + return self.template_images.first() + panels = [ FieldPanel("category"), FieldPanel("author"), FieldPanel('title'), FieldPanel('code'), FieldPanel('description'), - InlinePanel("images"), + InlinePanel("template_images", label="Template Images"), FieldPanel("tags"), ] -class ProductImage(models.Model): +class ProductTemplateImage(BaseImageModel): template = ParentalKey( - ProductTemplate, on_delete=models.CASCADE, related_name="images" + ProductTemplate, on_delete=models.CASCADE, related_name="template_images" ) image = models.ImageField() + is_main = models.BooleanField(default=False) class Product(ClusterableModel): name = models.CharField(max_length=255, blank=True) - info = models.TextField(blank=True) template = models.ForeignKey(ProductTemplate, on_delete=models.CASCADE, related_name="products") + params = models.ManyToManyField( + ProductCategoryParamValue, blank=True, through="ProductParam", + validators=(ProductParamDuplicateValidator(),) + ) price = models.FloatField() available = models.BooleanField(default=True) - + panels = [ FieldPanel("template"), FieldPanel("price"), - InlinePanel("param_values"), + FieldPanel("params"), FieldPanel("available"), FieldPanel("name"), - FieldPanel("info") + InlinePanel("product_images", label="Variant Images"), ] @property def main_image(self): - images = self.template.images.all() - if images: - return images.first().image + try: + return self.product_images.get(is_main=True) + except ProductImage.DoesNotExist: + return self.product_images.first() + @property def tags(self): @@ -163,10 +205,30 @@ class Product(ClusterableModel): return self.name or self.template.title -class TemplateParamValue(models.Model): - param = models.ForeignKey(ProductCategoryParam, on_delete=models.CASCADE) - product = ParentalKey(Product, on_delete=models.CASCADE, related_name="param_values") - value = models.CharField(max_length=255) +class ProductImage(BaseImageModel): + product = ParentalKey( + "Product", on_delete=models.CASCADE, related_name="product_images" + ) + + +class ProductParam(models.Model): + product = ParentalKey(Product, on_delete=models.CASCADE, related_name="product_params") + param_value = models.ForeignKey(ProductCategoryParamValue, on_delete=models.CASCADE) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + def clean(self) -> None: + print("SDADASDASDASD") + # get all of this product params with same category + product_params_count = self.product.product_params.filter( + param_value__param=self.param_value.param + ).count() + if product_params_count > 1: + raise ValidationError("Product can't have two values for one param") + + return super().clean() class ProductListPage(Page): @@ -177,9 +239,9 @@ class ProductListPage(Page): def _get_items(self): if self.tags.all(): - return Product.objects.filter(available=True, template__tags__in=self.tags.all()) - return Product.objects.filter(available=True) - + return ProductTemplate.objects.filter(tags__in=self.tags.all()) + return ProductTemplate.objects.all() + def get_context(self, request): context = super().get_context(request) items = self._get_items() diff --git a/artel/store/static/js/product_configurator.js b/artel/store/static/js/product_configurator.js new file mode 100644 index 0000000..e69de29 diff --git a/artel/store/templates/store/configure_product.html b/artel/store/templates/store/configure_product.html new file mode 100644 index 0000000..b46e34d --- /dev/null +++ b/artel/store/templates/store/configure_product.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} + +{% block content %} + +
+ +
+

{{template.title}}

+
+ + + + +
+
+
+
+ Responsive image +
+
+

{{template.description}}

+
+
+ +
+ +

Dostępne konfiguracje:

+ +
+
+ {% for product in template.products.all %} +
+
+
+ {{product.name}} +
+
+ Responsive image +
+ +
+
+ {% if forloop.counter|divisibleby:3 %}
{% endif %} + {% endfor %} +
+
+
+ +{% endblock %} diff --git a/artel/store/templates/store/partials/product_card.html b/artel/store/templates/store/partials/product_card.html index a67c7e9..16623ab 100644 --- a/artel/store/templates/store/partials/product_card.html +++ b/artel/store/templates/store/partials/product_card.html @@ -3,21 +3,12 @@
{{item.title}}
- {{item.title}} + {{item.title}} -