- {{ order_product.product.name }}
- |
- {% if not order.open == None %}
-
- {% bootstrap_button '' 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 %}
+ {{ form.instance.product.left_in_stock }} left in stock
{% endif %}
|
- {{ order_product.product.price|currency }}
+ {% if not order.open == None %}
+ {% bootstrap_field form.quantity show_label=False %}
+ {% else %}
+ {{ form.instance.quantity }}
+ {% endif %}
|
- {{ order_product.total|currency }}
+ {{ form.instance.product.price|currency }}
+ |
+ {{ form.instance.total|currency }}
+ |
+ {% bootstrap_button '' button_type="submit" button_class="btn-danger" name="remove_product" value=form.instance.pk %}
{% endfor %}
diff --git a/src/shop/factories.py b/src/shop/factories.py
index 9b0c5e0c..3c65c43f 100644
--- a/src/shop/factories.py
+++ b/src/shop/factories.py
@@ -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)
diff --git a/src/shop/forms.py b/src/shop/forms.py
index 3d8dcbcd..9df1ce2c 100644
--- a/src/shop/forms.py
+++ b/src/shop/forms.py
@@ -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
+)
diff --git a/src/shop/managers.py b/src/shop/managers.py
index dc01c2e6..db8e3920 100644
--- a/src/shop/managers.py
+++ b/src/shop/managers.py
@@ -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)
diff --git a/src/shop/models.py b/src/shop/models.py
index 6b3cccf5..d35030e6 100644
--- a/src/shop/models.py
+++ b/src/shop/models.py
@@ -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)
diff --git a/src/shop/templates/product_detail.html b/src/shop/templates/product_detail.html
index 87d77ab9..2296fe3a 100644
--- a/src/shop/templates/product_detail.html
+++ b/src/shop/templates/product_detail.html
@@ -31,7 +31,7 @@
{% if product.left_in_stock > 0 %}
{{ product.left_in_stock }} available
{% else %}
- Sold out.
+ Sold out.
{% endif %}
@@ -40,7 +40,9 @@
{% if product.is_stock_available %}
- Add to order
+ {% if already_in_order %}Update order{% else %}Add to order{% endif %}
+
+ {% if already_in_order %}You already have this product in your order. {% endif %}
{% if user.is_authenticated %}
@@ -49,7 +51,9 @@
{% else %}
diff --git a/src/shop/templates/shop_index.html b/src/shop/templates/shop_index.html
index c0915e5d..2ac5a0b6 100644
--- a/src/shop/templates/shop_index.html
+++ b/src/shop/templates/shop_index.html
@@ -55,11 +55,22 @@ Shop | {{ block.super }}
{{ product.name }}
- {% if product.stock_amount and product.left_in_stock <= 10 %}
+
+
+ {% if product.stock_amount %}
+
+ {% if product.left_in_stock == 0 %}
- Only {{ product.left_in_stock }} left!
+ Sold out!
+
+ {% elif product.left_in_stock <= 10 %}
+
+ Only {{ product.left_in_stock }} left!
{% endif %}
+
+ {% endif %}
+
|
diff --git a/src/shop/templatetags/shop_tags.py b/src/shop/templatetags/shop_tags.py
index ea4a083b..aaa957b6 100644
--- a/src/shop/templatetags/shop_tags.py
+++ b/src/shop/templatetags/shop_tags.py
@@ -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
-
diff --git a/src/shop/tests.py b/src/shop/tests.py
index f28ec463..0fc39228 100644
--- a/src/shop/tests.py
+++ b/src/shop/tests.py
@@ -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, "1 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)
diff --git a/src/shop/views.py b/src/shop/views.py
index 7eaa9fba..42d7c721 100644
--- a/src/shop/views.py
+++ b/src/shop/views.py
@@ -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"
diff --git a/src/utils/factories.py b/src/utils/factories.py
index 6cdf524e..aed2c5b5 100644
--- a/src/utils/factories.py
+++ b/src/utils/factories.py
@@ -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')
|