From 4d74e4d173edd5401032c2b9b7cfe59809e28274 Mon Sep 17 00:00:00 2001 From: Max Yakovenko Date: Mon, 20 Aug 2018 11:06:45 +0300 Subject: [PATCH] update cart --- cart/forms.py | 91 ++++++++++++++++++++++------- cart/models.py | 152 ++++++++++++++++++++++++++----------------------- cart/tasks.py | 54 ++++++++++++------ cart/urls.py | 3 +- cart/utils.py | 1 + cart/views.py | 85 ++++++++++++++++++++------- 6 files changed, 257 insertions(+), 129 deletions(-) diff --git a/cart/forms.py b/cart/forms.py index b0f649f..1c358e7 100644 --- a/cart/forms.py +++ b/cart/forms.py @@ -10,7 +10,10 @@ from django.urls import reverse_lazy from cart.models import ( Buying, BUYING_STATUS_IN_CART, Offer, SupplyType, SupplyTarget, Discount, Order ) +from cart.tasks import send_user_order_notification, send_admin_order_notification +from contact_us.mixins import RequestNotifiable from core.forms import QueryFormBase +from core.models import City from core.utils import parse_path from django.utils.translation import ugettext_lazy as _ @@ -28,15 +31,13 @@ class CartAddInlineForm(forms.ModelForm): Field('offer'), Field('amount'), Div( - Submit('Купить',value='submit'), + Submit('add', value=_('Купить')), css_class='catalog__btn' ) ) super().__init__(*args, **kwargs) - - def clean_amount(self): amount = self.cleaned_data['amount'] offer = self.cleaned_data['offer'] @@ -73,7 +74,7 @@ class CartRemoveBuyingForm(forms.ModelForm): self.helper.layout = Layout( Field('offer'), Div( - Submit('Убрать'), + Submit('cancel', value=_('Убрать')), css_class='catalog__btn' ) ) @@ -88,38 +89,90 @@ class CartRemoveBuyingForm(forms.ModelForm): } -CartRemoveBuyingFormset = formset_factory(CartRemoveBuyingForm) +class CartCheckoutBuyingForm(forms.Form): + order = forms.Field() + offer = forms.Field() + amount = forms.IntegerField() + bonus_points = forms.IntegerField() + + def get_initial_for_field(self, field, field_name): + if field_name == 'offer': + field = Offer.active.get(product_id=self.initia[field_name]) + elif field_name == 'order': + field = Order.objects.get(order_code=self.initial[field_name]) + elif field_name == 'amount' or field_name == 'bonus_points': + field.value = self.initial[field_name] + return super().get_initial_for_field(field, field_name) + def save(self, user): + buying = Buying() + buying.user = user + buying.offer = self.offer + buying.order = self.order + buying.bonus_points = self.bonus_points + buying.amount = self.amount + buying.total_price = self.offer.get_price_with_discount * self.amount + buying.save() + return buying + + +class CartCheckoutForm(RequestNotifiable, forms.ModelForm): + form_action = {'viewname': 'cart:confirm', 'kwargs': {}} + field_template = 'bootstrap/forms/cart_checkout.html' -class CartCheckoutForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.helper = FormHelper() self.helper.form_method = 'post' self.helper.form_action = reverse_lazy(**self.form_action) self.helper.layout = Layout( - Field('customer_name'), - Field('customer_email'), - Field('customer_user'), - Field('phone'), - Field('customer_address'), - Field('city'), - Field('buyings'), - Field('comment'), + Field('customer_name', css_class="order__input", template=self.field_template), + Field('customer_email', css_class="order__input", template=self.field_template), + Field('customer_user', css_class="order__input", template=self.field_template), + Field('phone', css_class="order__input", template=self.field_template), + Field('customer_address', css_class="order__input", template=self.field_template), + Field('city', css_class="order__input", template=self.field_template), + Field('comment', css_class="order__input", template=self.field_template), Div( - Submit('Подвердить'), + Submit('checkout', value=_('Подтвердить'), style="margin: 0 auto;"), css_class='catalog__btn' ) ) super().__init__(*args, **kwargs) - class Model: + def save(self, commit=True): + if not City.objects.filter(name=self.cleaned_data['city']).exists(): + city = City() + city.name = self.cleaned_data['city'] + city.save() + self.instance.city = city + return super().save(commit) + + def send_order_invoice(self, request): + return send_user_order_notification.delay(self.instance.id, request) + + def send_order_request(self, request): + context = { + 'from_email': settings.DEFAULT_FROM_EMAIL, + 'recipients': (settings.DEFAULT_FROM_EMAIL,), + 'email': { + 'subject': _('У вас новый заказ'), + 'order': self.instance, + }, + 'send_at_date': self.instance.create_at, + + } + return send_admin_order_notification.delay(context) + + class Meta: model = Order fields = ( - 'customer_name', 'customer_email', 'customer_user', - 'phone', 'customer_address', 'city', 'buyings', - 'comment' + 'customer_name', 'customer_email', 'phone', 'customer_address', 'city', 'comment' ) + widgets = { + 'city': forms.TextInput() + } + class ProductOfferPriceFilterForm(QueryFormBase): min_price = 0 diff --git a/cart/models.py b/cart/models.py index 22a3b3f..e4ab387 100644 --- a/cart/models.py +++ b/cart/models.py @@ -99,12 +99,20 @@ class SupplyTarget(AbstractDateTimeModel): class Discount(AbstractDateTimeModel): - code = models.CharField(max_length=50, blank=True, unique=True, default=str(uuid.uuid4())) - valid_from = models.DateTimeField(auto_now_add=True, blank=True) - valid_to = models.DateTimeField(default=now() + timedelta(days=7), blank=True) - value = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(100)], default=0, + def upload_file_to(self, filename): + return "discounts/{name}/{filename}".format(**{ + 'name': self.name, + 'filename': filename + }) + + name = models.CharField(_('Имя'), max_length=255) + image = models.FileField(_('Изображение'), upload_to=upload_file_to, blank=True, null=True) + code = models.CharField(_('Код'), max_length=50, blank=True, unique=True, default=str(uuid.uuid4())) + valid_from = models.DateTimeField(_('Начало'), auto_now_add=True, blank=True) + valid_to = models.DateTimeField(_('Конец'), default=now() + timedelta(days=7), blank=True) + value = models.IntegerField(_('Процент'), validators=[MinValueValidator(0), MaxValueValidator(100)], default=0, help_text=_('Указываем целым числом. Пример: 30 = 30%')) - active = models.BooleanField(default=True) + active = models.BooleanField(_('Активная'), default=True) def __str__(self): return self.code @@ -171,71 +179,6 @@ class Offer(AbstractStatusModel): verbose_name_plural = _('Позиции') -# ------------------------------------------ Buying status --------------------------------------------------- # -BUYING_STATUS_IN_CART = 25 -BUYING_STATUS_PENDING = 50 -BUYING_STATUS_PAID = 75 -BUYING_STATUS_CHOICES = ( - (BUYING_STATUS_IN_CART, _('В корзине')), - (BUYING_STATUS_PENDING, _('Обрабатываеться')), - (BUYING_STATUS_PAID, _('Оплаченно')) -) - -BUYING_DEFAULT_CHOICE = BUYING_STATUS_IN_CART - - -class BuyingManager(ActiveOnlyManager, models.Manager): - def get_user_buyings(self, user): - qs = self.get_queryset() - return qs.filter(user=user).all() - - def get_buying_total_price(self, user=None): - qs = self.get_user_buyings(user) if user else self.get_queryset() - return qs.aggregate(Sum('total_price')) - - def get_buying_total_bonus_points(self, user=None): - qs = self.get_user_buyings(user) if user else self.get_queryset() - return qs.aggregate(Sum('bonus_points')) - - def get_buying_total_cashback(self, user=None): - qs = self.get_user_buyings(user) if user else self.get_queryset() - return qs.select_related('buying_cashback').aggregate(Sum('amount')) - - -class Buying(AbstractStatusModel): - user = models.ForeignKey(get_user_model(), verbose_name=_('пользователь'), on_delete=models.CASCADE) - offer = models.ForeignKey(Offer, verbose_name=_('позиция'), on_delete=models.CASCADE) - bonus_points = models.IntegerField(_('бонусы'), validators=(MinValueValidator(0),)) - status = models.SmallIntegerField(_('статус'), default=BUYING_DEFAULT_CHOICE, choices=BUYING_STATUS_CHOICES) - amount = models.SmallIntegerField(_('колличество'), default=0) - total_price = models.DecimalField(_('цена'), max_digits=10, decimal_places=2) - - active = BuyingManager() - - @property - def is_in_cart(self): - return self.status == BUYING_STATUS_IN_CART - - @property - def is_pending(self): - return self.status == BUYING_STATUS_PENDING - - @property - def is_paid(self): - return self.status == BUYING_STATUS_PAID - - def __str__(self): - return "{product_name}({product_amount}) - {price}".format(**{ - 'product_name': self.offer.product.name, - 'product_amount': self.amount, - 'price': self.total_price - }) - - class Meta: - verbose_name = _('Покупка') - verbose_name_plural = _('Покупки') - - STATUS_GAINED = 0 STATUS_SPENT = 100 CASHBACK_STATUS_CHOICES = ( @@ -285,7 +228,7 @@ ORDER_STATUS_DEFAULT = ORDER_STATUS_NEW class Order(AbstractStatusModel): order_code = models.CharField(_('код заказа'), max_length=255, default=str(uuid.uuid4())) - customer_name = models.CharField(_('bмя'), max_length=255) + customer_name = models.CharField(_('имя'), max_length=255) customer_email = models.EmailField(_('email'), blank=True, null=True, default=None) customer_user = models.ForeignKey( get_user_model(), on_delete=models.SET_NULL, @@ -302,7 +245,6 @@ class Order(AbstractStatusModel): customer_address = models.TextField(_('адрес')) city = models.ForeignKey(City, on_delete=models.PROTECT, verbose_name=_('Город')) - buyings = models.ManyToManyField(Buying, verbose_name=_('Покупки')) total_price = models.DecimalField(_('стоимость'), max_digits=10, decimal_places=2, default=0) comment = models.TextField(_('комментарий'), blank=True, null=True, default=None) status = models.SmallIntegerField(_('статус'), default=ORDER_STATUS_CHOICES, choices=ORDER_STATUS_DEFAULT) @@ -316,6 +258,72 @@ class Order(AbstractStatusModel): verbose_name_plural = _('Заказы') +# ------------------------------------------ Buying status --------------------------------------------------- # +BUYING_STATUS_IN_CART = 25 +BUYING_STATUS_PENDING = 50 +BUYING_STATUS_PAID = 75 +BUYING_STATUS_CHOICES = ( + (BUYING_STATUS_IN_CART, _('В корзине')), + (BUYING_STATUS_PENDING, _('Обрабатываеться')), + (BUYING_STATUS_PAID, _('Оплаченно')) +) + +BUYING_DEFAULT_CHOICE = BUYING_STATUS_IN_CART + + +class BuyingManager(ActiveOnlyManager, models.Manager): + def get_user_buyings(self, user): + qs = self.get_queryset() + return qs.filter(user=user).all() + + def get_buying_total_price(self, user=None): + qs = self.get_user_buyings(user) if user else self.get_queryset() + return qs.aggregate(Sum('total_price')) + + def get_buying_total_bonus_points(self, user=None): + qs = self.get_user_buyings(user) if user else self.get_queryset() + return qs.aggregate(Sum('bonus_points')) + + def get_buying_total_cashback(self, user=None): + qs = self.get_user_buyings(user) if user else self.get_queryset() + return qs.select_related('buying_cashback').aggregate(Sum('amount')) + + +class Buying(AbstractStatusModel): + order = models.ForeignKey(Order, verbose_name=_('пользователь'), on_delete=models.CASCADE) + user = models.ForeignKey(get_user_model(), verbose_name=_('пользователь'), on_delete=models.CASCADE) + offer = models.ForeignKey(Offer, verbose_name=_('позиция'), on_delete=models.CASCADE) + bonus_points = models.IntegerField(_('бонусы'), validators=(MinValueValidator(0),)) + status = models.SmallIntegerField(_('статус'), default=BUYING_DEFAULT_CHOICE, choices=BUYING_STATUS_CHOICES) + amount = models.SmallIntegerField(_('колличество'), default=0) + total_price = models.DecimalField(_('цена'), max_digits=10, decimal_places=2) + + active = BuyingManager() + + @property + def is_in_cart(self): + return self.status == BUYING_STATUS_IN_CART + + @property + def is_pending(self): + return self.status == BUYING_STATUS_PENDING + + @property + def is_paid(self): + return self.status == BUYING_STATUS_PAID + + def __str__(self): + return "{product_name}({product_amount}) - {price}".format(**{ + 'product_name': self.offer.product.name, + 'product_amount': self.amount, + 'price': self.total_price + }) + + class Meta: + verbose_name = _('Покупка') + verbose_name_plural = _('Покупки') + + @receiver(post_save, sender=Order) def product_in_order_post_save(sender, instance, created, **kwargs): order = instance.order diff --git a/cart/tasks.py b/cart/tasks.py index 6693d0b..2444d4c 100644 --- a/cart/tasks.py +++ b/cart/tasks.py @@ -1,23 +1,17 @@ import celery -from django.conf import settings -from celery import task -from django.template.loader import render_to_string -from django.core.mail import send_mail, EmailMessage -from io import BytesIO import weasyprint import pytils +from io import BytesIO -from cart.models import Order +from django.conf import settings +from django.template.loader import render_to_string, get_template +from django.core.mail import EmailMessage, EmailMultiAlternatives -SUPPLIER_INFO = '''ООО "Русские Программы", ИНН 7713409230, КПП 771301001, - 127411, Москва г, Дмитровское ш., дом № 157, корпус 7, тел.: +74957258950''' +from cart.models import Order -requisites = {'name': 'ООО "Русские Программы"', 'bank': 'АО "СМП БАНК" Г. МОСКВА', 'INN': '7713409230', - 'KPP': '771301001', 'BIK': '44525503', 'bank_acc': '30101810545250000503', 'acc': '40702810300750000177', - 'sup_info': SUPPLIER_INFO} @celery.task -def send_user_order_notification(order_id): +def send_user_order_notification(order_id, request): """ Sending Email of order creating """ @@ -26,20 +20,44 @@ def send_user_order_notification(order_id): verb_cur = pytils.numeral.choose_plural(round(order.total_price), ("рубль", "рубля", "рублей")) subject = 'Заказ № {}'.format(order.id) message = 'Уважаемый, {}, номер Вашего заказа {}. \ - Пожалуйста, совершите платеж по поручению в приложении к этому письму в течение 14 дней.'.format(order.customer_name, order.id) + Пожалуйста, совершите платеж по поручению в приложении к этому письму в течение 14 дней.'.format( + order.customer_name, order.id) mail_send = EmailMessage(subject, message, 'admin@myshop.ru', [order.customer_email]) # html = render_to_string('orders:AdminOrderPDF', args=[order_id]) - html = render_to_string('orders/pdf.html', {**requisites, 'order': order, + html = render_to_string('orders/pdf.html', {**settings.PAY_REQUISITES, 'order': order, 'verb_cur': verb_cur, 'verb_price': verb_price}) rendered_html = html.encode(encoding="UTF-8") out = BytesIO() weasyprint.HTML(string=rendered_html).write_pdf(out, - stylesheets=[weasyprint.CSS(settings.STATIC_ROOT + 'css/bootstrap.min.css')]) - - # weasyprint.HTML(string=rendered_html, base_url=request.build_absolute_uri()).write_pdf(response, - # stylesheets=[weasyprint.CSS(settings.STATIC_ROOT + '/css/bootstrap.min.css')]) + stylesheets=[ + weasyprint.CSS(settings.STATIC_ROOT + 'css/build.css')]) + order_invoice_name = 'Order_' + order.order_code + weasyprint.HTML(string=rendered_html, base_url=request.build_absolute_uri()).write_pdf(order_invoice_name, + stylesheets=[weasyprint.CSS( + settings.STATIC_ROOT + '/css/build.css')]) mail_send.attach('order_{}.pdf'.format(order_id), out.getvalue(), 'application/pdf') mail_send.send() return mail_send + + +@celery.task +def send_admin_order_notification(context): + body = get_template('emails/html/admin_order_request.html') + body_text = get_template('emails/txt/admin_order_request.txt') + + email = EmailMultiAlternatives( + context['email']['subject'], + body_text.render(context['email']), + context['from_email'], + context['recipients'] + ) + + email.attach_alternative(body.render(context['email']), 'text/html') + try: + email.send() + except Exception as e: + return False + + return True diff --git a/cart/urls.py b/cart/urls.py index 11a949a..70963cd 100644 --- a/cart/urls.py +++ b/cart/urls.py @@ -6,10 +6,11 @@ from . import views urlpatterns = [ re_path(r'^history/', views.BuyingsHistory.as_view(), name='history'), re_path(r'^buyings/$', views.CartView.as_view(), name='buyings'), + re_path(r'^discounts/$', views.DiscountListView.as_view(), name='discounts'), re_path(r'^add/$', views.CartAddView.as_view(), name='add'), re_path(r'^remove/$', views.CartRemoveView.as_view(), name='remove'), - re_path(r'^checkout/$', views.CartCheckoutView.as_view(),name='checkout'), + re_path(r'^checkout/$', views.CartCheckoutView.as_view(), name='checkout'), re_path(r'^confirm/$', views.CartConfirmView.as_view(), name='confirm'), # discount: @TODO: check if this logic is ready for production diff --git a/cart/utils.py b/cart/utils.py index 3463146..d47f0ff 100644 --- a/cart/utils.py +++ b/cart/utils.py @@ -29,6 +29,7 @@ class Cart(object): self.store.modified = True def remove(self, offer_id): + offer_id = str(offer_id) if offer_id in self.cart: del self.cart[offer_id] self.save() diff --git a/cart/views.py b/cart/views.py index 65e0314..cdedacd 100644 --- a/cart/views.py +++ b/cart/views.py @@ -1,26 +1,30 @@ import datetime +import logging import uuid from functools import reduce from decimal import Decimal from django.conf import settings +from django.db import transaction from django.shortcuts import redirect from django.urls import reverse_lazy from django.utils.translation import ugettext_lazy as _ -from django.views.generic.edit import BaseFormView +from django.views.generic import ListView -from cart.utils import Cart from cart.models import Buying, Discount, Offer -from core.views import ProtectedListView, ProtectedTemplateView, ProtectedView, ProtectedFormView +from core.models import City +from core.views import ProtectedListView, ProtectedTemplateView, ProtectedView, ProtectedFormView, ProtectedBaseFormView from .forms import ( CartRemoveBuyingForm, DiscountForm, - CartAddInlineForm, CartCheckoutForm -) + CartAddInlineForm, CartCheckoutForm, + CartCheckoutBuyingForm) +logger = logging.getLogger(__name__) -class CartAddView(BaseFormView): + +class CartAddView(ProtectedBaseFormView): http_method_names = ('post',) form_class = CartAddInlineForm @@ -32,7 +36,7 @@ class CartAddView(BaseFormView): return super().form_valid(form) -class CartRemoveView(BaseFormView): +class CartRemoveView(ProtectedBaseFormView): http_method_names = ('post',) model = Offer form_class = CartRemoveBuyingForm @@ -41,8 +45,8 @@ class CartRemoveView(BaseFormView): return self.request.META.get('HTTP_REFERRER', reverse_lazy('products:product_list')) def form_valid(self, form): - self.request.cart.remove(form.cleaned_data['offer']) - return super().form_valid() + self.request.cart.remove(form.cleaned_data['offer'].product_id) + return super().form_valid(form) class CartView(ProtectedListView): @@ -75,7 +79,7 @@ class CartView(ProtectedListView): context = super().get_context_data(object_list=object_list, **kwargs) context['title'] = self.title context['total_price'] = self.get_total_price(self.object_list) - context['total_price_currency'] = context['total_cashback_currency'] = self.object_list.first().currency.sign + context['total_price_currency'] = context['total_cashback_currency'] = self.object_list.first().currency.sign if self.object_list.first() else '' context['total_cashback'] = self.get_total_cashback(self.object_list) return context @@ -84,17 +88,50 @@ class CartCheckoutView(ProtectedFormView): http_method_names = ('get', 'post',) template_name = 'cart/checkout.html' form_class = CartCheckoutForm + title = _('Оформление заказа') def get_form_kwargs(self): - return super().get_form_kwargs() - - def form_valid(self, form): - return super().form_valid(form) - - -class CartConfirmView(ProtectedTemplateView): + kwargs = super().get_form_kwargs() + kwargs['initial'] = { + 'customer_name': self.request.user.profile.full_name, + 'customer_email': self.request.user.email, + 'phone': self.request.user.profile.phone, + 'customer_address': self.request.user.profile.address, + 'city': City.objects.filter(name=self.request.user.profile.city).first() + } + return kwargs + + +class CartConfirmView(ProtectedFormView): http_method_names = ('post',) template_name = 'cart/confirm.html' + form_class = CartCheckoutForm + title = _('Подтверждение заказа') + + def form_valid(self, form): + if form.is_valid(): + try: + with transaction.atomic(): + form.save() + for item in self.request.cart: + buying_form = CartCheckoutBuyingForm(initial={ + 'offer': item, + 'order': form.instance.order_code, + 'amount': self.request.cart[item]['quantity'] + }) + buying_form.save(self.request.user) + form.send_order_invoice(self.request) + form.send_order_request(self.request) + self.request.cart.clear() + except Exception as e: + logger.critical(e) + + return super().form_invalid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['title'] = self.title + return context class BuyingsHistory(ProtectedListView): @@ -114,6 +151,18 @@ class BuyingsHistory(ProtectedListView): context['title'] = self.title return context + +class DiscountListView(ListView): + template_name = 'cart/discount_list.html' + model = Discount + title = _('Акции / Cashback') + + def get_context_data(self, *, object_list=None, **kwargs): + context = super().get_context_data(object_list=object_list, **kwargs) + context['title'] = self.title + return context + + # Discount views # @TODO: TEST FOR PRODUCTION class PointsApply(ProtectedTemplateView): http_method_names = ('post',) @@ -124,8 +173,6 @@ class PointsApply(ProtectedTemplateView): return redirect('cart:buyings') - - class PointsRevoke(ProtectedView): http_method_names = ('post',)