diff --git a/src/profiles/templates/shop/order_detail.html b/src/profiles/templates/shop/order_detail.html index c659be04..ce505954 100644 --- a/src/profiles/templates/shop/order_detail.html +++ b/src/profiles/templates/shop/order_detail.html @@ -12,6 +12,7 @@ {% if not order.paid %}
{% csrf_token %} + {{ order_product_formset.management_form }} {% endif %} @@ -25,28 +26,29 @@ Price - {% for order_product in order.orderproductrelation_set.all %} + {% for form in order_product_formset %} + {{ form.id }}
Total +
- {{ 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 @@ {% csrf_token %} {% bootstrap_form form %} - {% bootstrap_button "Add to order" button_type="submit" button_class="btn-primary" %} + {% 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')