From 39383e0acb094d67a036f0e36b6ee1e8780c09ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 12:57:50 +0100 Subject: [PATCH 01/19] Check for orders which are cancelled=False instead of those who are paid to avoid "overselling". --- src/shop/models.py | 2 +- src/shop/tests.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/shop/models.py b/src/shop/models.py index 6b3cccf5..7ab42d04 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -418,7 +418,7 @@ class Product(CreatedUpdatedModel, UUIDModel): if self.stock_amount: sold = OrderProductRelation.objects.filter( product=self, - order__paid=True, + order__cancelled=False, ).aggregate(Sum('quantity'))['quantity__sum'] total_left = self.stock_amount - (sold or 0) diff --git a/src/shop/tests.py b/src/shop/tests.py index f28ec463..6646edef 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -22,16 +22,21 @@ class ProductAvailabilityTest(TestCase): """ If max orders have been made, the product is NOT available. """ product = ProductFactory(stock_amount=2) - for i in range(2): - opr = OrderProductRelationFactory(product=product) - order = opr.order - order.paid = True - order.save() + opr1 = OrderProductRelationFactory(product=product) + opr2 = OrderProductRelationFactory(product=product) self.assertEqual(product.left_in_stock, 0) self.assertFalse(product.is_stock_available) self.assertFalse(product.is_available()) + # Cancel one order + opr1.order.mark_as_cancelled() + + self.assertEqual(product.left_in_stock, 1) + self.assertTrue(product.is_stock_available) + self.assertTrue(product.is_available()) + + def test_product_available_by_time(self): """ The product is available if now is in the right timeframe. """ product = ProductFactory() From 8a5b2e5ed1f7d7152d8f174e94bbc8796e5c2574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 13:49:46 +0100 Subject: [PATCH 02/19] Add a worker to cancel old orders. --- src/bornhack/environment_settings.py.dist | 2 ++ src/bornhack/environment_settings.py.dist.dev | 3 ++ src/shop/email.py | 25 +++++++++++++++- src/shop/order_cleanup_worker.py | 30 +++++++++++++++++++ .../emails/order_cancelled_email.html | 11 +++++++ .../emails/order_cancelled_email.txt | 9 ++++++ 6 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/shop/order_cleanup_worker.py create mode 100644 src/shop/templates/emails/order_cancelled_email.html create mode 100644 src/shop/templates/emails/order_cancelled_email.txt diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index c62434b5..cc0389a7 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -94,3 +94,5 @@ ACCOUNTINGSYSTEM_EMAIL = "{{ django_accountingsystem_email }}" ECONOMYTEAM_EMAIL = "{{ django_economyteam_email }}" ECONOMYTEAM_NAME = "Economy" +ORDER_TTL = 24 +ORDER_TTL_UNIT = 'hours' diff --git a/src/bornhack/environment_settings.py.dist.dev b/src/bornhack/environment_settings.py.dist.dev index 3009d2c9..488767e4 100644 --- a/src/bornhack/environment_settings.py.dist.dev +++ b/src/bornhack/environment_settings.py.dist.dev @@ -78,3 +78,6 @@ CHANNEL_LAYERS = { } REIMBURSEMENT_MAIL = "reimbursement@example.com" + +ORDER_TTL = 30 +ORDER_TTL_UNIT = 'minutes' diff --git a/src/shop/email.py b/src/shop/email.py index ba1edef5..14042b9d 100644 --- a/src/shop/email.py +++ b/src/shop/email.py @@ -1,5 +1,9 @@ -from utils.email import add_outgoing_email import logging + +from django.conf import settings + +from utils.email import add_outgoing_email + logger = logging.getLogger("bornhack.%s" % __name__) @@ -45,6 +49,25 @@ def add_invoice_email(invoice): ) +def add_order_cancelled_email(order): + formatdict = { + 'ordernumber': order.pk, + 'order_ttl': settings.ORDER_TTL, + 'order_ttl_unit': settings.ORDER_TTL_UNIT + } + + subject = 'Your non-paid BornHack order has been cancelled.' + + # add email to outgoing email queue + return add_outgoing_email( + text_template='emails/order_cancelled_email.txt', + html_template='emails/order_cancelled_email.html', + to_recipients=order.user.email, + formatdict=formatdict, + subject=subject, + ) + + def add_test_email(recipient): return add_outgoing_email( text_template='emails/testmail.txt', diff --git a/src/shop/order_cleanup_worker.py b/src/shop/order_cleanup_worker.py new file mode 100644 index 00000000..ff4e94c5 --- /dev/null +++ b/src/shop/order_cleanup_worker.py @@ -0,0 +1,30 @@ +from dateutil import relativedelta +from django.conf import settings +from django.utils import timezone + +from shop.models import Order +from shop.email import add_order_cancelled_email + +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('bornhack.%s' % __name__) + + +def do_work(): + """ + The order cleanup worker scans for orders that are still open + but are older than ORDER_TTL, and marks those as closed. + """ + + time_threshold = timezone.now() - relativedelta.relativedelta(**{settings.ORDER_TTL_UNIT: settings.ORDER_TTL}) + + orders_to_delete = Order.objects.filter(open=True, cancelled=False, created__lt=time_threshold) + + for order in orders_to_delete: + logger.info( + "Cancelling order %s since it has been open for more than %s %s" % + (order.pk, settings.ORDER_TTL, settings.ORDER_TTL_UNIT) + ) + order.mark_as_cancelled() + add_order_cancelled_email(order) diff --git a/src/shop/templates/emails/order_cancelled_email.html b/src/shop/templates/emails/order_cancelled_email.html new file mode 100644 index 00000000..44650fe1 --- /dev/null +++ b/src/shop/templates/emails/order_cancelled_email.html @@ -0,0 +1,11 @@ +Hello!
+
+Your order number {{ ordernumber }} has been open for more than {{ order_ttl }} {{ order_ttl_unit }} and has been cancelled.
+
+This means you will have to open a new order.
+
+
+Best regards,
+
+The BornHack Team
+
diff --git a/src/shop/templates/emails/order_cancelled_email.txt b/src/shop/templates/emails/order_cancelled_email.txt new file mode 100644 index 00000000..c530ee12 --- /dev/null +++ b/src/shop/templates/emails/order_cancelled_email.txt @@ -0,0 +1,9 @@ +Hello! + +Your order number {{ ordernumber }} has been open for more than {{ order_ttl }} {{ order_ttl_unit }} and has been cancelled. + +This means you will have to open a new order. + +Best regards, + +The BornHack Team From 101cb2db638f9aab17c235f518ee17cc33602dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 20:25:06 +0100 Subject: [PATCH 03/19] Check stock when incrementing orderproduct quantity. --- src/shop/templates/shop_index.html | 8 ++++++-- src/shop/views.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/shop/templates/shop_index.html b/src/shop/templates/shop_index.html index c0915e5d..d7b9e072 100644 --- a/src/shop/templates/shop_index.html +++ b/src/shop/templates/shop_index.html @@ -55,9 +55,13 @@ Shop | {{ block.super }} {{ product.name }} - {% if product.stock_amount and product.left_in_stock <= 10 %} + {% if product.stock_amount %}
- Only {{ product.left_in_stock }} left! + {% if product.left_in_stock == 0 %} + Sold out! + {% elif product.left_in_stock <= 10 %} + Only {{ product.left_in_stock }} left! + {% endif %}
{% endif %} diff --git a/src/shop/views.py b/src/shop/views.py index 7eaa9fba..1c97e9c6 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -335,6 +335,20 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH order_product_id = str(order_product.pk) if order_product_id in request.POST: new_quantity = int(request.POST.get(order_product_id)) + + if order_product.quantity < new_quantity: + # We are incrementing and thus need to check stock + incrementing_by = new_quantity - order_product.quantity + if incrementing_by > order_product.product.left_in_stock: + messages.error( + request, + "Sadly we only have {} '{}' left in stock.".format( + order_product.product.left_in_stock, + order_product.product.name, + ) + ) + return super(OrderDetailView, self).get(request, *args, **kwargs) + order_product.quantity = new_quantity order_product.save() order.customer_comment = request.POST.get('customer_comment') or '' From 59cde9163f3fc08d113e493b720b74720a781ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 22:53:23 +0100 Subject: [PATCH 04/19] Use a modelformset for the order detail view to be able to validate stock on updates of quantities and payment of the order. --- src/profiles/templates/shop/order_detail.html | 32 ++--- src/shop/forms.py | 28 +++++ src/shop/models.py | 9 ++ src/shop/order_cleanup_worker.py | 30 ----- src/shop/templates/product_detail.html | 2 +- src/shop/templates/shop_index.html | 19 ++- src/shop/views.py | 115 ++++++++++-------- 7 files changed, 135 insertions(+), 100 deletions(-) delete mode 100644 src/shop/order_cleanup_worker.py diff --git a/src/profiles/templates/shop/order_detail.html b/src/profiles/templates/shop/order_detail.html index c659be04..7ad6491b 100644 --- a/src/profiles/templates/shop/order_detail.html +++ b/src/profiles/templates/shop/order_detail.html @@ -12,6 +12,7 @@ {% if not order.paid %}
{% csrf_token %} + {{ order_product_formset.management_form }} {% endif %} @@ -25,28 +26,29 @@ Price - {% for order_product in order.orderproductrelation_set.all %} + {% for form in order_product_formset %} + {{ form.id }}
Total +
- {{ order_product.product.name }} - - {% if not order.open == None %} - - {% bootstrap_button '' button_type="submit" button_class="btn-danger" name="remove_product" value=order_product.pk %} - {% else %} - {{ order_product.quantity }} + {{ form.instance.product.name }} + {% if form.instance.product.stock_amount %} +
{{ form.instance.product.left_in_stock }} left in stock {% endif %}
- {{ order_product.product.price|currency }} + {% if not order.open == None %} + {% bootstrap_field form.quantity show_label=False %} + {% else %} + {{ form.instance.quantity }} + {% endif %} - {{ order_product.total|currency }} + {{ form.instance.product.price|currency }} + + {{ form.instance.total|currency }} + + {% bootstrap_button '' button_type="submit" button_class="btn-danger" name="remove_product" value=order_product.pk %} {% endfor %} diff --git a/src/shop/forms.py b/src/shop/forms.py index 3d8dcbcd..7ed44bcb 100644 --- a/src/shop/forms.py +++ b/src/shop/forms.py @@ -1,6 +1,34 @@ from django import forms +from django.forms import modelformset_factory + +from shop.models import OrderProductRelation class AddToOrderForm(forms.Form): quantity = forms.IntegerField(initial=1) + +class OrderProductRelationForm(forms.ModelForm): + class Meta: + model = OrderProductRelation + fields = ['quantity'] + + def clean_quantity(self): + product = self.instance.product + new_quantity = self.cleaned_data['quantity'] + + if product.left_in_stock < new_quantity: + raise forms.ValidationError( + "Only {} left in stock.".format( + product.left_in_stock, + ) + ) + + return new_quantity + + +OrderProductRelationFormSet = modelformset_factory( + OrderProductRelation, + form=OrderProductRelationForm, + extra=0 +) diff --git a/src/shop/models.py b/src/shop/models.py index 7ab42d04..c4f6f5c3 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -80,6 +80,8 @@ class Order(CreatedUpdatedModel): default=False, ) + # We are using a NullBooleanField here to ensure that we only have one open order per user at a time. + # This "hack" is possible since postgres treats null values as different, and thus we have database level integrity. open = models.NullBooleanField( verbose_name=_('Open?'), help_text=_('Whether this shop order is open or not. "None" means closed.'), @@ -416,8 +418,15 @@ class Product(CreatedUpdatedModel, UUIDModel): @property def left_in_stock(self): if self.stock_amount: + # All orders that are not open and not cancelled count towards what has + # been "reserved" from stock. + # + # This means that an order has either been paid (by card or blockchain) + # or is marked to be paid with cash or bank transfer, meaning it is a + # "reservation" of the product in question. sold = OrderProductRelation.objects.filter( product=self, + order__open=None, order__cancelled=False, ).aggregate(Sum('quantity'))['quantity__sum'] diff --git a/src/shop/order_cleanup_worker.py b/src/shop/order_cleanup_worker.py deleted file mode 100644 index ff4e94c5..00000000 --- a/src/shop/order_cleanup_worker.py +++ /dev/null @@ -1,30 +0,0 @@ -from dateutil import relativedelta -from django.conf import settings -from django.utils import timezone - -from shop.models import Order -from shop.email import add_order_cancelled_email - -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger('bornhack.%s' % __name__) - - -def do_work(): - """ - The order cleanup worker scans for orders that are still open - but are older than ORDER_TTL, and marks those as closed. - """ - - time_threshold = timezone.now() - relativedelta.relativedelta(**{settings.ORDER_TTL_UNIT: settings.ORDER_TTL}) - - orders_to_delete = Order.objects.filter(open=True, cancelled=False, created__lt=time_threshold) - - for order in orders_to_delete: - logger.info( - "Cancelling order %s since it has been open for more than %s %s" % - (order.pk, settings.ORDER_TTL, settings.ORDER_TTL_UNIT) - ) - order.mark_as_cancelled() - add_order_cancelled_email(order) diff --git a/src/shop/templates/product_detail.html b/src/shop/templates/product_detail.html index 87d77ab9..34fc50c2 100644 --- a/src/shop/templates/product_detail.html +++ b/src/shop/templates/product_detail.html @@ -31,7 +31,7 @@ {% if product.left_in_stock > 0 %} {{ product.left_in_stock }} available
{% else %} - Sold out. + Sold out.
{% endif %} diff --git a/src/shop/templates/shop_index.html b/src/shop/templates/shop_index.html index d7b9e072..1c67d61f 100644 --- a/src/shop/templates/shop_index.html +++ b/src/shop/templates/shop_index.html @@ -55,15 +55,24 @@ Shop | {{ block.super }} {{ product.name }} + + {% if product.stock_amount %} -
- {% if product.left_in_stock == 0 %} - Sold out! - {% elif product.left_in_stock <= 10 %} + + {% if product.left_in_stock <= 10 %} +
Only {{ product.left_in_stock }} left! - {% endif %}
{% endif %} + + {% if product.left_in_stock == 0 %} +
+ Sold out! +
+ {% endif %} + + {% endif %} +
diff --git a/src/shop/views.py b/src/shop/views.py index 1c97e9c6..18fda6a3 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -1,7 +1,9 @@ +import logging +from collections import OrderedDict + from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse, reverse_lazy from django.db.models import Count, F from django.http import ( HttpResponse, @@ -10,6 +12,10 @@ from django.http import ( Http404 ) from django.shortcuts import get_object_or_404 +from django.urls import reverse, reverse_lazy +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt from django.views.generic import ( View, ListView, @@ -18,9 +24,6 @@ from django.views.generic import ( ) from django.views.generic.base import RedirectView from django.views.generic.detail import SingleObjectMixin -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import csrf_exempt -from django.utils import timezone from shop.models import ( Order, @@ -31,16 +34,15 @@ from shop.models import ( EpayPayment, CreditNote, ) -from .forms import AddToOrderForm -from .epay import calculate_epay_hash, validate_epay_callback -from collections import OrderedDict from vendor.coinify.coinify_callback import CoinifyCallback from .coinify import ( create_coinify_invoice, save_coinify_callback, process_coinify_invoice_json ) -import logging +from .epay import calculate_epay_hash, validate_epay_callback +from .forms import AddToOrderForm, OrderProductRelationFormSet + logger = logging.getLogger("bornhack.%s" % __name__) @@ -291,10 +293,63 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH template_name = 'shop/order_detail.html' context_object_name = 'order' - def post(self, request, *args, **kwargs): - order = self.get_object() - payment_method = request.POST.get('payment_method') + def get_context_data(self, **kwargs): + if 'order_product_formset' not in kwargs: + kwargs['order_product_formset'] = OrderProductRelationFormSet( + queryset=OrderProductRelation.objects.filter(order=self.get_object()), + ) + return super().get_context_data(**kwargs) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + order = self.object + + # First check if the user is removing a product from the order. + product_remove = request.POST.get('remove_product') + if product_remove: + order.orderproductrelation_set.filter(pk=product_remove).delete() + if not order.products.count() > 0: + order.mark_as_cancelled() + messages.info(request, 'Order cancelled!') + return HttpResponseRedirect(reverse_lazy('shop:index')) + + # Then see if the user is cancelling the order. + if 'cancel_order' in request.POST: + order.mark_as_cancelled() + messages.info(request, 'Order cancelled!') + return HttpResponseRedirect(reverse_lazy('shop:index')) + + # The user is not removing products or cancelling the order, + # so from now on we do stuff that require us to check stock. + # We use a formset for this to be able to display exactly + # which product is not in stock if that is the case. + formset = OrderProductRelationFormSet( + request.POST, + queryset=OrderProductRelation.objects.filter(order=order), + ) + + # If the formset is not valid it means that we cannot fulfill the order, so return and inform the user. + if not formset.is_valid(): + messages.error( + request, + "Some of the products you are ordering are out of stock. Review the order and try again." + ) + return self.render_to_response( + self.get_context_data(order_product_formset=formset) + ) + + # No stock issues, proceed to check if the user is updating the order. + if 'update_order' in request.POST: + # We have already made sure the formset is valid, so just save it to update quantities. + formset.save() + + order.customer_comment = request.POST.get('customer_comment') or '' + order.invoice_address = request.POST.get('invoice_address') or '' + order.save() + + # Then at last see if the user is paying for the order. + payment_method = request.POST.get('payment_method') if payment_method in order.PAYMENT_METHODS: if not request.POST.get('accept_terms'): messages.error(request, "You need to accept the general terms and conditions before you can continue!") @@ -330,44 +385,6 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH return HttpResponseRedirect(reverses[payment_method]) - if 'update_order' in request.POST: - for order_product in order.orderproductrelation_set.all(): - order_product_id = str(order_product.pk) - if order_product_id in request.POST: - new_quantity = int(request.POST.get(order_product_id)) - - if order_product.quantity < new_quantity: - # We are incrementing and thus need to check stock - incrementing_by = new_quantity - order_product.quantity - if incrementing_by > order_product.product.left_in_stock: - messages.error( - request, - "Sadly we only have {} '{}' left in stock.".format( - order_product.product.left_in_stock, - order_product.product.name, - ) - ) - return super(OrderDetailView, self).get(request, *args, **kwargs) - - order_product.quantity = new_quantity - order_product.save() - order.customer_comment = request.POST.get('customer_comment') or '' - order.invoice_address = request.POST.get('invoice_address') or '' - order.save() - - product_remove = request.POST.get('remove_product') - if product_remove: - order.orderproductrelation_set.filter(pk=product_remove).delete() - if not order.products.count() > 0: - order.mark_as_cancelled() - messages.info(request, 'Order cancelled!') - return HttpResponseRedirect(reverse_lazy('shop:index')) - - if 'cancel_order' in request.POST: - order.mark_as_cancelled() - messages.info(request, 'Order cancelled!') - return HttpResponseRedirect(reverse_lazy('shop:index')) - return super(OrderDetailView, self).get(request, *args, **kwargs) From 4cbb25a537a6c3dfbb6d2d3e934bb5b5d00e000c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 23:34:44 +0100 Subject: [PATCH 05/19] Use OrderProductRelationForm to validate stock when adding a new product to order, also make it possible to update the quantity of a product on the product detail page if it is already in the current order. --- src/profiles/templates/shop/order_detail.html | 2 +- src/shop/forms.py | 5 +- src/shop/templates/product_detail.html | 8 ++- src/shop/templates/shop_index.html | 10 ++-- src/shop/views.py | 60 +++++++++++-------- 5 files changed, 47 insertions(+), 38 deletions(-) diff --git a/src/profiles/templates/shop/order_detail.html b/src/profiles/templates/shop/order_detail.html index 7ad6491b..ce505954 100644 --- a/src/profiles/templates/shop/order_detail.html +++ b/src/profiles/templates/shop/order_detail.html @@ -48,7 +48,7 @@
{{ form.instance.total|currency }} - {% bootstrap_button '' button_type="submit" button_class="btn-danger" name="remove_product" value=order_product.pk %} + {% bootstrap_button '' button_type="submit" button_class="btn-danger" name="remove_product" value=form.instance.pk %} {% endfor %} diff --git a/src/shop/forms.py b/src/shop/forms.py index 7ed44bcb..29910321 100644 --- a/src/shop/forms.py +++ b/src/shop/forms.py @@ -4,11 +4,8 @@ from django.forms import modelformset_factory from shop.models import OrderProductRelation -class AddToOrderForm(forms.Form): - quantity = forms.IntegerField(initial=1) - - class OrderProductRelationForm(forms.ModelForm): + class Meta: model = OrderProductRelation fields = ['quantity'] diff --git a/src/shop/templates/product_detail.html b/src/shop/templates/product_detail.html index 34fc50c2..2296fe3a 100644 --- a/src/shop/templates/product_detail.html +++ b/src/shop/templates/product_detail.html @@ -40,7 +40,9 @@ {% if product.is_stock_available %} -

Add to order

+

{% if already_in_order %}Update order{% else %}Add to order{% endif %}

+ + {% if already_in_order %}

You already have this product in your order.

{% endif %} {% if user.is_authenticated %} @@ -49,7 +51,9 @@ {% csrf_token %} {% bootstrap_form form %} - {% bootstrap_button "Add to order" button_type="submit" button_class="btn-primary" %} + {% else %} diff --git a/src/shop/templates/shop_index.html b/src/shop/templates/shop_index.html index 1c67d61f..2ac5a0b6 100644 --- a/src/shop/templates/shop_index.html +++ b/src/shop/templates/shop_index.html @@ -59,16 +59,14 @@ Shop | {{ block.super }} {% if product.stock_amount %} - {% if product.left_in_stock <= 10 %} -
- Only {{ product.left_in_stock }} left! -
- {% endif %} - {% if product.left_in_stock == 0 %}
Sold out!
+ {% elif product.left_in_stock <= 10 %} +
+ Only {{ product.left_in_stock }} left! +
{% endif %} {% endif %} diff --git a/src/shop/views.py b/src/shop/views.py index 18fda6a3..f94378d2 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -41,7 +41,7 @@ from .coinify import ( process_coinify_invoice_json ) from .epay import calculate_epay_hash, validate_epay_callback -from .forms import AddToOrderForm, OrderProductRelationFormSet +from .forms import OrderProductRelationFormSet, OrderProductRelationForm logger = logging.getLogger("bornhack.%s" % __name__) @@ -219,11 +219,39 @@ class ShopIndexView(ListView): class ProductDetailView(FormView, DetailView): model = Product template_name = 'product_detail.html' - form_class = AddToOrderForm + form_class = OrderProductRelationForm context_object_name = 'product' + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['instance'] = self.opr + return kwargs + + def get_initial(self): + return {'quantity': self.opr.quantity} + + def get_context_data(self, **kwargs): + if hasattr(self.opr, 'order'): + kwargs['already_in_order'] = True + + return super().get_context_data(**kwargs) + def dispatch(self, request, *args, **kwargs): - if not self.get_object().category.public: + self.object = self.get_object() + + try: + self.opr = OrderProductRelation.objects.get( + order__user=self.request.user, + order__open__isnull=False, + product=self.object + ) + except OrderProductRelation.DoesNotExist: + self.opr = OrderProductRelation( + product=self.get_object(), + quantity=1, + ) + + if not self.object.category.public: # this product is not publicly available raise Http404("Product not found") @@ -235,31 +263,13 @@ class ProductDetailView(FormView, DetailView): product = self.get_object() quantity = form.cleaned_data.get('quantity') - # do we have an open order? - try: - order = Order.objects.get( - user=self.request.user, - open__isnull=False - ) - except Order.DoesNotExist: - # no open order - open a new one - order = Order.objects.create( + if not hasattr(self.opr, 'order'): + self.opr.order = Order.objects.create( user=self.request.user, ) - # get product from kwargs - if product in order.products.all(): - # this product is already added to this order, - # increase count by quantity - OrderProductRelation.objects.filter( - product=product, - order=order - ).update(quantity=F('quantity') + quantity) - else: - order.orderproductrelation_set.create( - product=product, - quantity=quantity, - ) + self.opr.quantity = quantity + self.opr.save() messages.info( self.request, From ce4a744da6902ba5fc949ea9fb08743227585e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 23:37:34 +0100 Subject: [PATCH 06/19] No need for this. --- src/bornhack/environment_settings.py.dist | 3 --- src/bornhack/environment_settings.py.dist.dev | 3 --- src/shop/email.py | 19 ------------------- .../emails/order_cancelled_email.html | 11 ----------- .../emails/order_cancelled_email.txt | 9 --------- 5 files changed, 45 deletions(-) delete mode 100644 src/shop/templates/emails/order_cancelled_email.html delete mode 100644 src/shop/templates/emails/order_cancelled_email.txt diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index cc0389a7..092e35fa 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -93,6 +93,3 @@ CHANNEL_LAYERS = { ACCOUNTINGSYSTEM_EMAIL = "{{ django_accountingsystem_email }}" ECONOMYTEAM_EMAIL = "{{ django_economyteam_email }}" ECONOMYTEAM_NAME = "Economy" - -ORDER_TTL = 24 -ORDER_TTL_UNIT = 'hours' diff --git a/src/bornhack/environment_settings.py.dist.dev b/src/bornhack/environment_settings.py.dist.dev index 488767e4..3009d2c9 100644 --- a/src/bornhack/environment_settings.py.dist.dev +++ b/src/bornhack/environment_settings.py.dist.dev @@ -78,6 +78,3 @@ CHANNEL_LAYERS = { } REIMBURSEMENT_MAIL = "reimbursement@example.com" - -ORDER_TTL = 30 -ORDER_TTL_UNIT = 'minutes' diff --git a/src/shop/email.py b/src/shop/email.py index 14042b9d..80ef8751 100644 --- a/src/shop/email.py +++ b/src/shop/email.py @@ -49,25 +49,6 @@ def add_invoice_email(invoice): ) -def add_order_cancelled_email(order): - formatdict = { - 'ordernumber': order.pk, - 'order_ttl': settings.ORDER_TTL, - 'order_ttl_unit': settings.ORDER_TTL_UNIT - } - - subject = 'Your non-paid BornHack order has been cancelled.' - - # add email to outgoing email queue - return add_outgoing_email( - text_template='emails/order_cancelled_email.txt', - html_template='emails/order_cancelled_email.html', - to_recipients=order.user.email, - formatdict=formatdict, - subject=subject, - ) - - def add_test_email(recipient): return add_outgoing_email( text_template='emails/testmail.txt', diff --git a/src/shop/templates/emails/order_cancelled_email.html b/src/shop/templates/emails/order_cancelled_email.html deleted file mode 100644 index 44650fe1..00000000 --- a/src/shop/templates/emails/order_cancelled_email.html +++ /dev/null @@ -1,11 +0,0 @@ -Hello!
-
-Your order number {{ ordernumber }} has been open for more than {{ order_ttl }} {{ order_ttl_unit }} and has been cancelled.
-
-This means you will have to open a new order.
-
-
-Best regards,
-
-The BornHack Team
-
diff --git a/src/shop/templates/emails/order_cancelled_email.txt b/src/shop/templates/emails/order_cancelled_email.txt deleted file mode 100644 index c530ee12..00000000 --- a/src/shop/templates/emails/order_cancelled_email.txt +++ /dev/null @@ -1,9 +0,0 @@ -Hello! - -Your order number {{ ordernumber }} has been open for more than {{ order_ttl }} {{ order_ttl_unit }} and has been cancelled. - -This means you will have to open a new order. - -Best regards, - -The BornHack Team From f708864d144d13b1dc31de1f8a5e60ae277c6b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 23:39:42 +0100 Subject: [PATCH 07/19] Bah. --- src/bornhack/environment_settings.py.dist | 1 + src/shop/email.py | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index 092e35fa..c62434b5 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -93,3 +93,4 @@ CHANNEL_LAYERS = { ACCOUNTINGSYSTEM_EMAIL = "{{ django_accountingsystem_email }}" ECONOMYTEAM_EMAIL = "{{ django_economyteam_email }}" ECONOMYTEAM_NAME = "Economy" + diff --git a/src/shop/email.py b/src/shop/email.py index 80ef8751..ba1edef5 100644 --- a/src/shop/email.py +++ b/src/shop/email.py @@ -1,9 +1,5 @@ -import logging - -from django.conf import settings - from utils.email import add_outgoing_email - +import logging logger = logging.getLogger("bornhack.%s" % __name__) From 863e089c2b1a375d41fd82c0db0922429275649a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Wed, 27 Mar 2019 23:45:08 +0100 Subject: [PATCH 08/19] Fix tests. --- src/shop/tests.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shop/tests.py b/src/shop/tests.py index 6646edef..7cf03842 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -23,14 +23,17 @@ class ProductAvailabilityTest(TestCase): product = ProductFactory(stock_amount=2) opr1 = OrderProductRelationFactory(product=product) + opr1.order.mark_as_paid() opr2 = OrderProductRelationFactory(product=product) + opr2.order.mark_as_paid() self.assertEqual(product.left_in_stock, 0) self.assertFalse(product.is_stock_available) self.assertFalse(product.is_available()) # Cancel one order - opr1.order.mark_as_cancelled() + opr1.order.cancelled = True + opr1.order.save() self.assertEqual(product.left_in_stock, 1) self.assertTrue(product.is_stock_available) From b04e7235d9ae91c1c32fc41685b50bd060d4829d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 11:19:31 +0100 Subject: [PATCH 09/19] Adding a test for the OrderProductRelationForm. --- src/shop/tests.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/shop/tests.py b/src/shop/tests.py index 7cf03842..3cd492d0 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -1,8 +1,10 @@ +from django.core.exceptions import ValidationError from django.test import TestCase from django.utils import timezone from psycopg2.extras import DateTimeTZRange +from shop.forms import OrderProductRelationForm from .factories import ( ProductFactory, OrderProductRelationFactory, @@ -81,3 +83,32 @@ class ProductAvailabilityTest(TestCase): # The factory defines the timeframe as now and 31 days forward. self.assertTrue(product.is_time_available) self.assertTrue(product.is_available()) + + +class TestOrderProductRelationForm(TestCase): + + def test_clean_quantity_succeeds_when_stock_not_exceeded(self): + product = ProductFactory(stock_amount=2) + + # Mark an order as paid/reserved by setting open to None + opr1 = OrderProductRelationFactory(product=product, quantity=1) + opr1.order.open = None + opr1.order.save() + + opr2 = OrderProductRelationFactory(product=product, quantity=1) + + form = OrderProductRelationForm(instance=opr2) + self.assertTrue(form.is_valid) + + def test_clean_quantity_fails_when_stock_exceeded(self): + product = ProductFactory(stock_amount=2) + # Mark an order as paid/reserved by setting open to None + opr1 = OrderProductRelationFactory(product=product, quantity=1) + opr1.order.open = None + opr1.order.save() + + # There should only be 1 product left, since we just reserved 1 + opr2 = OrderProductRelationFactory(product=product, quantity=2) + + form = OrderProductRelationForm(instance=opr2) + self.assertFalse(form.is_valid()) From 32b4cdfbee02d82a243bbc1b2664b29cb9468663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 11:49:04 +0100 Subject: [PATCH 10/19] Adding test for ProductDetailView. --- src/shop/tests.py | 65 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/shop/tests.py b/src/shop/tests.py index 3cd492d0..452baf25 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -1,14 +1,14 @@ +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.test import TestCase +from django.urls import reverse from django.utils import timezone from psycopg2.extras import DateTimeTZRange from shop.forms import OrderProductRelationForm -from .factories import ( - ProductFactory, - OrderProductRelationFactory, -) +from utils.factories import UserFactory +from .factories import ProductFactory, OrderProductRelationFactory class ProductAvailabilityTest(TestCase): @@ -41,7 +41,6 @@ class ProductAvailabilityTest(TestCase): self.assertTrue(product.is_stock_available) self.assertTrue(product.is_available()) - def test_product_available_by_time(self): """ The product is available if now is in the right timeframe. """ product = ProductFactory() @@ -53,7 +52,7 @@ class ProductAvailabilityTest(TestCase): """ The product is not available if now is outside the timeframe. """ available_in = DateTimeTZRange( lower=timezone.now() - timezone.timedelta(5), - upper=timezone.now() - timezone.timedelta(1) + upper=timezone.now() - timezone.timedelta(1), ) product = ProductFactory(available_in=available_in) # The factory defines the timeframe as now and 31 days forward. @@ -62,9 +61,7 @@ class ProductAvailabilityTest(TestCase): def test_product_is_not_available_yet(self): """ The product is not available because we are before lower bound. """ - available_in = DateTimeTZRange( - lower=timezone.now() + timezone.timedelta(5) - ) + available_in = DateTimeTZRange(lower=timezone.now() + timezone.timedelta(5)) product = ProductFactory(available_in=available_in) # Make sure there is no upper - just in case. self.assertEqual(product.available_in.upper, None) @@ -74,9 +71,7 @@ class ProductAvailabilityTest(TestCase): def test_product_is_available_from_now_on(self): """ The product is available because we are after lower bound. """ - available_in = DateTimeTZRange( - lower=timezone.now() - timezone.timedelta(1) - ) + available_in = DateTimeTZRange(lower=timezone.now() - timezone.timedelta(1)) product = ProductFactory(available_in=available_in) # Make sure there is no upper - just in case. self.assertEqual(product.available_in.upper, None) @@ -86,7 +81,6 @@ class ProductAvailabilityTest(TestCase): class TestOrderProductRelationForm(TestCase): - def test_clean_quantity_succeeds_when_stock_not_exceeded(self): product = ProductFactory(stock_amount=2) @@ -112,3 +106,48 @@ class TestOrderProductRelationForm(TestCase): form = OrderProductRelationForm(instance=opr2) self.assertFalse(form.is_valid()) + + +class TestProductDetailView(TestCase): + def setUp(self): + self.user = UserFactory() + + def test_product_is_available(self): + product = ProductFactory() + self.client.force_login(self.user) + response = self.client.get( + reverse("shop:product_detail", kwargs={"slug": product.slug}) + ) + + self.assertIn("Add to order", str(response.content)) + self.assertEqual(response.status_code, 200) + + def test_product_is_available_with_stock_left(self): + product = ProductFactory(stock_amount=2) + + opr1 = OrderProductRelationFactory(product=product, quantity=1) + opr1.order.open = None + opr1.order.save() + + self.client.force_login(self.user) + response = self.client.get( + reverse("shop:product_detail", kwargs={"slug": product.slug}) + ) + + self.assertIn("1 available", str(response.content)) + self.assertEqual(response.status_code, 200) + + def test_product_is_sold_out(self): + product = ProductFactory(stock_amount=1) + + opr1 = OrderProductRelationFactory(product=product, quantity=1) + opr1.order.open = None + opr1.order.save() + + self.client.force_login(self.user) + response = self.client.get( + reverse("shop:product_detail", kwargs={"slug": product.slug}) + ) + + self.assertIn("Sold out.", str(response.content)) + self.assertEqual(response.status_code, 200) From 26c2e492c30d3fceb9c1111776385fd48be6e9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 12:15:15 +0100 Subject: [PATCH 11/19] Optimize tests a bit. --- src/shop/tests.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/shop/tests.py b/src/shop/tests.py index 452baf25..5a16ebc1 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -1,9 +1,6 @@ -from django.contrib.auth.models import User -from django.core.exceptions import ValidationError from django.test import TestCase from django.urls import reverse from django.utils import timezone - from psycopg2.extras import DateTimeTZRange from shop.forms import OrderProductRelationForm @@ -111,42 +108,44 @@ class TestOrderProductRelationForm(TestCase): class TestProductDetailView(TestCase): def setUp(self): self.user = UserFactory() + self.product = ProductFactory() def test_product_is_available(self): - product = ProductFactory() self.client.force_login(self.user) response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": product.slug}) + reverse("shop:product_detail", kwargs={"slug": self.product.slug}) ) self.assertIn("Add to order", str(response.content)) self.assertEqual(response.status_code, 200) def test_product_is_available_with_stock_left(self): - product = ProductFactory(stock_amount=2) + self.product.stock_amount = 2 + self.product.save() - opr1 = OrderProductRelationFactory(product=product, quantity=1) + opr1 = OrderProductRelationFactory(product=self.product, quantity=1) opr1.order.open = None opr1.order.save() self.client.force_login(self.user) response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": product.slug}) + reverse("shop:product_detail", kwargs={"slug": self.product.slug}) ) self.assertIn("1 available", str(response.content)) self.assertEqual(response.status_code, 200) def test_product_is_sold_out(self): - product = ProductFactory(stock_amount=1) + self.product.stock_amount = 1 + self.product.save() - opr1 = OrderProductRelationFactory(product=product, quantity=1) + opr1 = OrderProductRelationFactory(product=self.product, quantity=1) opr1.order.open = None opr1.order.save() self.client.force_login(self.user) response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": product.slug}) + reverse("shop:product_detail", kwargs={"slug": self.product.slug}) ) self.assertIn("Sold out.", str(response.content)) From d65624e95b754d03a25a2c69cebf401d227a75c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 15:21:55 +0100 Subject: [PATCH 12/19] Actually found and error by writing tests! Yay! --- src/shop/forms.py | 2 +- src/shop/tests.py | 81 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/shop/forms.py b/src/shop/forms.py index 29910321..43619cbb 100644 --- a/src/shop/forms.py +++ b/src/shop/forms.py @@ -14,7 +14,7 @@ class OrderProductRelationForm(forms.ModelForm): product = self.instance.product new_quantity = self.cleaned_data['quantity'] - if product.left_in_stock < new_quantity: + if product.stock_amount and product.left_in_stock < new_quantity: raise forms.ValidationError( "Only {} left in stock.".format( product.left_in_stock, diff --git a/src/shop/tests.py b/src/shop/tests.py index 5a16ebc1..5a004178 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -21,18 +21,16 @@ class ProductAvailabilityTest(TestCase): """ If max orders have been made, the product is NOT available. """ product = ProductFactory(stock_amount=2) - opr1 = OrderProductRelationFactory(product=product) - opr1.order.mark_as_paid() - opr2 = OrderProductRelationFactory(product=product) - opr2.order.mark_as_paid() + OrderProductRelationFactory(product=product, order__open=None) + opr = OrderProductRelationFactory(product=product, order__open=None) self.assertEqual(product.left_in_stock, 0) self.assertFalse(product.is_stock_available) self.assertFalse(product.is_available()) # Cancel one order - opr1.order.cancelled = True - opr1.order.save() + opr.order.cancelled = True + opr.order.save() self.assertEqual(product.left_in_stock, 1) self.assertTrue(product.is_stock_available) @@ -82,21 +80,17 @@ class TestOrderProductRelationForm(TestCase): product = ProductFactory(stock_amount=2) # Mark an order as paid/reserved by setting open to None - opr1 = OrderProductRelationFactory(product=product, quantity=1) - opr1.order.open = None - opr1.order.save() + OrderProductRelationFactory(product=product, quantity=1, order__open=None) - opr2 = OrderProductRelationFactory(product=product, quantity=1) + opr = OrderProductRelationFactory(product=product) - form = OrderProductRelationForm(instance=opr2) - self.assertTrue(form.is_valid) + form = OrderProductRelationForm({'quantity': 1}, instance=opr) + self.assertTrue(form.is_valid()) def test_clean_quantity_fails_when_stock_exceeded(self): product = ProductFactory(stock_amount=2) # Mark an order as paid/reserved by setting open to None - opr1 = OrderProductRelationFactory(product=product, quantity=1) - opr1.order.open = None - opr1.order.save() + OrderProductRelationFactory(product=product, quantity=1, order__open=None) # There should only be 1 product left, since we just reserved 1 opr2 = OrderProductRelationFactory(product=product, quantity=2) @@ -104,6 +98,12 @@ class TestOrderProductRelationForm(TestCase): form = OrderProductRelationForm(instance=opr2) self.assertFalse(form.is_valid()) + def test_clean_quantity_when_no_stock_amount(self): + product = ProductFactory() + opr = OrderProductRelationFactory(product=product) + form = OrderProductRelationForm({'quantity': 3}, instance=opr) + self.assertTrue(form.is_valid()) + class TestProductDetailView(TestCase): def setUp(self): @@ -116,37 +116,70 @@ class TestProductDetailView(TestCase): reverse("shop:product_detail", kwargs={"slug": self.product.slug}) ) - self.assertIn("Add to order", str(response.content)) + self.assertContains(response, "Add to order") self.assertEqual(response.status_code, 200) def test_product_is_available_with_stock_left(self): self.product.stock_amount = 2 self.product.save() - opr1 = OrderProductRelationFactory(product=self.product, quantity=1) - opr1.order.open = None - opr1.order.save() + OrderProductRelationFactory(product=self.product, quantity=1, order__open=None) self.client.force_login(self.user) response = self.client.get( reverse("shop:product_detail", kwargs={"slug": self.product.slug}) ) - self.assertIn("1 available", str(response.content)) + self.assertContains(response, "1 available") self.assertEqual(response.status_code, 200) def test_product_is_sold_out(self): self.product.stock_amount = 1 self.product.save() - opr1 = OrderProductRelationFactory(product=self.product, quantity=1) - opr1.order.open = None - opr1.order.save() + OrderProductRelationFactory(product=self.product, quantity=1, order__open=None) self.client.force_login(self.user) response = self.client.get( reverse("shop:product_detail", kwargs={"slug": self.product.slug}) ) - self.assertIn("Sold out.", str(response.content)) + self.assertContains(response, "Sold out.") self.assertEqual(response.status_code, 200) + + def test_adding_product_to_new_order(self): + self.client.force_login(self.user) + + path = reverse("shop:product_detail", kwargs={"slug": self.product.slug}) + response = self.client.post(path, data={'quantity': 1}) + + order = self.user.orders.get() + + self.assertRedirects(response, reverse('shop:order_detail', kwargs={"pk": order.pk})) + + def test_product_is_in_order(self): + # Put the product in an order owned by the user + OrderProductRelationFactory(product=self.product, quantity=1, order__open=True, order__user=self.user) + + self.client.force_login(self.user) + response = self.client.get( + reverse("shop:product_detail", kwargs={"slug": self.product.slug}) + ) + + self.assertContains(response, "Update order") + + def test_product_is_in_order_update(self): + self.product.stock_amount = 2 + self.product.save() + + # Put the product in an order owned by the user + opr = OrderProductRelationFactory(product=self.product, quantity=1, order__open=True, order__user=self.user) + + self.client.force_login(self.user) + + path = reverse("shop:product_detail", kwargs={"slug": self.product.slug}) + response = self.client.post(path, data={'quantity': 2}) + + self.assertRedirects(response, reverse('shop:order_detail', kwargs={"pk": opr.order.pk})) + opr.refresh_from_db() + self.assertEquals(opr.quantity, 2) From 95dcba3fc31058cc43b0bd31daad36270bdbc5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 15:37:08 +0100 Subject: [PATCH 13/19] Actually test this - it returnet False because the form was unbound. --- src/shop/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shop/tests.py b/src/shop/tests.py index 5a004178..7a994ece 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -93,9 +93,9 @@ class TestOrderProductRelationForm(TestCase): OrderProductRelationFactory(product=product, quantity=1, order__open=None) # There should only be 1 product left, since we just reserved 1 - opr2 = OrderProductRelationFactory(product=product, quantity=2) + opr2 = OrderProductRelationFactory(product=product) - form = OrderProductRelationForm(instance=opr2) + form = OrderProductRelationForm({'quantity': 2}, instance=opr2) self.assertFalse(form.is_valid()) def test_clean_quantity_when_no_stock_amount(self): From 9d97a7184a2fbe39b76252c61ba75dd84b925712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 15:59:07 +0100 Subject: [PATCH 14/19] Make sure we do not get an IntegrityError because we are trying to create a new user with the same username as already created by another factory. --- src/utils/factories.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/factories.py b/src/utils/factories.py index 6cdf524e..aed2c5b5 100644 --- a/src/utils/factories.py +++ b/src/utils/factories.py @@ -5,6 +5,7 @@ from factory.django import DjangoModelFactory class UserFactory(DjangoModelFactory): class Meta: model = 'auth.User' + django_get_or_create = ('username',) username = factory.Faker('word') email = factory.Faker('ascii_email') From 2ce2205bd3b2ca88146c2bf82b78508f9b702e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 17:06:53 +0100 Subject: [PATCH 15/19] Adding more tests, finding more errors. --- src/shop/tests.py | 29 +++++++++++++---------------- src/shop/views.py | 28 ++++++++++++++++------------ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/shop/tests.py b/src/shop/tests.py index 7a994ece..b622f908 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -109,12 +109,11 @@ class TestProductDetailView(TestCase): def setUp(self): self.user = UserFactory() self.product = ProductFactory() + self.path = reverse("shop:product_detail", kwargs={"slug": self.product.slug}) def test_product_is_available(self): self.client.force_login(self.user) - response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - ) + response = self.client.get(self.path) self.assertContains(response, "Add to order") self.assertEqual(response.status_code, 200) @@ -126,9 +125,7 @@ class TestProductDetailView(TestCase): OrderProductRelationFactory(product=self.product, quantity=1, order__open=None) self.client.force_login(self.user) - response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - ) + response = self.client.get(self.path) self.assertContains(response, "1 available") self.assertEqual(response.status_code, 200) @@ -140,9 +137,7 @@ class TestProductDetailView(TestCase): OrderProductRelationFactory(product=self.product, quantity=1, order__open=None) self.client.force_login(self.user) - response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - ) + response = self.client.get(self.path) self.assertContains(response, "Sold out.") self.assertEqual(response.status_code, 200) @@ -150,8 +145,7 @@ class TestProductDetailView(TestCase): def test_adding_product_to_new_order(self): self.client.force_login(self.user) - path = reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - response = self.client.post(path, data={'quantity': 1}) + response = self.client.post(self.path, data={'quantity': 1}) order = self.user.orders.get() @@ -162,9 +156,7 @@ class TestProductDetailView(TestCase): OrderProductRelationFactory(product=self.product, quantity=1, order__open=True, order__user=self.user) self.client.force_login(self.user) - response = self.client.get( - reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - ) + response = self.client.get(self.path) self.assertContains(response, "Update order") @@ -177,9 +169,14 @@ class TestProductDetailView(TestCase): self.client.force_login(self.user) - path = reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - response = self.client.post(path, data={'quantity': 2}) + response = self.client.post(self.path, data={'quantity': 2}) self.assertRedirects(response, reverse('shop:order_detail', kwargs={"pk": opr.order.pk})) opr.refresh_from_db() self.assertEquals(opr.quantity, 2) + + def test_product_category_not_public(self): + self.product.category.public = False + self.product.category.save() + response = self.client.get(self.path) + self.assertEquals(response.status_code, 404) diff --git a/src/shop/views.py b/src/shop/views.py index f94378d2..d8958d05 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -228,7 +228,9 @@ class ProductDetailView(FormView, DetailView): return kwargs def get_initial(self): - return {'quantity': self.opr.quantity} + if self.opr: + return {'quantity': self.opr.quantity} + return super().get_initial() def get_context_data(self, **kwargs): if hasattr(self.opr, 'order'): @@ -239,17 +241,19 @@ class ProductDetailView(FormView, DetailView): def dispatch(self, request, *args, **kwargs): self.object = self.get_object() - try: - self.opr = OrderProductRelation.objects.get( - order__user=self.request.user, - order__open__isnull=False, - product=self.object - ) - except OrderProductRelation.DoesNotExist: - self.opr = OrderProductRelation( - product=self.get_object(), - quantity=1, - ) + self.opr = None + if self.request.user.is_authenticated: + try: + self.opr = OrderProductRelation.objects.get( + order__user=self.request.user, + order__open__isnull=False, + product=self.object + ) + except OrderProductRelation.DoesNotExist: + self.opr = OrderProductRelation( + product=self.get_object(), + quantity=1, + ) if not self.object.category.public: # this product is not publicly available From c69bf46255bd93f42e777aba4ab199ffa2bb2ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 20:58:35 +0100 Subject: [PATCH 16/19] Adding more tests, finding more errors - again! --- src/shop/managers.py | 2 +- src/shop/tests.py | 106 ++++++++++++++++++++++++++++++- src/shop/views.py | 146 +++++++++++++++++++++---------------------- 3 files changed, 177 insertions(+), 77 deletions(-) diff --git a/src/shop/managers.py b/src/shop/managers.py index dc01c2e6..db8e3920 100644 --- a/src/shop/managers.py +++ b/src/shop/managers.py @@ -17,7 +17,7 @@ class OrderQuerySet(QuerySet): return self.filter(cancelled=False) def open(self): - return self.filter(open__isnull=True) + return self.filter(open__isnull=False) def paid(self): return self.filter(paid=True) diff --git a/src/shop/tests.py b/src/shop/tests.py index b622f908..a20232e7 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -5,7 +5,7 @@ from psycopg2.extras import DateTimeTZRange from shop.forms import OrderProductRelationForm from utils.factories import UserFactory -from .factories import ProductFactory, OrderProductRelationFactory +from .factories import ProductFactory, OrderProductRelationFactory, OrderFactory class ProductAvailabilityTest(TestCase): @@ -111,7 +111,12 @@ class TestProductDetailView(TestCase): self.product = ProductFactory() self.path = reverse("shop:product_detail", kwargs={"slug": self.product.slug}) - def test_product_is_available(self): + def test_product_is_available_for_anonymous_user(self): + response = self.client.get(self.path) + + self.assertEqual(response.status_code, 200) + + def test_product_is_available_for_logged_in_user(self): self.client.force_login(self.user) response = self.client.get(self.path) @@ -180,3 +185,100 @@ class TestProductDetailView(TestCase): self.product.category.save() response = self.client.get(self.path) self.assertEquals(response.status_code, 404) + + +class TestOrderDetailView(TestCase): + + def setUp(self): + self.user = UserFactory() + self.order = OrderFactory(user=self.user) + self.path = reverse('shop:order_detail', kwargs={'pk': self.order.pk}) + + # We are using a formset which means we have to include some "management form" data. + self.base_form_data = { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '1', + 'form-MAX_NUM_FORMS': '', + } + + def test_redirects_when_no_products(self): + self.client.force_login(self.user) + response = self.client.get(self.path) + self.assertEquals(response.status_code, 302) + self.assertRedirects(response, reverse('shop:index')) + + def test_redirects_when_cancelled(self): + self.client.force_login(self.user) + + OrderProductRelationFactory(order=self.order) + + self.order.cancelled = True + self.order.save() + + response = self.client.get(self.path) + + self.assertEquals(response.status_code, 302) + self.assertRedirects(response, reverse('shop:index')) + + def test_remove_product(self): + self.client.force_login(self.user) + + OrderProductRelationFactory(order=self.order) + orp = OrderProductRelationFactory(order=self.order) + + order = orp.order + + data = self.base_form_data + data['remove_product'] = orp.pk + + response = self.client.post(self.path, data=data) + self.assertEquals(response.status_code, 200) + + order.refresh_from_db() + + self.assertEquals(order.products.count(), 1) + + def test_remove_last_product_cancels_order(self): + self.client.force_login(self.user) + + orp = OrderProductRelationFactory(order=self.order) + + order = orp.order + + data = self.base_form_data + data['remove_product'] = orp.pk + + response = self.client.post(self.path, data=data) + self.assertEquals(response.status_code, 302) + self.assertRedirects(response, reverse('shop:index')) + + order.refresh_from_db() + + self.assertTrue(order.cancelled) + + def test_cancel_order(self): + self.client.force_login(self.user) + + orp = OrderProductRelationFactory(order=self.order) + order = orp.order + + data = self.base_form_data + data['cancel_order'] = None + + response = self.client.post(self.path, data=data) + self.assertEquals(response.status_code, 302) + self.assertRedirects(response, reverse('shop:index')) + + order.refresh_from_db() + + self.assertTrue(order.cancelled) + + +class TestOrderListView(TestCase): + + def test_order_list_view_as_logged_in(self): + user = UserFactory() + self.client.force_login(user) + path = reverse('shop:order_list') + response = self.client.get(path) + self.assertEquals(response.status_code, 200) diff --git a/src/shop/views.py b/src/shop/views.py index d8958d05..f160dc3a 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -224,16 +224,18 @@ class ProductDetailView(FormView, DetailView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs['instance'] = self.opr + if hasattr(self, 'opr'): + kwargs['instance'] = self.opr return kwargs def get_initial(self): - if self.opr: + if hasattr(self, 'opr'): return {'quantity': self.opr.quantity} - return super().get_initial() + return None def get_context_data(self, **kwargs): - if hasattr(self.opr, 'order'): + # If the OrderProductRelation already exists it has a primary key in the database + if self.request.user.is_authenticated and self.opr.pk: kwargs['already_in_order'] = True return super().get_context_data(**kwargs) @@ -241,7 +243,10 @@ class ProductDetailView(FormView, DetailView): def dispatch(self, request, *args, **kwargs): self.object = self.get_object() - self.opr = None + if not self.object.category.public: + # this product is not publicly available + raise Http404("Product not found") + if self.request.user.is_authenticated: try: self.opr = OrderProductRelation.objects.get( @@ -255,31 +260,23 @@ class ProductDetailView(FormView, DetailView): quantity=1, ) - if not self.object.category.public: - # this product is not publicly available - raise Http404("Product not found") - return super(ProductDetailView, self).dispatch( request, *args, **kwargs ) def form_valid(self, form): - product = self.get_object() - quantity = form.cleaned_data.get('quantity') + opr = form.save(commit=False) - if not hasattr(self.opr, 'order'): - self.opr.order = Order.objects.create( - user=self.request.user, - ) + if not opr.pk: + opr.order, _ = Order.objects.get_or_create(user=self.request.user, open=True, cancelled=False) - self.opr.quantity = quantity - self.opr.save() + opr.save() messages.info( self.request, '{}x {} has been added to your order.'.format( - quantity, - product.name + opr.quantity, + opr.product.name ) ) @@ -329,7 +326,7 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH return HttpResponseRedirect(reverse_lazy('shop:index')) # Then see if the user is cancelling the order. - if 'cancel_order' in request.POST: + elif 'cancel_order' in request.POST: order.mark_as_cancelled() messages.info(request, 'Order cancelled!') return HttpResponseRedirect(reverse_lazy('shop:index')) @@ -338,66 +335,67 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH # so from now on we do stuff that require us to check stock. # We use a formset for this to be able to display exactly # which product is not in stock if that is the case. - formset = OrderProductRelationFormSet( - request.POST, - queryset=OrderProductRelation.objects.filter(order=order), - ) - - # If the formset is not valid it means that we cannot fulfill the order, so return and inform the user. - if not formset.is_valid(): - messages.error( - request, - "Some of the products you are ordering are out of stock. Review the order and try again." - ) - return self.render_to_response( - self.get_context_data(order_product_formset=formset) + else: + formset = OrderProductRelationFormSet( + request.POST, + queryset=OrderProductRelation.objects.filter(order=order), ) - # No stock issues, proceed to check if the user is updating the order. - if 'update_order' in request.POST: - # We have already made sure the formset is valid, so just save it to update quantities. - formset.save() - - order.customer_comment = request.POST.get('customer_comment') or '' - order.invoice_address = request.POST.get('invoice_address') or '' - order.save() - - # Then at last see if the user is paying for the order. - payment_method = request.POST.get('payment_method') - if payment_method in order.PAYMENT_METHODS: - if not request.POST.get('accept_terms'): - messages.error(request, "You need to accept the general terms and conditions before you can continue!") - return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': order.pk}) + # If the formset is not valid it means that we cannot fulfill the order, so return and inform the user. + if not formset.is_valid(): + messages.error( + request, + "Some of the products you are ordering are out of stock. Review the order and try again." + ) + return self.render_to_response( + self.get_context_data(order_product_formset=formset) ) - # Set payment method and mark the order as closed - order.payment_method = payment_method - order.open = None - order.customer_comment = request.POST.get('customer_comment') or '' - order.invoice_address = request.POST.get('invoice_address') or '' - order.save() + # No stock issues, proceed to check if the user is updating the order. + if 'update_order' in request.POST: + # We have already made sure the formset is valid, so just save it to update quantities. + formset.save() - reverses = { - Order.CREDIT_CARD: reverse_lazy( - 'shop:epay_form', - kwargs={'pk': order.id} - ), - Order.BLOCKCHAIN: reverse_lazy( - 'shop:coinify_pay', - kwargs={'pk': order.id} - ), - Order.BANK_TRANSFER: reverse_lazy( - 'shop:bank_transfer', - kwargs={'pk': order.id} - ), - Order.CASH: reverse_lazy( - 'shop:cash', - kwargs={'pk': order.id} - ) - } + order.customer_comment = request.POST.get('customer_comment') or '' + order.invoice_address = request.POST.get('invoice_address') or '' + order.save() - return HttpResponseRedirect(reverses[payment_method]) + # Then at last see if the user is paying for the order. + payment_method = request.POST.get('payment_method') + if payment_method in order.PAYMENT_METHODS: + if not request.POST.get('accept_terms'): + messages.error(request, "You need to accept the general terms and conditions before you can continue!") + return HttpResponseRedirect( + reverse_lazy('shop:order_detail', kwargs={'pk': order.pk}) + ) + + # Set payment method and mark the order as closed + order.payment_method = payment_method + order.open = None + order.customer_comment = request.POST.get('customer_comment') or '' + order.invoice_address = request.POST.get('invoice_address') or '' + order.save() + + reverses = { + Order.CREDIT_CARD: reverse_lazy( + 'shop:epay_form', + kwargs={'pk': order.id} + ), + Order.BLOCKCHAIN: reverse_lazy( + 'shop:coinify_pay', + kwargs={'pk': order.id} + ), + Order.BANK_TRANSFER: reverse_lazy( + 'shop:bank_transfer', + kwargs={'pk': order.id} + ), + Order.CASH: reverse_lazy( + 'shop:cash', + kwargs={'pk': order.id} + ) + } + + return HttpResponseRedirect(reverses[payment_method]) return super(OrderDetailView, self).get(request, *args, **kwargs) From e5e1443218a89f66470c5667081f454104941f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 21:21:24 +0100 Subject: [PATCH 17/19] Running black. --- src/shop/tests.py | 52 ++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/shop/tests.py b/src/shop/tests.py index a20232e7..5e7c1dcb 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -84,7 +84,7 @@ class TestOrderProductRelationForm(TestCase): opr = OrderProductRelationFactory(product=product) - form = OrderProductRelationForm({'quantity': 1}, instance=opr) + form = OrderProductRelationForm({"quantity": 1}, instance=opr) self.assertTrue(form.is_valid()) def test_clean_quantity_fails_when_stock_exceeded(self): @@ -95,13 +95,13 @@ class TestOrderProductRelationForm(TestCase): # There should only be 1 product left, since we just reserved 1 opr2 = OrderProductRelationFactory(product=product) - form = OrderProductRelationForm({'quantity': 2}, instance=opr2) + form = OrderProductRelationForm({"quantity": 2}, instance=opr2) self.assertFalse(form.is_valid()) def test_clean_quantity_when_no_stock_amount(self): product = ProductFactory() opr = OrderProductRelationFactory(product=product) - form = OrderProductRelationForm({'quantity': 3}, instance=opr) + form = OrderProductRelationForm({"quantity": 3}, instance=opr) self.assertTrue(form.is_valid()) @@ -150,15 +150,19 @@ class TestProductDetailView(TestCase): def test_adding_product_to_new_order(self): self.client.force_login(self.user) - response = self.client.post(self.path, data={'quantity': 1}) + response = self.client.post(self.path, data={"quantity": 1}) order = self.user.orders.get() - self.assertRedirects(response, reverse('shop:order_detail', kwargs={"pk": order.pk})) + self.assertRedirects( + response, reverse("shop:order_detail", kwargs={"pk": order.pk}) + ) def test_product_is_in_order(self): # Put the product in an order owned by the user - OrderProductRelationFactory(product=self.product, quantity=1, order__open=True, order__user=self.user) + OrderProductRelationFactory( + product=self.product, quantity=1, order__open=True, order__user=self.user + ) self.client.force_login(self.user) response = self.client.get(self.path) @@ -170,13 +174,17 @@ class TestProductDetailView(TestCase): self.product.save() # Put the product in an order owned by the user - opr = OrderProductRelationFactory(product=self.product, quantity=1, order__open=True, order__user=self.user) + opr = OrderProductRelationFactory( + product=self.product, quantity=1, order__open=True, order__user=self.user + ) self.client.force_login(self.user) - response = self.client.post(self.path, data={'quantity': 2}) + response = self.client.post(self.path, data={"quantity": 2}) - self.assertRedirects(response, reverse('shop:order_detail', kwargs={"pk": opr.order.pk})) + self.assertRedirects( + response, reverse("shop:order_detail", kwargs={"pk": opr.order.pk}) + ) opr.refresh_from_db() self.assertEquals(opr.quantity, 2) @@ -188,24 +196,23 @@ class TestProductDetailView(TestCase): class TestOrderDetailView(TestCase): - def setUp(self): self.user = UserFactory() self.order = OrderFactory(user=self.user) - self.path = reverse('shop:order_detail', kwargs={'pk': self.order.pk}) + self.path = reverse("shop:order_detail", kwargs={"pk": self.order.pk}) # We are using a formset which means we have to include some "management form" data. self.base_form_data = { - 'form-TOTAL_FORMS': '1', - 'form-INITIAL_FORMS': '1', - 'form-MAX_NUM_FORMS': '', + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "1", + "form-MAX_NUM_FORMS": "", } def test_redirects_when_no_products(self): self.client.force_login(self.user) response = self.client.get(self.path) self.assertEquals(response.status_code, 302) - self.assertRedirects(response, reverse('shop:index')) + self.assertRedirects(response, reverse("shop:index")) def test_redirects_when_cancelled(self): self.client.force_login(self.user) @@ -218,7 +225,7 @@ class TestOrderDetailView(TestCase): response = self.client.get(self.path) self.assertEquals(response.status_code, 302) - self.assertRedirects(response, reverse('shop:index')) + self.assertRedirects(response, reverse("shop:index")) def test_remove_product(self): self.client.force_login(self.user) @@ -229,7 +236,7 @@ class TestOrderDetailView(TestCase): order = orp.order data = self.base_form_data - data['remove_product'] = orp.pk + data["remove_product"] = orp.pk response = self.client.post(self.path, data=data) self.assertEquals(response.status_code, 200) @@ -246,11 +253,11 @@ class TestOrderDetailView(TestCase): order = orp.order data = self.base_form_data - data['remove_product'] = orp.pk + data["remove_product"] = orp.pk response = self.client.post(self.path, data=data) self.assertEquals(response.status_code, 302) - self.assertRedirects(response, reverse('shop:index')) + self.assertRedirects(response, reverse("shop:index")) order.refresh_from_db() @@ -263,11 +270,11 @@ class TestOrderDetailView(TestCase): order = orp.order data = self.base_form_data - data['cancel_order'] = None + data["cancel_order"] = None response = self.client.post(self.path, data=data) self.assertEquals(response.status_code, 302) - self.assertRedirects(response, reverse('shop:index')) + self.assertRedirects(response, reverse("shop:index")) order.refresh_from_db() @@ -275,10 +282,9 @@ class TestOrderDetailView(TestCase): class TestOrderListView(TestCase): - def test_order_list_view_as_logged_in(self): user = UserFactory() self.client.force_login(user) - path = reverse('shop:order_list') + path = reverse("shop:order_list") response = self.client.get(path) self.assertEquals(response.status_code, 200) From 4aad051c723b2806ed0dde9ac7bb550ff0c5d8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 22:06:52 +0100 Subject: [PATCH 18/19] More tests - and some blackness. --- src/shop/templatetags/shop_tags.py | 2 +- src/shop/tests.py | 81 ++++++- src/shop/views.py | 375 ++++++++++++++++------------- 3 files changed, 280 insertions(+), 178 deletions(-) diff --git a/src/shop/templatetags/shop_tags.py b/src/shop/templatetags/shop_tags.py index ea4a083b..aaa957b6 100644 --- a/src/shop/templatetags/shop_tags.py +++ b/src/shop/templatetags/shop_tags.py @@ -3,10 +3,10 @@ from decimal import Decimal register = template.Library() + @register.filter def currency(value): try: return "{0:.2f} DKK".format(Decimal(value)) except ValueError: return False - diff --git a/src/shop/tests.py b/src/shop/tests.py index 5e7c1dcb..0fc39228 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -231,12 +231,12 @@ class TestOrderDetailView(TestCase): self.client.force_login(self.user) OrderProductRelationFactory(order=self.order) - orp = OrderProductRelationFactory(order=self.order) + opr = OrderProductRelationFactory(order=self.order) - order = orp.order + order = opr.order data = self.base_form_data - data["remove_product"] = orp.pk + data["remove_product"] = opr.pk response = self.client.post(self.path, data=data) self.assertEquals(response.status_code, 200) @@ -248,12 +248,12 @@ class TestOrderDetailView(TestCase): def test_remove_last_product_cancels_order(self): self.client.force_login(self.user) - orp = OrderProductRelationFactory(order=self.order) + opr = OrderProductRelationFactory(order=self.order) - order = orp.order + order = opr.order data = self.base_form_data - data["remove_product"] = orp.pk + data["remove_product"] = opr.pk response = self.client.post(self.path, data=data) self.assertEquals(response.status_code, 302) @@ -266,11 +266,11 @@ class TestOrderDetailView(TestCase): def test_cancel_order(self): self.client.force_login(self.user) - orp = OrderProductRelationFactory(order=self.order) - order = orp.order + opr = OrderProductRelationFactory(order=self.order) + order = opr.order data = self.base_form_data - data["cancel_order"] = None + data["cancel_order"] = "" response = self.client.post(self.path, data=data) self.assertEquals(response.status_code, 302) @@ -280,6 +280,69 @@ class TestOrderDetailView(TestCase): self.assertTrue(order.cancelled) + def test_incrementing_product_quantity(self): + self.client.force_login(self.user) + + opr = OrderProductRelationFactory(order=self.order) + opr.product.stock_amount = 100 + opr.product.save() + + data = self.base_form_data + data["update_order"] = "" + data["form-0-id"] = opr.pk + data["form-0-quantity"] = 11 + + response = self.client.post(self.path, data=data) + opr.refresh_from_db() + self.assertEquals(response.status_code, 200) + self.assertEquals(opr.quantity, 11) + + def test_incrementing_product_quantity_beyond_stock_fails(self): + self.client.force_login(self.user) + + opr = OrderProductRelationFactory(order=self.order) + opr.product.stock_amount = 10 + opr.product.save() + + data = self.base_form_data + data["update_order"] = "" + data["form-0-id"] = opr.pk + data["form-0-quantity"] = 11 + + response = self.client.post(self.path, data=data) + self.assertEquals(response.status_code, 200) + self.assertIn("quantity", response.context["order_product_formset"].errors[0]) + + def test_terms_have_to_be_accepted(self): + self.client.force_login(self.user) + + opr = OrderProductRelationFactory(order=self.order) + + data = self.base_form_data + data["form-0-id"] = opr.pk + data["form-0-quantity"] = 11 + data["payment_method"] = "bank_transfer" + + response = self.client.post(self.path, data=data) + self.assertEquals(response.status_code, 200) + + def test_accepted_terms_and_chosen_payment_method(self): + self.client.force_login(self.user) + + opr = OrderProductRelationFactory(order=self.order) + + data = self.base_form_data + data["form-0-id"] = opr.pk + data["form-0-quantity"] = 11 + data["payment_method"] = "bank_transfer" + data["accept_terms"] = True + + response = self.client.post(self.path, data=data) + self.assertEquals(response.status_code, 302) + self.assertRedirects( + response, reverse("shop:bank_transfer", kwargs={"pk": self.order.id}) + ) + class TestOrderListView(TestCase): def test_order_list_view_as_logged_in(self): diff --git a/src/shop/views.py b/src/shop/views.py index f160dc3a..42d7c721 100644 --- a/src/shop/views.py +++ b/src/shop/views.py @@ -9,19 +9,14 @@ from django.http import ( HttpResponse, HttpResponseRedirect, HttpResponseBadRequest, - Http404 + Http404, ) from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.generic import ( - View, - ListView, - DetailView, - FormView, -) +from django.views.generic import View, ListView, DetailView, FormView from django.views.generic.base import RedirectView from django.views.generic.detail import SingleObjectMixin @@ -38,7 +33,7 @@ from vendor.coinify.coinify_callback import CoinifyCallback from .coinify import ( create_coinify_invoice, save_coinify_callback, - process_coinify_invoice_json + process_coinify_invoice_json, ) from .epay import calculate_epay_hash, validate_epay_callback from .forms import OrderProductRelationFormSet, OrderProductRelationForm @@ -53,7 +48,7 @@ class EnsureCreditNoteHasPDFMixin(SingleObjectMixin): def dispatch(self, request, *args, **kwargs): if not self.get_object().pdf: messages.error(request, "This creditnote has no PDF yet!") - return HttpResponseRedirect(reverse_lazy('shop:creditnote_list')) + return HttpResponseRedirect(reverse_lazy("shop:creditnote_list")) return super(EnsureCreditNoteHasPDFMixin, self).dispatch( request, *args, **kwargs @@ -83,9 +78,7 @@ class EnsureUserOwnsOrderMixin(SingleObjectMixin): if self.get_object().user != request.user: raise Http404("Order not found") - return super(EnsureUserOwnsOrderMixin, self).dispatch( - request, *args, **kwargs - ) + return super(EnsureUserOwnsOrderMixin, self).dispatch(request, *args, **kwargs) class EnsureUnpaidOrderMixin(SingleObjectMixin): @@ -95,12 +88,10 @@ class EnsureUnpaidOrderMixin(SingleObjectMixin): if self.get_object().paid: messages.error(request, "This order is already paid for!") return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) + reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk}) ) - return super(EnsureUnpaidOrderMixin, self).dispatch( - request, *args, **kwargs - ) + return super(EnsureUnpaidOrderMixin, self).dispatch(request, *args, **kwargs) class EnsurePaidOrderMixin(SingleObjectMixin): @@ -110,12 +101,10 @@ class EnsurePaidOrderMixin(SingleObjectMixin): if not self.get_object().paid: messages.error(request, "This order is not paid for!") return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) + reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk}) ) - return super(EnsurePaidOrderMixin, self).dispatch( - request, *args, **kwargs - ) + return super(EnsurePaidOrderMixin, self).dispatch(request, *args, **kwargs) class EnsureClosedOrderMixin(SingleObjectMixin): @@ -123,14 +112,12 @@ class EnsureClosedOrderMixin(SingleObjectMixin): def dispatch(self, request, *args, **kwargs): if self.get_object().open is not None: - messages.error(request, 'This order is still open!') + messages.error(request, "This order is still open!") return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) + reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk}) ) - return super(EnsureClosedOrderMixin, self).dispatch( - request, *args, **kwargs - ) + return super(EnsureClosedOrderMixin, self).dispatch(request, *args, **kwargs) class EnsureOrderHasProductsMixin(SingleObjectMixin): @@ -138,8 +125,8 @@ class EnsureOrderHasProductsMixin(SingleObjectMixin): def dispatch(self, request, *args, **kwargs): if not self.get_object().products.count() > 0: - messages.error(request, 'This order has no products!') - return HttpResponseRedirect(reverse_lazy('shop:index')) + messages.error(request, "This order has no products!") + return HttpResponseRedirect(reverse_lazy("shop:index")) return super(EnsureOrderHasProductsMixin, self).dispatch( request, *args, **kwargs @@ -152,10 +139,9 @@ class EnsureOrderIsNotCancelledMixin(SingleObjectMixin): def dispatch(self, request, *args, **kwargs): if self.get_object().cancelled: messages.error( - request, - 'Order #{} is cancelled!'.format(self.get_object().id) + request, "Order #{} is cancelled!".format(self.get_object().id) ) - return HttpResponseRedirect(reverse_lazy('shop:index')) + return HttpResponseRedirect(reverse_lazy("shop:index")) return super(EnsureOrderIsNotCancelledMixin, self).dispatch( request, *args, **kwargs @@ -169,7 +155,7 @@ class EnsureOrderHasInvoicePDFMixin(SingleObjectMixin): if not self.get_object().invoice.pdf: messages.error(request, "This order has no invoice yet!") return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) + reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk}) ) return super(EnsureOrderHasInvoicePDFMixin, self).dispatch( @@ -181,17 +167,17 @@ class EnsureOrderHasInvoicePDFMixin(SingleObjectMixin): class ShopIndexView(ListView): model = Product template_name = "shop_index.html" - context_object_name = 'products' + context_object_name = "products" def get_queryset(self): queryset = super(ShopIndexView, self).get_queryset() - return queryset.available().order_by('category__name', 'price', 'name') + return queryset.available().order_by("category__name", "price", "name") def get_context_data(self, **kwargs): context = super(ShopIndexView, self).get_context_data(**kwargs) - if 'category' in self.request.GET: - category = self.request.GET.get('category') + if "category" in self.request.GET: + category = self.request.GET.get("category") # is this a public category try: @@ -202,41 +188,39 @@ class ShopIndexView(ListView): raise Http404("Category not found") # filter products by the chosen category - context['products'] = context['products'].filter( - category__slug=category - ) - context['current_category'] = categoryobj - context['categories'] = ProductCategory.objects.annotate( - num_products=Count('products') + context["products"] = context["products"].filter(category__slug=category) + context["current_category"] = categoryobj + context["categories"] = ProductCategory.objects.annotate( + num_products=Count("products") ).filter( num_products__gt=0, public=True, - products__available_in__contains=timezone.now() + products__available_in__contains=timezone.now(), ) return context class ProductDetailView(FormView, DetailView): model = Product - template_name = 'product_detail.html' + template_name = "product_detail.html" form_class = OrderProductRelationForm - context_object_name = 'product' + context_object_name = "product" def get_form_kwargs(self): kwargs = super().get_form_kwargs() - if hasattr(self, 'opr'): - kwargs['instance'] = self.opr + if hasattr(self, "opr"): + kwargs["instance"] = self.opr return kwargs def get_initial(self): - if hasattr(self, 'opr'): - return {'quantity': self.opr.quantity} + if hasattr(self, "opr"): + return {"quantity": self.opr.quantity} return None def get_context_data(self, **kwargs): # If the OrderProductRelation already exists it has a primary key in the database if self.request.user.is_authenticated and self.opr.pk: - kwargs['already_in_order'] = True + kwargs["already_in_order"] = True return super().get_context_data(**kwargs) @@ -252,32 +236,28 @@ class ProductDetailView(FormView, DetailView): self.opr = OrderProductRelation.objects.get( order__user=self.request.user, order__open__isnull=False, - product=self.object + product=self.object, ) except OrderProductRelation.DoesNotExist: - self.opr = OrderProductRelation( - product=self.get_object(), - quantity=1, - ) + self.opr = OrderProductRelation(product=self.get_object(), quantity=1) - return super(ProductDetailView, self).dispatch( - request, *args, **kwargs - ) + return super(ProductDetailView, self).dispatch(request, *args, **kwargs) def form_valid(self, form): opr = form.save(commit=False) if not opr.pk: - opr.order, _ = Order.objects.get_or_create(user=self.request.user, open=True, cancelled=False) + opr.order, _ = Order.objects.get_or_create( + user=self.request.user, open=True, cancelled=False + ) opr.save() messages.info( self.request, - '{}x {} has been added to your order.'.format( - opr.quantity, - opr.product.name - ) + "{}x {} has been added to your order.".format( + opr.quantity, opr.product.name + ), ) # done @@ -292,22 +272,28 @@ class ProductDetailView(FormView, DetailView): class OrderListView(LoginRequiredMixin, ListView): model = Order template_name = "shop/order_list.html" - context_object_name = 'orders' + context_object_name = "orders" def get_queryset(self): queryset = super(OrderListView, self).get_queryset() return queryset.filter(user=self.request.user).not_cancelled() -class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderHasProductsMixin, EnsureOrderIsNotCancelledMixin, DetailView): +class OrderDetailView( + LoginRequiredMixin, + EnsureUserOwnsOrderMixin, + EnsureOrderHasProductsMixin, + EnsureOrderIsNotCancelledMixin, + DetailView, +): model = Order - template_name = 'shop/order_detail.html' - context_object_name = 'order' + template_name = "shop/order_detail.html" + context_object_name = "order" def get_context_data(self, **kwargs): - if 'order_product_formset' not in kwargs: - kwargs['order_product_formset'] = OrderProductRelationFormSet( - queryset=OrderProductRelation.objects.filter(order=self.get_object()), + if "order_product_formset" not in kwargs: + kwargs["order_product_formset"] = OrderProductRelationFormSet( + queryset=OrderProductRelation.objects.filter(order=self.get_object()) ) return super().get_context_data(**kwargs) @@ -317,19 +303,19 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH order = self.object # First check if the user is removing a product from the order. - product_remove = request.POST.get('remove_product') + product_remove = request.POST.get("remove_product") if product_remove: order.orderproductrelation_set.filter(pk=product_remove).delete() if not order.products.count() > 0: order.mark_as_cancelled() - messages.info(request, 'Order cancelled!') - return HttpResponseRedirect(reverse_lazy('shop:index')) + messages.info(request, "Order cancelled!") + return HttpResponseRedirect(reverse_lazy("shop:index")) # Then see if the user is cancelling the order. - elif 'cancel_order' in request.POST: + elif "cancel_order" in request.POST: order.mark_as_cancelled() - messages.info(request, 'Order cancelled!') - return HttpResponseRedirect(reverse_lazy('shop:index')) + messages.info(request, "Order cancelled!") + return HttpResponseRedirect(reverse_lazy("shop:index")) # The user is not removing products or cancelling the order, # so from now on we do stuff that require us to check stock. @@ -337,62 +323,58 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH # which product is not in stock if that is the case. else: formset = OrderProductRelationFormSet( - request.POST, - queryset=OrderProductRelation.objects.filter(order=order), + request.POST, queryset=OrderProductRelation.objects.filter(order=order) ) # If the formset is not valid it means that we cannot fulfill the order, so return and inform the user. if not formset.is_valid(): messages.error( request, - "Some of the products you are ordering are out of stock. Review the order and try again." + "Some of the products you are ordering are out of stock. Review the order and try again.", ) return self.render_to_response( self.get_context_data(order_product_formset=formset) ) # No stock issues, proceed to check if the user is updating the order. - if 'update_order' in request.POST: + if "update_order" in request.POST: # We have already made sure the formset is valid, so just save it to update quantities. formset.save() - order.customer_comment = request.POST.get('customer_comment') or '' - order.invoice_address = request.POST.get('invoice_address') or '' + order.customer_comment = request.POST.get("customer_comment") or "" + order.invoice_address = request.POST.get("invoice_address") or "" order.save() # Then at last see if the user is paying for the order. - payment_method = request.POST.get('payment_method') + payment_method = request.POST.get("payment_method") if payment_method in order.PAYMENT_METHODS: - if not request.POST.get('accept_terms'): - messages.error(request, "You need to accept the general terms and conditions before you can continue!") - return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': order.pk}) + if not request.POST.get("accept_terms"): + messages.error( + request, + "You need to accept the general terms and conditions before you can continue!", + ) + return self.render_to_response( + self.get_context_data(order_product_formset=formset) ) # Set payment method and mark the order as closed order.payment_method = payment_method order.open = None - order.customer_comment = request.POST.get('customer_comment') or '' - order.invoice_address = request.POST.get('invoice_address') or '' + order.customer_comment = request.POST.get("customer_comment") or "" + order.invoice_address = request.POST.get("invoice_address") or "" order.save() reverses = { Order.CREDIT_CARD: reverse_lazy( - 'shop:epay_form', - kwargs={'pk': order.id} + "shop:epay_form", kwargs={"pk": order.id} ), Order.BLOCKCHAIN: reverse_lazy( - 'shop:coinify_pay', - kwargs={'pk': order.id} + "shop:coinify_pay", kwargs={"pk": order.id} ), Order.BANK_TRANSFER: reverse_lazy( - 'shop:bank_transfer', - kwargs={'pk': order.id} + "shop:bank_transfer", kwargs={"pk": order.id} ), - Order.CASH: reverse_lazy( - 'shop:cash', - kwargs={'pk': order.id} - ) + Order.CASH: reverse_lazy("shop:cash", kwargs={"pk": order.id}), } return HttpResponseRedirect(reverses[payment_method]) @@ -400,12 +382,21 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH return super(OrderDetailView, self).get(request, *args, **kwargs) -class DownloadInvoiceView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsurePaidOrderMixin, EnsureOrderHasInvoicePDFMixin, SingleObjectMixin, View): +class DownloadInvoiceView( + LoginRequiredMixin, + EnsureUserOwnsOrderMixin, + EnsurePaidOrderMixin, + EnsureOrderHasInvoicePDFMixin, + SingleObjectMixin, + View, +): model = Order def get(self, request, *args, **kwargs): - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = 'attachment; filename="%s"' % self.get_object().invoice.filename + response = HttpResponse(content_type="application/pdf") + response["Content-Disposition"] = ( + 'attachment; filename="%s"' % self.get_object().invoice.filename + ) response.write(self.get_object().invoice.pdf.read()) return response @@ -413,19 +404,27 @@ class DownloadInvoiceView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsurePa class CreditNoteListView(LoginRequiredMixin, ListView): model = CreditNote template_name = "shop/creditnote_list.html" - context_object_name = 'creditnotes' + context_object_name = "creditnotes" def get_queryset(self): queryset = super().get_queryset() return queryset.filter(user=self.request.user) -class DownloadCreditNoteView(LoginRequiredMixin, EnsureUserOwnsCreditNoteMixin, EnsureCreditNoteHasPDFMixin, SingleObjectMixin, View): +class DownloadCreditNoteView( + LoginRequiredMixin, + EnsureUserOwnsCreditNoteMixin, + EnsureCreditNoteHasPDFMixin, + SingleObjectMixin, + View, +): model = CreditNote def get(self, request, *args, **kwargs): - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = 'attachment; filename="%s"' % self.get_object().filename + response = HttpResponse(content_type="application/pdf") + response["Content-Disposition"] = ( + 'attachment; filename="%s"' % self.get_object().filename + ) response.write(self.get_object().pdf.read()) return response @@ -436,31 +435,38 @@ class OrderMarkAsPaidView(LoginRequiredMixin, SingleObjectMixin, View): def get(self, request, *args, **kwargs): if not request.user.is_staff: - messages.error(request, 'You do not have permissions to do that.') - return HttpResponseRedirect(reverse_lazy('shop:index')) + messages.error(request, "You do not have permissions to do that.") + return HttpResponseRedirect(reverse_lazy("shop:index")) else: - messages.success(request, 'The order has been marked as paid.') + messages.success(request, "The order has been marked as paid.") order = self.get_object() order.mark_as_paid() - return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + return HttpResponseRedirect(request.META.get("HTTP_REFERER")) # Epay views -class EpayFormView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureClosedOrderMixin, EnsureOrderHasProductsMixin, DetailView): +class EpayFormView( + LoginRequiredMixin, + EnsureUserOwnsOrderMixin, + EnsureUnpaidOrderMixin, + EnsureClosedOrderMixin, + EnsureOrderHasProductsMixin, + DetailView, +): model = Order - template_name = 'epay_form.html' + template_name = "epay_form.html" def get_context_data(self, **kwargs): order = self.get_object() context = super(EpayFormView, self).get_context_data(**kwargs) - context['merchant_number'] = settings.EPAY_MERCHANT_NUMBER - context['description'] = order.description - context['amount'] = order.total * 100 - context['order_id'] = order.pk - context['accept_url'] = order.get_epay_accept_url(self.request) - context['cancel_url'] = order.get_cancel_url(self.request) - context['callback_url'] = order.get_epay_callback_url(self.request) - context['epay_hash'] = calculate_epay_hash(order, self.request) + context["merchant_number"] = settings.EPAY_MERCHANT_NUMBER + context["description"] = order.description + context["amount"] = order.total * 100 + context["order_id"] = order.pk + context["accept_url"] = order.get_epay_accept_url(self.request) + context["cancel_url"] = order.get_cancel_url(self.request) + context["callback_url"] = order.get_epay_callback_url(self.request) + context["epay_hash"] = calculate_epay_hash(order, self.request) return context @@ -468,21 +474,19 @@ class EpayCallbackView(SingleObjectMixin, View): model = Order def get(self, request, *args, **kwargs): - callback = EpayCallback.objects.create( - payload=request.GET - ) + callback = EpayCallback.objects.create(payload=request.GET) - if 'orderid' in request.GET: + if "orderid" in request.GET: query = OrderedDict( - [tuple(x.split('=')) for x in request.META['QUERY_STRING'].split('&')] + [tuple(x.split("=")) for x in request.META["QUERY_STRING"].split("&")] ) - order = get_object_or_404(Order, pk=query.get('orderid')) + order = get_object_or_404(Order, pk=query.get("orderid")) if order.pk != self.get_object().pk: logger.error("bad epay callback, orders do not match!") return HttpResponse(status=400) if validate_epay_callback(query): - callback.md5valid=True + callback.md5valid = True callback.save() else: logger.error("bad epay callback!") @@ -490,15 +494,13 @@ class EpayCallbackView(SingleObjectMixin, View): if order.paid: # this order is already paid, perhaps we are seeing a double callback? - return HttpResponse('OK') + return HttpResponse("OK") # epay callback is valid - has the order been paid in full? - if int(query['amount']) == order.total * 100: + if int(query["amount"]) == order.total * 100: # create an EpayPayment object linking the callback to the order EpayPayment.objects.create( - order=order, - callback=callback, - txnid=query.get('txnid'), + order=order, callback=callback, txnid=query.get("txnid") ) # and mark order as paid (this will create tickets) order.mark_as_paid(request) @@ -507,53 +509,76 @@ class EpayCallbackView(SingleObjectMixin, View): else: return HttpResponse(status=400) - return HttpResponse('OK') + return HttpResponse("OK") -class EpayThanksView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView): +class EpayThanksView( + LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView +): model = Order - template_name = 'epay_thanks.html' + template_name = "epay_thanks.html" def dispatch(self, request, *args, **kwargs): if request.GET: # epay redirects the user back to our accepturl with a long # and ugly querystring, redirect user to the clean url return HttpResponseRedirect( - reverse('shop:epay_thanks', kwargs={'pk': self.get_object().pk}) + reverse("shop:epay_thanks", kwargs={"pk": self.get_object().pk}) ) - return super(EpayThanksView, self).dispatch( - request, *args, **kwargs - ) + return super(EpayThanksView, self).dispatch(request, *args, **kwargs) # Bank Transfer view -class BankTransferView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureOrderHasProductsMixin, DetailView): + +class BankTransferView( + LoginRequiredMixin, + EnsureUserOwnsOrderMixin, + EnsureUnpaidOrderMixin, + EnsureOrderHasProductsMixin, + DetailView, +): model = Order - template_name = 'bank_transfer.html' + template_name = "bank_transfer.html" def get_context_data(self, **kwargs): context = super(BankTransferView, self).get_context_data(**kwargs) - context['iban'] = settings.BANKACCOUNT_IBAN - context['swiftbic'] = settings.BANKACCOUNT_SWIFTBIC - context['orderid'] = self.get_object().pk - context['regno'] = settings.BANKACCOUNT_REG - context['accountno'] = settings.BANKACCOUNT_ACCOUNT - context['total'] = self.get_object().total + context["iban"] = settings.BANKACCOUNT_IBAN + context["swiftbic"] = settings.BANKACCOUNT_SWIFTBIC + context["orderid"] = self.get_object().pk + context["regno"] = settings.BANKACCOUNT_REG + context["accountno"] = settings.BANKACCOUNT_ACCOUNT + context["total"] = self.get_object().total return context # Cash payment view -class CashView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureOrderHasProductsMixin, DetailView): + +class CashView( + LoginRequiredMixin, + EnsureUserOwnsOrderMixin, + EnsureUnpaidOrderMixin, + EnsureOrderHasProductsMixin, + DetailView, +): model = Order - template_name = 'cash.html' + template_name = "cash.html" # Coinify views -class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureClosedOrderMixin, EnsureOrderHasProductsMixin, SingleObjectMixin, RedirectView): + +class CoinifyRedirectView( + LoginRequiredMixin, + EnsureUserOwnsOrderMixin, + EnsureUnpaidOrderMixin, + EnsureClosedOrderMixin, + EnsureOrderHasProductsMixin, + SingleObjectMixin, + RedirectView, +): model = Order def dispatch(self, request, *args, **kwargs): @@ -563,17 +588,20 @@ class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUn if not order.coinifyapiinvoice: coinifyinvoice = create_coinify_invoice(order, request) if not coinifyinvoice: - messages.error(request, "There was a problem with the payment provider. Please try again later") + messages.error( + request, + "There was a problem with the payment provider. Please try again later", + ) return HttpResponseRedirect( - reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) + reverse_lazy( + "shop:order_detail", kwargs={"pk": self.get_object().pk} + ) ) - return super(CoinifyRedirectView, self).dispatch( - request, *args, **kwargs - ) + return super(CoinifyRedirectView, self).dispatch(request, *args, **kwargs) def get_redirect_url(self, *args, **kwargs): - return self.get_object().coinifyapiinvoice.invoicejson['payment_url'] + return self.get_object().coinifyapiinvoice.invoicejson["payment_url"] class CoinifyCallbackView(SingleObjectMixin, View): @@ -590,34 +618,45 @@ class CoinifyCallbackView(SingleObjectMixin, View): # do we have a json body? if not callbackobject.payload: # no, return an error - logger.error("unable to parse JSON body in callback for order %s" % callbackobject.order.id) - return HttpResponseBadRequest('unable to parse json') + logger.error( + "unable to parse JSON body in callback for order %s" + % callbackobject.order.id + ) + return HttpResponseBadRequest("unable to parse json") # initiate SDK - sdk = CoinifyCallback(settings.COINIFY_IPN_SECRET.encode('utf-8')) + sdk = CoinifyCallback(settings.COINIFY_IPN_SECRET.encode("utf-8")) # attemt to validate the callbackc - if sdk.validate_callback(request.body, request.META['HTTP_X_COINIFY_CALLBACK_SIGNATURE']): + if sdk.validate_callback( + request.body, request.META["HTTP_X_COINIFY_CALLBACK_SIGNATURE"] + ): # mark callback as valid in db callbackobject.valid = True callbackobject.save() else: logger.error("invalid coinify callback detected") - return HttpResponseBadRequest('something is fucky') + return HttpResponseBadRequest("something is fucky") - if callbackobject.payload['event'] == 'invoice_state_change' or callbackobject.payload['event'] == 'invoice_manual_resend': + if ( + callbackobject.payload["event"] == "invoice_state_change" + or callbackobject.payload["event"] == "invoice_manual_resend" + ): process_coinify_invoice_json( - invoicejson=callbackobject.payload['data'], + invoicejson=callbackobject.payload["data"], order=self.get_object(), request=request, ) - return HttpResponse('OK') + return HttpResponse("OK") else: - logger.error("unsupported callback event %s" % callbackobject.payload['event']) - return HttpResponseBadRequest('unsupported event') + logger.error( + "unsupported callback event %s" % callbackobject.payload["event"] + ) + return HttpResponseBadRequest("unsupported event") -class CoinifyThanksView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView): +class CoinifyThanksView( + LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView +): model = Order - template_name = 'coinify_thanks.html' - + template_name = "coinify_thanks.html" From e78013c87cd934b577949147b3efa7dd12e1a303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Fri, 29 Mar 2019 22:19:49 +0100 Subject: [PATCH 19/19] Blackness. --- src/shop/factories.py | 26 ++- src/shop/forms.py | 13 +- src/shop/models.py | 370 +++++++++++++++++++++--------------------- 3 files changed, 202 insertions(+), 207 deletions(-) diff --git a/src/shop/factories.py b/src/shop/factories.py index 9b0c5e0c..3c65c43f 100644 --- a/src/shop/factories.py +++ b/src/shop/factories.py @@ -11,39 +11,37 @@ from utils.factories import UserFactory class ProductCategoryFactory(DjangoModelFactory): class Meta: - model = 'shop.ProductCategory' + model = "shop.ProductCategory" - name = factory.Faker('word') + name = factory.Faker("word") class ProductFactory(DjangoModelFactory): class Meta: - model = 'shop.Product' + model = "shop.Product" - name = factory.Faker('word') - slug = factory.Faker('word') + name = factory.Faker("word") + slug = factory.Faker("word") category = factory.SubFactory(ProductCategoryFactory) - description = factory.Faker('paragraph') - price = factory.Faker('pyint') + description = factory.Faker("paragraph") + price = factory.Faker("pyint") available_in = factory.LazyFunction( - lambda: - DateTimeTZRange( - lower=timezone.now(), - upper=timezone.now() + timezone.timedelta(31) - ) + lambda: DateTimeTZRange( + lower=timezone.now(), upper=timezone.now() + timezone.timedelta(31) + ) ) class OrderFactory(DjangoModelFactory): class Meta: - model = 'shop.Order' + model = "shop.Order" user = factory.SubFactory(UserFactory) class OrderProductRelationFactory(DjangoModelFactory): class Meta: - model = 'shop.OrderProductRelation' + model = "shop.OrderProductRelation" product = factory.SubFactory(ProductFactory) order = factory.SubFactory(OrderFactory) diff --git a/src/shop/forms.py b/src/shop/forms.py index 43619cbb..9df1ce2c 100644 --- a/src/shop/forms.py +++ b/src/shop/forms.py @@ -5,27 +5,22 @@ from shop.models import OrderProductRelation class OrderProductRelationForm(forms.ModelForm): - class Meta: model = OrderProductRelation - fields = ['quantity'] + fields = ["quantity"] def clean_quantity(self): product = self.instance.product - new_quantity = self.cleaned_data['quantity'] + new_quantity = self.cleaned_data["quantity"] if product.stock_amount and product.left_in_stock < new_quantity: raise forms.ValidationError( - "Only {} left in stock.".format( - product.left_in_stock, - ) + "Only {} left in stock.".format(product.left_in_stock) ) return new_quantity OrderProductRelationFormSet = modelformset_factory( - OrderProductRelation, - form=OrderProductRelationForm, - extra=0 + OrderProductRelation, form=OrderProductRelationForm, extra=0 ) diff --git a/src/shop/models.py b/src/shop/models.py index c4f6f5c3..d35030e6 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -22,177 +22,188 @@ logger = logging.getLogger("bornhack.%s" % __name__) class CustomOrder(CreatedUpdatedModel): - text = models.TextField( - help_text=_('The invoice text') - ) + text = models.TextField(help_text=_("The invoice text")) - customer = models.TextField( - help_text=_('The customer info for this order') - ) + customer = models.TextField(help_text=_("The customer info for this order")) amount = models.IntegerField( - help_text=_('Amount of this custom order (in DKK, including VAT).') + help_text=_("Amount of this custom order (in DKK, including VAT).") ) paid = models.BooleanField( - verbose_name=_('Paid?'), - help_text=_('Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)'), + verbose_name=_("Paid?"), + help_text=_( + "Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)" + ), default=False, ) - danish_vat = models.BooleanField( - help_text="Danish VAT?", - default=True - ) + danish_vat = models.BooleanField(help_text="Danish VAT?", default=True) def __str__(self): - return 'custom order id #%s' % self.pk + return "custom order id #%s" % self.pk @property def vat(self): if self.danish_vat: - return Decimal(round(self.amount*Decimal(0.2), 2)) + return Decimal(round(self.amount * Decimal(0.2), 2)) else: return 0 class Order(CreatedUpdatedModel): class Meta: - unique_together = ('user', 'open') - ordering = ['-created'] + unique_together = ("user", "open") + ordering = ["-created"] products = models.ManyToManyField( - 'shop.Product', - through='shop.OrderProductRelation' + "shop.Product", through="shop.OrderProductRelation" ) user = models.ForeignKey( - 'auth.User', - verbose_name=_('User'), - help_text=_('The user this shop order belongs to.'), - related_name='orders', + "auth.User", + verbose_name=_("User"), + help_text=_("The user this shop order belongs to."), + related_name="orders", on_delete=models.PROTECT, ) paid = models.BooleanField( - verbose_name=_('Paid?'), - help_text=_('Whether this shop order has been paid.'), + verbose_name=_("Paid?"), + help_text=_("Whether this shop order has been paid."), default=False, ) # We are using a NullBooleanField here to ensure that we only have one open order per user at a time. # This "hack" is possible since postgres treats null values as different, and thus we have database level integrity. open = models.NullBooleanField( - verbose_name=_('Open?'), + verbose_name=_("Open?"), help_text=_('Whether this shop order is open or not. "None" means closed.'), default=True, ) - CREDIT_CARD = 'credit_card' - BLOCKCHAIN = 'blockchain' - BANK_TRANSFER = 'bank_transfer' - CASH = 'cash' + CREDIT_CARD = "credit_card" + BLOCKCHAIN = "blockchain" + BANK_TRANSFER = "bank_transfer" + CASH = "cash" - PAYMENT_METHODS = [ - CREDIT_CARD, - BLOCKCHAIN, - BANK_TRANSFER, - CASH, - ] + PAYMENT_METHODS = [CREDIT_CARD, BLOCKCHAIN, BANK_TRANSFER, CASH] PAYMENT_METHOD_CHOICES = [ - (CREDIT_CARD, 'Credit card'), - (BLOCKCHAIN, 'Blockchain'), - (BANK_TRANSFER, 'Bank transfer'), - (CASH, 'Cash'), + (CREDIT_CARD, "Credit card"), + (BLOCKCHAIN, "Blockchain"), + (BANK_TRANSFER, "Bank transfer"), + (CASH, "Cash"), ] payment_method = models.CharField( - max_length=50, - choices=PAYMENT_METHOD_CHOICES, - default='', - blank=True + max_length=50, choices=PAYMENT_METHOD_CHOICES, default="", blank=True ) cancelled = models.BooleanField(default=False) refunded = models.BooleanField( - verbose_name=_('Refunded?'), - help_text=_('Whether this order has been refunded.'), + verbose_name=_("Refunded?"), + help_text=_("Whether this order has been refunded."), default=False, ) customer_comment = models.TextField( - verbose_name=_('Customer comment'), - help_text=_('If you have any comments about the order please enter them here.'), - default='', + verbose_name=_("Customer comment"), + help_text=_("If you have any comments about the order please enter them here."), + default="", blank=True, ) invoice_address = models.TextField( - help_text=_('The invoice address for this order. Leave blank to use the email associated with the logged in user.'), - blank=True + help_text=_( + "The invoice address for this order. Leave blank to use the email associated with the logged in user." + ), + blank=True, ) notes = models.TextField( - help_text='Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.', - default='', + help_text="Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.", + default="", blank=True, ) objects = OrderQuerySet.as_manager() def __str__(self): - return 'shop order id #%s' % self.pk + return "shop order id #%s" % self.pk def get_number_of_items(self): - return self.products.aggregate( - sum=Sum('orderproductrelation__quantity') - )['sum'] + return self.products.aggregate(sum=Sum("orderproductrelation__quantity"))["sum"] @property def vat(self): - return Decimal(self.total*Decimal(0.2)) + return Decimal(self.total * Decimal(0.2)) @property def total(self): if self.products.all(): - return Decimal(self.products.aggregate( - sum=Sum( - models.F('orderproductrelation__product__price') * - models.F('orderproductrelation__quantity'), - output_field=models.IntegerField() - ) - )['sum']) + return Decimal( + self.products.aggregate( + sum=Sum( + models.F("orderproductrelation__product__price") + * models.F("orderproductrelation__quantity"), + output_field=models.IntegerField(), + ) + )["sum"] + ) else: return False def get_coinify_callback_url(self, request): """ Check settings for an alternative COINIFY_CALLBACK_HOSTNAME otherwise use the one from the request """ - if hasattr(settings, 'COINIFY_CALLBACK_HOSTNAME') and settings.COINIFY_CALLBACK_HOSTNAME: + if ( + hasattr(settings, "COINIFY_CALLBACK_HOSTNAME") + and settings.COINIFY_CALLBACK_HOSTNAME + ): host = settings.COINIFY_CALLBACK_HOSTNAME else: host = request.get_host() - return 'https://' + host + str(reverse_lazy('shop:coinify_callback', kwargs={'pk': self.pk})) + return ( + "https://" + + host + + str(reverse_lazy("shop:coinify_callback", kwargs={"pk": self.pk})) + ) def get_coinify_thanks_url(self, request): - return 'https://' + request.get_host() + str(reverse_lazy('shop:coinify_thanks', kwargs={'pk': self.pk})) + return ( + "https://" + + request.get_host() + + str(reverse_lazy("shop:coinify_thanks", kwargs={"pk": self.pk})) + ) def get_epay_accept_url(self, request): - return 'https://' + request.get_host() + str(reverse_lazy('shop:epay_thanks', kwargs={'pk': self.pk})) + return ( + "https://" + + request.get_host() + + str(reverse_lazy("shop:epay_thanks", kwargs={"pk": self.pk})) + ) def get_cancel_url(self, request): - return 'https://' + request.get_host() + str(reverse_lazy('shop:order_detail', kwargs={'pk': self.pk})) + return ( + "https://" + + request.get_host() + + str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk})) + ) def get_epay_callback_url(self, request): - return 'https://' + request.get_host() + str(reverse_lazy('shop:epay_callback', kwargs={'pk': self.pk})) + return ( + "https://" + + request.get_host() + + str(reverse_lazy("shop:epay_callback", kwargs={"pk": self.pk})) + ) @property def description(self): return "Order #%s" % self.pk def get_absolute_url(self): - return str(reverse_lazy('shop:order_detail', kwargs={'pk': self.pk})) + return str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk})) def create_tickets(self, request=None): for order_product in self.orderproductrelation_set.all(): @@ -203,17 +214,24 @@ class Order(CreatedUpdatedModel): ticket_type=order_product.product.ticket_type, ) - already_created_tickets = self.shoptickets.filter(**query_kwargs).count() - tickets_to_create = max(0, order_product.quantity - already_created_tickets) + already_created_tickets = self.shoptickets.filter( + **query_kwargs + ).count() + tickets_to_create = max( + 0, order_product.quantity - already_created_tickets + ) # create the number of tickets required if tickets_to_create > 0: - for _ in range(0, (order_product.quantity - already_created_tickets)): - self.shoptickets.create( - **query_kwargs - ) + for _ in range( + 0, (order_product.quantity - already_created_tickets) + ): + self.shoptickets.create(**query_kwargs) - msg = "Created %s tickets of type: %s" % (order_product.quantity, order_product.product.ticket_type.name) + msg = "Created %s tickets of type: %s" % ( + order_product.quantity, + order_product.product.ticket_type.name, + ) if request: messages.success(request, msg) else: @@ -240,7 +258,10 @@ class Order(CreatedUpdatedModel): self.refunded = True # delete any tickets related to this order if self.shoptickets.all(): - msg = "Order %s marked as refunded, deleting %s tickets..." % (self.pk, self.shoptickets.count()) + msg = "Order %s marked as refunded, deleting %s tickets..." % ( + self.pk, + self.shoptickets.count(), + ) if request: messages.success(request, msg) else: @@ -273,7 +294,10 @@ class Order(CreatedUpdatedModel): return False def is_partially_handed_out(self): - if self.orderproductrelation_set.filter(handed_out=True).count() != 0 and self.orderproductrelation_set.filter(handed_out=False).count() != 0: + if ( + self.orderproductrelation_set.filter(handed_out=True).count() != 0 + and self.orderproductrelation_set.filter(handed_out=False).count() != 0 + ): # some products are handed out, others are not return True else: @@ -313,8 +337,8 @@ class Order(CreatedUpdatedModel): class ProductCategory(CreatedUpdatedModel, UUIDModel): class Meta: - verbose_name = 'Product category' - verbose_name_plural = 'Product categories' + verbose_name = "Product category" + verbose_name_plural = "Product categories" name = models.CharField(max_length=150) slug = models.SlugField() @@ -330,61 +354,51 @@ class ProductCategory(CreatedUpdatedModel, UUIDModel): class Product(CreatedUpdatedModel, UUIDModel): class Meta: - verbose_name = 'Product' - verbose_name_plural = 'Products' - ordering = ['available_in', 'price', 'name'] + verbose_name = "Product" + verbose_name_plural = "Products" + ordering = ["available_in", "price", "name"] category = models.ForeignKey( - 'shop.ProductCategory', - related_name='products', - on_delete=models.PROTECT, + "shop.ProductCategory", related_name="products", on_delete=models.PROTECT ) name = models.CharField(max_length=150) slug = models.SlugField(unique=True, max_length=100) price = models.IntegerField( - help_text=_('Price of the product (in DKK, including VAT).') + help_text=_("Price of the product (in DKK, including VAT).") ) description = models.TextField() available_in = DateTimeRangeField( help_text=_( - 'Which period is this product available for purchase? | ' - '(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required' + "Which period is this product available for purchase? | " + "(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required" ) ) ticket_type = models.ForeignKey( - 'tickets.TicketType', - on_delete=models.PROTECT, - null=True, - blank=True + "tickets.TicketType", on_delete=models.PROTECT, null=True, blank=True ) stock_amount = models.IntegerField( help_text=( - 'Initial amount available in stock if there is a limited ' - 'supply, e.g. fridge space' + "Initial amount available in stock if there is a limited " + "supply, e.g. fridge space" ), null=True, - blank=True + blank=True, ) objects = ProductQuerySet.as_manager() def __str__(self): - return '{} ({} DKK)'.format( - self.name, - self.price, - ) + return "{} ({} DKK)".format(self.name, self.price) def clean(self): - if self.category.name == 'Tickets' and not self.ticket_type: - raise ValidationError( - 'Products with category Tickets need a ticket_type' - ) + if self.category.name == "Tickets" and not self.ticket_type: + raise ValidationError("Products with category Tickets need a ticket_type") def is_available(self): """ Is the product available or not? @@ -407,7 +421,7 @@ class Product(CreatedUpdatedModel, UUIDModel): def is_old(self): now = timezone.now() - if hasattr(self.available_in, 'upper') and self.available_in.upper: + if hasattr(self.available_in, "upper") and self.available_in.upper: return self.available_in.upper < now return False @@ -425,10 +439,8 @@ class Product(CreatedUpdatedModel, UUIDModel): # or is marked to be paid with cash or bank transfer, meaning it is a # "reservation" of the product in question. sold = OrderProductRelation.objects.filter( - product=self, - order__open=None, - order__cancelled=False, - ).aggregate(Sum('quantity'))['quantity__sum'] + product=self, order__open=None, order__cancelled=False + ).aggregate(Sum("quantity"))["quantity__sum"] total_left = self.stock_amount - (sold or 0) @@ -445,8 +457,8 @@ class Product(CreatedUpdatedModel, UUIDModel): class OrderProductRelation(CreatedUpdatedModel): - order = models.ForeignKey('shop.Order', on_delete=models.PROTECT) - product = models.ForeignKey('shop.Product', on_delete=models.PROTECT) + order = models.ForeignKey("shop.Order", on_delete=models.PROTECT) + product = models.ForeignKey("shop.Product", on_delete=models.PROTECT) quantity = models.PositiveIntegerField() handed_out = models.BooleanField(default=False) @@ -457,76 +469,64 @@ class OrderProductRelation(CreatedUpdatedModel): def clean(self): if self.handed_out and not self.order.paid: raise ValidationError( - 'Product can not be handed out when order is not paid.' + "Product can not be handed out when order is not paid." ) class EpayCallback(CreatedUpdatedModel, UUIDModel): class Meta: - verbose_name = 'Epay Callback' - verbose_name_plural = 'Epay Callbacks' - ordering = ['-created'] + verbose_name = "Epay Callback" + verbose_name_plural = "Epay Callbacks" + ordering = ["-created"] payload = JSONField() md5valid = models.BooleanField(default=False) def __str__(self): - return 'callback at %s (md5 valid: %s)' % (self.created, self.md5valid) + return "callback at %s (md5 valid: %s)" % (self.created, self.md5valid) class EpayPayment(CreatedUpdatedModel, UUIDModel): class Meta: - verbose_name = 'Epay Payment' - verbose_name_plural = 'Epay Payments' + verbose_name = "Epay Payment" + verbose_name_plural = "Epay Payments" - order = models.OneToOneField('shop.Order', on_delete=models.PROTECT) - callback = models.ForeignKey('shop.EpayCallback', on_delete=models.PROTECT) + order = models.OneToOneField("shop.Order", on_delete=models.PROTECT) + callback = models.ForeignKey("shop.EpayCallback", on_delete=models.PROTECT) txnid = models.IntegerField() class CreditNote(CreatedUpdatedModel): class Meta: - ordering = ['-created'] + ordering = ["-created"] - amount = models.DecimalField( - max_digits=10, - decimal_places=2 - ) + amount = models.DecimalField(max_digits=10, decimal_places=2) - text = models.TextField( - help_text="Description of what this credit note covers" - ) + text = models.TextField(help_text="Description of what this credit note covers") - pdf = models.FileField( - null=True, - blank=True, - upload_to='creditnotes/' - ) + pdf = models.FileField(null=True, blank=True, upload_to="creditnotes/") user = models.ForeignKey( - 'auth.User', - verbose_name=_('User'), - help_text=_('The user this credit note belongs to, if any.'), - related_name='creditnotes', + "auth.User", + verbose_name=_("User"), + help_text=_("The user this credit note belongs to, if any."), + related_name="creditnotes", on_delete=models.PROTECT, null=True, - blank=True + blank=True, ) customer = models.TextField( - help_text="Customer info if no user is selected", - blank=True, - default='', + help_text="Customer info if no user is selected", blank=True, default="" ) - danish_vat = models.BooleanField( - help_text="Danish VAT?", - default=True - ) + danish_vat = models.BooleanField(help_text="Danish VAT?", default=True) paid = models.BooleanField( - verbose_name=_('Paid?'), - help_text=_('Whether the amount in this creditnote has been paid back to the customer.'), + verbose_name=_("Paid?"), + help_text=_( + "Whether the amount in this creditnote has been paid back to the customer." + ), default=False, ) @@ -536,24 +536,24 @@ class CreditNote(CreatedUpdatedModel): errors = [] if self.user and self.customer: msg = "Customer info should be blank if a user is selected." - errors.append(ValidationError({'user', msg})) - errors.append(ValidationError({'customer', msg})) + errors.append(ValidationError({"user", msg})) + errors.append(ValidationError({"customer", msg})) if not self.user and not self.customer: msg = "Either pick a user or fill in Customer info" - errors.append(ValidationError({'user', msg})) - errors.append(ValidationError({'customer', msg})) + errors.append(ValidationError({"user", msg})) + errors.append(ValidationError({"customer", msg})) if errors: raise ValidationError(errors) def __str__(self): if self.user: - return 'creditnoote#%s - %s DKK (customer: user %s)' % ( + return "creditnoote#%s - %s DKK (customer: user %s)" % ( self.id, self.amount, self.user.email, ) else: - return 'creditnoote#%s - %s DKK (customer: %s)' % ( + return "creditnoote#%s - %s DKK (customer: %s)" % ( self.id, self.amount, self.customer, @@ -562,34 +562,28 @@ class CreditNote(CreatedUpdatedModel): @property def vat(self): if self.danish_vat: - return Decimal(round(self.amount*Decimal(0.2), 2)) + return Decimal(round(self.amount * Decimal(0.2), 2)) else: return 0 @property def filename(self): - return 'bornhack_creditnote_%s.pdf' % self.pk + return "bornhack_creditnote_%s.pdf" % self.pk class Invoice(CreatedUpdatedModel): order = models.OneToOneField( - 'shop.Order', - null=True, - blank=True, - on_delete=models.PROTECT + "shop.Order", null=True, blank=True, on_delete=models.PROTECT ) customorder = models.OneToOneField( - 'shop.CustomOrder', - null=True, - blank=True, - on_delete=models.PROTECT + "shop.CustomOrder", null=True, blank=True, on_delete=models.PROTECT ) - pdf = models.FileField(null=True, blank=True, upload_to='invoices/') + pdf = models.FileField(null=True, blank=True, upload_to="invoices/") sent_to_customer = models.BooleanField(default=False) def __str__(self): if self.order: - return 'invoice#%s - shop order %s - %s - total %s DKK (sent to %s: %s)' % ( + return "invoice#%s - shop order %s - %s - total %s DKK (sent to %s: %s)" % ( self.id, self.order.id, self.order.created, @@ -598,52 +592,60 @@ class Invoice(CreatedUpdatedModel): self.sent_to_customer, ) elif self.customorder: - return 'invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)' % ( - self.id, - self.customorder.id, - self.customorder.created, - self.customorder.amount, - unidecode(self.customorder.customer), + return ( + "invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)" + % ( + self.id, + self.customorder.id, + self.customorder.created, + self.customorder.amount, + unidecode(self.customorder.customer), + ) ) @property def filename(self): - return 'bornhack_invoice_%s.pdf' % self.pk + return "bornhack_invoice_%s.pdf" % self.pk def regretdate(self): - return self.created+timedelta(days=15) + return self.created + timedelta(days=15) class CoinifyAPIInvoice(CreatedUpdatedModel): coinify_id = models.IntegerField(null=True) invoicejson = JSONField() - order = models.ForeignKey('shop.Order', related_name="coinify_api_invoices", on_delete=models.PROTECT) + order = models.ForeignKey( + "shop.Order", related_name="coinify_api_invoices", on_delete=models.PROTECT + ) def __str__(self): return "coinifyinvoice for order #%s" % self.order.id @property def expired(self): - return parse_datetime(self.invoicejson['expire_time']) < timezone.now() + return parse_datetime(self.invoicejson["expire_time"]) < timezone.now() class CoinifyAPICallback(CreatedUpdatedModel): headers = JSONField() payload = JSONField(blank=True) - body = models.TextField(default='') - order = models.ForeignKey('shop.Order', related_name="coinify_api_callbacks", on_delete=models.PROTECT) + body = models.TextField(default="") + order = models.ForeignKey( + "shop.Order", related_name="coinify_api_callbacks", on_delete=models.PROTECT + ) authenticated = models.BooleanField(default=False) def __str__(self): - return 'order #%s callback at %s' % (self.order.id, self.created) + return "order #%s callback at %s" % (self.order.id, self.created) class CoinifyAPIRequest(CreatedUpdatedModel): - order = models.ForeignKey('shop.Order', related_name="coinify_api_requests", on_delete=models.PROTECT) + order = models.ForeignKey( + "shop.Order", related_name="coinify_api_requests", on_delete=models.PROTECT + ) method = models.CharField(max_length=100) payload = JSONField() response = JSONField() def __str__(self): - return 'order %s api request %s' % (self.order.id, self.method) - + return "order %s api request %s" % (self.order.id, self.method)