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

View file

@ -11,39 +11,37 @@ from utils.factories import UserFactory
class ProductCategoryFactory(DjangoModelFactory): class ProductCategoryFactory(DjangoModelFactory):
class Meta: class Meta:
model = 'shop.ProductCategory' model = "shop.ProductCategory"
name = factory.Faker('word') name = factory.Faker("word")
class ProductFactory(DjangoModelFactory): class ProductFactory(DjangoModelFactory):
class Meta: class Meta:
model = 'shop.Product' model = "shop.Product"
name = factory.Faker('word') name = factory.Faker("word")
slug = factory.Faker('word') slug = factory.Faker("word")
category = factory.SubFactory(ProductCategoryFactory) category = factory.SubFactory(ProductCategoryFactory)
description = factory.Faker('paragraph') description = factory.Faker("paragraph")
price = factory.Faker('pyint') price = factory.Faker("pyint")
available_in = factory.LazyFunction( available_in = factory.LazyFunction(
lambda: lambda: DateTimeTZRange(
DateTimeTZRange( lower=timezone.now(), upper=timezone.now() + timezone.timedelta(31)
lower=timezone.now(), )
upper=timezone.now() + timezone.timedelta(31)
)
) )
class OrderFactory(DjangoModelFactory): class OrderFactory(DjangoModelFactory):
class Meta: class Meta:
model = 'shop.Order' model = "shop.Order"
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
class OrderProductRelationFactory(DjangoModelFactory): class OrderProductRelationFactory(DjangoModelFactory):
class Meta: class Meta:
model = 'shop.OrderProductRelation' model = "shop.OrderProductRelation"
product = factory.SubFactory(ProductFactory) product = factory.SubFactory(ProductFactory)
order = factory.SubFactory(OrderFactory) order = factory.SubFactory(OrderFactory)

View file

@ -1,6 +1,26 @@
from django import forms from django import forms
from django.forms import modelformset_factory
from shop.models import OrderProductRelation
class AddToOrderForm(forms.Form): class OrderProductRelationForm(forms.ModelForm):
quantity = forms.IntegerField(initial=1) class Meta:
model = OrderProductRelation
fields = ["quantity"]
def clean_quantity(self):
product = self.instance.product
new_quantity = self.cleaned_data["quantity"]
if product.stock_amount and product.left_in_stock < new_quantity:
raise forms.ValidationError(
"Only {} left in stock.".format(product.left_in_stock)
)
return new_quantity
OrderProductRelationFormSet = modelformset_factory(
OrderProductRelation, form=OrderProductRelationForm, extra=0
)

View file

@ -17,7 +17,7 @@ class OrderQuerySet(QuerySet):
return self.filter(cancelled=False) return self.filter(cancelled=False)
def open(self): def open(self):
return self.filter(open__isnull=True) return self.filter(open__isnull=False)
def paid(self): def paid(self):
return self.filter(paid=True) return self.filter(paid=True)

View file

@ -22,175 +22,188 @@ logger = logging.getLogger("bornhack.%s" % __name__)
class CustomOrder(CreatedUpdatedModel): class CustomOrder(CreatedUpdatedModel):
text = models.TextField( text = models.TextField(help_text=_("The invoice text"))
help_text=_('The invoice text')
)
customer = models.TextField( customer = models.TextField(help_text=_("The customer info for this order"))
help_text=_('The customer info for this order')
)
amount = models.IntegerField( amount = models.IntegerField(
help_text=_('Amount of this custom order (in DKK, including VAT).') help_text=_("Amount of this custom order (in DKK, including VAT).")
) )
paid = models.BooleanField( paid = models.BooleanField(
verbose_name=_('Paid?'), verbose_name=_("Paid?"),
help_text=_('Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)'), help_text=_(
"Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)"
),
default=False, default=False,
) )
danish_vat = models.BooleanField( danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
help_text="Danish VAT?",
default=True
)
def __str__(self): def __str__(self):
return 'custom order id #%s' % self.pk return "custom order id #%s" % self.pk
@property @property
def vat(self): def vat(self):
if self.danish_vat: if self.danish_vat:
return Decimal(round(self.amount*Decimal(0.2), 2)) return Decimal(round(self.amount * Decimal(0.2), 2))
else: else:
return 0 return 0
class Order(CreatedUpdatedModel): class Order(CreatedUpdatedModel):
class Meta: class Meta:
unique_together = ('user', 'open') unique_together = ("user", "open")
ordering = ['-created'] ordering = ["-created"]
products = models.ManyToManyField( products = models.ManyToManyField(
'shop.Product', "shop.Product", through="shop.OrderProductRelation"
through='shop.OrderProductRelation'
) )
user = models.ForeignKey( user = models.ForeignKey(
'auth.User', "auth.User",
verbose_name=_('User'), verbose_name=_("User"),
help_text=_('The user this shop order belongs to.'), help_text=_("The user this shop order belongs to."),
related_name='orders', related_name="orders",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
paid = models.BooleanField( paid = models.BooleanField(
verbose_name=_('Paid?'), verbose_name=_("Paid?"),
help_text=_('Whether this shop order has been paid.'), help_text=_("Whether this shop order has been paid."),
default=False, default=False,
) )
# We are using a NullBooleanField here to ensure that we only have one open order per user at a time.
# This "hack" is possible since postgres treats null values as different, and thus we have database level integrity.
open = models.NullBooleanField( open = models.NullBooleanField(
verbose_name=_('Open?'), verbose_name=_("Open?"),
help_text=_('Whether this shop order is open or not. "None" means closed.'), help_text=_('Whether this shop order is open or not. "None" means closed.'),
default=True, default=True,
) )
CREDIT_CARD = 'credit_card' CREDIT_CARD = "credit_card"
BLOCKCHAIN = 'blockchain' BLOCKCHAIN = "blockchain"
BANK_TRANSFER = 'bank_transfer' BANK_TRANSFER = "bank_transfer"
CASH = 'cash' CASH = "cash"
PAYMENT_METHODS = [ PAYMENT_METHODS = [CREDIT_CARD, BLOCKCHAIN, BANK_TRANSFER, CASH]
CREDIT_CARD,
BLOCKCHAIN,
BANK_TRANSFER,
CASH,
]
PAYMENT_METHOD_CHOICES = [ PAYMENT_METHOD_CHOICES = [
(CREDIT_CARD, 'Credit card'), (CREDIT_CARD, "Credit card"),
(BLOCKCHAIN, 'Blockchain'), (BLOCKCHAIN, "Blockchain"),
(BANK_TRANSFER, 'Bank transfer'), (BANK_TRANSFER, "Bank transfer"),
(CASH, 'Cash'), (CASH, "Cash"),
] ]
payment_method = models.CharField( payment_method = models.CharField(
max_length=50, max_length=50, choices=PAYMENT_METHOD_CHOICES, default="", blank=True
choices=PAYMENT_METHOD_CHOICES,
default='',
blank=True
) )
cancelled = models.BooleanField(default=False) cancelled = models.BooleanField(default=False)
refunded = models.BooleanField( refunded = models.BooleanField(
verbose_name=_('Refunded?'), verbose_name=_("Refunded?"),
help_text=_('Whether this order has been refunded.'), help_text=_("Whether this order has been refunded."),
default=False, default=False,
) )
customer_comment = models.TextField( customer_comment = models.TextField(
verbose_name=_('Customer comment'), verbose_name=_("Customer comment"),
help_text=_('If you have any comments about the order please enter them here.'), help_text=_("If you have any comments about the order please enter them here."),
default='', default="",
blank=True, blank=True,
) )
invoice_address = models.TextField( invoice_address = models.TextField(
help_text=_('The invoice address for this order. Leave blank to use the email associated with the logged in user.'), help_text=_(
blank=True "The invoice address for this order. Leave blank to use the email associated with the logged in user."
),
blank=True,
) )
notes = models.TextField( notes = models.TextField(
help_text='Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.', help_text="Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.",
default='', default="",
blank=True, blank=True,
) )
objects = OrderQuerySet.as_manager() objects = OrderQuerySet.as_manager()
def __str__(self): def __str__(self):
return 'shop order id #%s' % self.pk return "shop order id #%s" % self.pk
def get_number_of_items(self): def get_number_of_items(self):
return self.products.aggregate( return self.products.aggregate(sum=Sum("orderproductrelation__quantity"))["sum"]
sum=Sum('orderproductrelation__quantity')
)['sum']
@property @property
def vat(self): def vat(self):
return Decimal(self.total*Decimal(0.2)) return Decimal(self.total * Decimal(0.2))
@property @property
def total(self): def total(self):
if self.products.all(): if self.products.all():
return Decimal(self.products.aggregate( return Decimal(
sum=Sum( self.products.aggregate(
models.F('orderproductrelation__product__price') * sum=Sum(
models.F('orderproductrelation__quantity'), models.F("orderproductrelation__product__price")
output_field=models.IntegerField() * models.F("orderproductrelation__quantity"),
) output_field=models.IntegerField(),
)['sum']) )
)["sum"]
)
else: else:
return False return False
def get_coinify_callback_url(self, request): def get_coinify_callback_url(self, request):
""" Check settings for an alternative COINIFY_CALLBACK_HOSTNAME otherwise use the one from the request """ """ Check settings for an alternative COINIFY_CALLBACK_HOSTNAME otherwise use the one from the request """
if hasattr(settings, 'COINIFY_CALLBACK_HOSTNAME') and settings.COINIFY_CALLBACK_HOSTNAME: if (
hasattr(settings, "COINIFY_CALLBACK_HOSTNAME")
and settings.COINIFY_CALLBACK_HOSTNAME
):
host = settings.COINIFY_CALLBACK_HOSTNAME host = settings.COINIFY_CALLBACK_HOSTNAME
else: else:
host = request.get_host() host = request.get_host()
return 'https://' + host + str(reverse_lazy('shop:coinify_callback', kwargs={'pk': self.pk})) return (
"https://"
+ host
+ str(reverse_lazy("shop:coinify_callback", kwargs={"pk": self.pk}))
)
def get_coinify_thanks_url(self, request): def get_coinify_thanks_url(self, request):
return 'https://' + request.get_host() + str(reverse_lazy('shop:coinify_thanks', kwargs={'pk': self.pk})) return (
"https://"
+ request.get_host()
+ str(reverse_lazy("shop:coinify_thanks", kwargs={"pk": self.pk}))
)
def get_epay_accept_url(self, request): def get_epay_accept_url(self, request):
return 'https://' + request.get_host() + str(reverse_lazy('shop:epay_thanks', kwargs={'pk': self.pk})) return (
"https://"
+ request.get_host()
+ str(reverse_lazy("shop:epay_thanks", kwargs={"pk": self.pk}))
)
def get_cancel_url(self, request): def get_cancel_url(self, request):
return 'https://' + request.get_host() + str(reverse_lazy('shop:order_detail', kwargs={'pk': self.pk})) return (
"https://"
+ request.get_host()
+ str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
)
def get_epay_callback_url(self, request): def get_epay_callback_url(self, request):
return 'https://' + request.get_host() + str(reverse_lazy('shop:epay_callback', kwargs={'pk': self.pk})) return (
"https://"
+ request.get_host()
+ str(reverse_lazy("shop:epay_callback", kwargs={"pk": self.pk}))
)
@property @property
def description(self): def description(self):
return "Order #%s" % self.pk return "Order #%s" % self.pk
def get_absolute_url(self): def get_absolute_url(self):
return str(reverse_lazy('shop:order_detail', kwargs={'pk': self.pk})) return str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
def create_tickets(self, request=None): def create_tickets(self, request=None):
for order_product in self.orderproductrelation_set.all(): for order_product in self.orderproductrelation_set.all():
@ -201,17 +214,24 @@ class Order(CreatedUpdatedModel):
ticket_type=order_product.product.ticket_type, ticket_type=order_product.product.ticket_type,
) )
already_created_tickets = self.shoptickets.filter(**query_kwargs).count() already_created_tickets = self.shoptickets.filter(
tickets_to_create = max(0, order_product.quantity - already_created_tickets) **query_kwargs
).count()
tickets_to_create = max(
0, order_product.quantity - already_created_tickets
)
# create the number of tickets required # create the number of tickets required
if tickets_to_create > 0: if tickets_to_create > 0:
for _ in range(0, (order_product.quantity - already_created_tickets)): for _ in range(
self.shoptickets.create( 0, (order_product.quantity - already_created_tickets)
**query_kwargs ):
) self.shoptickets.create(**query_kwargs)
msg = "Created %s tickets of type: %s" % (order_product.quantity, order_product.product.ticket_type.name) msg = "Created %s tickets of type: %s" % (
order_product.quantity,
order_product.product.ticket_type.name,
)
if request: if request:
messages.success(request, msg) messages.success(request, msg)
else: else:
@ -238,7 +258,10 @@ class Order(CreatedUpdatedModel):
self.refunded = True self.refunded = True
# delete any tickets related to this order # delete any tickets related to this order
if self.shoptickets.all(): if self.shoptickets.all():
msg = "Order %s marked as refunded, deleting %s tickets..." % (self.pk, self.shoptickets.count()) msg = "Order %s marked as refunded, deleting %s tickets..." % (
self.pk,
self.shoptickets.count(),
)
if request: if request:
messages.success(request, msg) messages.success(request, msg)
else: else:
@ -271,7 +294,10 @@ class Order(CreatedUpdatedModel):
return False return False
def is_partially_handed_out(self): def is_partially_handed_out(self):
if self.orderproductrelation_set.filter(handed_out=True).count() != 0 and self.orderproductrelation_set.filter(handed_out=False).count() != 0: if (
self.orderproductrelation_set.filter(handed_out=True).count() != 0
and self.orderproductrelation_set.filter(handed_out=False).count() != 0
):
# some products are handed out, others are not # some products are handed out, others are not
return True return True
else: else:
@ -311,8 +337,8 @@ class Order(CreatedUpdatedModel):
class ProductCategory(CreatedUpdatedModel, UUIDModel): class ProductCategory(CreatedUpdatedModel, UUIDModel):
class Meta: class Meta:
verbose_name = 'Product category' verbose_name = "Product category"
verbose_name_plural = 'Product categories' verbose_name_plural = "Product categories"
name = models.CharField(max_length=150) name = models.CharField(max_length=150)
slug = models.SlugField() slug = models.SlugField()
@ -328,61 +354,51 @@ class ProductCategory(CreatedUpdatedModel, UUIDModel):
class Product(CreatedUpdatedModel, UUIDModel): class Product(CreatedUpdatedModel, UUIDModel):
class Meta: class Meta:
verbose_name = 'Product' verbose_name = "Product"
verbose_name_plural = 'Products' verbose_name_plural = "Products"
ordering = ['available_in', 'price', 'name'] ordering = ["available_in", "price", "name"]
category = models.ForeignKey( category = models.ForeignKey(
'shop.ProductCategory', "shop.ProductCategory", related_name="products", on_delete=models.PROTECT
related_name='products',
on_delete=models.PROTECT,
) )
name = models.CharField(max_length=150) name = models.CharField(max_length=150)
slug = models.SlugField(unique=True, max_length=100) slug = models.SlugField(unique=True, max_length=100)
price = models.IntegerField( price = models.IntegerField(
help_text=_('Price of the product (in DKK, including VAT).') help_text=_("Price of the product (in DKK, including VAT).")
) )
description = models.TextField() description = models.TextField()
available_in = DateTimeRangeField( available_in = DateTimeRangeField(
help_text=_( help_text=_(
'Which period is this product available for purchase? | ' "Which period is this product available for purchase? | "
'(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required' "(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required"
) )
) )
ticket_type = models.ForeignKey( ticket_type = models.ForeignKey(
'tickets.TicketType', "tickets.TicketType", on_delete=models.PROTECT, null=True, blank=True
on_delete=models.PROTECT,
null=True,
blank=True
) )
stock_amount = models.IntegerField( stock_amount = models.IntegerField(
help_text=( help_text=(
'Initial amount available in stock if there is a limited ' "Initial amount available in stock if there is a limited "
'supply, e.g. fridge space' "supply, e.g. fridge space"
), ),
null=True, null=True,
blank=True blank=True,
) )
objects = ProductQuerySet.as_manager() objects = ProductQuerySet.as_manager()
def __str__(self): def __str__(self):
return '{} ({} DKK)'.format( return "{} ({} DKK)".format(self.name, self.price)
self.name,
self.price,
)
def clean(self): def clean(self):
if self.category.name == 'Tickets' and not self.ticket_type: if self.category.name == "Tickets" and not self.ticket_type:
raise ValidationError( raise ValidationError("Products with category Tickets need a ticket_type")
'Products with category Tickets need a ticket_type'
)
def is_available(self): def is_available(self):
""" Is the product available or not? """ Is the product available or not?
@ -405,7 +421,7 @@ class Product(CreatedUpdatedModel, UUIDModel):
def is_old(self): def is_old(self):
now = timezone.now() now = timezone.now()
if hasattr(self.available_in, 'upper') and self.available_in.upper: if hasattr(self.available_in, "upper") and self.available_in.upper:
return self.available_in.upper < now return self.available_in.upper < now
return False return False
@ -416,10 +432,15 @@ class Product(CreatedUpdatedModel, UUIDModel):
@property @property
def left_in_stock(self): def left_in_stock(self):
if self.stock_amount: if self.stock_amount:
# All orders that are not open and not cancelled count towards what has
# been "reserved" from stock.
#
# This means that an order has either been paid (by card or blockchain)
# or is marked to be paid with cash or bank transfer, meaning it is a
# "reservation" of the product in question.
sold = OrderProductRelation.objects.filter( sold = OrderProductRelation.objects.filter(
product=self, product=self, order__open=None, order__cancelled=False
order__paid=True, ).aggregate(Sum("quantity"))["quantity__sum"]
).aggregate(Sum('quantity'))['quantity__sum']
total_left = self.stock_amount - (sold or 0) total_left = self.stock_amount - (sold or 0)
@ -436,8 +457,8 @@ class Product(CreatedUpdatedModel, UUIDModel):
class OrderProductRelation(CreatedUpdatedModel): class OrderProductRelation(CreatedUpdatedModel):
order = models.ForeignKey('shop.Order', on_delete=models.PROTECT) order = models.ForeignKey("shop.Order", on_delete=models.PROTECT)
product = models.ForeignKey('shop.Product', on_delete=models.PROTECT) product = models.ForeignKey("shop.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField() quantity = models.PositiveIntegerField()
handed_out = models.BooleanField(default=False) handed_out = models.BooleanField(default=False)
@ -448,76 +469,64 @@ class OrderProductRelation(CreatedUpdatedModel):
def clean(self): def clean(self):
if self.handed_out and not self.order.paid: if self.handed_out and not self.order.paid:
raise ValidationError( raise ValidationError(
'Product can not be handed out when order is not paid.' "Product can not be handed out when order is not paid."
) )
class EpayCallback(CreatedUpdatedModel, UUIDModel): class EpayCallback(CreatedUpdatedModel, UUIDModel):
class Meta: class Meta:
verbose_name = 'Epay Callback' verbose_name = "Epay Callback"
verbose_name_plural = 'Epay Callbacks' verbose_name_plural = "Epay Callbacks"
ordering = ['-created'] ordering = ["-created"]
payload = JSONField() payload = JSONField()
md5valid = models.BooleanField(default=False) md5valid = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return 'callback at %s (md5 valid: %s)' % (self.created, self.md5valid) return "callback at %s (md5 valid: %s)" % (self.created, self.md5valid)
class EpayPayment(CreatedUpdatedModel, UUIDModel): class EpayPayment(CreatedUpdatedModel, UUIDModel):
class Meta: class Meta:
verbose_name = 'Epay Payment' verbose_name = "Epay Payment"
verbose_name_plural = 'Epay Payments' verbose_name_plural = "Epay Payments"
order = models.OneToOneField('shop.Order', on_delete=models.PROTECT) order = models.OneToOneField("shop.Order", on_delete=models.PROTECT)
callback = models.ForeignKey('shop.EpayCallback', on_delete=models.PROTECT) callback = models.ForeignKey("shop.EpayCallback", on_delete=models.PROTECT)
txnid = models.IntegerField() txnid = models.IntegerField()
class CreditNote(CreatedUpdatedModel): class CreditNote(CreatedUpdatedModel):
class Meta: class Meta:
ordering = ['-created'] ordering = ["-created"]
amount = models.DecimalField( amount = models.DecimalField(max_digits=10, decimal_places=2)
max_digits=10,
decimal_places=2
)
text = models.TextField( text = models.TextField(help_text="Description of what this credit note covers")
help_text="Description of what this credit note covers"
)
pdf = models.FileField( pdf = models.FileField(null=True, blank=True, upload_to="creditnotes/")
null=True,
blank=True,
upload_to='creditnotes/'
)
user = models.ForeignKey( user = models.ForeignKey(
'auth.User', "auth.User",
verbose_name=_('User'), verbose_name=_("User"),
help_text=_('The user this credit note belongs to, if any.'), help_text=_("The user this credit note belongs to, if any."),
related_name='creditnotes', related_name="creditnotes",
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True blank=True,
) )
customer = models.TextField( customer = models.TextField(
help_text="Customer info if no user is selected", help_text="Customer info if no user is selected", blank=True, default=""
blank=True,
default='',
) )
danish_vat = models.BooleanField( danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
help_text="Danish VAT?",
default=True
)
paid = models.BooleanField( paid = models.BooleanField(
verbose_name=_('Paid?'), verbose_name=_("Paid?"),
help_text=_('Whether the amount in this creditnote has been paid back to the customer.'), help_text=_(
"Whether the amount in this creditnote has been paid back to the customer."
),
default=False, default=False,
) )
@ -527,24 +536,24 @@ class CreditNote(CreatedUpdatedModel):
errors = [] errors = []
if self.user and self.customer: if self.user and self.customer:
msg = "Customer info should be blank if a user is selected." msg = "Customer info should be blank if a user is selected."
errors.append(ValidationError({'user', msg})) errors.append(ValidationError({"user", msg}))
errors.append(ValidationError({'customer', msg})) errors.append(ValidationError({"customer", msg}))
if not self.user and not self.customer: if not self.user and not self.customer:
msg = "Either pick a user or fill in Customer info" msg = "Either pick a user or fill in Customer info"
errors.append(ValidationError({'user', msg})) errors.append(ValidationError({"user", msg}))
errors.append(ValidationError({'customer', msg})) errors.append(ValidationError({"customer", msg}))
if errors: if errors:
raise ValidationError(errors) raise ValidationError(errors)
def __str__(self): def __str__(self):
if self.user: if self.user:
return 'creditnoote#%s - %s DKK (customer: user %s)' % ( return "creditnoote#%s - %s DKK (customer: user %s)" % (
self.id, self.id,
self.amount, self.amount,
self.user.email, self.user.email,
) )
else: else:
return 'creditnoote#%s - %s DKK (customer: %s)' % ( return "creditnoote#%s - %s DKK (customer: %s)" % (
self.id, self.id,
self.amount, self.amount,
self.customer, self.customer,
@ -553,34 +562,28 @@ class CreditNote(CreatedUpdatedModel):
@property @property
def vat(self): def vat(self):
if self.danish_vat: if self.danish_vat:
return Decimal(round(self.amount*Decimal(0.2), 2)) return Decimal(round(self.amount * Decimal(0.2), 2))
else: else:
return 0 return 0
@property @property
def filename(self): def filename(self):
return 'bornhack_creditnote_%s.pdf' % self.pk return "bornhack_creditnote_%s.pdf" % self.pk
class Invoice(CreatedUpdatedModel): class Invoice(CreatedUpdatedModel):
order = models.OneToOneField( order = models.OneToOneField(
'shop.Order', "shop.Order", null=True, blank=True, on_delete=models.PROTECT
null=True,
blank=True,
on_delete=models.PROTECT
) )
customorder = models.OneToOneField( customorder = models.OneToOneField(
'shop.CustomOrder', "shop.CustomOrder", null=True, blank=True, on_delete=models.PROTECT
null=True,
blank=True,
on_delete=models.PROTECT
) )
pdf = models.FileField(null=True, blank=True, upload_to='invoices/') pdf = models.FileField(null=True, blank=True, upload_to="invoices/")
sent_to_customer = models.BooleanField(default=False) sent_to_customer = models.BooleanField(default=False)
def __str__(self): def __str__(self):
if self.order: if self.order:
return 'invoice#%s - shop order %s - %s - total %s DKK (sent to %s: %s)' % ( return "invoice#%s - shop order %s - %s - total %s DKK (sent to %s: %s)" % (
self.id, self.id,
self.order.id, self.order.id,
self.order.created, self.order.created,
@ -589,52 +592,60 @@ class Invoice(CreatedUpdatedModel):
self.sent_to_customer, self.sent_to_customer,
) )
elif self.customorder: elif self.customorder:
return 'invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)' % ( return (
self.id, "invoice#%s - custom order %s - %s - amount %s DKK (customer: %s)"
self.customorder.id, % (
self.customorder.created, self.id,
self.customorder.amount, self.customorder.id,
unidecode(self.customorder.customer), self.customorder.created,
self.customorder.amount,
unidecode(self.customorder.customer),
)
) )
@property @property
def filename(self): def filename(self):
return 'bornhack_invoice_%s.pdf' % self.pk return "bornhack_invoice_%s.pdf" % self.pk
def regretdate(self): def regretdate(self):
return self.created+timedelta(days=15) return self.created + timedelta(days=15)
class CoinifyAPIInvoice(CreatedUpdatedModel): class CoinifyAPIInvoice(CreatedUpdatedModel):
coinify_id = models.IntegerField(null=True) coinify_id = models.IntegerField(null=True)
invoicejson = JSONField() invoicejson = JSONField()
order = models.ForeignKey('shop.Order', related_name="coinify_api_invoices", on_delete=models.PROTECT) order = models.ForeignKey(
"shop.Order", related_name="coinify_api_invoices", on_delete=models.PROTECT
)
def __str__(self): def __str__(self):
return "coinifyinvoice for order #%s" % self.order.id return "coinifyinvoice for order #%s" % self.order.id
@property @property
def expired(self): def expired(self):
return parse_datetime(self.invoicejson['expire_time']) < timezone.now() return parse_datetime(self.invoicejson["expire_time"]) < timezone.now()
class CoinifyAPICallback(CreatedUpdatedModel): class CoinifyAPICallback(CreatedUpdatedModel):
headers = JSONField() headers = JSONField()
payload = JSONField(blank=True) payload = JSONField(blank=True)
body = models.TextField(default='') body = models.TextField(default="")
order = models.ForeignKey('shop.Order', related_name="coinify_api_callbacks", on_delete=models.PROTECT) order = models.ForeignKey(
"shop.Order", related_name="coinify_api_callbacks", on_delete=models.PROTECT
)
authenticated = models.BooleanField(default=False) authenticated = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return 'order #%s callback at %s' % (self.order.id, self.created) return "order #%s callback at %s" % (self.order.id, self.created)
class CoinifyAPIRequest(CreatedUpdatedModel): class CoinifyAPIRequest(CreatedUpdatedModel):
order = models.ForeignKey('shop.Order', related_name="coinify_api_requests", on_delete=models.PROTECT) order = models.ForeignKey(
"shop.Order", related_name="coinify_api_requests", on_delete=models.PROTECT
)
method = models.CharField(max_length=100) method = models.CharField(max_length=100)
payload = JSONField() payload = JSONField()
response = JSONField() response = JSONField()
def __str__(self): def __str__(self):
return 'order %s api request %s' % (self.order.id, self.method) return "order %s api request %s" % (self.order.id, self.method)

View file

@ -31,7 +31,7 @@
{% if product.left_in_stock > 0 %} {% if product.left_in_stock > 0 %}
<bold>{{ product.left_in_stock }}</bold> available<br /> <bold>{{ product.left_in_stock }}</bold> available<br />
{% else %} {% else %}
<bold>Sold out.</bold> <bold>Sold out.</bold><br />
{% endif %} {% endif %}
</h3> </h3>
@ -40,7 +40,9 @@
{% if product.is_stock_available %} {% if product.is_stock_available %}
<h3>Add to order</h3> <h3>{% if already_in_order %}Update order{% else %}Add to order{% endif %}</h3>
{% if already_in_order %}<p>You already have this product in your order.</p>{% endif %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -49,7 +51,9 @@
<form method="POST"> <form method="POST">
{% csrf_token %} {% csrf_token %}
{% bootstrap_form form %} {% bootstrap_form form %}
{% bootstrap_button "Add to order" button_type="submit" button_class="btn-primary" %} <button type="submit" class="btn btn-primary">
{% if already_in_order %}Update{% else %}Add{% endif %}
</button>
</form> </form>
{% else %} {% else %}

View file

@ -55,11 +55,22 @@ Shop | {{ block.super }}
<a href="{% url 'shop:product_detail' slug=product.slug %}"> <a href="{% url 'shop:product_detail' slug=product.slug %}">
{{ product.name }} {{ product.name }}
</a> </a>
{% if product.stock_amount and product.left_in_stock <= 10 %}
{% if product.stock_amount %}
{% if product.left_in_stock == 0 %}
<div class="label label-danger"> <div class="label label-danger">
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> </div>
{% endif %} {% endif %}
{% endif %}
</td> </td>
<td> <td>
<div class="pull-right"> <div class="pull-right">

View file

@ -3,10 +3,10 @@ from decimal import Decimal
register = template.Library() register = template.Library()
@register.filter @register.filter
def currency(value): def currency(value):
try: try:
return "{0:.2f} DKK".format(Decimal(value)) return "{0:.2f} DKK".format(Decimal(value))
except ValueError: except ValueError:
return False return False

View file

@ -1,12 +1,11 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from psycopg2.extras import DateTimeTZRange from psycopg2.extras import DateTimeTZRange
from .factories import ( from shop.forms import OrderProductRelationForm
ProductFactory, from utils.factories import UserFactory
OrderProductRelationFactory, from .factories import ProductFactory, OrderProductRelationFactory, OrderFactory
)
class ProductAvailabilityTest(TestCase): class ProductAvailabilityTest(TestCase):
@ -22,16 +21,21 @@ class ProductAvailabilityTest(TestCase):
""" If max orders have been made, the product is NOT available. """ """ If max orders have been made, the product is NOT available. """
product = ProductFactory(stock_amount=2) product = ProductFactory(stock_amount=2)
for i in range(2): OrderProductRelationFactory(product=product, order__open=None)
opr = OrderProductRelationFactory(product=product) opr = OrderProductRelationFactory(product=product, order__open=None)
order = opr.order
order.paid = True
order.save()
self.assertEqual(product.left_in_stock, 0) self.assertEqual(product.left_in_stock, 0)
self.assertFalse(product.is_stock_available) self.assertFalse(product.is_stock_available)
self.assertFalse(product.is_available()) self.assertFalse(product.is_available())
# Cancel one order
opr.order.cancelled = True
opr.order.save()
self.assertEqual(product.left_in_stock, 1)
self.assertTrue(product.is_stock_available)
self.assertTrue(product.is_available())
def test_product_available_by_time(self): def test_product_available_by_time(self):
""" The product is available if now is in the right timeframe. """ """ The product is available if now is in the right timeframe. """
product = ProductFactory() product = ProductFactory()
@ -43,7 +47,7 @@ class ProductAvailabilityTest(TestCase):
""" The product is not available if now is outside the timeframe. """ """ The product is not available if now is outside the timeframe. """
available_in = DateTimeTZRange( available_in = DateTimeTZRange(
lower=timezone.now() - timezone.timedelta(5), lower=timezone.now() - timezone.timedelta(5),
upper=timezone.now() - timezone.timedelta(1) upper=timezone.now() - timezone.timedelta(1),
) )
product = ProductFactory(available_in=available_in) product = ProductFactory(available_in=available_in)
# The factory defines the timeframe as now and 31 days forward. # The factory defines the timeframe as now and 31 days forward.
@ -52,9 +56,7 @@ class ProductAvailabilityTest(TestCase):
def test_product_is_not_available_yet(self): def test_product_is_not_available_yet(self):
""" The product is not available because we are before lower bound. """ """ The product is not available because we are before lower bound. """
available_in = DateTimeTZRange( available_in = DateTimeTZRange(lower=timezone.now() + timezone.timedelta(5))
lower=timezone.now() + timezone.timedelta(5)
)
product = ProductFactory(available_in=available_in) product = ProductFactory(available_in=available_in)
# Make sure there is no upper - just in case. # Make sure there is no upper - just in case.
self.assertEqual(product.available_in.upper, None) self.assertEqual(product.available_in.upper, None)
@ -64,12 +66,288 @@ class ProductAvailabilityTest(TestCase):
def test_product_is_available_from_now_on(self): def test_product_is_available_from_now_on(self):
""" The product is available because we are after lower bound. """ """ The product is available because we are after lower bound. """
available_in = DateTimeTZRange( available_in = DateTimeTZRange(lower=timezone.now() - timezone.timedelta(1))
lower=timezone.now() - timezone.timedelta(1)
)
product = ProductFactory(available_in=available_in) product = ProductFactory(available_in=available_in)
# Make sure there is no upper - just in case. # Make sure there is no upper - just in case.
self.assertEqual(product.available_in.upper, None) self.assertEqual(product.available_in.upper, None)
# The factory defines the timeframe as now and 31 days forward. # The factory defines the timeframe as now and 31 days forward.
self.assertTrue(product.is_time_available) self.assertTrue(product.is_time_available)
self.assertTrue(product.is_available()) self.assertTrue(product.is_available())
class TestOrderProductRelationForm(TestCase):
def test_clean_quantity_succeeds_when_stock_not_exceeded(self):
product = ProductFactory(stock_amount=2)
# Mark an order as paid/reserved by setting open to None
OrderProductRelationFactory(product=product, quantity=1, order__open=None)
opr = OrderProductRelationFactory(product=product)
form = OrderProductRelationForm({"quantity": 1}, instance=opr)
self.assertTrue(form.is_valid())
def test_clean_quantity_fails_when_stock_exceeded(self):
product = ProductFactory(stock_amount=2)
# Mark an order as paid/reserved by setting open to None
OrderProductRelationFactory(product=product, quantity=1, order__open=None)
# There should only be 1 product left, since we just reserved 1
opr2 = OrderProductRelationFactory(product=product)
form = OrderProductRelationForm({"quantity": 2}, instance=opr2)
self.assertFalse(form.is_valid())
def test_clean_quantity_when_no_stock_amount(self):
product = ProductFactory()
opr = OrderProductRelationFactory(product=product)
form = OrderProductRelationForm({"quantity": 3}, instance=opr)
self.assertTrue(form.is_valid())
class TestProductDetailView(TestCase):
def setUp(self):
self.user = UserFactory()
self.product = ProductFactory()
self.path = reverse("shop:product_detail", kwargs={"slug": self.product.slug})
def test_product_is_available_for_anonymous_user(self):
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
def test_product_is_available_for_logged_in_user(self):
self.client.force_login(self.user)
response = self.client.get(self.path)
self.assertContains(response, "Add to order")
self.assertEqual(response.status_code, 200)
def test_product_is_available_with_stock_left(self):
self.product.stock_amount = 2
self.product.save()
OrderProductRelationFactory(product=self.product, quantity=1, order__open=None)
self.client.force_login(self.user)
response = self.client.get(self.path)
self.assertContains(response, "<bold>1</bold> available")
self.assertEqual(response.status_code, 200)
def test_product_is_sold_out(self):
self.product.stock_amount = 1
self.product.save()
OrderProductRelationFactory(product=self.product, quantity=1, order__open=None)
self.client.force_login(self.user)
response = self.client.get(self.path)
self.assertContains(response, "Sold out.")
self.assertEqual(response.status_code, 200)
def test_adding_product_to_new_order(self):
self.client.force_login(self.user)
response = self.client.post(self.path, data={"quantity": 1})
order = self.user.orders.get()
self.assertRedirects(
response, reverse("shop:order_detail", kwargs={"pk": order.pk})
)
def test_product_is_in_order(self):
# Put the product in an order owned by the user
OrderProductRelationFactory(
product=self.product, quantity=1, order__open=True, order__user=self.user
)
self.client.force_login(self.user)
response = self.client.get(self.path)
self.assertContains(response, "Update order")
def test_product_is_in_order_update(self):
self.product.stock_amount = 2
self.product.save()
# Put the product in an order owned by the user
opr = OrderProductRelationFactory(
product=self.product, quantity=1, order__open=True, order__user=self.user
)
self.client.force_login(self.user)
response = self.client.post(self.path, data={"quantity": 2})
self.assertRedirects(
response, reverse("shop:order_detail", kwargs={"pk": opr.order.pk})
)
opr.refresh_from_db()
self.assertEquals(opr.quantity, 2)
def test_product_category_not_public(self):
self.product.category.public = False
self.product.category.save()
response = self.client.get(self.path)
self.assertEquals(response.status_code, 404)
class TestOrderDetailView(TestCase):
def setUp(self):
self.user = UserFactory()
self.order = OrderFactory(user=self.user)
self.path = reverse("shop:order_detail", kwargs={"pk": self.order.pk})
# We are using a formset which means we have to include some "management form" data.
self.base_form_data = {
"form-TOTAL_FORMS": "1",
"form-INITIAL_FORMS": "1",
"form-MAX_NUM_FORMS": "",
}
def test_redirects_when_no_products(self):
self.client.force_login(self.user)
response = self.client.get(self.path)
self.assertEquals(response.status_code, 302)
self.assertRedirects(response, reverse("shop:index"))
def test_redirects_when_cancelled(self):
self.client.force_login(self.user)
OrderProductRelationFactory(order=self.order)
self.order.cancelled = True
self.order.save()
response = self.client.get(self.path)
self.assertEquals(response.status_code, 302)
self.assertRedirects(response, reverse("shop:index"))
def test_remove_product(self):
self.client.force_login(self.user)
OrderProductRelationFactory(order=self.order)
opr = OrderProductRelationFactory(order=self.order)
order = opr.order
data = self.base_form_data
data["remove_product"] = opr.pk
response = self.client.post(self.path, data=data)
self.assertEquals(response.status_code, 200)
order.refresh_from_db()
self.assertEquals(order.products.count(), 1)
def test_remove_last_product_cancels_order(self):
self.client.force_login(self.user)
opr = OrderProductRelationFactory(order=self.order)
order = opr.order
data = self.base_form_data
data["remove_product"] = opr.pk
response = self.client.post(self.path, data=data)
self.assertEquals(response.status_code, 302)
self.assertRedirects(response, reverse("shop:index"))
order.refresh_from_db()
self.assertTrue(order.cancelled)
def test_cancel_order(self):
self.client.force_login(self.user)
opr = OrderProductRelationFactory(order=self.order)
order = opr.order
data = self.base_form_data
data["cancel_order"] = ""
response = self.client.post(self.path, data=data)
self.assertEquals(response.status_code, 302)
self.assertRedirects(response, reverse("shop:index"))
order.refresh_from_db()
self.assertTrue(order.cancelled)
def test_incrementing_product_quantity(self):
self.client.force_login(self.user)
opr = OrderProductRelationFactory(order=self.order)
opr.product.stock_amount = 100
opr.product.save()
data = self.base_form_data
data["update_order"] = ""
data["form-0-id"] = opr.pk
data["form-0-quantity"] = 11
response = self.client.post(self.path, data=data)
opr.refresh_from_db()
self.assertEquals(response.status_code, 200)
self.assertEquals(opr.quantity, 11)
def test_incrementing_product_quantity_beyond_stock_fails(self):
self.client.force_login(self.user)
opr = OrderProductRelationFactory(order=self.order)
opr.product.stock_amount = 10
opr.product.save()
data = self.base_form_data
data["update_order"] = ""
data["form-0-id"] = opr.pk
data["form-0-quantity"] = 11
response = self.client.post(self.path, data=data)
self.assertEquals(response.status_code, 200)
self.assertIn("quantity", response.context["order_product_formset"].errors[0])
def test_terms_have_to_be_accepted(self):
self.client.force_login(self.user)
opr = OrderProductRelationFactory(order=self.order)
data = self.base_form_data
data["form-0-id"] = opr.pk
data["form-0-quantity"] = 11
data["payment_method"] = "bank_transfer"
response = self.client.post(self.path, data=data)
self.assertEquals(response.status_code, 200)
def test_accepted_terms_and_chosen_payment_method(self):
self.client.force_login(self.user)
opr = OrderProductRelationFactory(order=self.order)
data = self.base_form_data
data["form-0-id"] = opr.pk
data["form-0-quantity"] = 11
data["payment_method"] = "bank_transfer"
data["accept_terms"] = True
response = self.client.post(self.path, data=data)
self.assertEquals(response.status_code, 302)
self.assertRedirects(
response, reverse("shop:bank_transfer", kwargs={"pk": self.order.id})
)
class TestOrderListView(TestCase):
def test_order_list_view_as_logged_in(self):
user = UserFactory()
self.client.force_login(user)
path = reverse("shop:order_list")
response = self.client.get(path)
self.assertEquals(response.status_code, 200)

View file

@ -1,26 +1,24 @@
import logging
from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse, reverse_lazy
from django.db.models import Count, F from django.db.models import Count, F
from django.http import ( from django.http import (
HttpResponse, HttpResponse,
HttpResponseRedirect, HttpResponseRedirect,
HttpResponseBadRequest, HttpResponseBadRequest,
Http404 Http404,
) )
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.views.generic import ( from django.urls import reverse, reverse_lazy
View, from django.utils import timezone
ListView,
DetailView,
FormView,
)
from django.views.generic.base import RedirectView
from django.views.generic.detail import SingleObjectMixin
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone from django.views.generic import View, ListView, DetailView, FormView
from django.views.generic.base import RedirectView
from django.views.generic.detail import SingleObjectMixin
from shop.models import ( from shop.models import (
Order, Order,
@ -31,16 +29,15 @@ from shop.models import (
EpayPayment, EpayPayment,
CreditNote, CreditNote,
) )
from .forms import AddToOrderForm
from .epay import calculate_epay_hash, validate_epay_callback
from collections import OrderedDict
from vendor.coinify.coinify_callback import CoinifyCallback from vendor.coinify.coinify_callback import CoinifyCallback
from .coinify import ( from .coinify import (
create_coinify_invoice, create_coinify_invoice,
save_coinify_callback, save_coinify_callback,
process_coinify_invoice_json process_coinify_invoice_json,
) )
import logging from .epay import calculate_epay_hash, validate_epay_callback
from .forms import OrderProductRelationFormSet, OrderProductRelationForm
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
@ -51,7 +48,7 @@ class EnsureCreditNoteHasPDFMixin(SingleObjectMixin):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not self.get_object().pdf: if not self.get_object().pdf:
messages.error(request, "This creditnote has no PDF yet!") messages.error(request, "This creditnote has no PDF yet!")
return HttpResponseRedirect(reverse_lazy('shop:creditnote_list')) return HttpResponseRedirect(reverse_lazy("shop:creditnote_list"))
return super(EnsureCreditNoteHasPDFMixin, self).dispatch( return super(EnsureCreditNoteHasPDFMixin, self).dispatch(
request, *args, **kwargs request, *args, **kwargs
@ -81,9 +78,7 @@ class EnsureUserOwnsOrderMixin(SingleObjectMixin):
if self.get_object().user != request.user: if self.get_object().user != request.user:
raise Http404("Order not found") raise Http404("Order not found")
return super(EnsureUserOwnsOrderMixin, self).dispatch( return super(EnsureUserOwnsOrderMixin, self).dispatch(request, *args, **kwargs)
request, *args, **kwargs
)
class EnsureUnpaidOrderMixin(SingleObjectMixin): class EnsureUnpaidOrderMixin(SingleObjectMixin):
@ -93,12 +88,10 @@ class EnsureUnpaidOrderMixin(SingleObjectMixin):
if self.get_object().paid: if self.get_object().paid:
messages.error(request, "This order is already paid for!") messages.error(request, "This order is already paid for!")
return HttpResponseRedirect( return HttpResponseRedirect(
reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk})
) )
return super(EnsureUnpaidOrderMixin, self).dispatch( return super(EnsureUnpaidOrderMixin, self).dispatch(request, *args, **kwargs)
request, *args, **kwargs
)
class EnsurePaidOrderMixin(SingleObjectMixin): class EnsurePaidOrderMixin(SingleObjectMixin):
@ -108,12 +101,10 @@ class EnsurePaidOrderMixin(SingleObjectMixin):
if not self.get_object().paid: if not self.get_object().paid:
messages.error(request, "This order is not paid for!") messages.error(request, "This order is not paid for!")
return HttpResponseRedirect( return HttpResponseRedirect(
reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk})
) )
return super(EnsurePaidOrderMixin, self).dispatch( return super(EnsurePaidOrderMixin, self).dispatch(request, *args, **kwargs)
request, *args, **kwargs
)
class EnsureClosedOrderMixin(SingleObjectMixin): class EnsureClosedOrderMixin(SingleObjectMixin):
@ -121,14 +112,12 @@ class EnsureClosedOrderMixin(SingleObjectMixin):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if self.get_object().open is not None: if self.get_object().open is not None:
messages.error(request, 'This order is still open!') messages.error(request, "This order is still open!")
return HttpResponseRedirect( return HttpResponseRedirect(
reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk})
) )
return super(EnsureClosedOrderMixin, self).dispatch( return super(EnsureClosedOrderMixin, self).dispatch(request, *args, **kwargs)
request, *args, **kwargs
)
class EnsureOrderHasProductsMixin(SingleObjectMixin): class EnsureOrderHasProductsMixin(SingleObjectMixin):
@ -136,8 +125,8 @@ class EnsureOrderHasProductsMixin(SingleObjectMixin):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not self.get_object().products.count() > 0: if not self.get_object().products.count() > 0:
messages.error(request, 'This order has no products!') messages.error(request, "This order has no products!")
return HttpResponseRedirect(reverse_lazy('shop:index')) return HttpResponseRedirect(reverse_lazy("shop:index"))
return super(EnsureOrderHasProductsMixin, self).dispatch( return super(EnsureOrderHasProductsMixin, self).dispatch(
request, *args, **kwargs request, *args, **kwargs
@ -150,10 +139,9 @@ class EnsureOrderIsNotCancelledMixin(SingleObjectMixin):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if self.get_object().cancelled: if self.get_object().cancelled:
messages.error( messages.error(
request, request, "Order #{} is cancelled!".format(self.get_object().id)
'Order #{} is cancelled!'.format(self.get_object().id)
) )
return HttpResponseRedirect(reverse_lazy('shop:index')) return HttpResponseRedirect(reverse_lazy("shop:index"))
return super(EnsureOrderIsNotCancelledMixin, self).dispatch( return super(EnsureOrderIsNotCancelledMixin, self).dispatch(
request, *args, **kwargs request, *args, **kwargs
@ -167,7 +155,7 @@ class EnsureOrderHasInvoicePDFMixin(SingleObjectMixin):
if not self.get_object().invoice.pdf: if not self.get_object().invoice.pdf:
messages.error(request, "This order has no invoice yet!") messages.error(request, "This order has no invoice yet!")
return HttpResponseRedirect( return HttpResponseRedirect(
reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) reverse_lazy("shop:order_detail", kwargs={"pk": self.get_object().pk})
) )
return super(EnsureOrderHasInvoicePDFMixin, self).dispatch( return super(EnsureOrderHasInvoicePDFMixin, self).dispatch(
@ -179,17 +167,17 @@ class EnsureOrderHasInvoicePDFMixin(SingleObjectMixin):
class ShopIndexView(ListView): class ShopIndexView(ListView):
model = Product model = Product
template_name = "shop_index.html" template_name = "shop_index.html"
context_object_name = 'products' context_object_name = "products"
def get_queryset(self): def get_queryset(self):
queryset = super(ShopIndexView, self).get_queryset() queryset = super(ShopIndexView, self).get_queryset()
return queryset.available().order_by('category__name', 'price', 'name') return queryset.available().order_by("category__name", "price", "name")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(ShopIndexView, self).get_context_data(**kwargs) context = super(ShopIndexView, self).get_context_data(**kwargs)
if 'category' in self.request.GET: if "category" in self.request.GET:
category = self.request.GET.get('category') category = self.request.GET.get("category")
# is this a public category # is this a public category
try: try:
@ -200,71 +188,76 @@ class ShopIndexView(ListView):
raise Http404("Category not found") raise Http404("Category not found")
# filter products by the chosen category # filter products by the chosen category
context['products'] = context['products'].filter( context["products"] = context["products"].filter(category__slug=category)
category__slug=category context["current_category"] = categoryobj
) context["categories"] = ProductCategory.objects.annotate(
context['current_category'] = categoryobj num_products=Count("products")
context['categories'] = ProductCategory.objects.annotate(
num_products=Count('products')
).filter( ).filter(
num_products__gt=0, num_products__gt=0,
public=True, public=True,
products__available_in__contains=timezone.now() products__available_in__contains=timezone.now(),
) )
return context return context
class ProductDetailView(FormView, DetailView): class ProductDetailView(FormView, DetailView):
model = Product model = Product
template_name = 'product_detail.html' template_name = "product_detail.html"
form_class = AddToOrderForm form_class = OrderProductRelationForm
context_object_name = 'product' context_object_name = "product"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
if hasattr(self, "opr"):
kwargs["instance"] = self.opr
return kwargs
def get_initial(self):
if hasattr(self, "opr"):
return {"quantity": self.opr.quantity}
return None
def get_context_data(self, **kwargs):
# If the OrderProductRelation already exists it has a primary key in the database
if self.request.user.is_authenticated and self.opr.pk:
kwargs["already_in_order"] = True
return super().get_context_data(**kwargs)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not self.get_object().category.public: self.object = self.get_object()
if not self.object.category.public:
# this product is not publicly available # this product is not publicly available
raise Http404("Product not found") raise Http404("Product not found")
return super(ProductDetailView, self).dispatch( if self.request.user.is_authenticated:
request, *args, **kwargs try:
) self.opr = OrderProductRelation.objects.get(
order__user=self.request.user,
order__open__isnull=False,
product=self.object,
)
except OrderProductRelation.DoesNotExist:
self.opr = OrderProductRelation(product=self.get_object(), quantity=1)
return super(ProductDetailView, self).dispatch(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
product = self.get_object() opr = form.save(commit=False)
quantity = form.cleaned_data.get('quantity')
# do we have an open order? if not opr.pk:
try: opr.order, _ = Order.objects.get_or_create(
order = Order.objects.get( user=self.request.user, open=True, cancelled=False
user=self.request.user,
open__isnull=False
)
except Order.DoesNotExist:
# no open order - open a new one
order = Order.objects.create(
user=self.request.user,
) )
# get product from kwargs opr.save()
if product in order.products.all():
# this product is already added to this order,
# increase count by quantity
OrderProductRelation.objects.filter(
product=product,
order=order
).update(quantity=F('quantity') + quantity)
else:
order.orderproductrelation_set.create(
product=product,
quantity=quantity,
)
messages.info( messages.info(
self.request, self.request,
'{}x {} has been added to your order.'.format( "{}x {} has been added to your order.".format(
quantity, opr.quantity, opr.product.name
product.name ),
)
) )
# done # done
@ -279,90 +272,131 @@ class ProductDetailView(FormView, DetailView):
class OrderListView(LoginRequiredMixin, ListView): class OrderListView(LoginRequiredMixin, ListView):
model = Order model = Order
template_name = "shop/order_list.html" template_name = "shop/order_list.html"
context_object_name = 'orders' context_object_name = "orders"
def get_queryset(self): def get_queryset(self):
queryset = super(OrderListView, self).get_queryset() queryset = super(OrderListView, self).get_queryset()
return queryset.filter(user=self.request.user).not_cancelled() return queryset.filter(user=self.request.user).not_cancelled()
class OrderDetailView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureOrderHasProductsMixin, EnsureOrderIsNotCancelledMixin, DetailView): class OrderDetailView(
LoginRequiredMixin,
EnsureUserOwnsOrderMixin,
EnsureOrderHasProductsMixin,
EnsureOrderIsNotCancelledMixin,
DetailView,
):
model = Order model = Order
template_name = 'shop/order_detail.html' template_name = "shop/order_detail.html"
context_object_name = 'order' context_object_name = "order"
def get_context_data(self, **kwargs):
if "order_product_formset" not in kwargs:
kwargs["order_product_formset"] = OrderProductRelationFormSet(
queryset=OrderProductRelation.objects.filter(order=self.get_object())
)
return super().get_context_data(**kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
order = self.get_object() self.object = self.get_object()
payment_method = request.POST.get('payment_method') order = self.object
if payment_method in order.PAYMENT_METHODS: # First check if the user is removing a product from the order.
if not request.POST.get('accept_terms'): product_remove = request.POST.get("remove_product")
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')
if product_remove: if product_remove:
order.orderproductrelation_set.filter(pk=product_remove).delete() order.orderproductrelation_set.filter(pk=product_remove).delete()
if not order.products.count() > 0: if not order.products.count() > 0:
order.mark_as_cancelled() order.mark_as_cancelled()
messages.info(request, 'Order cancelled!') messages.info(request, "Order cancelled!")
return HttpResponseRedirect(reverse_lazy('shop:index')) 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() order.mark_as_cancelled()
messages.info(request, 'Order cancelled!') messages.info(request, "Order cancelled!")
return HttpResponseRedirect(reverse_lazy('shop:index')) 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) return super(OrderDetailView, self).get(request, *args, **kwargs)
class DownloadInvoiceView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsurePaidOrderMixin, EnsureOrderHasInvoicePDFMixin, SingleObjectMixin, View): class DownloadInvoiceView(
LoginRequiredMixin,
EnsureUserOwnsOrderMixin,
EnsurePaidOrderMixin,
EnsureOrderHasInvoicePDFMixin,
SingleObjectMixin,
View,
):
model = Order model = Order
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
response = HttpResponse(content_type='application/pdf') response = HttpResponse(content_type="application/pdf")
response['Content-Disposition'] = 'attachment; filename="%s"' % self.get_object().invoice.filename response["Content-Disposition"] = (
'attachment; filename="%s"' % self.get_object().invoice.filename
)
response.write(self.get_object().invoice.pdf.read()) response.write(self.get_object().invoice.pdf.read())
return response return response
@ -370,19 +404,27 @@ class DownloadInvoiceView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsurePa
class CreditNoteListView(LoginRequiredMixin, ListView): class CreditNoteListView(LoginRequiredMixin, ListView):
model = CreditNote model = CreditNote
template_name = "shop/creditnote_list.html" template_name = "shop/creditnote_list.html"
context_object_name = 'creditnotes' context_object_name = "creditnotes"
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
return queryset.filter(user=self.request.user) return queryset.filter(user=self.request.user)
class DownloadCreditNoteView(LoginRequiredMixin, EnsureUserOwnsCreditNoteMixin, EnsureCreditNoteHasPDFMixin, SingleObjectMixin, View): class DownloadCreditNoteView(
LoginRequiredMixin,
EnsureUserOwnsCreditNoteMixin,
EnsureCreditNoteHasPDFMixin,
SingleObjectMixin,
View,
):
model = CreditNote model = CreditNote
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
response = HttpResponse(content_type='application/pdf') response = HttpResponse(content_type="application/pdf")
response['Content-Disposition'] = 'attachment; filename="%s"' % self.get_object().filename response["Content-Disposition"] = (
'attachment; filename="%s"' % self.get_object().filename
)
response.write(self.get_object().pdf.read()) response.write(self.get_object().pdf.read())
return response return response
@ -393,31 +435,38 @@ class OrderMarkAsPaidView(LoginRequiredMixin, SingleObjectMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if not request.user.is_staff: if not request.user.is_staff:
messages.error(request, 'You do not have permissions to do that.') messages.error(request, "You do not have permissions to do that.")
return HttpResponseRedirect(reverse_lazy('shop:index')) return HttpResponseRedirect(reverse_lazy("shop:index"))
else: else:
messages.success(request, 'The order has been marked as paid.') messages.success(request, "The order has been marked as paid.")
order = self.get_object() order = self.get_object()
order.mark_as_paid() order.mark_as_paid()
return HttpResponseRedirect(request.META.get('HTTP_REFERER')) return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
# Epay views # Epay views
class EpayFormView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureClosedOrderMixin, EnsureOrderHasProductsMixin, DetailView): class EpayFormView(
LoginRequiredMixin,
EnsureUserOwnsOrderMixin,
EnsureUnpaidOrderMixin,
EnsureClosedOrderMixin,
EnsureOrderHasProductsMixin,
DetailView,
):
model = Order model = Order
template_name = 'epay_form.html' template_name = "epay_form.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
order = self.get_object() order = self.get_object()
context = super(EpayFormView, self).get_context_data(**kwargs) context = super(EpayFormView, self).get_context_data(**kwargs)
context['merchant_number'] = settings.EPAY_MERCHANT_NUMBER context["merchant_number"] = settings.EPAY_MERCHANT_NUMBER
context['description'] = order.description context["description"] = order.description
context['amount'] = order.total * 100 context["amount"] = order.total * 100
context['order_id'] = order.pk context["order_id"] = order.pk
context['accept_url'] = order.get_epay_accept_url(self.request) context["accept_url"] = order.get_epay_accept_url(self.request)
context['cancel_url'] = order.get_cancel_url(self.request) context["cancel_url"] = order.get_cancel_url(self.request)
context['callback_url'] = order.get_epay_callback_url(self.request) context["callback_url"] = order.get_epay_callback_url(self.request)
context['epay_hash'] = calculate_epay_hash(order, self.request) context["epay_hash"] = calculate_epay_hash(order, self.request)
return context return context
@ -425,21 +474,19 @@ class EpayCallbackView(SingleObjectMixin, View):
model = Order model = Order
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
callback = EpayCallback.objects.create( callback = EpayCallback.objects.create(payload=request.GET)
payload=request.GET
)
if 'orderid' in request.GET: if "orderid" in request.GET:
query = OrderedDict( query = OrderedDict(
[tuple(x.split('=')) for x in request.META['QUERY_STRING'].split('&')] [tuple(x.split("=")) for x in request.META["QUERY_STRING"].split("&")]
) )
order = get_object_or_404(Order, pk=query.get('orderid')) order = get_object_or_404(Order, pk=query.get("orderid"))
if order.pk != self.get_object().pk: if order.pk != self.get_object().pk:
logger.error("bad epay callback, orders do not match!") logger.error("bad epay callback, orders do not match!")
return HttpResponse(status=400) return HttpResponse(status=400)
if validate_epay_callback(query): if validate_epay_callback(query):
callback.md5valid=True callback.md5valid = True
callback.save() callback.save()
else: else:
logger.error("bad epay callback!") logger.error("bad epay callback!")
@ -447,15 +494,13 @@ class EpayCallbackView(SingleObjectMixin, View):
if order.paid: if order.paid:
# this order is already paid, perhaps we are seeing a double callback? # this order is already paid, perhaps we are seeing a double callback?
return HttpResponse('OK') return HttpResponse("OK")
# epay callback is valid - has the order been paid in full? # epay callback is valid - has the order been paid in full?
if int(query['amount']) == order.total * 100: if int(query["amount"]) == order.total * 100:
# create an EpayPayment object linking the callback to the order # create an EpayPayment object linking the callback to the order
EpayPayment.objects.create( EpayPayment.objects.create(
order=order, order=order, callback=callback, txnid=query.get("txnid")
callback=callback,
txnid=query.get('txnid'),
) )
# and mark order as paid (this will create tickets) # and mark order as paid (this will create tickets)
order.mark_as_paid(request) order.mark_as_paid(request)
@ -464,53 +509,76 @@ class EpayCallbackView(SingleObjectMixin, View):
else: else:
return HttpResponse(status=400) return HttpResponse(status=400)
return HttpResponse('OK') return HttpResponse("OK")
class EpayThanksView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView): class EpayThanksView(
LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView
):
model = Order model = Order
template_name = 'epay_thanks.html' template_name = "epay_thanks.html"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if request.GET: if request.GET:
# epay redirects the user back to our accepturl with a long # epay redirects the user back to our accepturl with a long
# and ugly querystring, redirect user to the clean url # and ugly querystring, redirect user to the clean url
return HttpResponseRedirect( return HttpResponseRedirect(
reverse('shop:epay_thanks', kwargs={'pk': self.get_object().pk}) reverse("shop:epay_thanks", kwargs={"pk": self.get_object().pk})
) )
return super(EpayThanksView, self).dispatch( return super(EpayThanksView, self).dispatch(request, *args, **kwargs)
request, *args, **kwargs
)
# Bank Transfer view # Bank Transfer view
class BankTransferView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureOrderHasProductsMixin, DetailView):
class BankTransferView(
LoginRequiredMixin,
EnsureUserOwnsOrderMixin,
EnsureUnpaidOrderMixin,
EnsureOrderHasProductsMixin,
DetailView,
):
model = Order model = Order
template_name = 'bank_transfer.html' template_name = "bank_transfer.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(BankTransferView, self).get_context_data(**kwargs) context = super(BankTransferView, self).get_context_data(**kwargs)
context['iban'] = settings.BANKACCOUNT_IBAN context["iban"] = settings.BANKACCOUNT_IBAN
context['swiftbic'] = settings.BANKACCOUNT_SWIFTBIC context["swiftbic"] = settings.BANKACCOUNT_SWIFTBIC
context['orderid'] = self.get_object().pk context["orderid"] = self.get_object().pk
context['regno'] = settings.BANKACCOUNT_REG context["regno"] = settings.BANKACCOUNT_REG
context['accountno'] = settings.BANKACCOUNT_ACCOUNT context["accountno"] = settings.BANKACCOUNT_ACCOUNT
context['total'] = self.get_object().total context["total"] = self.get_object().total
return context return context
# Cash payment view # Cash payment view
class CashView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureOrderHasProductsMixin, DetailView):
class CashView(
LoginRequiredMixin,
EnsureUserOwnsOrderMixin,
EnsureUnpaidOrderMixin,
EnsureOrderHasProductsMixin,
DetailView,
):
model = Order model = Order
template_name = 'cash.html' template_name = "cash.html"
# Coinify views # Coinify views
class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUnpaidOrderMixin, EnsureClosedOrderMixin, EnsureOrderHasProductsMixin, SingleObjectMixin, RedirectView):
class CoinifyRedirectView(
LoginRequiredMixin,
EnsureUserOwnsOrderMixin,
EnsureUnpaidOrderMixin,
EnsureClosedOrderMixin,
EnsureOrderHasProductsMixin,
SingleObjectMixin,
RedirectView,
):
model = Order model = Order
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@ -520,17 +588,20 @@ class CoinifyRedirectView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureUn
if not order.coinifyapiinvoice: if not order.coinifyapiinvoice:
coinifyinvoice = create_coinify_invoice(order, request) coinifyinvoice = create_coinify_invoice(order, request)
if not coinifyinvoice: if not coinifyinvoice:
messages.error(request, "There was a problem with the payment provider. Please try again later") messages.error(
request,
"There was a problem with the payment provider. Please try again later",
)
return HttpResponseRedirect( return HttpResponseRedirect(
reverse_lazy('shop:order_detail', kwargs={'pk': self.get_object().pk}) reverse_lazy(
"shop:order_detail", kwargs={"pk": self.get_object().pk}
)
) )
return super(CoinifyRedirectView, self).dispatch( return super(CoinifyRedirectView, self).dispatch(request, *args, **kwargs)
request, *args, **kwargs
)
def get_redirect_url(self, *args, **kwargs): def get_redirect_url(self, *args, **kwargs):
return self.get_object().coinifyapiinvoice.invoicejson['payment_url'] return self.get_object().coinifyapiinvoice.invoicejson["payment_url"]
class CoinifyCallbackView(SingleObjectMixin, View): class CoinifyCallbackView(SingleObjectMixin, View):
@ -547,34 +618,45 @@ class CoinifyCallbackView(SingleObjectMixin, View):
# do we have a json body? # do we have a json body?
if not callbackobject.payload: if not callbackobject.payload:
# no, return an error # no, return an error
logger.error("unable to parse JSON body in callback for order %s" % callbackobject.order.id) logger.error(
return HttpResponseBadRequest('unable to parse json') "unable to parse JSON body in callback for order %s"
% callbackobject.order.id
)
return HttpResponseBadRequest("unable to parse json")
# initiate SDK # initiate SDK
sdk = CoinifyCallback(settings.COINIFY_IPN_SECRET.encode('utf-8')) sdk = CoinifyCallback(settings.COINIFY_IPN_SECRET.encode("utf-8"))
# attemt to validate the callbackc # attemt to validate the callbackc
if sdk.validate_callback(request.body, request.META['HTTP_X_COINIFY_CALLBACK_SIGNATURE']): if sdk.validate_callback(
request.body, request.META["HTTP_X_COINIFY_CALLBACK_SIGNATURE"]
):
# mark callback as valid in db # mark callback as valid in db
callbackobject.valid = True callbackobject.valid = True
callbackobject.save() callbackobject.save()
else: else:
logger.error("invalid coinify callback detected") logger.error("invalid coinify callback detected")
return HttpResponseBadRequest('something is fucky') return HttpResponseBadRequest("something is fucky")
if callbackobject.payload['event'] == 'invoice_state_change' or callbackobject.payload['event'] == 'invoice_manual_resend': if (
callbackobject.payload["event"] == "invoice_state_change"
or callbackobject.payload["event"] == "invoice_manual_resend"
):
process_coinify_invoice_json( process_coinify_invoice_json(
invoicejson=callbackobject.payload['data'], invoicejson=callbackobject.payload["data"],
order=self.get_object(), order=self.get_object(),
request=request, request=request,
) )
return HttpResponse('OK') return HttpResponse("OK")
else: else:
logger.error("unsupported callback event %s" % callbackobject.payload['event']) logger.error(
return HttpResponseBadRequest('unsupported event') "unsupported callback event %s" % callbackobject.payload["event"]
)
return HttpResponseBadRequest("unsupported event")
class CoinifyThanksView(LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView): class CoinifyThanksView(
LoginRequiredMixin, EnsureUserOwnsOrderMixin, EnsureClosedOrderMixin, DetailView
):
model = Order model = Order
template_name = 'coinify_thanks.html' template_name = "coinify_thanks.html"

View file

@ -5,6 +5,7 @@ from factory.django import DjangoModelFactory
class UserFactory(DjangoModelFactory): class UserFactory(DjangoModelFactory):
class Meta: class Meta:
model = 'auth.User' model = 'auth.User'
django_get_or_create = ('username',)
username = factory.Faker('word') username = factory.Faker('word')
email = factory.Faker('ascii_email') email = factory.Faker('ascii_email')