Merge pull request #296 from bornhack/handle_negative_stock

Fix #263
This commit is contained in:
Víðir Valberg Guðmundsson 2019-03-29 22:24:00 +01:00 committed by GitHub
commit 132d65087f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 859 additions and 452 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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