Use a modelformset for the order detail view to be able to validate stock on updates of quantities and payment of the order.

This commit is contained in:
Víðir Valberg Guðmundsson 2019-03-27 22:53:23 +01:00
parent 101cb2db63
commit 59cde9163f
7 changed files with 135 additions and 100 deletions

View file

@ -12,6 +12,7 @@
{% if not order.paid %} {% if not order.paid %}
<form method="POST" class="form-inline"> <form method="POST" class="form-inline">
{% csrf_token %} {% csrf_token %}
{{ order_product_formset.management_form }}
{% endif %} {% endif %}
<table class="table table-bordered {% if not order.open == None %}table-hover{% endif %}"> <table class="table table-bordered {% if not order.open == None %}table-hover{% endif %}">
@ -25,28 +26,29 @@
Price Price
<th> <th>
Total Total
<th></th>
<tbody> <tbody>
{% for order_product in order.orderproductrelation_set.all %} {% for form in order_product_formset %}
{{ form.id }}
<tr> <tr>
<td> <td>
{{ order_product.product.name }} {{ form.instance.product.name }}
<td> {% if form.instance.product.stock_amount %}
{% if not order.open == None %} <br /><small>{{ form.instance.product.left_in_stock }} left in stock</small>
<input type="number"
class="form-control"
style="width: 75px;"
min=1
name="{{ order_product.id }}"
value="{{ order_product.quantity }}" />
{% bootstrap_button '<i class="glyphicon glyphicon-remove"></i>' button_type="submit" button_class="btn-danger" name="remove_product" value=order_product.pk %}
{% else %}
{{ order_product.quantity }}
{% endif %} {% endif %}
<td> <td>
{{ order_product.product.price|currency }} {% if not order.open == None %}
{% bootstrap_field form.quantity show_label=False %}
{% else %}
{{ form.instance.quantity }}
{% endif %}
<td> <td>
{{ order_product.total|currency }} {{ form.instance.product.price|currency }}
<td>
{{ form.instance.total|currency }}
<td>
{% bootstrap_button '<i class="glyphicon glyphicon-remove"></i>' button_type="submit" button_class="btn-danger" name="remove_product" value=order_product.pk %}
{% endfor %} {% endfor %}

View file

@ -1,6 +1,34 @@
from django import forms from django import forms
from django.forms import modelformset_factory
from shop.models import OrderProductRelation
class AddToOrderForm(forms.Form): class AddToOrderForm(forms.Form):
quantity = forms.IntegerField(initial=1) 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
)

View file

@ -80,6 +80,8 @@ class Order(CreatedUpdatedModel):
default=False, 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( open = models.NullBooleanField(
verbose_name=_('Open?'), verbose_name=_('Open?'),
help_text=_('Whether this shop order is open or not. "None" means closed.'), help_text=_('Whether this shop order is open or not. "None" means closed.'),
@ -416,8 +418,15 @@ class Product(CreatedUpdatedModel, UUIDModel):
@property @property
def left_in_stock(self): def left_in_stock(self):
if self.stock_amount: 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( sold = OrderProductRelation.objects.filter(
product=self, product=self,
order__open=None,
order__cancelled=False, order__cancelled=False,
).aggregate(Sum('quantity'))['quantity__sum'] ).aggregate(Sum('quantity'))['quantity__sum']

View file

@ -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)

View file

@ -31,7 +31,7 @@
{% if product.left_in_stock > 0 %} {% if product.left_in_stock > 0 %}
<bold>{{ product.left_in_stock }}</bold> available<br /> <bold>{{ product.left_in_stock }}</bold> available<br />
{% else %} {% else %}
<bold>Sold out.</bold> <bold>Sold out.</bold><br />
{% endif %} {% endif %}
</h3> </h3>

View file

@ -55,15 +55,24 @@ Shop | {{ block.super }}
<a href="{% url 'shop:product_detail' slug=product.slug %}"> <a href="{% url 'shop:product_detail' slug=product.slug %}">
{{ product.name }} {{ product.name }}
</a> </a>
{% if product.stock_amount %} {% if product.stock_amount %}
<div class="label label-danger">
{% if product.left_in_stock == 0 %} {% if product.left_in_stock <= 10 %}
Sold out! <div class="label label-info">
{% elif product.left_in_stock <= 10 %}
Only {{ product.left_in_stock }} left! Only {{ product.left_in_stock }} left!
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% if product.left_in_stock == 0 %}
<div class="label label-danger">
Sold out!
</div>
{% endif %}
{% endif %}
</td> </td>
<td> <td>
<div class="pull-right"> <div class="pull-right">

View file

@ -1,7 +1,9 @@
import logging
from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse, reverse_lazy
from django.db.models import Count, F from django.db.models import Count, F
from django.http import ( from django.http import (
HttpResponse, HttpResponse,
@ -10,6 +12,10 @@ from django.http import (
Http404 Http404
) )
from django.shortcuts import get_object_or_404 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 ( from django.views.generic import (
View, View,
ListView, ListView,
@ -18,9 +24,6 @@ from django.views.generic import (
) )
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from django.views.generic.detail import SingleObjectMixin 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 ( from shop.models import (
Order, Order,
@ -31,16 +34,15 @@ from shop.models import (
EpayPayment, EpayPayment,
CreditNote, 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 vendor.coinify.coinify_callback import CoinifyCallback
from .coinify import ( from .coinify import (
create_coinify_invoice, create_coinify_invoice,
save_coinify_callback, save_coinify_callback,
process_coinify_invoice_json 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__) logger = logging.getLogger("bornhack.%s" % __name__)
@ -291,10 +293,63 @@ class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderH
template_name = 'shop/order_detail.html' template_name = 'shop/order_detail.html'
context_object_name = 'order' context_object_name = 'order'
def post(self, request, *args, **kwargs): def get_context_data(self, **kwargs):
order = self.get_object() if 'order_product_formset' not in kwargs:
payment_method = request.POST.get('payment_method') 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 payment_method in order.PAYMENT_METHODS:
if not request.POST.get('accept_terms'): if not request.POST.get('accept_terms'):
messages.error(request, "You need to accept the general terms and conditions before you can continue!") 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]) 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) return super(OrderDetailView, self).get(request, *args, **kwargs)