diff --git a/artel/artel/settings/base.py b/artel/artel/settings/base.py index 33f2dbf..9a423d2 100644 --- a/artel/artel/settings/base.py +++ b/artel/artel/settings/base.py @@ -21,6 +21,7 @@ from sentry_sdk.integrations.django import DjangoIntegration # -> GlitchTip error reporting sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN", ''), integrations=[DjangoIntegration()], auto_session_tracking=False, traces_sample_rate=0 @@ -212,3 +213,18 @@ 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', 'artel-sklep@tepewu.pl') + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, +} diff --git a/artel/artel/templates/base.html b/artel/artel/templates/base.html index 086f284..86be8f0 100644 --- a/artel/artel/templates/base.html +++ b/artel/artel/templates/base.html @@ -53,7 +53,6 @@ {# Global javascript #} - diff --git a/artel/artel/urls.py b/artel/artel/urls.py index edfa02e..28515a4 100644 --- a/artel/artel/urls.py +++ b/artel/artel/urls.py @@ -14,6 +14,7 @@ from search import views as search_views handler404 = 'artel.views.my_custom_page_not_found_view' + urlpatterns = [ path("django-admin/", admin.site.urls), path("admin/", include(wagtailadmin_urls)), diff --git a/artel/docker-compose.yml b/artel/docker-compose.yml index 7a4e937..2a7f970 100644 --- a/artel/docker-compose.yml +++ b/artel/docker-compose.yml @@ -6,6 +6,10 @@ services: - "1025:1025" - "8025:8025" comfy: + restart: always + depends_on: + - smtp-server + - db build: dockerfile: Dockerfile.local context: ./ @@ -13,7 +17,7 @@ services: ports: - "8001:8000" volumes: -# - ./:/app + - ./:/app - media:/app/media environment: - SECRET_KEY diff --git a/artel/mailings/admin.py b/artel/mailings/admin.py index 4440840..3600a42 100644 --- a/artel/mailings/admin.py +++ b/artel/mailings/admin.py @@ -40,12 +40,10 @@ class OutgoingMailAdmin(ModelAdmin): exclude_from_explorer = False list_display = ( "subject", - "to", "sent", ) search_fields = ( "subject", - "to", ) list_filter = ( "subject", diff --git a/artel/mailings/models.py b/artel/mailings/models.py index 8612435..0e63bef 100644 --- a/artel/mailings/models.py +++ b/artel/mailings/models.py @@ -1,3 +1,5 @@ +import logging + from typing import Any from dataclasses import dataclass @@ -11,6 +13,9 @@ from django.core.mail import EmailMessage from django.conf import settings +logger = logging.getLogger(__name__) + + @dataclass class Attachment: name: str @@ -34,7 +39,11 @@ def send_mail( message.content_subtype = 'html' for attachment in attachments: message.attach(attachment.name, attachment.content, attachment.contenttype) - return bool(message.send()) + + sent = bool(message.send()) + if not sent: + logger.exception(f"Sending email to {to} with subject {subject} caused an exception") + return sent class MailTemplate(models.Model): @@ -54,6 +63,10 @@ class MailTemplate(models.Model): def load_and_process_template(self, context: dict|Context): if not self.template: + logger.exception( + f"Template file is missing for template with "+ + f"pk={self.pk}, template_name={self.template_name}" + ) raise FileNotFoundError("Template file is missing") if isinstance(context, dict): context = Context(context) diff --git a/artel/store/admin.py b/artel/store/admin.py index fe0eeef..0a6aa7f 100644 --- a/artel/store/admin.py +++ b/artel/store/admin.py @@ -42,6 +42,11 @@ class PaymentMethodAdmin(ModelAdmin): list_display = ("name", "active") +class DeliveryMethodAdmin(ModelAdmin): + model = models.DeliveryMethod + list_display = ("name", "active") + + class DocumentTemplateAdmin(ModelAdmin): model = models.DocumentTemplate list_display = ("name", ) @@ -58,7 +63,8 @@ class StoreAdminGroup(ModelAdminGroup): ProductTemplateAdmin, ProductAdmin, DocumentTemplateAdmin, - PaymentMethodAdmin + PaymentMethodAdmin, + DeliveryMethodAdmin ) diff --git a/artel/store/cart.py b/artel/store/cart.py index 6eb3782..39727a7 100644 --- a/artel/store/cart.py +++ b/artel/store/cart.py @@ -1,6 +1,9 @@ +import logging + from abc import ( ABC, - abstractmethod + abstractmethod, + abstractproperty ) from typing import ( List, @@ -9,12 +12,16 @@ from typing import ( from dataclasses import dataclass from django.http.request import HttpRequest from django.conf import settings +from django.core import signing from store.models import ( Product, - ProductAuthor + ProductAuthor, + DeliveryMethod ) +logger = logging.getLogger("cart_logger") + class BaseCart(ABC): @@ -33,27 +40,58 @@ class BaseCart(ABC): def update_item_quantity(self, item_id, change): ... - @abstractmethod - def get_items(self): + @abstractproperty + def display_items(self): ... class SessionCart(BaseCart): - def __init__(self, request: HttpRequest) -> None: + + def _get_author_total_price(self, author_id: int): + author_cart = self._cart[str(author_id)] + author_price = 0 + product_ids = list(int(pk) for pk in author_cart.keys()) + queryset = Product.objects.filter(id__in=product_ids) + for product in queryset: + author_price += product.price * author_cart[str(product.id)] + + if self._delivery_info: + author_price += self._delivery_info.price + + return author_price + + def _prepare_display_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, + "group_price": self._get_author_total_price(author_id) + }) + return items + + def __init__(self, request: HttpRequest, delivery: DeliveryMethod=None) -> None: super().__init__() self.session = request.session self._cart = self.session.get(settings.CART_SESSION_ID, None) if not self._cart: self._cart = {} self.session[settings.CART_SESSION_ID] = self._cart - + self._delivery_info = delivery + self._display_items = self._prepare_display_items() + def save_cart(self): + self._display_items = self._prepare_display_items() 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 product = self.validate_and_get_product(item_id) author = product.author quantity = int(quantity) @@ -75,8 +113,7 @@ class SessionCart(BaseCart): self._cart[str(author.id)].pop(str(item_id)) self.save_cart() except KeyError: - # TODO - add logging - ... + logger.exception(f"Item {item_id} not found in cart") def update_item_quantity(self, item_id: int, new_quantity: int) -> None: product = self.validate_and_get_product(item_id) @@ -90,17 +127,14 @@ class SessionCart(BaseCart): self._cart[str(author.id)][str(product.id)] = new_quantity self.save_cart() - 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 delivery_info(self): + return self._delivery_info + @property + def display_items(self) -> List[dict[str, dict|str]]: + return self._display_items + @property def total_price(self): total = 0 @@ -108,6 +142,8 @@ class SessionCart(BaseCart): for item_id, quantity in cart_items.items(): product = Product.objects.get(id=int(item_id)) total += product.price * quantity + if self._delivery_info: + total += self._delivery_info.price * len(self._cart.keys()) return total def is_empty(self) -> bool: @@ -116,3 +152,25 @@ class SessionCart(BaseCart): def clear(self) -> None: self._cart = {} self.save_cart() + + +class CustomerData: + + def _encrypt_data(self, data: dict[str, Any]) -> str: + signer = signing.Signer() + return signer.sign_object(data) + + def _decrypt_data(self, data: str) -> dict[str, Any]: + signer = signing.Signer() + return signer.unsign_object(data) + + def __init__(self, data: dict[str, Any]=None, encrypted_data: str=None) -> None: + self._data = self._encrypt_data(data) if data else encrypted_data + + @property + def data(self) -> dict[str, Any]: + return self._data + + @property + def decrypted_data(self) -> dict[str, Any]: + return self._decrypt_data(self._data) diff --git a/artel/store/forms.py b/artel/store/forms.py index 74fe4bb..64ab14f 100644 --- a/artel/store/forms.py +++ b/artel/store/forms.py @@ -1,6 +1,16 @@ from django import forms from phonenumber_field.formfields import PhoneNumberField -# from phonenumber_field.widgets import PhoneNumberPrefixWidget +from phonenumber_field.phonenumber import PhoneNumber +from django.db.models import Model + +from store.models import ( + ProductTemplate, + ProductCategoryParamValue, + Product, + PaymentMethod, + DeliveryMethod +) + @@ -32,3 +42,49 @@ class CustomerDataForm(forms.Form): choices=(("PL", "Polska"), ), label="Kraj", widget=forms.Select(attrs={"class": "form-control"}) ) + payment_method = forms.ModelChoiceField( + queryset=PaymentMethod.objects.filter(active=True), label="Sposób płatności", + widget=forms.Select(attrs={"class": "form-control"}) + ) + delivery_method = forms.ModelChoiceField( + queryset=DeliveryMethod.objects.filter(active=True), label="Sposób dostawy", + widget=forms.Select(attrs={"class": "form-control"}) + ) + + def serialize(self): + """Clean method should return JSON serializable""" + new_cleaned_data = {} + for key, value in self.cleaned_data.items(): + if isinstance(value, PhoneNumber): + new_cleaned_data[key] = str(value) + elif isinstance(value, Model): + new_cleaned_data[key] = value.pk + else: + new_cleaned_data[key] = value + return new_cleaned_data + + +class ButtonToggleSelect(forms.RadioSelect): + template_name = "store/forms/button_toggle_select.html" + + +class ProductTemplateConfigForm(forms.Form): + + def _create_dynamic_fields(self, template: ProductTemplate): + category_params = template.category.category_params.all() + for param in category_params: + self.fields[param.key] = forms.ModelChoiceField( + queryset=ProductCategoryParamValue.objects.filter(param=param), + widget=ButtonToggleSelect(attrs={"class": "btn-group btn-group-toggle"}), + ) + + def __init__( + self, template: ProductTemplate, *args, **kwargs + ): + self.template = template + super().__init__(*args, **kwargs) + self._create_dynamic_fields(template) + + def get_product(self): + params = list(self.cleaned_data.values()) + return Product.objects.get_or_create_by_params(template=self.template, params=params) 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/migrations/0012_deliverymethod_order_uuid_product_uuid_and_more.py b/artel/store/migrations/0012_deliverymethod_order_uuid_product_uuid_and_more.py new file mode 100644 index 0000000..f3ad473 --- /dev/null +++ b/artel/store/migrations/0012_deliverymethod_order_uuid_product_uuid_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.9 on 2023-07-22 17:18 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0011_productparam_delete_templateparamvalue_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DeliveryMethod", + 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)), + ("price", models.FloatField(default=0)), + ("active", models.BooleanField(default=True)), + ], + ), + migrations.AddField( + model_name="order", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.AddField( + model_name="product", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.AddField( + model_name="order", + name="delivery_method", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="store.deliverymethod"), + ), + ] diff --git a/artel/store/models.py b/artel/store/models.py index 1f29204..beef666 100644 --- a/artel/store/models.py +++ b/artel/store/models.py @@ -1,8 +1,14 @@ import pdfkit import datetime +import builtins +import uuid +import logging from decimal import Decimal -from typing import Any +from typing import ( + Any, + Iterator +) from django.db import models from django.core.paginator import ( Paginator, @@ -14,6 +20,8 @@ from django.template import ( Template, Context ) +from django.core.exceptions import ValidationError +from django.db.models.signals import m2m_changed from modelcluster.models import ClusterableModel from modelcluster.fields import ParentalKey @@ -27,16 +35,23 @@ 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 ) +logger = logging.getLogger(__name__) + + +class BaseImageModel(models.Model): + image = models.ImageField() + is_main = models.BooleanField(default=False) + + class Meta: + abstract = True + + class PersonalData(models.Model): class Meta: @@ -92,6 +107,32 @@ class ProductCategoryParam(ClusterableModel): def __str__(self): return self.key + + panels = [ + FieldPanel("category"), + FieldPanel("key"), + FieldPanel("param_type"), + InlinePanel("param_values") + ] + + def get_available_values(self) -> Iterator[any]: + for elem in self.param_values.all(): + yield elem.get_value() + + +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 +147,90 @@ 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 ProductManager(models.Manager): + + def get_or_create_by_params(self, params: list[ProductCategoryParamValue], template: ProductTemplate): + products = self.filter(template=template) + + for param in params: + products = products.filter(params__pk=param.pk) + + # There should be only one + if not products.count() <= 1: + logger.exception( + f"There should be only one product with given set of params, detected: " + + f"{products.count()}, params: {params}, template: {template}" + ) + + product = products.first() + if not product: + product = self.create( + name=f"{template.title} - AUTOGENERATED", + template=template, + price=0, + available=False + ) + for param in params: + product.params.add(param) + + return product 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" + ) price = models.FloatField() available = models.BooleanField(default=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False) + + objects = ProductManager() 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: + if main_image := self.template.main_image: + return main_image + return self.product_images.first() @property def tags(self): @@ -163,10 +249,43 @@ 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) + + +# SIGNALS +def validate_param(sender, **kwargs): + action = kwargs.pop("action") + if action != "pre_add": + return + pk_set = kwargs.get("pk_set") + product_instance = kwargs.get("instance") + errors = [] + for pk in pk_set: + try: + param = ProductCategoryParamValue.objects.get(pk=pk).param + except ProductCategoryParamValue.DoesNotExist as e: + logger.exception(f"Product param validation failed with exception: {str(e)}") + count = product_instance.params.filter(productparam__param_value__param=param).count() + if count >= 1: + errors.append(ValueError("Product param with this key already exists.")) + + if errors: + raise ValidationError(errors) + + +m2m_changed.connect(validate_param, Product.params.through) class ProductListPage(Page): @@ -177,9 +296,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() @@ -207,7 +326,10 @@ class OrderProductManager(models.Manager): pks = [] for item in items: if item["quantity"] < 1: - # TODO - logging + logger.exception( + f"This is not possible to add less than one item to Order, omitting item: "+ + f"{item['product']}" + ) continue pk = self.create( @@ -307,8 +429,9 @@ class OrderManager(models.Manager): sent = self._send_notifications(order, author, customer_data, docs) if not sent: - # TODO - store data temporarily - raise Exception("Error while sending emails") + logger.exception( + f"Error while sending emails, for order: {order.order_number}" + ) return Order.objects.filter(pk__in=orders_pks) @@ -319,14 +442,29 @@ class PaymentMethod(models.Model): description = models.TextField(blank=True) active = models.BooleanField(default=True) + def __str__(self) -> str: + return self.name + + +class DeliveryMethod(models.Model): + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + price = models.FloatField(default=0) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + class Order(models.Model): payment_method = models.ForeignKey(PaymentMethod, on_delete=models.CASCADE) + delivery_method = models.ForeignKey(DeliveryMethod, on_delete=models.CASCADE, null=True) 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) + uuid = models.UUIDField(default=uuid.uuid4, editable=False) objects = OrderManager() @property @@ -335,10 +473,12 @@ class Order(models.Model): @property def total_price(self) -> Decimal: - return sum( + price = sum( [order_product.product.price * order_product.quantity for order_product in self.products.all()] ) + delivery_price = self.delivery_method.price if self.delivery_method else 5.0 + return price + delivery_price @property def total_price_words(self) -> str: diff --git a/artel/store/static/images/icons/truck.svg b/artel/store/static/images/icons/truck.svg new file mode 100644 index 0000000..1afc549 --- /dev/null +++ b/artel/store/static/images/icons/truck.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/artel/store/static/js/cart.js b/artel/store/static/js/cart.js index 0d40000..b0c1261 100644 --- a/artel/store/static/js/cart.js +++ b/artel/store/static/js/cart.js @@ -10,7 +10,7 @@ $(document).on('click', '.add-to-cart-button', function(event) { const csrfToken = $(this).data('csrf-token'); console.log(productID); formData.append('product_id', productID); - formData.append('quantity', quantity); // Serialize the form data correctly + formData.append('quantity', 1); // Serialize the form data correctly button.prop('disabled', true); $.ajax({ type: 'POST', 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/tasks.py b/artel/store/tasks.py new file mode 100644 index 0000000..cebc7f4 --- /dev/null +++ b/artel/store/tasks.py @@ -0,0 +1,32 @@ +import logging +from django.conf import settings + +from mailings.models import OutgoingEmail +from store.models import Product +from store.admin import ProductAdmin + + +logger = logging.getLogger(__name__) + +# TODO - those should be modified to be celery tasks + +def send_produt_request_email(variant_pk: int): + try: + variant = Product.objects.get(pk=variant_pk) + except Product.DoesNotExist: + logger.exception(f"Product with pk={variant_pk} does not exist") + + try: + admin_url = ProductAdmin().url_helper.get_action_url("edit", variant.pk) + send = OutgoingEmail.objects.send( + template_name="product_request", + subject="Złożono zapytanie ofertowe", + recipient=variant.template.author.email, + context={"product": variant, "admin_url": admin_url}, + sender=settings.DEFAULT_FROM_EMAIL + ) + except Exception as e: + logger.exception(f"Could not send email for variant pk={variant_pk}, exception: {e} has occured") + else: + if not send: + logger.exception(f"Could not send email for variant pk={variant_pk}") diff --git a/artel/store/templates/store/cart.html b/artel/store/templates/store/cart.html index 4fb1df7..b19a5cc 100644 --- a/artel/store/templates/store/cart.html +++ b/artel/store/templates/store/cart.html @@ -9,7 +9,7 @@

Koszyk

- {% for group in cart.get_items %} + {% for group in cart.display_items %} {% if group.products %}

Wykonawca: {{group.author.display_name}}

{% for item in group.products %} diff --git a/artel/store/templates/store/configure_product.html b/artel/store/templates/store/configure_product.html new file mode 100644 index 0000000..4e59610 --- /dev/null +++ b/artel/store/templates/store/configure_product.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+

{{template.title}}

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

{{template.description}}

+
+
+ +
+ +
+
+ {% csrf_token %} +
+ {% for field in form %} +
+

{{field.label}}

+ {{field}} +
+ {% if forloop.counter|divisibleby:"2" %}
{% endif %} + {% endfor %} +
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock %} diff --git a/artel/store/templates/store/configure_product_summary.html b/artel/store/templates/store/configure_product_summary.html new file mode 100644 index 0000000..a44a4b2 --- /dev/null +++ b/artel/store/templates/store/configure_product_summary.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} + +{% block content %} + + +
+
+
+
+ Responsive image +
+
+
+
+ {% for value in params_values %} +
+
+

{{value}}

+
+
+
+ {% endfor %} +
+
+
+
+ {% if not variant.available %} +
+
+ Niestety skonfigurowany przez Ciebie wariant produktu nie jest jeszcze dostępny. + Jeżeli jesteś zainteresowany tą konfiguracją złóż zapytanie ofertowe. +
+
+ {% else %} +
+
+

Cena: {{variant.price}} zł

+
+
+ {% endif %} +
+ +
+ + {% if variant.available %} + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+
+ +{% endblock %} diff --git a/artel/store/templates/store/forms/button_toggle_select.html b/artel/store/templates/store/forms/button_toggle_select.html new file mode 100644 index 0000000..2d5e4b6 --- /dev/null +++ b/artel/store/templates/store/forms/button_toggle_select.html @@ -0,0 +1,28 @@ + +{% with id=widget.attrs.id %} +
+ {% for group, options, index in widget.optgroups %} + {% for option in options %} + + + {% endfor %} + {% endfor %} +
+{% endwith %} + diff --git a/artel/store/templates/store/order.html b/artel/store/templates/store/order.html index 9e8ec8f..0776346 100644 --- a/artel/store/templates/store/order.html +++ b/artel/store/templates/store/order.html @@ -8,9 +8,9 @@
-

Twoje dane

-
+

Twoje dane

+
@@ -42,12 +42,13 @@
-
- -
- +
+
+

Dane do wysyłki

-
+
+
+
@@ -72,7 +73,30 @@ {{form.zip_code}}
+
+
+
+

Płatność i wysyłka

+
+
+
+
+
+
+ + {{form.payment_method}} +
+
+
+
+ + {{form.delivery_method}} +
+
+
+ +
diff --git a/artel/store/templates/store/order_confirm.html b/artel/store/templates/store/order_confirm.html index 75d9436..bca41d1 100644 --- a/artel/store/templates/store/order_confirm.html +++ b/artel/store/templates/store/order_confirm.html @@ -57,20 +57,28 @@

Zamówione przedmioty

- {% for group in cart.get_items %} + {% for group in cart.display_items %} {% if group.products %}

Wykonawca: {{group.author.display_name}}

{% for item in group.products %} {% include 'store/partials/summary_cart_item.html' %} {% endfor %} + {% if cart.delivery_info %} + {% with delivery=cart.delivery_info %} + {% include 'store/partials/delivery_cart_item.html' %} + {% endwith %} + {% endif %} +
+
W sumie: {{group.group_price}} zł
+
{% endif %} {% endfor %} -
+
-
Do zapłaty: {{cart.total_price}}
+
Do zapłaty: {{cart.total_price}} zł
diff --git a/artel/store/templates/store/order_success.html b/artel/store/templates/store/order_success.html new file mode 100644 index 0000000..b0c3efe --- /dev/null +++ b/artel/store/templates/store/order_success.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ + + + +
+
+

Dziękujemy za zakup!

+
+ Twoje zamówienia zostały przekazane do realizacji. + Oczekuj maili z dokumentami to zamówień.
+ + Twoje numery zamówień: +
+ {% for order in orders %} + {{order.order_number}}
+ {% endfor %} +
+ +
+ + Powrót do sklepu + +
+
+{% endblock %} diff --git a/artel/store/templates/store/partials/cart_item.html b/artel/store/templates/store/partials/cart_item.html index 52c2c41..c2be5d9 100644 --- a/artel/store/templates/store/partials/cart_item.html +++ b/artel/store/templates/store/partials/cart_item.html @@ -5,7 +5,7 @@
diff --git a/artel/store/templates/store/partials/delivery_cart_item.html b/artel/store/templates/store/partials/delivery_cart_item.html new file mode 100644 index 0000000..2488e7e --- /dev/null +++ b/artel/store/templates/store/partials/delivery_cart_item.html @@ -0,0 +1,25 @@ +{% load static %} + +
+
+
+
+ +
+
+

{{delivery.name}}

+
+
+ 1 +
+
+
{{delivery.price}} zł
+
+ +
+
+ +
+ \ No newline at end of file diff --git a/artel/store/templates/store/partials/product_card.html b/artel/store/templates/store/partials/product_card.html index ffd8bb4..b3f72ee 100644 --- a/artel/store/templates/store/partials/product_card.html +++ b/artel/store/templates/store/partials/product_card.html @@ -3,23 +3,12 @@
{{item.title}}
- {{item.title}} + {{item.title}} -