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:
parent
101cb2db63
commit
59cde9163f
|
@ -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 %}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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']
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue