From c51be4d30aff2325bd44dd0033305e1ca849b349 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 3 Mar 2016 18:18:58 -0800 Subject: [PATCH 01/12] Adds set_quantity as a method on CartController. Refactors add_to_cart to be in terms of set_quantity --- registrasion/controllers/cart.py | 94 +++++++++++++++++++++----------- registrasion/tests/test_cart.py | 33 +++++++++++ 2 files changed, 95 insertions(+), 32 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index e9532bf5..acdce035 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -54,40 +54,73 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) - def add_to_cart(self, product, quantity): - ''' Adds _quantity_ of the given _product_ to the cart. Raises - ValidationError if constraints are violated.''' - - prod = ProductController(product) - - # TODO: Check enabling conditions for product for user - - if not prod.can_add_with_enabling_conditions(self.cart.user, quantity): - raise ValidationError("Not enough of that product left (ec)") - - if not prod.user_can_add_within_limit(self.cart.user, quantity): - raise ValidationError("Not enough of that product left (user)") - - try: - # Try to update an existing item within this cart if possible. - product_item = rego.ProductItem.objects.get( - cart=self.cart, - product=product) - product_item.quantity += quantity - except ObjectDoesNotExist: - product_item = rego.ProductItem.objects.create( - cart=self.cart, - product=product, - quantity=quantity, - ) - product_item.save() - + def end_batch(self): + ''' Performs operations that occur occur at the end of a batch of + product changes/voucher applications etc. ''' self.recalculate_discounts() self.extend_reservation() self.cart.revision += 1 self.cart.save() + def set_quantity(self, product, quantity, batched=False): + ''' Sets the _quantity_ of the given _product_ in the cart to the given + _quantity_. ''' + + if quantity < 0: + raise ValidationError("Cannot have fewer than 0 items in cart.") + + try: + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product) + old_quantity = product_item.quantity + + if quantity == 0: + product_item.delete() + return + except ObjectDoesNotExist: + if quantity == 0: + return + + product_item = rego.ProductItem.objects.create( + cart=self.cart, + product=product, + quantity=0, + ) + + old_quantity = 0 + + # Validate the addition to the cart + adjustment = quantity - old_quantity + prod = ProductController(product) + + if not prod.can_add_with_enabling_conditions( + self.cart.user, adjustment): + raise ValidationError("Not enough of that product left (ec)") + + if not prod.user_can_add_within_limit(self.cart.user, adjustment): + raise ValidationError("Not enough of that product left (user)") + + product_item.quantity = quantity + product_item.save() + + if not batched: + self.end_batch() + + def add_to_cart(self, product, quantity): + ''' Adds _quantity_ of the given _product_ to the cart. Raises + ValidationError if constraints are violated.''' + + try: + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product) + old_quantity = product_item.quantity + except ObjectDoesNotExist: + old_quantity = 0 + self.set_quantity(product, old_quantity + quantity) + def apply_voucher(self, voucher): ''' Applies the given voucher to this cart. ''' @@ -101,10 +134,7 @@ class CartController(object): # If successful... self.cart.vouchers.add(voucher) - - self.extend_reservation() - self.cart.revision += 1 - self.cart.save() + self.end_batch() def validate_cart(self): ''' Determines whether the status of the current cart is valid; diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index c0c0bc49..dad32efa 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -3,6 +3,7 @@ import pytz from decimal import Decimal from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.test import TestCase @@ -159,6 +160,38 @@ class BasicCartTests(RegistrationCartTestCase): item = items[0] self.assertEquals(2, item.quantity) + def test_set_quantity(self): + current_cart = CartController.for_user(self.USER_1) + + def get_item(): + return rego.ProductItem.objects.get( + cart=current_cart.cart, + product=self.PROD_1) + + current_cart.set_quantity(self.PROD_1, 1) + self.assertEqual(1, get_item().quantity) + + # Setting the quantity to zero should remove the entry from the cart. + current_cart.set_quantity(self.PROD_1, 0) + with self.assertRaises(ObjectDoesNotExist): + get_item() + + current_cart.set_quantity(self.PROD_1, 9) + self.assertEqual(9, get_item().quantity) + + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_1, 11) + + self.assertEqual(9, get_item().quantity) + + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_1, -1) + + self.assertEqual(9, get_item().quantity) + + current_cart.set_quantity(self.PROD_1, 2) + self.assertEqual(2, get_item().quantity) + def test_add_to_cart_per_user_limit(self): current_cart = CartController.for_user(self.USER_1) From 1b7d8a60c1197cf4648ad54de20ae34267f235c8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 3 Mar 2016 13:40:44 -0800 Subject: [PATCH 02/12] Adds product_category form, which allows users to add products from a specific category to their cart. --- registrasion/forms.py | 13 +++++ registrasion/models.py | 4 +- registrasion/templates/product_category.html | 20 +++++++ registrasion/urls.py | 7 +++ registrasion/views.py | 57 ++++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 registrasion/forms.py create mode 100644 registrasion/templates/product_category.html create mode 100644 registrasion/urls.py create mode 100644 registrasion/views.py diff --git a/registrasion/forms.py b/registrasion/forms.py new file mode 100644 index 00000000..dc32d7b1 --- /dev/null +++ b/registrasion/forms.py @@ -0,0 +1,13 @@ +import models as rego + +from django import forms + + +class ProductItemForm(forms.Form): + product = forms.ModelChoiceField(queryset=None, empty_label=None) + quantity = forms.IntegerField() + + def __init__(self, category, *a, **k): + super(ProductItemForm, self).__init__(*a, **k) + products = rego.Product.objects.filter(category=category) + self.fields['product'].queryset = products diff --git a/registrasion/models.py b/registrasion/models.py index 4866c00b..35b44305 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -155,7 +155,7 @@ class DiscountForProduct(models.Model): if len(cats) != 0: raise ValidationError( _("You may only have one discount for " - "a product or its category")) + "a product or its category")) discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) @@ -184,7 +184,7 @@ class DiscountForCategory(models.Model): if len(prods) != 0: raise ValidationError( _("You may only have one discount for " - "a product or its category")) + "a product or its category")) if len(cats) > 1 or self not in cats: raise ValidationError( _("You may only have one discount line per category")) diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html new file mode 100644 index 00000000..54a5bb7b --- /dev/null +++ b/registrasion/templates/product_category.html @@ -0,0 +1,20 @@ + + +{% extends "site_base.html" %} +{% block body %} + +

Product Category: {{ category.name }}

+ +

{{ category.description }}

+ +
+ {% csrf_token %} + + {{ formset }} +
+ + + +
+ +{% endblock %} diff --git a/registrasion/urls.py b/registrasion/urls.py new file mode 100644 index 00000000..257f2e2f --- /dev/null +++ b/registrasion/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls import url, patterns + +urlpatterns = patterns( + "registrasion.views", + url(r"^category/([0-9]+)$", "product_category", name="product_category"), + # url(r"^category$", "product_category", name="product_category"), +) diff --git a/registrasion/views.py b/registrasion/views.py new file mode 100644 index 00000000..1b5bc254 --- /dev/null +++ b/registrasion/views.py @@ -0,0 +1,57 @@ +from registrasion import forms +from registrasion import models as rego +from registrasion.controllers.cart import CartController + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.forms import formset_factory +from django.shortcuts import render +from functools import partial, wraps + + +@login_required +def product_category(request, category_id): + ''' Registration selections form for a specific category of items ''' + + category_id = int(category_id) # Routing is [0-9]+ + category = rego.Category.objects.get(pk=category_id) + + ProductItemFormForCategory = ( + wraps(forms.ProductItemForm) + (partial(forms.ProductItemForm, category=category))) + ProductItemFormSet = formset_factory(ProductItemFormForCategory, extra=0) + + if request.method == "POST": + formset = ProductItemFormSet(request.POST, request.FILES) + if formset.is_valid(): + current_cart = CartController.for_user(request.user) + with transaction.atomic(): + for form in formset.forms: + data = form.cleaned_data + # TODO set form error instead of failing completely + current_cart.set_quantity( + data["product"], data["quantity"], batched=True) + current_cart.end_batch() + else: + # Create initial data for each of products in category + initial = [] + products = rego.Product.objects.filter(category=category) + items = rego.ProductItem.objects.filter(product__category=category) + products = products.order_by("order") + for product in products: + try: + quantity = items.get(product=product).quantity + except ObjectDoesNotExist: + quantity = 0 + data = {"product": product, "quantity": quantity} + initial.append(data) + + formset = ProductItemFormSet(initial=initial) + + data = { + "category": category, + "formset": formset, + } + + return render(request, "product_category.html", data) From a4de15830c1824464810d7a1b58e1fe474b5eb1d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 12:22:01 -0800 Subject: [PATCH 03/12] Adds checkout view, which generates an invoice, and then redirects to the invoice itself. --- registrasion/controllers/invoice.py | 9 ++++--- registrasion/templates/invoice.html | 37 +++++++++++++++++++++++++++++ registrasion/urls.py | 3 ++- registrasion/views.py | 27 +++++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 registrasion/templates/invoice.html diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 5ce562f3..e9a654e4 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -59,20 +59,23 @@ class InvoiceController(object): # TODO: calculate line items. product_items = rego.ProductItem.objects.filter(cart=cart) + product_items = product_items.order_by( + "product__category__order", "product__order" + ) discount_items = rego.DiscountItem.objects.filter(cart=cart) invoice_value = Decimal() for item in product_items: + product = item.product line_item = rego.LineItem.objects.create( invoice=invoice, - description=item.product.name, + description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, - price=item.product.price, + price=product.price, ) line_item.save() invoice_value += line_item.quantity * line_item.price for item in discount_items: - line_item = rego.LineItem.objects.create( invoice=invoice, description=item.discount.description, diff --git a/registrasion/templates/invoice.html b/registrasion/templates/invoice.html new file mode 100644 index 00000000..a66132cf --- /dev/null +++ b/registrasion/templates/invoice.html @@ -0,0 +1,37 @@ + + +{% extends "site_base.html" %} +{% block body %} + +

Invoice {{ invoice.id }}

+ +
    +
  • Void: {{ invoice.void }}
  • +
  • Paid: {{ invoice.paid }}
  • +
+ + + + + + + + + {% for line_item in invoice.lineitem_set.all %} + + + + + + + {% endfor %} + + + + + + +
DescriptionQuantityPrice/UnitTotal
{{line_item.description}}{{line_item.quantity}}{{line_item.price}} FIXME
TOTAL{{ invoice.value }}
+ + +{% endblock %} diff --git a/registrasion/urls.py b/registrasion/urls.py index 257f2e2f..d8a1c6db 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -3,5 +3,6 @@ from django.conf.urls import url, patterns urlpatterns = patterns( "registrasion.views", url(r"^category/([0-9]+)$", "product_category", name="product_category"), - # url(r"^category$", "product_category", name="product_category"), + url(r"^checkout$", "checkout", name="checkout"), + url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 1b5bc254..4e5f372a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -1,11 +1,13 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.forms import formset_factory +from django.shortcuts import redirect from django.shortcuts import render from functools import partial, wraps @@ -55,3 +57,28 @@ def product_category(request, category_id): } return render(request, "product_category.html", data) + +@login_required +def checkout(request): + ''' Runs checkout for the current cart of items, ideally generating an + invoice. ''' + + current_cart = CartController.for_user(request.user) + current_invoice = InvoiceController.for_cart(current_cart.cart) + + return redirect("invoice", current_invoice.invoice.id) + + +@login_required +def invoice(request, invoice_id): + ''' Displays an invoice for a given invoice id. ''' + + invoice_id = int(invoice_id) + inv = rego.Invoice.objects.get(pk=invoice_id) + current_invoice = InvoiceController(inv) + + data = { + "invoice": current_invoice.invoice, + } + + return render(request, "invoice.html", data) From 99f4b8dfe09a9e693c051771b22314dbcf98385a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 13:07:18 -0800 Subject: [PATCH 04/12] Fixes validation error in models.py for adding discounts --- registrasion/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index 35b44305..26e6b6b6 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -149,7 +149,7 @@ class DiscountForProduct(models.Model): cats = DiscountForCategory.objects.filter( discount=self.discount, category=self.product.category) - if len(prods) > 1 or self not in prods: + if len(prods) > 1: raise ValidationError( _("You may only have one discount line per product")) if len(cats) != 0: @@ -185,7 +185,7 @@ class DiscountForCategory(models.Model): raise ValidationError( _("You may only have one discount for " "a product or its category")) - if len(cats) > 1 or self not in cats: + if len(cats) > 1: raise ValidationError( _("You may only have one discount line per category")) From 0182a32f03475d673d745ec7fad9d10c41a847fc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 13:07:45 -0800 Subject: [PATCH 05/12] Fixes various errors in discount calculation, and adds tests for these --- registrasion/controllers/cart.py | 9 +++++--- registrasion/tests/test_cart.py | 13 ++++++++++- registrasion/tests/test_discount.py | 35 +++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index acdce035..5cbe5c8c 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -183,7 +183,12 @@ class CartController(object): # Delete the existing entries. rego.DiscountItem.objects.filter(cart=self.cart).delete() - for item in self.cart.productitem_set.all(): + # The highest-value discounts will apply to the highest-value + # products first. + product_items = self.cart.productitem_set.all() + product_items = product_items.order_by('product__price') + product_items = reversed(product_items) + for item in product_items: self._add_discount(item.product, item.quantity) def _add_discount(self, product, quantity): @@ -202,9 +207,7 @@ class CartController(object): # Get the count of past uses of this discount condition # as this affects the total amount we're allowed to use now. past_uses = rego.DiscountItem.objects.filter( - cart__active=False, discount=discount.discount, - product=product, ) agg = past_uses.aggregate(Sum("quantity")) past_uses = agg["quantity__sum"] diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index dad32efa..c65011b9 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -82,7 +82,18 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): limit_per_user=10, order=10, ) - cls.PROD_2.save() + cls.PROD_3.save() + + cls.PROD_4 = rego.Product.objects.create( + name="Product 4", + description="This is a test product. It costs $5. " + "A user may have 10 of them.", + category=cls.CAT_2, + price=Decimal("5.00"), + limit_per_user=10, + order=10, + ) + cls.PROD_4.save() @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index c0709e8e..5d325eff 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -29,7 +29,10 @@ class DiscountTestCase(RegistrationCartTestCase): return discount @classmethod - def add_discount_prod_1_includes_cat_2(cls, amount=Decimal(100)): + def add_discount_prod_1_includes_cat_2( + cls, + amount=Decimal(100), + quantity=2): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) @@ -40,7 +43,7 @@ class DiscountTestCase(RegistrationCartTestCase): discount=discount, category=cls.CAT_2, percentage=amount, - quantity=2 + quantity=quantity, ).save() return discount @@ -169,3 +172,31 @@ class DiscountTestCase(RegistrationCartTestCase): discount_items = list(cart.cart.discountitem_set.all()) self.assertEqual(2, discount_items[0].quantity) + + def test_category_discount_applies_once_per_category(self): + self.add_discount_prod_1_includes_cat_2(quantity=1) + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + # Add two items from category 2 + cart.add_to_cart(self.PROD_3, 1) + cart.add_to_cart(self.PROD_4, 1) + + discount_items = list(cart.cart.discountitem_set.all()) + # There is one discount, and it should apply to one item. + self.assertEqual(1, len(discount_items)) + self.assertEqual(1, discount_items[0].quantity) + + def test_category_discount_applies_to_highest_value(self): + self.add_discount_prod_1_includes_cat_2(quantity=1) + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) + + # Add two items from category 2, add the less expensive one first + cart.add_to_cart(self.PROD_4, 1) + cart.add_to_cart(self.PROD_3, 1) + + discount_items = list(cart.cart.discountitem_set.all()) + # There is one discount, and it should apply to the more expensive. + self.assertEqual(1, len(discount_items)) + self.assertEqual(self.PROD_3, discount_items[0].product) From 8400da17da600eff505337ebe8d37b21adacc484 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 14:28:58 -0800 Subject: [PATCH 06/12] Fixes error in EnablingConditionBase, adds admins for Product and Category enabling conditions --- registrasion/admin.py | 12 ++++++++++++ ...l.py => 0001_squashed_0002_auto_20160304_1723.py} | 6 ++++-- registrasion/models.py | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) rename registrasion/migrations/{0001_initial.py => 0001_squashed_0002_auto_20160304_1723.py} (99%) diff --git a/registrasion/admin.py b/registrasion/admin.py index e00b58ea..17969cd1 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -80,3 +80,15 @@ class VoucherAdmin(nested_admin.NestedAdmin): VoucherDiscountInline, VoucherEnablingConditionInline, ] + + +# Enabling conditions +@admin.register(rego.ProductEnablingCondition) +class ProductEnablingConditionAdmin(nested_admin.NestedAdmin): + model = rego.ProductEnablingCondition + + +# Enabling conditions +@admin.register(rego.CategoryEnablingCondition) +class CategoryEnablingConditionAdmin(nested_admin.NestedAdmin): + model = rego.CategoryEnablingCondition diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py similarity index 99% rename from registrasion/migrations/0001_initial.py rename to registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py index ab78d1f0..cc1e3f0d 100644 --- a/registrasion/migrations/0001_initial.py +++ b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py @@ -9,6 +9,8 @@ from django.conf import settings class Migration(migrations.Migration): + replaces = [('registrasion', '0001_initial'), ('registrasion', '0002_auto_20160304_1723')] + dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -225,12 +227,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='enablingconditionbase', name='categories', - field=models.ManyToManyField(to=b'registrasion.Category'), + field=models.ManyToManyField(to=b'registrasion.Category', blank=True), ), migrations.AddField( model_name='enablingconditionbase', name='products', - field=models.ManyToManyField(to=b'registrasion.Product'), + field=models.ManyToManyField(to=b'registrasion.Product', blank=True), ), migrations.AddField( model_name='discountitem', diff --git a/registrasion/models.py b/registrasion/models.py index 26e6b6b6..c84a6abf 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -257,8 +257,8 @@ class EnablingConditionBase(models.Model): description = models.CharField(max_length=255) mandatory = models.BooleanField(default=False) - products = models.ManyToManyField(Product) - categories = models.ManyToManyField(Category) + products = models.ManyToManyField(Product, blank=True) + categories = models.ManyToManyField(Category, blank=True) class TimeOrStockLimitEnablingCondition(EnablingConditionBase): From 68e7e4e594fbfb862bf3da63d714ed0fde2c6a45 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 14:35:09 -0800 Subject: [PATCH 07/12] Checks enabling conditions before adding items to the list --- registrasion/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/registrasion/views.py b/registrasion/views.py index 4e5f372a..d13028ac 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -2,6 +2,7 @@ from registrasion import forms from registrasion import models as rego from registrasion.controllers.cart import CartController from registrasion.controllers.invoice import InvoiceController +from registrasion.controllers.product import ProductController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist @@ -42,6 +43,11 @@ def product_category(request, category_id): items = rego.ProductItem.objects.filter(product__category=category) products = products.order_by("order") for product in products: + # Only add items that are enabled. + prod = ProductController(product) + if not prod.can_add_with_enabling_conditions(request.user, 0): + continue + try: quantity = items.get(product=product).quantity except ObjectDoesNotExist: From 745f6db444fa81384bc36c4a3da3e37d39f92875 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 4 Mar 2016 18:01:16 -0800 Subject: [PATCH 08/12] =?UTF-8?q?Adds=20=E2=80=9CCategoryForm=E2=80=9D=20t?= =?UTF-8?q?o=20forms.py.=20It=E2=80=99s=20about=20to=20replace=20the=20exi?= =?UTF-8?q?sting=20ProductItem=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- registrasion/forms.py | 49 +++++++++++++--- registrasion/templates/product_category.html | 4 +- registrasion/views.py | 60 ++++++++++++-------- 3 files changed, 80 insertions(+), 33 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index dc32d7b1..34ccc878 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,11 +3,46 @@ import models as rego from django import forms -class ProductItemForm(forms.Form): - product = forms.ModelChoiceField(queryset=None, empty_label=None) - quantity = forms.IntegerField() +def CategoryForm(category): - def __init__(self, category, *a, **k): - super(ProductItemForm, self).__init__(*a, **k) - products = rego.Product.objects.filter(category=category) - self.fields['product'].queryset = products + PREFIX = "product_" + + def field_name(product): + return PREFIX + ("%d" % product.id) + + class _CategoryForm(forms.Form): + + @staticmethod + def initial_data(product_quantities): + ''' Prepares initial data for an instance of this form. + product_quantities is a sequence of (product,quantity) tuples ''' + initial = {} + for product, quantity in product_quantities: + initial[field_name(product)] = quantity + + return initial + + def product_quantities(self): + ''' Yields a sequence of (product, quantity) tuples from the + cleaned form data. ''' + for name, value in self.cleaned_data.items(): + if name.startswith(PREFIX): + product_id = int(name[len(PREFIX):]) + yield (product_id, value, name) + + def disable_product(self, product): + ''' Removes a given product from this form. ''' + del self.fields[field_name(product)] + + products = rego.Product.objects.filter(category=category).order_by("order") + for product in products: + + help_text = "$%d -- %s" % (product.price, product.description) + + field = forms.IntegerField( + label=product.name, + help_text=help_text, + ) + _CategoryForm.base_fields[field_name(product)] = field + + return _CategoryForm diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html index 54a5bb7b..9822cfcd 100644 --- a/registrasion/templates/product_category.html +++ b/registrasion/templates/product_category.html @@ -9,12 +9,14 @@
{% csrf_token %} + - {{ formset }} + {{ form }}
+ {% endblock %} diff --git a/registrasion/views.py b/registrasion/views.py index d13028ac..9f54f46e 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -6,11 +6,10 @@ from registrasion.controllers.product import ProductController from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError from django.db import transaction -from django.forms import formset_factory from django.shortcuts import redirect from django.shortcuts import render -from functools import partial, wraps @login_required @@ -20,50 +19,61 @@ def product_category(request, category_id): category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) - ProductItemFormForCategory = ( - wraps(forms.ProductItemForm) - (partial(forms.ProductItemForm, category=category))) - ProductItemFormSet = formset_factory(ProductItemFormForCategory, extra=0) + CategoryForm = forms.CategoryForm(category) + + products = rego.Product.objects.filter(category=category) + products = products.order_by("order") if request.method == "POST": - formset = ProductItemFormSet(request.POST, request.FILES) - if formset.is_valid(): + cat_form = CategoryForm(request.POST, request.FILES) + if cat_form.is_valid(): current_cart = CartController.for_user(request.user) - with transaction.atomic(): - for form in formset.forms: - data = form.cleaned_data - # TODO set form error instead of failing completely - current_cart.set_quantity( - data["product"], data["quantity"], batched=True) - current_cart.end_batch() + try: + with transaction.atomic(): + for product_id, quantity, field_name \ + in cat_form.product_quantities(): + product = rego.Product.objects.get(pk=product_id) + try: + current_cart.set_quantity( + product, quantity, batched=True) + except ValidationError as ve: + cat_form.add_error(field_name, ve) + if cat_form.errors: + raise ValidationError("Cannot add that stuff") + current_cart.end_batch() + except ValidationError as ve: + pass + else: # Create initial data for each of products in category - initial = [] - products = rego.Product.objects.filter(category=category) items = rego.ProductItem.objects.filter(product__category=category) - products = products.order_by("order") + quantities = [] for product in products: # Only add items that are enabled. prod = ProductController(product) - if not prod.can_add_with_enabling_conditions(request.user, 0): - continue - try: quantity = items.get(product=product).quantity except ObjectDoesNotExist: quantity = 0 - data = {"product": product, "quantity": quantity} - initial.append(data) + quantities.append((product, quantity)) - formset = ProductItemFormSet(initial=initial) + initial = CategoryForm.initial_data(quantities) + cat_form = CategoryForm(initial=initial) + + for product in products: + # Remove fields that do not have an enabling condition. + prod = ProductController(product) + if not prod.can_add_with_enabling_conditions(request.user, 0): + cat_form.disable_product(product) data = { "category": category, - "formset": formset, + "form": cat_form, } return render(request, "product_category.html", data) + @login_required def checkout(request): ''' Runs checkout for the current cart of items, ideally generating an From cc424908321921582f5b6db50b155a7271e6f061 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 13:29:18 +1100 Subject: [PATCH 09/12] Applying a voucher to a cart now uses the voucher code rather than the voucher object. Adds tests for constraints on vouchers. --- registrasion/controllers/cart.py | 8 ++++++-- registrasion/models.py | 5 +++++ registrasion/tests/test_invoice.py | 2 +- registrasion/tests/test_voucher.py | 31 ++++++++++++++++++++++-------- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 5cbe5c8c..59b308ed 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -121,13 +121,17 @@ class CartController(object): old_quantity = 0 self.set_quantity(product, old_quantity + quantity) - def apply_voucher(self, voucher): - ''' Applies the given voucher to this cart. ''' + def apply_voucher(self, voucher_code): + ''' Applies the voucher with the given code to this cart. ''' # TODO: is it valid for a cart to re-add a voucher that they have? # Is voucher exhausted? active_carts = rego.Cart.reserved_carts() + + # Try and find the voucher + voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + carts_with_voucher = active_carts.filter(vouchers=voucher) if len(carts_with_voucher) >= voucher.limit: raise ValidationError("This voucher is no longer available") diff --git a/registrasion/models.py b/registrasion/models.py index c84a6abf..96ecf219 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -99,6 +99,11 @@ class Voucher(models.Model): def __str__(self): return "Voucher for %s" % self.recipient + def save(self, *a, **k): + ''' Normalise the voucher code to be uppercase ''' + self.code = self.code.upper() + super(Voucher, self).save(*a, **k) + recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) code = models.CharField(max_length=16, unique=True, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 4d0360ce..637e82db 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -91,7 +91,7 @@ class InvoiceTestCase(RegistrationCartTestCase): ).save() current_cart = CartController.for_user(self.USER_1) - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 169a1b79..50b83417 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -3,6 +3,7 @@ import pytz from decimal import Decimal from django.core.exceptions import ValidationError +from django.db import IntegrityError from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -15,10 +16,10 @@ UTC = pytz.timezone('UTC') class VoucherTestCases(RegistrationCartTestCase): @classmethod - def new_voucher(self): + def new_voucher(self, code="VOUCHER"): voucher = rego.Voucher.objects.create( recipient="Voucher recipient", - code="VOUCHER", + code=code, limit=1 ) voucher.save() @@ -30,18 +31,18 @@ class VoucherTestCases(RegistrationCartTestCase): self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) cart_1 = CartController.for_user(self.USER_1) - cart_1.apply_voucher(voucher) + cart_1.apply_voucher(voucher.code) self.assertIn(voucher, cart_1.cart.vouchers.all()) # Second user should not be able to apply this voucher (it's exhausted) cart_2 = CartController.for_user(self.USER_2) with self.assertRaises(ValidationError): - cart_2.apply_voucher(voucher) + cart_2.apply_voucher(voucher.code) # After the reservation duration # user 2 should be able to apply voucher self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) - cart_2.apply_voucher(voucher) + cart_2.apply_voucher(voucher.code) cart_2.cart.active = False cart_2.cart.save() @@ -49,7 +50,7 @@ class VoucherTestCases(RegistrationCartTestCase): # voucher, as user 2 has paid for their cart. self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) with self.assertRaises(ValidationError): - cart_1.apply_voucher(voucher) + cart_1.apply_voucher(voucher.code) def test_voucher_enables_item(self): voucher = self.new_voucher() @@ -69,7 +70,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) # Apply the voucher - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) def test_voucher_enables_discount(self): @@ -89,6 +90,20 @@ class VoucherTestCases(RegistrationCartTestCase): # Having PROD_1 in place should add a discount current_cart = CartController.for_user(self.USER_1) - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) self.assertEqual(1, len(current_cart.cart.discountitem_set.all())) + + def test_voucher_codes_unique(self): + voucher1 = self.new_voucher(code="VOUCHER") + with self.assertRaises(IntegrityError): + voucher2 = self.new_voucher(code="VOUCHER") + + def test_multiple_vouchers_work(self): + voucher1 = self.new_voucher(code="VOUCHER1") + voucher2 = self.new_voucher(code="VOUCHER2") + + def test_vouchers_case_insensitive(self): + voucher = self.new_voucher(code="VOUCHeR") + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code.lower()) From 2d6b28c5a6daabcdbea819c5af4adc8e67eb5e07 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 13:33:33 +1100 Subject: [PATCH 10/12] Adds mechanism for entering a voucher code --- registrasion/forms.py | 7 ++++++ registrasion/templates/product_category.html | 6 +++++ registrasion/views.py | 25 ++++++++++++++++---- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 34ccc878..54b3115a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -46,3 +46,10 @@ def CategoryForm(category): _CategoryForm.base_fields[field_name(product)] = field return _CategoryForm + +class VoucherForm(forms.Form): + voucher = forms.CharField( + label="Voucher code", + help_text="If you have a voucher code, enter it here", + required=True, + ) diff --git a/registrasion/templates/product_category.html b/registrasion/templates/product_category.html index 9822cfcd..0e567bf0 100644 --- a/registrasion/templates/product_category.html +++ b/registrasion/templates/product_category.html @@ -10,6 +10,12 @@
{% csrf_token %} + + {{ voucher_form }} +
+ + + {{ form }}
diff --git a/registrasion/views.py b/registrasion/views.py index 9f54f46e..0c6c5ee6 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -16,6 +16,9 @@ from django.shortcuts import render def product_category(request, category_id): ''' Registration selections form for a specific category of items ''' + PRODUCTS_FORM_PREFIX = "products" + VOUCHERS_FORM_PREFIX = "vouchers" + category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) @@ -25,9 +28,19 @@ def product_category(request, category_id): products = products.order_by("order") if request.method == "POST": - cat_form = CategoryForm(request.POST, request.FILES) - if cat_form.is_valid(): - current_cart = CartController.for_user(request.user) + cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) + voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) + current_cart = CartController.for_user(request.user) + + if voucher_form.is_valid(): + # Apply voucher + # leave + voucher = voucher_form.cleaned_data["voucher"] + try: + current_cart.apply_voucher(voucher) + except Exception as e: + voucher_form.add_error("voucher", e) + elif cat_form.is_valid(): try: with transaction.atomic(): for product_id, quantity, field_name \ @@ -58,7 +71,9 @@ def product_category(request, category_id): quantities.append((product, quantity)) initial = CategoryForm.initial_data(quantities) - cat_form = CategoryForm(initial=initial) + cat_form = CategoryForm(prefix=PRODUCTS_FORM_PREFIX, initial=initial) + + voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) for product in products: # Remove fields that do not have an enabling condition. @@ -66,9 +81,11 @@ def product_category(request, category_id): if not prod.can_add_with_enabling_conditions(request.user, 0): cat_form.disable_product(product) + data = { "category": category, "form": cat_form, + "voucher_form": voucher_form, } return render(request, "product_category.html", data) From 4dc150d7348f84d8fb05eabd44f9bff886dc1d1a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 14:50:52 +1100 Subject: [PATCH 11/12] Fills in quantity boxes from the quantities in the current cart, not overall --- registrasion/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index 0c6c5ee6..075cd2e7 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -21,6 +21,7 @@ def product_category(request, category_id): category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) + current_cart = CartController.for_user(request.user) CategoryForm = forms.CategoryForm(category) @@ -30,7 +31,6 @@ def product_category(request, category_id): if request.method == "POST": cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) - current_cart = CartController.for_user(request.user) if voucher_form.is_valid(): # Apply voucher @@ -59,7 +59,10 @@ def product_category(request, category_id): else: # Create initial data for each of products in category - items = rego.ProductItem.objects.filter(product__category=category) + items = rego.ProductItem.objects.filter( + product__category=category, + cart=current_cart.cart, + ) quantities = [] for product in products: # Only add items that are enabled. From e118a4e74c8cec8d06637b7dab14344e190b2f5d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 23 Mar 2016 14:51:04 +1100 Subject: [PATCH 12/12] Adds dumb process for paying invoices. --- registrasion/urls.py | 1 + registrasion/views.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/registrasion/urls.py b/registrasion/urls.py index d8a1c6db..746d163d 100644 --- a/registrasion/urls.py +++ b/registrasion/urls.py @@ -5,4 +5,5 @@ urlpatterns = patterns( url(r"^category/([0-9]+)$", "product_category", name="product_category"), url(r"^checkout$", "checkout", name="checkout"), url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), + url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), ) diff --git a/registrasion/views.py b/registrasion/views.py index 075cd2e7..3957da7d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -118,3 +118,18 @@ def invoice(request, invoice_id): } return render(request, "invoice.html", data) + +@login_required +def pay_invoice(request, invoice_id): + ''' Marks the invoice with the given invoice id as paid. + WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. + + ''' + + invoice_id = int(invoice_id) + inv = rego.Invoice.objects.get(pk=invoice_id) + current_invoice = InvoiceController(inv) + if not inv.paid and not current_invoice.is_valid(): + current_invoice.pay("Demo invoice payment", inv.value) + + return redirect("invoice", current_invoice.invoice.id)