commit
132d65087f
|
@ -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=form.instance.pk %}
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
|
|
@ -11,39 +11,37 @@ from utils.factories import UserFactory
|
||||||
|
|
||||||
class ProductCategoryFactory(DjangoModelFactory):
|
class ProductCategoryFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'shop.ProductCategory'
|
model = "shop.ProductCategory"
|
||||||
|
|
||||||
name = factory.Faker('word')
|
name = factory.Faker("word")
|
||||||
|
|
||||||
|
|
||||||
class ProductFactory(DjangoModelFactory):
|
class ProductFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'shop.Product'
|
model = "shop.Product"
|
||||||
|
|
||||||
name = factory.Faker('word')
|
name = factory.Faker("word")
|
||||||
slug = factory.Faker('word')
|
slug = factory.Faker("word")
|
||||||
category = factory.SubFactory(ProductCategoryFactory)
|
category = factory.SubFactory(ProductCategoryFactory)
|
||||||
description = factory.Faker('paragraph')
|
description = factory.Faker("paragraph")
|
||||||
price = factory.Faker('pyint')
|
price = factory.Faker("pyint")
|
||||||
available_in = factory.LazyFunction(
|
available_in = factory.LazyFunction(
|
||||||
lambda:
|
lambda: DateTimeTZRange(
|
||||||
DateTimeTZRange(
|
lower=timezone.now(), upper=timezone.now() + timezone.timedelta(31)
|
||||||
lower=timezone.now(),
|
|
||||||
upper=timezone.now() + timezone.timedelta(31)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OrderFactory(DjangoModelFactory):
|
class OrderFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'shop.Order'
|
model = "shop.Order"
|
||||||
|
|
||||||
user = factory.SubFactory(UserFactory)
|
user = factory.SubFactory(UserFactory)
|
||||||
|
|
||||||
|
|
||||||
class OrderProductRelationFactory(DjangoModelFactory):
|
class OrderProductRelationFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'shop.OrderProductRelation'
|
model = "shop.OrderProductRelation"
|
||||||
|
|
||||||
product = factory.SubFactory(ProductFactory)
|
product = factory.SubFactory(ProductFactory)
|
||||||
order = factory.SubFactory(OrderFactory)
|
order = factory.SubFactory(OrderFactory)
|
||||||
|
|
|
@ -1,6 +1,26 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.forms import modelformset_factory
|
||||||
|
|
||||||
|
from shop.models import OrderProductRelation
|
||||||
|
|
||||||
|
|
||||||
class AddToOrderForm(forms.Form):
|
class OrderProductRelationForm(forms.ModelForm):
|
||||||
quantity = forms.IntegerField(initial=1)
|
class Meta:
|
||||||
|
model = OrderProductRelation
|
||||||
|
fields = ["quantity"]
|
||||||
|
|
||||||
|
def clean_quantity(self):
|
||||||
|
product = self.instance.product
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_quantity
|
||||||
|
|
||||||
|
|
||||||
|
OrderProductRelationFormSet = modelformset_factory(
|
||||||
|
OrderProductRelation, form=OrderProductRelationForm, extra=0
|
||||||
|
)
|
||||||
|
|
|
@ -17,7 +17,7 @@ class OrderQuerySet(QuerySet):
|
||||||
return self.filter(cancelled=False)
|
return self.filter(cancelled=False)
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
return self.filter(open__isnull=True)
|
return self.filter(open__isnull=False)
|
||||||
|
|
||||||
def paid(self):
|
def paid(self):
|
||||||
return self.filter(paid=True)
|
return self.filter(paid=True)
|
||||||
|
|
|
@ -22,175 +22,188 @@ logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
|
|
||||||
|
|
||||||
class CustomOrder(CreatedUpdatedModel):
|
class CustomOrder(CreatedUpdatedModel):
|
||||||
text = models.TextField(
|
text = models.TextField(help_text=_("The invoice text"))
|
||||||
help_text=_('The invoice text')
|
|
||||||
)
|
|
||||||
|
|
||||||
customer = models.TextField(
|
customer = models.TextField(help_text=_("The customer info for this order"))
|
||||||
help_text=_('The customer info for this order')
|
|
||||||
)
|
|
||||||
|
|
||||||
amount = models.IntegerField(
|
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(
|
paid = models.BooleanField(
|
||||||
verbose_name=_('Paid?'),
|
verbose_name=_("Paid?"),
|
||||||
help_text=_('Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)'),
|
help_text=_(
|
||||||
|
"Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)"
|
||||||
|
),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
danish_vat = models.BooleanField(
|
danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
|
||||||
help_text="Danish VAT?",
|
|
||||||
default=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'custom order id #%s' % self.pk
|
return "custom order id #%s" % self.pk
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vat(self):
|
def vat(self):
|
||||||
if self.danish_vat:
|
if self.danish_vat:
|
||||||
return Decimal(round(self.amount*Decimal(0.2), 2))
|
return Decimal(round(self.amount * Decimal(0.2), 2))
|
||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class Order(CreatedUpdatedModel):
|
class Order(CreatedUpdatedModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('user', 'open')
|
unique_together = ("user", "open")
|
||||||
ordering = ['-created']
|
ordering = ["-created"]
|
||||||
|
|
||||||
products = models.ManyToManyField(
|
products = models.ManyToManyField(
|
||||||
'shop.Product',
|
"shop.Product", through="shop.OrderProductRelation"
|
||||||
through='shop.OrderProductRelation'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
'auth.User',
|
"auth.User",
|
||||||
verbose_name=_('User'),
|
verbose_name=_("User"),
|
||||||
help_text=_('The user this shop order belongs to.'),
|
help_text=_("The user this shop order belongs to."),
|
||||||
related_name='orders',
|
related_name="orders",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
paid = models.BooleanField(
|
paid = models.BooleanField(
|
||||||
verbose_name=_('Paid?'),
|
verbose_name=_("Paid?"),
|
||||||
help_text=_('Whether this shop order has been paid.'),
|
help_text=_("Whether this shop order has been paid."),
|
||||||
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.'),
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
CREDIT_CARD = 'credit_card'
|
CREDIT_CARD = "credit_card"
|
||||||
BLOCKCHAIN = 'blockchain'
|
BLOCKCHAIN = "blockchain"
|
||||||
BANK_TRANSFER = 'bank_transfer'
|
BANK_TRANSFER = "bank_transfer"
|
||||||
CASH = 'cash'
|
CASH = "cash"
|
||||||
|
|
||||||
PAYMENT_METHODS = [
|
PAYMENT_METHODS = [CREDIT_CARD, BLOCKCHAIN, BANK_TRANSFER, CASH]
|
||||||
CREDIT_CARD,
|
|
||||||
BLOCKCHAIN,
|
|
||||||
BANK_TRANSFER,
|
|
||||||
CASH,
|
|
||||||
]
|
|
||||||
|
|
||||||
PAYMENT_METHOD_CHOICES = [
|
PAYMENT_METHOD_CHOICES = [
|
||||||
(CREDIT_CARD, 'Credit card'),
|
(CREDIT_CARD, "Credit card"),
|
||||||
(BLOCKCHAIN, 'Blockchain'),
|
(BLOCKCHAIN, "Blockchain"),
|
||||||
(BANK_TRANSFER, 'Bank transfer'),
|
(BANK_TRANSFER, "Bank transfer"),
|
||||||
(CASH, 'Cash'),
|
(CASH, "Cash"),
|
||||||
]
|
]
|
||||||
|
|
||||||
payment_method = models.CharField(
|
payment_method = models.CharField(
|
||||||
max_length=50,
|
max_length=50, choices=PAYMENT_METHOD_CHOICES, default="", blank=True
|
||||||
choices=PAYMENT_METHOD_CHOICES,
|
|
||||||
default='',
|
|
||||||
blank=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cancelled = models.BooleanField(default=False)
|
cancelled = models.BooleanField(default=False)
|
||||||
|
|
||||||
refunded = models.BooleanField(
|
refunded = models.BooleanField(
|
||||||
verbose_name=_('Refunded?'),
|
verbose_name=_("Refunded?"),
|
||||||
help_text=_('Whether this order has been refunded.'),
|
help_text=_("Whether this order has been refunded."),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
customer_comment = models.TextField(
|
customer_comment = models.TextField(
|
||||||
verbose_name=_('Customer comment'),
|
verbose_name=_("Customer comment"),
|
||||||
help_text=_('If you have any comments about the order please enter them here.'),
|
help_text=_("If you have any comments about the order please enter them here."),
|
||||||
default='',
|
default="",
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice_address = models.TextField(
|
invoice_address = models.TextField(
|
||||||
help_text=_('The invoice address for this order. Leave blank to use the email associated with the logged in user.'),
|
help_text=_(
|
||||||
blank=True
|
"The invoice address for this order. Leave blank to use the email associated with the logged in user."
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
notes = models.TextField(
|
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.',
|
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='',
|
default="",
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = OrderQuerySet.as_manager()
|
objects = OrderQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'shop order id #%s' % self.pk
|
return "shop order id #%s" % self.pk
|
||||||
|
|
||||||
def get_number_of_items(self):
|
def get_number_of_items(self):
|
||||||
return self.products.aggregate(
|
return self.products.aggregate(sum=Sum("orderproductrelation__quantity"))["sum"]
|
||||||
sum=Sum('orderproductrelation__quantity')
|
|
||||||
)['sum']
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vat(self):
|
def vat(self):
|
||||||
return Decimal(self.total*Decimal(0.2))
|
return Decimal(self.total * Decimal(0.2))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total(self):
|
def total(self):
|
||||||
if self.products.all():
|
if self.products.all():
|
||||||
return Decimal(self.products.aggregate(
|
return Decimal(
|
||||||
|
self.products.aggregate(
|
||||||
sum=Sum(
|
sum=Sum(
|
||||||
models.F('orderproductrelation__product__price') *
|
models.F("orderproductrelation__product__price")
|
||||||
models.F('orderproductrelation__quantity'),
|
* models.F("orderproductrelation__quantity"),
|
||||||
output_field=models.IntegerField()
|
output_field=models.IntegerField(),
|
||||||
|
)
|
||||||
|
)["sum"]
|
||||||
)
|
)
|
||||||
)['sum'])
|
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_coinify_callback_url(self, request):
|
def get_coinify_callback_url(self, request):
|
||||||
""" Check settings for an alternative COINIFY_CALLBACK_HOSTNAME otherwise use the one from the 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
|
host = settings.COINIFY_CALLBACK_HOSTNAME
|
||||||
else:
|
else:
|
||||||
host = request.get_host()
|
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):
|
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):
|
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):
|
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):
|
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
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
return "Order #%s" % self.pk
|
return "Order #%s" % self.pk
|
||||||
|
|
||||||
def get_absolute_url(self):
|
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):
|
def create_tickets(self, request=None):
|
||||||
for order_product in self.orderproductrelation_set.all():
|
for order_product in self.orderproductrelation_set.all():
|
||||||
|
@ -201,17 +214,24 @@ class Order(CreatedUpdatedModel):
|
||||||
ticket_type=order_product.product.ticket_type,
|
ticket_type=order_product.product.ticket_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
already_created_tickets = self.shoptickets.filter(**query_kwargs).count()
|
already_created_tickets = self.shoptickets.filter(
|
||||||
tickets_to_create = max(0, order_product.quantity - already_created_tickets)
|
**query_kwargs
|
||||||
|
).count()
|
||||||
|
tickets_to_create = max(
|
||||||
|
0, order_product.quantity - already_created_tickets
|
||||||
|
)
|
||||||
|
|
||||||
# create the number of tickets required
|
# create the number of tickets required
|
||||||
if tickets_to_create > 0:
|
if tickets_to_create > 0:
|
||||||
for _ in range(0, (order_product.quantity - already_created_tickets)):
|
for _ in range(
|
||||||
self.shoptickets.create(
|
0, (order_product.quantity - already_created_tickets)
|
||||||
**query_kwargs
|
):
|
||||||
)
|
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:
|
if request:
|
||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
else:
|
else:
|
||||||
|
@ -238,7 +258,10 @@ class Order(CreatedUpdatedModel):
|
||||||
self.refunded = True
|
self.refunded = True
|
||||||
# delete any tickets related to this order
|
# delete any tickets related to this order
|
||||||
if self.shoptickets.all():
|
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:
|
if request:
|
||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
else:
|
else:
|
||||||
|
@ -271,7 +294,10 @@ class Order(CreatedUpdatedModel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_partially_handed_out(self):
|
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
|
# some products are handed out, others are not
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
@ -311,8 +337,8 @@ class Order(CreatedUpdatedModel):
|
||||||
|
|
||||||
class ProductCategory(CreatedUpdatedModel, UUIDModel):
|
class ProductCategory(CreatedUpdatedModel, UUIDModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Product category'
|
verbose_name = "Product category"
|
||||||
verbose_name_plural = 'Product categories'
|
verbose_name_plural = "Product categories"
|
||||||
|
|
||||||
name = models.CharField(max_length=150)
|
name = models.CharField(max_length=150)
|
||||||
slug = models.SlugField()
|
slug = models.SlugField()
|
||||||
|
@ -328,61 +354,51 @@ class ProductCategory(CreatedUpdatedModel, UUIDModel):
|
||||||
|
|
||||||
class Product(CreatedUpdatedModel, UUIDModel):
|
class Product(CreatedUpdatedModel, UUIDModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Product'
|
verbose_name = "Product"
|
||||||
verbose_name_plural = 'Products'
|
verbose_name_plural = "Products"
|
||||||
ordering = ['available_in', 'price', 'name']
|
ordering = ["available_in", "price", "name"]
|
||||||
|
|
||||||
category = models.ForeignKey(
|
category = models.ForeignKey(
|
||||||
'shop.ProductCategory',
|
"shop.ProductCategory", related_name="products", on_delete=models.PROTECT
|
||||||
related_name='products',
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(max_length=150)
|
name = models.CharField(max_length=150)
|
||||||
slug = models.SlugField(unique=True, max_length=100)
|
slug = models.SlugField(unique=True, max_length=100)
|
||||||
|
|
||||||
price = models.IntegerField(
|
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()
|
description = models.TextField()
|
||||||
|
|
||||||
available_in = DateTimeRangeField(
|
available_in = DateTimeRangeField(
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'Which period is this product available for purchase? | '
|
"Which period is this product available for purchase? | "
|
||||||
'(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required'
|
"(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ticket_type = models.ForeignKey(
|
ticket_type = models.ForeignKey(
|
||||||
'tickets.TicketType',
|
"tickets.TicketType", on_delete=models.PROTECT, null=True, blank=True
|
||||||
on_delete=models.PROTECT,
|
|
||||||
null=True,
|
|
||||||
blank=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stock_amount = models.IntegerField(
|
stock_amount = models.IntegerField(
|
||||||
help_text=(
|
help_text=(
|
||||||
'Initial amount available in stock if there is a limited '
|
"Initial amount available in stock if there is a limited "
|
||||||
'supply, e.g. fridge space'
|
"supply, e.g. fridge space"
|
||||||
),
|
),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ProductQuerySet.as_manager()
|
objects = ProductQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{} ({} DKK)'.format(
|
return "{} ({} DKK)".format(self.name, self.price)
|
||||||
self.name,
|
|
||||||
self.price,
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.category.name == 'Tickets' and not self.ticket_type:
|
if self.category.name == "Tickets" and not self.ticket_type:
|
||||||
raise ValidationError(
|
raise ValidationError("Products with category Tickets need a ticket_type")
|
||||||
'Products with category Tickets need a ticket_type'
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
""" Is the product available or not?
|
""" Is the product available or not?
|
||||||
|
@ -405,7 +421,7 @@ class Product(CreatedUpdatedModel, UUIDModel):
|
||||||
|
|
||||||
def is_old(self):
|
def is_old(self):
|
||||||
now = timezone.now()
|
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 self.available_in.upper < now
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -416,10 +432,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__paid=True,
|
).aggregate(Sum("quantity"))["quantity__sum"]
|
||||||
).aggregate(Sum('quantity'))['quantity__sum']
|
|
||||||
|
|
||||||
total_left = self.stock_amount - (sold or 0)
|
total_left = self.stock_amount - (sold or 0)
|
||||||
|
|
||||||
|
@ -436,8 +457,8 @@ class Product(CreatedUpdatedModel, UUIDModel):
|
||||||
|
|
||||||
|
|
||||||
class OrderProductRelation(CreatedUpdatedModel):
|
class OrderProductRelation(CreatedUpdatedModel):
|
||||||
order = models.ForeignKey('shop.Order', on_delete=models.PROTECT)
|
order = models.ForeignKey("shop.Order", on_delete=models.PROTECT)
|
||||||
product = models.ForeignKey('shop.Product', on_delete=models.PROTECT)
|
product = models.ForeignKey("shop.Product", on_delete=models.PROTECT)
|
||||||
quantity = models.PositiveIntegerField()
|
quantity = models.PositiveIntegerField()
|
||||||
handed_out = models.BooleanField(default=False)
|
handed_out = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
@ -448,76 +469,64 @@ class OrderProductRelation(CreatedUpdatedModel):
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.handed_out and not self.order.paid:
|
if self.handed_out and not self.order.paid:
|
||||||
raise ValidationError(
|
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 EpayCallback(CreatedUpdatedModel, UUIDModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Epay Callback'
|
verbose_name = "Epay Callback"
|
||||||
verbose_name_plural = 'Epay Callbacks'
|
verbose_name_plural = "Epay Callbacks"
|
||||||
ordering = ['-created']
|
ordering = ["-created"]
|
||||||
|
|
||||||
payload = JSONField()
|
payload = JSONField()
|
||||||
md5valid = models.BooleanField(default=False)
|
md5valid = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
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 EpayPayment(CreatedUpdatedModel, UUIDModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Epay Payment'
|
verbose_name = "Epay Payment"
|
||||||
verbose_name_plural = 'Epay Payments'
|
verbose_name_plural = "Epay Payments"
|
||||||
|
|
||||||
order = models.OneToOneField('shop.Order', on_delete=models.PROTECT)
|
order = models.OneToOneField("shop.Order", on_delete=models.PROTECT)
|
||||||
callback = models.ForeignKey('shop.EpayCallback', on_delete=models.PROTECT)
|
callback = models.ForeignKey("shop.EpayCallback", on_delete=models.PROTECT)
|
||||||
txnid = models.IntegerField()
|
txnid = models.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
class CreditNote(CreatedUpdatedModel):
|
class CreditNote(CreatedUpdatedModel):
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created']
|
ordering = ["-created"]
|
||||||
|
|
||||||
amount = models.DecimalField(
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2
|
|
||||||
)
|
|
||||||
|
|
||||||
text = models.TextField(
|
text = models.TextField(help_text="Description of what this credit note covers")
|
||||||
help_text="Description of what this credit note covers"
|
|
||||||
)
|
|
||||||
|
|
||||||
pdf = models.FileField(
|
pdf = models.FileField(null=True, blank=True, upload_to="creditnotes/")
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
upload_to='creditnotes/'
|
|
||||||
)
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
'auth.User',
|
"auth.User",
|
||||||
verbose_name=_('User'),
|
verbose_name=_("User"),
|
||||||
help_text=_('The user this credit note belongs to, if any.'),
|
help_text=_("The user this credit note belongs to, if any."),
|
||||||
related_name='creditnotes',
|
related_name="creditnotes",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
customer = models.TextField(
|
customer = models.TextField(
|
||||||
help_text="Customer info if no user is selected",
|
help_text="Customer info if no user is selected", blank=True, default=""
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
danish_vat = models.BooleanField(
|
danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
|
||||||
help_text="Danish VAT?",
|
|
||||||
default=True
|
|
||||||
)
|
|
||||||
|
|
||||||
paid = models.BooleanField(
|
paid = models.BooleanField(
|
||||||
verbose_name=_('Paid?'),
|
verbose_name=_("Paid?"),
|
||||||
help_text=_('Whether the amount in this creditnote has been paid back to the customer.'),
|
help_text=_(
|
||||||
|
"Whether the amount in this creditnote has been paid back to the customer."
|
||||||
|
),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -527,24 +536,24 @@ class CreditNote(CreatedUpdatedModel):
|
||||||
errors = []
|
errors = []
|
||||||
if self.user and self.customer:
|
if self.user and self.customer:
|
||||||
msg = "Customer info should be blank if a user is selected."
|
msg = "Customer info should be blank if a user is selected."
|
||||||
errors.append(ValidationError({'user', msg}))
|
errors.append(ValidationError({"user", msg}))
|
||||||
errors.append(ValidationError({'customer', msg}))
|
errors.append(ValidationError({"customer", msg}))
|
||||||
if not self.user and not self.customer:
|
if not self.user and not self.customer:
|
||||||
msg = "Either pick a user or fill in Customer info"
|
msg = "Either pick a user or fill in Customer info"
|
||||||
errors.append(ValidationError({'user', msg}))
|
errors.append(ValidationError({"user", msg}))
|
||||||
errors.append(ValidationError({'customer', msg}))
|
errors.append(ValidationError({"customer", msg}))
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors)
|
raise ValidationError(errors)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.user:
|
if self.user:
|
||||||
return 'creditnoote#%s - %s DKK (customer: user %s)' % (
|
return "creditnoote#%s - %s DKK (customer: user %s)" % (
|
||||||
self.id,
|
self.id,
|
||||||
self.amount,
|
self.amount,
|
||||||
self.user.email,
|
self.user.email,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return 'creditnoote#%s - %s DKK (customer: %s)' % (
|
return "creditnoote#%s - %s DKK (customer: %s)" % (
|
||||||
self.id,
|
self.id,
|
||||||
self.amount,
|
self.amount,
|
||||||
self.customer,
|
self.customer,
|
||||||
|
@ -553,34 +562,28 @@ class CreditNote(CreatedUpdatedModel):
|
||||||
@property
|
@property
|
||||||
def vat(self):
|
def vat(self):
|
||||||
if self.danish_vat:
|
if self.danish_vat:
|
||||||
return Decimal(round(self.amount*Decimal(0.2), 2))
|
return Decimal(round(self.amount * Decimal(0.2), 2))
|
||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
return 'bornhack_creditnote_%s.pdf' % self.pk
|
return "bornhack_creditnote_%s.pdf" % self.pk
|
||||||
|
|
||||||
|
|
||||||
class Invoice(CreatedUpdatedModel):
|
class Invoice(CreatedUpdatedModel):
|
||||||
order = models.OneToOneField(
|
order = models.OneToOneField(
|
||||||
'shop.Order',
|
"shop.Order", null=True, blank=True, on_delete=models.PROTECT
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
on_delete=models.PROTECT
|
|
||||||
)
|
)
|
||||||
customorder = models.OneToOneField(
|
customorder = models.OneToOneField(
|
||||||
'shop.CustomOrder',
|
"shop.CustomOrder", null=True, blank=True, on_delete=models.PROTECT
|
||||||
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)
|
sent_to_customer = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.order:
|
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.id,
|
||||||
self.order.id,
|
self.order.id,
|
||||||
self.order.created,
|
self.order.created,
|
||||||
|
@ -589,52 +592,60 @@ class Invoice(CreatedUpdatedModel):
|
||||||
self.sent_to_customer,
|
self.sent_to_customer,
|
||||||
)
|
)
|
||||||
elif self.customorder:
|
elif self.customorder:
|
||||||
return 'invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)' % (
|
return (
|
||||||
|
"invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)"
|
||||||
|
% (
|
||||||
self.id,
|
self.id,
|
||||||
self.customorder.id,
|
self.customorder.id,
|
||||||
self.customorder.created,
|
self.customorder.created,
|
||||||
self.customorder.amount,
|
self.customorder.amount,
|
||||||
unidecode(self.customorder.customer),
|
unidecode(self.customorder.customer),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
return 'bornhack_invoice_%s.pdf' % self.pk
|
return "bornhack_invoice_%s.pdf" % self.pk
|
||||||
|
|
||||||
def regretdate(self):
|
def regretdate(self):
|
||||||
return self.created+timedelta(days=15)
|
return self.created + timedelta(days=15)
|
||||||
|
|
||||||
|
|
||||||
class CoinifyAPIInvoice(CreatedUpdatedModel):
|
class CoinifyAPIInvoice(CreatedUpdatedModel):
|
||||||
coinify_id = models.IntegerField(null=True)
|
coinify_id = models.IntegerField(null=True)
|
||||||
invoicejson = JSONField()
|
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):
|
def __str__(self):
|
||||||
return "coinifyinvoice for order #%s" % self.order.id
|
return "coinifyinvoice for order #%s" % self.order.id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def expired(self):
|
def expired(self):
|
||||||
return parse_datetime(self.invoicejson['expire_time']) < timezone.now()
|
return parse_datetime(self.invoicejson["expire_time"]) < timezone.now()
|
||||||
|
|
||||||
|
|
||||||
class CoinifyAPICallback(CreatedUpdatedModel):
|
class CoinifyAPICallback(CreatedUpdatedModel):
|
||||||
headers = JSONField()
|
headers = JSONField()
|
||||||
payload = JSONField(blank=True)
|
payload = JSONField(blank=True)
|
||||||
body = models.TextField(default='')
|
body = models.TextField(default="")
|
||||||
order = models.ForeignKey('shop.Order', related_name="coinify_api_callbacks", on_delete=models.PROTECT)
|
order = models.ForeignKey(
|
||||||
|
"shop.Order", related_name="coinify_api_callbacks", on_delete=models.PROTECT
|
||||||
|
)
|
||||||
authenticated = models.BooleanField(default=False)
|
authenticated = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
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):
|
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)
|
method = models.CharField(max_length=100)
|
||||||
payload = JSONField()
|
payload = JSONField()
|
||||||
response = JSONField()
|
response = JSONField()
|
||||||
|
|
||||||
def __str__(self):
|
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)
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
@ -40,7 +40,9 @@
|
||||||
|
|
||||||
{% if product.is_stock_available %}
|
{% if product.is_stock_available %}
|
||||||
|
|
||||||
<h3>Add to order</h3>
|
<h3>{% if already_in_order %}Update order{% else %}Add to order{% endif %}</h3>
|
||||||
|
|
||||||
|
{% if already_in_order %}<p>You already have this product in your order.</p>{% endif %}
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
|
|
||||||
|
@ -49,7 +51,9 @@
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% bootstrap_form form %}
|
{% bootstrap_form form %}
|
||||||
{% bootstrap_button "Add to order" button_type="submit" button_class="btn-primary" %}
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{% if already_in_order %}Update{% else %}Add{% endif %}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -55,11 +55,22 @@ 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 and product.left_in_stock <= 10 %}
|
|
||||||
|
|
||||||
|
{% if product.stock_amount %}
|
||||||
|
|
||||||
|
{% if product.left_in_stock == 0 %}
|
||||||
<div class="label label-danger">
|
<div class="label label-danger">
|
||||||
|
Sold out!
|
||||||
|
</div>
|
||||||
|
{% elif product.left_in_stock <= 10 %}
|
||||||
|
<div class="label label-info">
|
||||||
Only {{ product.left_in_stock }} left!
|
Only {{ product.left_in_stock }} left!
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="pull-right">
|
<div class="pull-right">
|
||||||
|
|
|
@ -3,10 +3,10 @@ from decimal import Decimal
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def currency(value):
|
def currency(value):
|
||||||
try:
|
try:
|
||||||
return "{0:.2f} DKK".format(Decimal(value))
|
return "{0:.2f} DKK".format(Decimal(value))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from psycopg2.extras import DateTimeTZRange
|
from psycopg2.extras import DateTimeTZRange
|
||||||
|
|
||||||
from .factories import (
|
from shop.forms import OrderProductRelationForm
|
||||||
ProductFactory,
|
from utils.factories import UserFactory
|
||||||
OrderProductRelationFactory,
|
from .factories import ProductFactory, OrderProductRelationFactory, OrderFactory
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductAvailabilityTest(TestCase):
|
class ProductAvailabilityTest(TestCase):
|
||||||
|
@ -22,16 +21,21 @@ class ProductAvailabilityTest(TestCase):
|
||||||
""" If max orders have been made, the product is NOT available. """
|
""" If max orders have been made, the product is NOT available. """
|
||||||
product = ProductFactory(stock_amount=2)
|
product = ProductFactory(stock_amount=2)
|
||||||
|
|
||||||
for i in range(2):
|
OrderProductRelationFactory(product=product, order__open=None)
|
||||||
opr = OrderProductRelationFactory(product=product)
|
opr = OrderProductRelationFactory(product=product, order__open=None)
|
||||||
order = opr.order
|
|
||||||
order.paid = True
|
|
||||||
order.save()
|
|
||||||
|
|
||||||
self.assertEqual(product.left_in_stock, 0)
|
self.assertEqual(product.left_in_stock, 0)
|
||||||
self.assertFalse(product.is_stock_available)
|
self.assertFalse(product.is_stock_available)
|
||||||
self.assertFalse(product.is_available())
|
self.assertFalse(product.is_available())
|
||||||
|
|
||||||
|
# Cancel one order
|
||||||
|
opr.order.cancelled = True
|
||||||
|
opr.order.save()
|
||||||
|
|
||||||
|
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):
|
def test_product_available_by_time(self):
|
||||||
""" The product is available if now is in the right timeframe. """
|
""" The product is available if now is in the right timeframe. """
|
||||||
product = ProductFactory()
|
product = ProductFactory()
|
||||||
|
@ -43,7 +47,7 @@ class ProductAvailabilityTest(TestCase):
|
||||||
""" The product is not available if now is outside the timeframe. """
|
""" The product is not available if now is outside the timeframe. """
|
||||||
available_in = DateTimeTZRange(
|
available_in = DateTimeTZRange(
|
||||||
lower=timezone.now() - timezone.timedelta(5),
|
lower=timezone.now() - timezone.timedelta(5),
|
||||||
upper=timezone.now() - timezone.timedelta(1)
|
upper=timezone.now() - timezone.timedelta(1),
|
||||||
)
|
)
|
||||||
product = ProductFactory(available_in=available_in)
|
product = ProductFactory(available_in=available_in)
|
||||||
# The factory defines the timeframe as now and 31 days forward.
|
# The factory defines the timeframe as now and 31 days forward.
|
||||||
|
@ -52,9 +56,7 @@ class ProductAvailabilityTest(TestCase):
|
||||||
|
|
||||||
def test_product_is_not_available_yet(self):
|
def test_product_is_not_available_yet(self):
|
||||||
""" The product is not available because we are before lower bound. """
|
""" The product is not available because we are before lower bound. """
|
||||||
available_in = DateTimeTZRange(
|
available_in = DateTimeTZRange(lower=timezone.now() + timezone.timedelta(5))
|
||||||
lower=timezone.now() + timezone.timedelta(5)
|
|
||||||
)
|
|
||||||
product = ProductFactory(available_in=available_in)
|
product = ProductFactory(available_in=available_in)
|
||||||
# Make sure there is no upper - just in case.
|
# Make sure there is no upper - just in case.
|
||||||
self.assertEqual(product.available_in.upper, None)
|
self.assertEqual(product.available_in.upper, None)
|
||||||
|
@ -64,12 +66,288 @@ class ProductAvailabilityTest(TestCase):
|
||||||
|
|
||||||
def test_product_is_available_from_now_on(self):
|
def test_product_is_available_from_now_on(self):
|
||||||
""" The product is available because we are after lower bound. """
|
""" The product is available because we are after lower bound. """
|
||||||
available_in = DateTimeTZRange(
|
available_in = DateTimeTZRange(lower=timezone.now() - timezone.timedelta(1))
|
||||||
lower=timezone.now() - timezone.timedelta(1)
|
|
||||||
)
|
|
||||||
product = ProductFactory(available_in=available_in)
|
product = ProductFactory(available_in=available_in)
|
||||||
# Make sure there is no upper - just in case.
|
# Make sure there is no upper - just in case.
|
||||||
self.assertEqual(product.available_in.upper, None)
|
self.assertEqual(product.available_in.upper, None)
|
||||||
# The factory defines the timeframe as now and 31 days forward.
|
# The factory defines the timeframe as now and 31 days forward.
|
||||||
self.assertTrue(product.is_time_available)
|
self.assertTrue(product.is_time_available)
|
||||||
self.assertTrue(product.is_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
|
||||||
|
OrderProductRelationFactory(product=product, quantity=1, order__open=None)
|
||||||
|
|
||||||
|
opr = OrderProductRelationFactory(product=product)
|
||||||
|
|
||||||
|
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
|
||||||
|
OrderProductRelationFactory(product=product, quantity=1, order__open=None)
|
||||||
|
|
||||||
|
# There should only be 1 product left, since we just reserved 1
|
||||||
|
opr2 = OrderProductRelationFactory(product=product)
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
|
||||||
|
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_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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
OrderProductRelationFactory(product=self.product, quantity=1, order__open=None)
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(self.path)
|
||||||
|
|
||||||
|
self.assertContains(response, "<bold>1</bold> available")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_product_is_sold_out(self):
|
||||||
|
self.product.stock_amount = 1
|
||||||
|
self.product.save()
|
||||||
|
|
||||||
|
OrderProductRelationFactory(product=self.product, quantity=1, order__open=None)
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(self.path)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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})
|
||||||
|
)
|
||||||
|
|
||||||
|
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(self.path)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
opr = OrderProductRelationFactory(order=self.order)
|
||||||
|
|
||||||
|
order = opr.order
|
||||||
|
|
||||||
|
data = self.base_form_data
|
||||||
|
data["remove_product"] = opr.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)
|
||||||
|
|
||||||
|
opr = OrderProductRelationFactory(order=self.order)
|
||||||
|
|
||||||
|
order = opr.order
|
||||||
|
|
||||||
|
data = self.base_form_data
|
||||||
|
data["remove_product"] = opr.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)
|
||||||
|
|
||||||
|
opr = OrderProductRelationFactory(order=self.order)
|
||||||
|
order = opr.order
|
||||||
|
|
||||||
|
data = self.base_form_data
|
||||||
|
data["cancel_order"] = ""
|
||||||
|
|
||||||
|
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_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):
|
||||||
|
user = UserFactory()
|
||||||
|
self.client.force_login(user)
|
||||||
|
path = reverse("shop:order_list")
|
||||||
|
response = self.client.get(path)
|
||||||
|
self.assertEquals(response.status_code, 200)
|
||||||
|
|
|
@ -1,26 +1,24 @@
|
||||||
|
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,
|
||||||
HttpResponseRedirect,
|
HttpResponseRedirect,
|
||||||
HttpResponseBadRequest,
|
HttpResponseBadRequest,
|
||||||
Http404
|
Http404,
|
||||||
)
|
)
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic import (
|
from django.urls import reverse, reverse_lazy
|
||||||
View,
|
from django.utils import timezone
|
||||||
ListView,
|
|
||||||
DetailView,
|
|
||||||
FormView,
|
|
||||||
)
|
|
||||||
from django.views.generic.base import RedirectView
|
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.utils import timezone
|
from django.views.generic import View, ListView, DetailView, FormView
|
||||||
|
from django.views.generic.base import RedirectView
|
||||||
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
|
||||||
from shop.models import (
|
from shop.models import (
|
||||||
Order,
|
Order,
|
||||||
|
@ -31,16 +29,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 OrderProductRelationFormSet, OrderProductRelationForm
|
||||||
|
|
||||||
logger = logging.getLogger("bornhack.%s" % __name__)
|
logger = logging.getLogger("bornhack.%s" % __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -51,7 +48,7 @@ class EnsureCreditNoteHasPDFMixin(SingleObjectMixin):
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if not self.get_object().pdf:
|
if not self.get_object().pdf:
|
||||||
messages.error(request, "This creditnote has no PDF yet!")
|
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(
|
return super(EnsureCreditNoteHasPDFMixin, self).dispatch(
|
||||||
request, *args, **kwargs
|
request, *args, **kwargs
|
||||||
|
@ -81,9 +78,7 @@ class EnsureUserOwnsOrderMixin(SingleObjectMixin):
|
||||||
if self.get_object().user != request.user:
|
if self.get_object().user != request.user:
|
||||||
raise Http404("Order not found")
|
raise Http404("Order not found")
|
||||||
|
|
||||||
return super(EnsureUserOwnsOrderMixin, self).dispatch(
|
return super(EnsureUserOwnsOrderMixin, self).dispatch(request, *args, **kwargs)
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnsureUnpaidOrderMixin(SingleObjectMixin):
|
class EnsureUnpaidOrderMixin(SingleObjectMixin):
|
||||||
|
@ -93,12 +88,10 @@ class EnsureUnpaidOrderMixin(SingleObjectMixin):
|
||||||
if self.get_object().paid:
|
if self.get_object().paid:
|
||||||
messages.error(request, "This order is already paid for!")
|
messages.error(request, "This order is already paid for!")
|
||||||
return HttpResponseRedirect(
|
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(
|
return super(EnsureUnpaidOrderMixin, self).dispatch(request, *args, **kwargs)
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnsurePaidOrderMixin(SingleObjectMixin):
|
class EnsurePaidOrderMixin(SingleObjectMixin):
|
||||||
|
@ -108,12 +101,10 @@ class EnsurePaidOrderMixin(SingleObjectMixin):
|
||||||
if not self.get_object().paid:
|
if not self.get_object().paid:
|
||||||
messages.error(request, "This order is not paid for!")
|
messages.error(request, "This order is not paid for!")
|
||||||
return HttpResponseRedirect(
|
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(
|
return super(EnsurePaidOrderMixin, self).dispatch(request, *args, **kwargs)
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnsureClosedOrderMixin(SingleObjectMixin):
|
class EnsureClosedOrderMixin(SingleObjectMixin):
|
||||||
|
@ -121,14 +112,12 @@ class EnsureClosedOrderMixin(SingleObjectMixin):
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if self.get_object().open is not None:
|
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(
|
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(
|
return super(EnsureClosedOrderMixin, self).dispatch(request, *args, **kwargs)
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnsureOrderHasProductsMixin(SingleObjectMixin):
|
class EnsureOrderHasProductsMixin(SingleObjectMixin):
|
||||||
|
@ -136,8 +125,8 @@ class EnsureOrderHasProductsMixin(SingleObjectMixin):
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if not self.get_object().products.count() > 0:
|
if not self.get_object().products.count() > 0:
|
||||||
messages.error(request, 'This order has no products!')
|
messages.error(request, "This order has no products!")
|
||||||
return HttpResponseRedirect(reverse_lazy('shop:index'))
|
return HttpResponseRedirect(reverse_lazy("shop:index"))
|
||||||
|
|
||||||
return super(EnsureOrderHasProductsMixin, self).dispatch(
|
return super(EnsureOrderHasProductsMixin, self).dispatch(
|
||||||
request, *args, **kwargs
|
request, *args, **kwargs
|
||||||
|
@ -150,10 +139,9 @@ class EnsureOrderIsNotCancelledMixin(SingleObjectMixin):
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if self.get_object().cancelled:
|
if self.get_object().cancelled:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request, "Order #{} is cancelled!".format(self.get_object().id)
|
||||||
'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(
|
return super(EnsureOrderIsNotCancelledMixin, self).dispatch(
|
||||||
request, *args, **kwargs
|
request, *args, **kwargs
|
||||||
|
@ -167,7 +155,7 @@ class EnsureOrderHasInvoicePDFMixin(SingleObjectMixin):
|
||||||
if not self.get_object().invoice.pdf:
|
if not self.get_object().invoice.pdf:
|
||||||
messages.error(request, "This order has no invoice yet!")
|
messages.error(request, "This order has no invoice yet!")
|
||||||
return HttpResponseRedirect(
|
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(
|
return super(EnsureOrderHasInvoicePDFMixin, self).dispatch(
|
||||||
|
@ -179,17 +167,17 @@ class EnsureOrderHasInvoicePDFMixin(SingleObjectMixin):
|
||||||
class ShopIndexView(ListView):
|
class ShopIndexView(ListView):
|
||||||
model = Product
|
model = Product
|
||||||
template_name = "shop_index.html"
|
template_name = "shop_index.html"
|
||||||
context_object_name = 'products'
|
context_object_name = "products"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super(ShopIndexView, self).get_queryset()
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(ShopIndexView, self).get_context_data(**kwargs)
|
context = super(ShopIndexView, self).get_context_data(**kwargs)
|
||||||
|
|
||||||
if 'category' in self.request.GET:
|
if "category" in self.request.GET:
|
||||||
category = self.request.GET.get('category')
|
category = self.request.GET.get("category")
|
||||||
|
|
||||||
# is this a public category
|
# is this a public category
|
||||||
try:
|
try:
|
||||||
|
@ -200,71 +188,76 @@ class ShopIndexView(ListView):
|
||||||
raise Http404("Category not found")
|
raise Http404("Category not found")
|
||||||
|
|
||||||
# filter products by the chosen category
|
# filter products by the chosen category
|
||||||
context['products'] = context['products'].filter(
|
context["products"] = context["products"].filter(category__slug=category)
|
||||||
category__slug=category
|
context["current_category"] = categoryobj
|
||||||
)
|
context["categories"] = ProductCategory.objects.annotate(
|
||||||
context['current_category'] = categoryobj
|
num_products=Count("products")
|
||||||
context['categories'] = ProductCategory.objects.annotate(
|
|
||||||
num_products=Count('products')
|
|
||||||
).filter(
|
).filter(
|
||||||
num_products__gt=0,
|
num_products__gt=0,
|
||||||
public=True,
|
public=True,
|
||||||
products__available_in__contains=timezone.now()
|
products__available_in__contains=timezone.now(),
|
||||||
)
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ProductDetailView(FormView, DetailView):
|
class ProductDetailView(FormView, DetailView):
|
||||||
model = Product
|
model = Product
|
||||||
template_name = 'product_detail.html'
|
template_name = "product_detail.html"
|
||||||
form_class = AddToOrderForm
|
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
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
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
|
||||||
|
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if not self.get_object().category.public:
|
self.object = self.get_object()
|
||||||
|
|
||||||
|
if not self.object.category.public:
|
||||||
# this product is not publicly available
|
# this product is not publicly available
|
||||||
raise Http404("Product not found")
|
raise Http404("Product not found")
|
||||||
|
|
||||||
return super(ProductDetailView, self).dispatch(
|
if self.request.user.is_authenticated:
|
||||||
request, *args, **kwargs
|
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)
|
||||||
|
|
||||||
|
return super(ProductDetailView, self).dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
product = self.get_object()
|
opr = form.save(commit=False)
|
||||||
quantity = form.cleaned_data.get('quantity')
|
|
||||||
|
|
||||||
# do we have an open order?
|
if not opr.pk:
|
||||||
try:
|
opr.order, _ = Order.objects.get_or_create(
|
||||||
order = Order.objects.get(
|
user=self.request.user, open=True, cancelled=False
|
||||||
user=self.request.user,
|
|
||||||
open__isnull=False
|
|
||||||
)
|
|
||||||
except Order.DoesNotExist:
|
|
||||||
# no open order - open a new one
|
|
||||||
order = Order.objects.create(
|
|
||||||
user=self.request.user,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# get product from kwargs
|
opr.save()
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
messages.info(
|
messages.info(
|
||||||
self.request,
|
self.request,
|
||||||
'{}x {} has been added to your order.'.format(
|
"{}x {} has been added to your order.".format(
|
||||||
quantity,
|
opr.quantity, opr.product.name
|
||||||
product.name
|
),
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# done
|
# done
|
||||||
|
@ -279,90 +272,131 @@ class ProductDetailView(FormView, DetailView):
|
||||||
class OrderListView(LoginRequiredMixin, ListView):
|
class OrderListView(LoginRequiredMixin, ListView):
|
||||||
model = Order
|
model = Order
|
||||||
template_name = "shop/order_list.html"
|
template_name = "shop/order_list.html"
|
||||||
context_object_name = 'orders'
|
context_object_name = "orders"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super(OrderListView, self).get_queryset()
|
queryset = super(OrderListView, self).get_queryset()
|
||||||
return queryset.filter(user=self.request.user).not_cancelled()
|
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
|
model = Order
|
||||||
template_name = 'shop/order_detail.html'
|
template_name = "shop/order_detail.html"
|
||||||
context_object_name = 'order'
|
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())
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
order = self.get_object()
|
self.object = self.get_object()
|
||||||
payment_method = request.POST.get('payment_method')
|
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.
|
||||||
|
elif "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.
|
||||||
|
else:
|
||||||
|
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(
|
||||||
return HttpResponseRedirect(
|
request,
|
||||||
reverse_lazy('shop:order_detail', kwargs={'pk': order.pk})
|
"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
|
# Set payment method and mark the order as closed
|
||||||
order.payment_method = payment_method
|
order.payment_method = payment_method
|
||||||
order.open = None
|
order.open = None
|
||||||
order.customer_comment = request.POST.get('customer_comment') or ''
|
order.customer_comment = request.POST.get("customer_comment") or ""
|
||||||
order.invoice_address = request.POST.get('invoice_address') or ''
|
order.invoice_address = request.POST.get("invoice_address") or ""
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
reverses = {
|
reverses = {
|
||||||
Order.CREDIT_CARD: reverse_lazy(
|
Order.CREDIT_CARD: reverse_lazy(
|
||||||
'shop:epay_form',
|
"shop:epay_form", kwargs={"pk": order.id}
|
||||||
kwargs={'pk': order.id}
|
|
||||||
),
|
),
|
||||||
Order.BLOCKCHAIN: reverse_lazy(
|
Order.BLOCKCHAIN: reverse_lazy(
|
||||||
'shop:coinify_pay',
|
"shop:coinify_pay", kwargs={"pk": order.id}
|
||||||
kwargs={'pk': order.id}
|
|
||||||
),
|
),
|
||||||
Order.BANK_TRANSFER: reverse_lazy(
|
Order.BANK_TRANSFER: reverse_lazy(
|
||||||
'shop:bank_transfer',
|
"shop:bank_transfer", kwargs={"pk": order.id}
|
||||||
kwargs={'pk': order.id}
|
|
||||||
),
|
),
|
||||||
Order.CASH: reverse_lazy(
|
Order.CASH: reverse_lazy("shop:cash", kwargs={"pk": order.id}),
|
||||||
'shop:cash',
|
|
||||||
kwargs={'pk': order.id}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
class DownloadInvoiceView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsurePaidOrderMixin, EnsureOrderHasInvoicePDFMixin, SingleObjectMixin, View):
|
class DownloadInvoiceView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
EnsureUserOwnsOrderMixin,
|
||||||
|
EnsurePaidOrderMixin,
|
||||||
|
EnsureOrderHasInvoicePDFMixin,
|
||||||
|
SingleObjectMixin,
|
||||||
|
View,
|
||||||
|
):
|
||||||
model = Order
|
model = Order
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
response = HttpResponse(content_type='application/pdf')
|
response = HttpResponse(content_type="application/pdf")
|
||||||
response['Content-Disposition'] = 'attachment; filename="%s"' % self.get_object().invoice.filename
|
response["Content-Disposition"] = (
|
||||||
|
'attachment; filename="%s"' % self.get_object().invoice.filename
|
||||||
|
)
|
||||||
response.write(self.get_object().invoice.pdf.read())
|
response.write(self.get_object().invoice.pdf.read())
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -370,19 +404,27 @@ class DownloadInvoiceView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsurePa
|
||||||
class CreditNoteListView(LoginRequiredMixin, ListView):
|
class CreditNoteListView(LoginRequiredMixin, ListView):
|
||||||
model = CreditNote
|
model = CreditNote
|
||||||
template_name = "shop/creditnote_list.html"
|
template_name = "shop/creditnote_list.html"
|
||||||
context_object_name = 'creditnotes'
|
context_object_name = "creditnotes"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
return queryset.filter(user=self.request.user)
|
return queryset.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class DownloadCreditNoteView(LoginRequiredMixin, EnsureUserOwnsCreditNoteMixin, EnsureCreditNoteHasPDFMixin, SingleObjectMixin, View):
|
class DownloadCreditNoteView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
EnsureUserOwnsCreditNoteMixin,
|
||||||
|
EnsureCreditNoteHasPDFMixin,
|
||||||
|
SingleObjectMixin,
|
||||||
|
View,
|
||||||
|
):
|
||||||
model = CreditNote
|
model = CreditNote
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
response = HttpResponse(content_type='application/pdf')
|
response = HttpResponse(content_type="application/pdf")
|
||||||
response['Content-Disposition'] = 'attachment; filename="%s"' % self.get_object().filename
|
response["Content-Disposition"] = (
|
||||||
|
'attachment; filename="%s"' % self.get_object().filename
|
||||||
|
)
|
||||||
response.write(self.get_object().pdf.read())
|
response.write(self.get_object().pdf.read())
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -393,31 +435,38 @@ class OrderMarkAsPaidView(LoginRequiredMixin, SingleObjectMixin, View):
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
if not request.user.is_staff:
|
if not request.user.is_staff:
|
||||||
messages.error(request, 'You do not have permissions to do that.')
|
messages.error(request, "You do not have permissions to do that.")
|
||||||
return HttpResponseRedirect(reverse_lazy('shop:index'))
|
return HttpResponseRedirect(reverse_lazy("shop:index"))
|
||||||
else:
|
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 = self.get_object()
|
||||||
order.mark_as_paid()
|
order.mark_as_paid()
|
||||||
return HttpResponseRedirect(request.META.get('HTTP_REFERER'))
|
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
||||||
|
|
||||||
|
|
||||||
# Epay views
|
# Epay views
|
||||||
class EpayFormView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureClosedOrderMixin, EnsureOrderHasProductsMixin, DetailView):
|
class EpayFormView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
EnsureUserOwnsOrderMixin,
|
||||||
|
EnsureUnpaidOrderMixin,
|
||||||
|
EnsureClosedOrderMixin,
|
||||||
|
EnsureOrderHasProductsMixin,
|
||||||
|
DetailView,
|
||||||
|
):
|
||||||
model = Order
|
model = Order
|
||||||
template_name = 'epay_form.html'
|
template_name = "epay_form.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
context = super(EpayFormView, self).get_context_data(**kwargs)
|
context = super(EpayFormView, self).get_context_data(**kwargs)
|
||||||
context['merchant_number'] = settings.EPAY_MERCHANT_NUMBER
|
context["merchant_number"] = settings.EPAY_MERCHANT_NUMBER
|
||||||
context['description'] = order.description
|
context["description"] = order.description
|
||||||
context['amount'] = order.total * 100
|
context["amount"] = order.total * 100
|
||||||
context['order_id'] = order.pk
|
context["order_id"] = order.pk
|
||||||
context['accept_url'] = order.get_epay_accept_url(self.request)
|
context["accept_url"] = order.get_epay_accept_url(self.request)
|
||||||
context['cancel_url'] = order.get_cancel_url(self.request)
|
context["cancel_url"] = order.get_cancel_url(self.request)
|
||||||
context['callback_url'] = order.get_epay_callback_url(self.request)
|
context["callback_url"] = order.get_epay_callback_url(self.request)
|
||||||
context['epay_hash'] = calculate_epay_hash(order, self.request)
|
context["epay_hash"] = calculate_epay_hash(order, self.request)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -425,21 +474,19 @@ class EpayCallbackView(SingleObjectMixin, View):
|
||||||
model = Order
|
model = Order
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
callback = EpayCallback.objects.create(
|
callback = EpayCallback.objects.create(payload=request.GET)
|
||||||
payload=request.GET
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'orderid' in request.GET:
|
if "orderid" in request.GET:
|
||||||
query = OrderedDict(
|
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:
|
if order.pk != self.get_object().pk:
|
||||||
logger.error("bad epay callback, orders do not match!")
|
logger.error("bad epay callback, orders do not match!")
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
if validate_epay_callback(query):
|
if validate_epay_callback(query):
|
||||||
callback.md5valid=True
|
callback.md5valid = True
|
||||||
callback.save()
|
callback.save()
|
||||||
else:
|
else:
|
||||||
logger.error("bad epay callback!")
|
logger.error("bad epay callback!")
|
||||||
|
@ -447,15 +494,13 @@ class EpayCallbackView(SingleObjectMixin, View):
|
||||||
|
|
||||||
if order.paid:
|
if order.paid:
|
||||||
# this order is already paid, perhaps we are seeing a double callback?
|
# 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?
|
# 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
|
# create an EpayPayment object linking the callback to the order
|
||||||
EpayPayment.objects.create(
|
EpayPayment.objects.create(
|
||||||
order=order,
|
order=order, callback=callback, txnid=query.get("txnid")
|
||||||
callback=callback,
|
|
||||||
txnid=query.get('txnid'),
|
|
||||||
)
|
)
|
||||||
# and mark order as paid (this will create tickets)
|
# and mark order as paid (this will create tickets)
|
||||||
order.mark_as_paid(request)
|
order.mark_as_paid(request)
|
||||||
|
@ -464,53 +509,76 @@ class EpayCallbackView(SingleObjectMixin, View):
|
||||||
else:
|
else:
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
return HttpResponse('OK')
|
return HttpResponse("OK")
|
||||||
|
|
||||||
|
|
||||||
class EpayThanksView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView):
|
class EpayThanksView(
|
||||||
|
LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView
|
||||||
|
):
|
||||||
model = Order
|
model = Order
|
||||||
template_name = 'epay_thanks.html'
|
template_name = "epay_thanks.html"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if request.GET:
|
if request.GET:
|
||||||
# epay redirects the user back to our accepturl with a long
|
# epay redirects the user back to our accepturl with a long
|
||||||
# and ugly querystring, redirect user to the clean url
|
# and ugly querystring, redirect user to the clean url
|
||||||
return HttpResponseRedirect(
|
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(
|
return super(EpayThanksView, self).dispatch(request, *args, **kwargs)
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Bank Transfer view
|
# Bank Transfer view
|
||||||
|
|
||||||
class BankTransferView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureOrderHasProductsMixin, DetailView):
|
|
||||||
|
class BankTransferView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
EnsureUserOwnsOrderMixin,
|
||||||
|
EnsureUnpaidOrderMixin,
|
||||||
|
EnsureOrderHasProductsMixin,
|
||||||
|
DetailView,
|
||||||
|
):
|
||||||
model = Order
|
model = Order
|
||||||
template_name = 'bank_transfer.html'
|
template_name = "bank_transfer.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(BankTransferView, self).get_context_data(**kwargs)
|
context = super(BankTransferView, self).get_context_data(**kwargs)
|
||||||
context['iban'] = settings.BANKACCOUNT_IBAN
|
context["iban"] = settings.BANKACCOUNT_IBAN
|
||||||
context['swiftbic'] = settings.BANKACCOUNT_SWIFTBIC
|
context["swiftbic"] = settings.BANKACCOUNT_SWIFTBIC
|
||||||
context['orderid'] = self.get_object().pk
|
context["orderid"] = self.get_object().pk
|
||||||
context['regno'] = settings.BANKACCOUNT_REG
|
context["regno"] = settings.BANKACCOUNT_REG
|
||||||
context['accountno'] = settings.BANKACCOUNT_ACCOUNT
|
context["accountno"] = settings.BANKACCOUNT_ACCOUNT
|
||||||
context['total'] = self.get_object().total
|
context["total"] = self.get_object().total
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
# Cash payment view
|
# Cash payment view
|
||||||
|
|
||||||
class CashView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureOrderHasProductsMixin, DetailView):
|
|
||||||
|
class CashView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
EnsureUserOwnsOrderMixin,
|
||||||
|
EnsureUnpaidOrderMixin,
|
||||||
|
EnsureOrderHasProductsMixin,
|
||||||
|
DetailView,
|
||||||
|
):
|
||||||
model = Order
|
model = Order
|
||||||
template_name = 'cash.html'
|
template_name = "cash.html"
|
||||||
|
|
||||||
|
|
||||||
# Coinify views
|
# Coinify views
|
||||||
|
|
||||||
class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureClosedOrderMixin, EnsureOrderHasProductsMixin, SingleObjectMixin, RedirectView):
|
|
||||||
|
class CoinifyRedirectView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
EnsureUserOwnsOrderMixin,
|
||||||
|
EnsureUnpaidOrderMixin,
|
||||||
|
EnsureClosedOrderMixin,
|
||||||
|
EnsureOrderHasProductsMixin,
|
||||||
|
SingleObjectMixin,
|
||||||
|
RedirectView,
|
||||||
|
):
|
||||||
model = Order
|
model = Order
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
@ -520,17 +588,20 @@ class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUn
|
||||||
if not order.coinifyapiinvoice:
|
if not order.coinifyapiinvoice:
|
||||||
coinifyinvoice = create_coinify_invoice(order, request)
|
coinifyinvoice = create_coinify_invoice(order, request)
|
||||||
if not coinifyinvoice:
|
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(
|
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(
|
return super(CoinifyRedirectView, self).dispatch(request, *args, **kwargs)
|
||||||
request, *args, **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_redirect_url(self, *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):
|
class CoinifyCallbackView(SingleObjectMixin, View):
|
||||||
|
@ -547,34 +618,45 @@ class CoinifyCallbackView(SingleObjectMixin, View):
|
||||||
# do we have a json body?
|
# do we have a json body?
|
||||||
if not callbackobject.payload:
|
if not callbackobject.payload:
|
||||||
# no, return an error
|
# no, return an error
|
||||||
logger.error("unable to parse JSON body in callback for order %s" % callbackobject.order.id)
|
logger.error(
|
||||||
return HttpResponseBadRequest('unable to parse json')
|
"unable to parse JSON body in callback for order %s"
|
||||||
|
% callbackobject.order.id
|
||||||
|
)
|
||||||
|
return HttpResponseBadRequest("unable to parse json")
|
||||||
|
|
||||||
# initiate SDK
|
# 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
|
# 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
|
# mark callback as valid in db
|
||||||
callbackobject.valid = True
|
callbackobject.valid = True
|
||||||
callbackobject.save()
|
callbackobject.save()
|
||||||
else:
|
else:
|
||||||
logger.error("invalid coinify callback detected")
|
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(
|
process_coinify_invoice_json(
|
||||||
invoicejson=callbackobject.payload['data'],
|
invoicejson=callbackobject.payload["data"],
|
||||||
order=self.get_object(),
|
order=self.get_object(),
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
return HttpResponse('OK')
|
return HttpResponse("OK")
|
||||||
else:
|
else:
|
||||||
logger.error("unsupported callback event %s" % callbackobject.payload['event'])
|
logger.error(
|
||||||
return HttpResponseBadRequest('unsupported event')
|
"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
|
model = Order
|
||||||
template_name = 'coinify_thanks.html'
|
template_name = "coinify_thanks.html"
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ from factory.django import DjangoModelFactory
|
||||||
class UserFactory(DjangoModelFactory):
|
class UserFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'auth.User'
|
model = 'auth.User'
|
||||||
|
django_get_or_create = ('username',)
|
||||||
|
|
||||||
username = factory.Faker('word')
|
username = factory.Faker('word')
|
||||||
email = factory.Faker('ascii_email')
|
email = factory.Faker('ascii_email')
|
||||||
|
|
Loading…
Reference in a new issue