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