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 %}
<form method="POST" class="form-inline">
{% csrf_token %}
{{ order_product_formset.management_form }}
{% endif %}
<table class="table table-bordered {% if not order.open == None %}table-hover{% endif %}">
@ -25,28 +26,29 @@
Price
<th>
Total
<th></th>
<tbody>
{% for order_product in order.orderproductrelation_set.all %}
{% for form in order_product_formset %}
{{ form.id }}
<tr>
<td>
{{ order_product.product.name }}
<td>
{% if not order.open == None %}
<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 }}
{{ form.instance.product.name }}
{% if form.instance.product.stock_amount %}
<br /><small>{{ form.instance.product.left_in_stock }} left in stock</small>
{% endif %}
<td>
{{ order_product.product.price|currency }}
{% if not order.open == None %}
{% bootstrap_field form.quantity show_label=False %}
{% else %}
{{ form.instance.quantity }}
{% endif %}
<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 %}

View file

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

View file

@ -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']

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 %}
<bold>{{ product.left_in_stock }}</bold> available<br />
{% else %}
<bold>Sold out.</bold>
<bold>Sold out.</bold><br />
{% endif %}
</h3>

View file

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

View file

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