From 2d5cd622c5174ec307bbaf7f2de29ec04ab22bf7 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 19:47:01 +1100 Subject: [PATCH 1/9] Makes it invalid for a user to re-enter a voucher code they already have. --- registrasion/controllers/cart.py | 11 +++++++++-- registrasion/tests/test_voucher.py | 23 +++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index ad680225..06eef3ef 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -125,18 +125,25 @@ class CartController(object): 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()) + # It's invalid for a user to enter a voucher that's exhausted carts_with_voucher = active_carts.filter(vouchers=voucher) if len(carts_with_voucher) >= voucher.limit: raise ValidationError("This voucher is no longer available") + # It's not valid for users to re-enter a voucher they already have + user_carts_with_voucher = rego.Cart.objects.filter( + user=self.cart.user, + vouchers=voucher, + ) + if len(user_carts_with_voucher) > 0: + raise ValidationError("You have already entered this voucher.") + # If successful... self.cart.vouchers.add(voucher) self.end_batch() diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index f0d1be61..db59d094 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -7,6 +7,7 @@ from django.db import IntegrityError from registrasion import models as rego from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -16,11 +17,11 @@ UTC = pytz.timezone('UTC') class VoucherTestCases(RegistrationCartTestCase): @classmethod - def new_voucher(self, code="VOUCHER"): + def new_voucher(self, code="VOUCHER", limit=1): voucher = rego.Voucher.objects.create( recipient="Voucher recipient", code=code, - limit=1 + limit=limit, ) voucher.save() return voucher @@ -107,3 +108,21 @@ class VoucherTestCases(RegistrationCartTestCase): voucher = self.new_voucher(code="VOUCHeR") current_cart = CartController.for_user(self.USER_1) current_cart.apply_voucher(voucher.code.lower()) + + def test_voucher_can_only_be_applied_once(self): + voucher = self.new_voucher(limit=2) + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code) + with self.assertRaises(ValidationError): + current_cart.apply_voucher(voucher.code) + + def test_voucher_can_only_be_applied_once_across_multiple_carts(self): + voucher = self.new_voucher(limit=2) + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code) + + inv = InvoiceController.for_cart(current_cart.cart) + inv.pay("Hello!", inv.invoice.value) + + with self.assertRaises(ValidationError): + current_cart.apply_voucher(voucher.code) From b13e6f7ce2c2c4dcc8563bd2b8a3ca5da4ce2c6d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 20:01:46 +1100 Subject: [PATCH 2/9] Factors out voucher form handling into its own function --- registrasion/models.py | 6 ++++- registrasion/views.py | 53 ++++++++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/registrasion/models.py b/registrasion/models.py index a1101fd9..75949e24 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -172,9 +172,13 @@ class Voucher(models.Model): def __str__(self): return "Voucher for %s" % self.recipient + @classmethod + def normalise_code(cls, code): + return code.upper() + def save(self, *a, **k): ''' Normalise the voucher code to be uppercase ''' - self.code = self.code.upper() + self.code = self.normalise_code(self.code) super(Voucher, self).save(*a, **k) recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) diff --git a/registrasion/views.py b/registrasion/views.py index 83e80a0d..b75c3e71 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -99,12 +99,20 @@ def product_category(request, category_id): attendee = rego.Attendee.get_instance(request.user) + # Handle the voucher form *before* listing products. + v = handle_voucher(request, VOUCHERS_FORM_PREFIX) + voucher_form, voucher_handled = v + if voucher_handled: + # Do not handle product form + pass + products = rego.Product.objects.filter(category=category) products = products.order_by("order") products = ProductController.available_products( request.user, products=products, ) + ProductsForm = forms.ProductsForm(products) if request.method == "POST": @@ -112,20 +120,10 @@ def product_category(request, category_id): request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) - voucher_form = forms.VoucherForm( - request.POST, - prefix=VOUCHERS_FORM_PREFIX) - if (voucher_form.is_valid() and - voucher_form.cleaned_data["voucher"].strip()): - # 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) - # Re-visit current page. + if voucher_handled: + # The voucher form was handled here. + pass elif cat_form.is_valid(): try: handle_valid_cat_form(cat_form, current_cart) @@ -174,8 +172,6 @@ def product_category(request, category_id): product_quantities=quantities, ) - voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) - discounts = discount.available_discounts(request.user, [], products) data = { "category": category, @@ -199,6 +195,33 @@ def handle_valid_cat_form(cat_form, current_cart): raise ValidationError("Cannot add that stuff") current_cart.end_batch() +def handle_voucher(request, prefix): + ''' Handles a voucher form in the given request. Returns the voucher + form instance, and whether the voucher code was handled. ''' + + voucher_form = forms.VoucherForm(request.POST or None, prefix=prefix) + current_cart = CartController.for_user(request.user) + + if (voucher_form.is_valid() and + voucher_form.cleaned_data["voucher"].strip()): + + voucher = voucher_form.cleaned_data["voucher"] + voucher = rego.Voucher.normalise_code(voucher) + + if len(current_cart.cart.vouchers.filter(code=voucher)) > 0: + # This voucher has already been applied to this cart. + # Do not apply code + handled = False + else: + try: + current_cart.apply_voucher(voucher) + except Exception as e: + voucher_form.add_error("voucher", e) + handled = True + else: + handled = False + + return (voucher_form, handled) @login_required def checkout(request): From 464684f13e7bc4c3ed2cb094e775b97fc73bfeac Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 20:21:54 +1100 Subject: [PATCH 3/9] Refactors the product_category view to be much simpler --- registrasion/views.py | 109 ++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/registrasion/views.py b/registrasion/views.py index b75c3e71..3efe658a 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -93,19 +93,18 @@ def product_category(request, category_id): PRODUCTS_FORM_PREFIX = "products" VOUCHERS_FORM_PREFIX = "vouchers" + # Handle the voucher form *before* listing products. + # Products can change as vouchers are entered. + v = handle_voucher(request, VOUCHERS_FORM_PREFIX) + voucher_form, voucher_handled = v + + # Handle the products form category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) current_cart = CartController.for_user(request.user) attendee = rego.Attendee.get_instance(request.user) - # Handle the voucher form *before* listing products. - v = handle_voucher(request, VOUCHERS_FORM_PREFIX) - voucher_form, voucher_handled = v - if voucher_handled: - # Do not handle product form - pass - products = rego.Product.objects.filter(category=category) products = products.order_by("order") products = ProductController.available_products( @@ -115,64 +114,60 @@ def product_category(request, category_id): ProductsForm = forms.ProductsForm(products) - if request.method == "POST": - cat_form = ProductsForm( - request.POST, - request.FILES, - prefix=PRODUCTS_FORM_PREFIX) + # Create initial data for each of products in category + items = rego.ProductItem.objects.filter( + product__category=category, + cart=current_cart.cart, + ) + quantities = [] + for product in products: + # Only add items that are enabled. + try: + quantity = items.get(product=product).quantity + except ObjectDoesNotExist: + quantity = 0 + quantities.append((product, quantity)) - if voucher_handled: - # The voucher form was handled here. - pass - elif cat_form.is_valid(): - try: + cat_form = ProductsForm( + request.POST or None, + product_quantities=quantities, + prefix=PRODUCTS_FORM_PREFIX, + ) + + if ( + not voucher_handled and + request.method == "POST" and + cat_form.is_valid()): + + try: + if cat_form.has_changed(): handle_valid_cat_form(cat_form, current_cart) - except ValidationError: - pass + except ValidationError: + pass - # If category is required, the user must have at least one - # in an active+valid cart + # If category is required, the user must have at least one + # in an active+valid cart - if category.required: - carts = rego.Cart.reserved_carts() - carts = carts.filter(user=request.user) - items = rego.ProductItem.objects.filter( - product__category=category, - cart=carts, + if category.required: + carts = rego.Cart.reserved_carts().filter(user=request.user) + items = rego.ProductItem.objects.filter( + product__category=category, + cart=carts, + ) + if len(items) == 0: + cat_form.add_error( + None, + "You must have at least one item from this category", ) - if len(items) == 0: - cat_form.add_error( - None, - "You must have at least one item from this category", - ) - if not cat_form.errors: - if category_id > attendee.highest_complete_category: - attendee.highest_complete_category = category_id - attendee.save() - return redirect("dashboard") - - else: - # Create initial data for each of products in category - items = rego.ProductItem.objects.filter( - product__category=category, - cart=current_cart.cart, - ) - quantities = [] - for product in products: - # Only add items that are enabled. - try: - quantity = items.get(product=product).quantity - except ObjectDoesNotExist: - quantity = 0 - quantities.append((product, quantity)) - - cat_form = ProductsForm( - prefix=PRODUCTS_FORM_PREFIX, - product_quantities=quantities, - ) + if not cat_form.errors: + if category_id > attendee.highest_complete_category: + attendee.highest_complete_category = category_id + attendee.save() + return redirect("dashboard") discounts = discount.available_discounts(request.user, [], products) + data = { "category": category, "discounts": discounts, From 834233cd72a973b9bd7953ffb7830ae9daae9a01 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 26 Mar 2016 20:43:20 +1100 Subject: [PATCH 4/9] Factors ProductsForm handling into its own function --- registrasion/controllers/product.py | 4 +- registrasion/views.py | 85 ++++++++++++++++------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 2d0f2963..d91a2541 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -28,11 +28,13 @@ class ProductController(object): if products is not None: all_products = itertools.chain(all_products, products) - return [ + out = [ product for product in all_products if cls(product).can_add_with_enabling_conditions(user, 0) ] + out.sort(key=lambda product: product.order) + return out def user_can_add_within_limit(self, user, quantity): ''' Return true if the user is able to add _quantity_ to their count of diff --git a/registrasion/views.py b/registrasion/views.py index 3efe658a..04c63ae2 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -98,25 +98,49 @@ def product_category(request, category_id): v = handle_voucher(request, VOUCHERS_FORM_PREFIX) voucher_form, voucher_handled = v - # Handle the products form category_id = int(category_id) # Routing is [0-9]+ category = rego.Category.objects.get(pk=category_id) - current_cart = CartController.for_user(request.user) - attendee = rego.Attendee.get_instance(request.user) - - products = rego.Product.objects.filter(category=category) - products = products.order_by("order") products = ProductController.available_products( request.user, - products=products, + category=category, ) + p = handle_products(request, category, products, PRODUCTS_FORM_PREFIX) + products_form, discounts, products_handled = p + + if request.POST and not voucher_handled and not products_form.errors: + # Only return to the dashboard if we didn't add a voucher code + # and if there's no errors in the products form + + attendee = rego.Attendee.get_instance(request.user) + if category_id > attendee.highest_complete_category: + attendee.highest_complete_category = category_id + attendee.save() + return redirect("dashboard") + + data = { + "category": category, + "discounts": discounts, + "form": products_form, + "voucher_form": voucher_form, + } + + return render(request, "product_category.html", data) + + +def handle_products(request, category, products, prefix): + ''' Handles a products list form in the given request. Returns the + form instance, the discounts applicable to this form, and whether the + contents were handled. ''' + + current_cart = CartController.for_user(request.user) + ProductsForm = forms.ProductsForm(products) # Create initial data for each of products in category items = rego.ProductItem.objects.filter( - product__category=category, + product__in=products, cart=current_cart.cart, ) quantities = [] @@ -128,65 +152,48 @@ def product_category(request, category_id): quantity = 0 quantities.append((product, quantity)) - cat_form = ProductsForm( + products_form = ProductsForm( request.POST or None, product_quantities=quantities, - prefix=PRODUCTS_FORM_PREFIX, + prefix=prefix, ) - if ( - not voucher_handled and - request.method == "POST" and - cat_form.is_valid()): - + if request.method == "POST" and products_form.is_valid(): try: - if cat_form.has_changed(): - handle_valid_cat_form(cat_form, current_cart) + if products_form.has_changed(): + set_quantities_from_products_form(products_form, current_cart) except ValidationError: + # There were errors, but they've already been added to the form. pass # If category is required, the user must have at least one # in an active+valid cart - if category.required: - carts = rego.Cart.reserved_carts().filter(user=request.user) + carts = rego.Cart.objects.filter(user=request.user) items = rego.ProductItem.objects.filter( product__category=category, cart=carts, ) if len(items) == 0: - cat_form.add_error( + products_form.add_error( None, "You must have at least one item from this category", ) - - if not cat_form.errors: - if category_id > attendee.highest_complete_category: - attendee.highest_complete_category = category_id - attendee.save() - return redirect("dashboard") + handled = False if products_form.errors else True discounts = discount.available_discounts(request.user, [], products) - data = { - "category": category, - "discounts": discounts, - "form": cat_form, - "voucher_form": voucher_form, - } - - return render(request, "product_category.html", data) - + return products_form, discounts, handled @transaction.atomic -def handle_valid_cat_form(cat_form, current_cart): - for product_id, quantity, field_name in cat_form.product_quantities(): +def set_quantities_from_products_form(products_form, current_cart): + for product_id, quantity, field_name in products_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: + products_form.add_error(field_name, ve) + if products_form.errors: raise ValidationError("Cannot add that stuff") current_cart.end_batch() From 0ae005a5f541ce0aba4d381b9be6035cd7cdf6d9 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 10:23:59 +1100 Subject: [PATCH 5/9] Factors _QuantityBoxForm out of _ProductsForm --- registrasion/forms.py | 116 +++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index fd0359bb..68ed9041 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -3,51 +3,83 @@ import models as rego from django import forms +# Products forms -- none of these have any fields: they are to be subclassed +# and the fields added as needs be. + +class _ProductsForm(forms.Form): + + PRODUCT_PREFIX = "product_" + + ''' Base class for product entry forms. ''' + def __init__(self, *a, **k): + if "product_quantities" in k: + initial = self.initial_data(k["product_quantities"]) + k["initial"] = initial + del k["product_quantities"] + super(_ProductsForm, self).__init__(*a, **k) + + @classmethod + def field_name(cls, product): + return cls.PRODUCT_PREFIX + ("%d" % product.id) + + @classmethod + def set_fields(cls, products): + ''' Sets the base_fields on this _ProductsForm to allow selecting + from the provided products. ''' + pass + + @classmethod + def initial_data(cls, product_quantites): + ''' Prepares initial data for an instance of this form. + product_quantities is a sequence of (product,quantity) tuples ''' + return {} + + def product_quantities(self): + ''' Yields a sequence of (product, quantity) tuples from the + cleaned form data. ''' + return iter([]) + + +class _QuantityBoxProductsForm(_ProductsForm): + ''' Products entry form that allows users to enter quantities + of desired products. ''' + + @classmethod + def set_fields(cls, products): + for product in products: + help_text = "$%d -- %s" % (product.price, product.description) + + field = forms.IntegerField( + label=product.name, + help_text=help_text, + ) + cls.base_fields[cls.field_name(product)] = field + + @classmethod + def initial_data(cls, product_quantities): + initial = {} + for product, quantity in product_quantities: + initial[cls.field_name(product)] = quantity + + return initial + + def product_quantities(self): + for name, value in self.cleaned_data.items(): + if name.startswith(self.PRODUCT_PREFIX): + product_id = int(name[len(self.PRODUCT_PREFIX):]) + yield (product_id, value, name) + + def ProductsForm(products): + ''' Produces an appropriate _ProductsForm subclass for the given render + type. ''' - PREFIX = "product_" + if True: + class ProductsForm(_QuantityBoxProductsForm): + pass - def field_name(product): - return PREFIX + ("%d" % product.id) - - class _ProductsForm(forms.Form): - - def __init__(self, *a, **k): - if "product_quantities" in k: - initial = _ProductsForm.initial_data(k["product_quantities"]) - k["initial"] = initial - del k["product_quantities"] - super(_ProductsForm, self).__init__(*a, **k) - - @classmethod - def initial_data(cls, 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) - - for product in products: - - help_text = "$%d -- %s" % (product.price, product.description) - - field = forms.IntegerField( - label=product.name, - help_text=help_text, - ) - _ProductsForm.base_fields[field_name(product)] = field - - return _ProductsForm + ProductsForm.set_fields(products) + return ProductsForm class ProfileForm(forms.ModelForm): From 3562772c13cc0583cbdf23d4cda682e78cccb383 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 11:18:26 +1100 Subject: [PATCH 6/9] Adds RadioBoxProductsForm --- registrasion/forms.py | 64 ++++++++++++++++++++++++++++++++++++++----- registrasion/views.py | 2 +- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 68ed9041..cc4b1b4e 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -23,7 +23,7 @@ class _ProductsForm(forms.Form): return cls.PRODUCT_PREFIX + ("%d" % product.id) @classmethod - def set_fields(cls, products): + def set_fields(cls, category, products): ''' Sets the base_fields on this _ProductsForm to allow selecting from the provided products. ''' pass @@ -45,7 +45,7 @@ class _QuantityBoxProductsForm(_ProductsForm): of desired products. ''' @classmethod - def set_fields(cls, products): + def set_fields(cls, category, products): for product in products: help_text = "$%d -- %s" % (product.price, product.description) @@ -70,15 +70,65 @@ class _QuantityBoxProductsForm(_ProductsForm): yield (product_id, value, name) -def ProductsForm(products): +class _RadioButtonProductsForm(_ProductsForm): + ''' Products entry form that allows users to enter quantities + of desired products. ''' + + FIELD = "chosen_product" + + @classmethod + def set_fields(cls, category, products): + choices = [] + for product in products: + + choice_text = "%s -- $%d" % (product.name, product.price) + choices.append((product.id, choice_text)) + + cls.base_fields[cls.FIELD] = forms.TypedChoiceField( + label=category.name, + widget=forms.RadioSelect, + choices=choices, + empty_value=0, + coerce=int, + ) + + @classmethod + def initial_data(cls, product_quantities): + initial = {} + + for product, quantity in product_quantities: + if quantity > 0: + initial[cls.FIELD] = product.id + break + + return initial + + def product_quantities(self): + ours = self.cleaned_data[self.FIELD] + choices = self.fields[self.FIELD].choices + for choice_value, choice_display in choices: + yield ( + choice_value, + 1 if ours == choice_value else 0, + self.FIELD, + ) + + +def ProductsForm(category, products): ''' Produces an appropriate _ProductsForm subclass for the given render type. ''' - if True: - class ProductsForm(_QuantityBoxProductsForm): - pass + # Each Category.RENDER_TYPE value has a subclass here. + RENDER_TYPES = { + rego.Category.RENDER_TYPE_QUANTITY : _QuantityBoxProductsForm, + rego.Category.RENDER_TYPE_RADIO : _RadioButtonProductsForm, + } - ProductsForm.set_fields(products) + # Produce a subclass of _ProductsForm which we can alter the base_fields on + class ProductsForm(RENDER_TYPES[category.render_type]): + pass + + ProductsForm.set_fields(category, products) return ProductsForm diff --git a/registrasion/views.py b/registrasion/views.py index 04c63ae2..6d42b2b3 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -136,7 +136,7 @@ def handle_products(request, category, products, prefix): current_cart = CartController.for_user(request.user) - ProductsForm = forms.ProductsForm(products) + ProductsForm = forms.ProductsForm(category, products) # Create initial data for each of products in category items = rego.ProductItem.objects.filter( From db332da9584d4b9dfb71b72aedc298d8d4f9d1dd Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 11:48:17 +1100 Subject: [PATCH 7/9] flake8 --- registrasion/forms.py | 4 ++-- registrasion/templatetags/registrasion_tags.py | 4 ++++ registrasion/views.py | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index cc4b1b4e..eb6f2949 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -120,8 +120,8 @@ def ProductsForm(category, products): # Each Category.RENDER_TYPE value has a subclass here. RENDER_TYPES = { - rego.Category.RENDER_TYPE_QUANTITY : _QuantityBoxProductsForm, - rego.Category.RENDER_TYPE_RADIO : _RadioButtonProductsForm, + rego.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, + rego.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on diff --git a/registrasion/templatetags/registrasion_tags.py b/registrasion/templatetags/registrasion_tags.py index a6741c0e..a583f1ea 100644 --- a/registrasion/templatetags/registrasion_tags.py +++ b/registrasion/templatetags/registrasion_tags.py @@ -8,16 +8,19 @@ register = template.Library() ProductAndQuantity = namedtuple("ProductAndQuantity", ["product", "quantity"]) + @register.assignment_tag(takes_context=True) def available_categories(context): ''' Returns all of the available product categories ''' return rego.Category.objects.all() + @register.assignment_tag(takes_context=True) def invoices(context): ''' Returns all of the invoices that this user has. ''' return rego.Invoice.objects.filter(cart__user=context.request.user) + @register.assignment_tag(takes_context=True) def items_pending(context): ''' Returns all of the items that this user has in their current cart, @@ -29,6 +32,7 @@ def items_pending(context): ) return all_items + @register.assignment_tag(takes_context=True) def items_purchased(context): ''' Returns all of the items that this user has purchased ''' diff --git a/registrasion/views.py b/registrasion/views.py index 6d42b2b3..2a82db1d 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -185,6 +185,7 @@ def handle_products(request, category, products, prefix): return products_form, discounts, handled + @transaction.atomic def set_quantities_from_products_form(products_form, current_cart): for product_id, quantity, field_name in products_form.product_quantities(): @@ -197,6 +198,7 @@ def set_quantities_from_products_form(products_form, current_cart): raise ValidationError("Cannot add that stuff") current_cart.end_batch() + def handle_voucher(request, prefix): ''' Handles a voucher form in the given request. Returns the voucher form instance, and whether the voucher code was handled. ''' @@ -225,6 +227,7 @@ def handle_voucher(request, prefix): return (voucher_form, handled) + @login_required def checkout(request): ''' Runs checkout for the current cart of items, ideally generating an From 7c99750f3ab2ebedfdee4405d34b5518b80ab8c0 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 12:08:17 +1100 Subject: [PATCH 8/9] Simplifies creation of test data in test_cart, adds an extra product category and two new products --- registrasion/tests/test_cart.py | 95 +++++++++++++-------------------- 1 file changed, 36 insertions(+), 59 deletions(-) diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index d000b946..b4ff3919 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -32,69 +32,46 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): email='test2@example.com', password='top_secret') - cls.CAT_1 = rego.Category.objects.create( - name="Category 1", - description="This is a test category", - order=10, - render_type=rego.Category.RENDER_TYPE_RADIO, - required=False, - ) - cls.CAT_1.save() - - cls.CAT_2 = rego.Category.objects.create( - name="Category 2", - description="This is a test category", - order=10, - render_type=rego.Category.RENDER_TYPE_RADIO, - required=False, - ) - cls.CAT_2.save() - cls.RESERVATION = datetime.timedelta(hours=1) - cls.PROD_1 = rego.Product.objects.create( - name="Product 1", - description="This is a test product. It costs $10. " - "A user may have 10 of them.", - category=cls.CAT_1, - price=Decimal("10.00"), - reservation_duration=cls.RESERVATION, - limit_per_user=10, - order=10, - ) - cls.PROD_1.save() + cls.categories = [] + for i in xrange(3): + cat = rego.Category.objects.create( + name="Category " + str(i + 1), + description="This is a test category", + order=i, + render_type=rego.Category.RENDER_TYPE_RADIO, + required=False, + ) + cat.save() + cls.categories.append(cat) - cls.PROD_2 = rego.Product.objects.create( - name="Product 2", - description="This is a test product. It costs $10. " - "A user may have 10 of them.", - category=cls.CAT_1, - price=Decimal("10.00"), - limit_per_user=10, - order=10, - ) - cls.PROD_2.save() + cls.CAT_1 = cls.categories[0] + cls.CAT_2 = cls.categories[1] + cls.CAT_3 = cls.categories[2] - cls.PROD_3 = rego.Product.objects.create( - name="Product 3", - description="This is a test product. It costs $10. " - "A user may have 10 of them.", - category=cls.CAT_2, - price=Decimal("10.00"), - limit_per_user=10, - order=10, - ) - cls.PROD_3.save() + cls.products = [] + for i in xrange(6): + prod = rego.Product.objects.create( + name="Product 1", + description="This is a test product." + category=cls.categories[i / 2], # 2 products per category + price=Decimal("10.00"), + reservation_duration=cls.RESERVATION, + limit_per_user=10, + order=1, + ) + prod.save() + cls.products.append(prod) - 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_1 = cls.products[0] + cls.PROD_2 = cls.products[1] + cls.PROD_3 = cls.products[2] + cls.PROD_4 = cls.products[3] + cls.PROD_5 = cls.products[4] + cls.PROD_6 = cls.products[5] + + cls.PROD_4.price = Decimal("5.00") cls.PROD_4.save() @classmethod @@ -205,7 +182,7 @@ class BasicCartTests(RegistrationCartTestCase): current_cart.set_quantity(self.PROD_1, 2) self.assertEqual(2, get_item().quantity) - def test_add_to_cart_per_user_limit(self): + def test_add_to_cart_product_per_user_limit(self): current_cart = CartController.for_user(self.USER_1) # User should be able to add 1 of PROD_1 to the current cart. From 0d458bea068e0b0c849859c4f010ef5c3b55e09c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 27 Mar 2016 12:24:48 +1100 Subject: [PATCH 9/9] Allows Product.limit_per_user to be blank and null. Adds Category.limit_per_user. Adds functionality and tests to verify that this is legal. --- registrasion/controllers/product.py | 25 +++-- .../migrations/0007_auto_20160326_2105.py | 24 +++++ registrasion/models.py | 5 + registrasion/tests/test_cart.py | 92 +++++++++++++++++-- 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 registrasion/migrations/0007_auto_20160326_2105.py diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index d91a2541..88beb8f8 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -1,6 +1,7 @@ import itertools from django.db.models import Q +from django.db.models import Sum from registrasion import models as rego from conditions import ConditionController @@ -42,17 +43,25 @@ class ProductController(object): carts = rego.Cart.objects.filter(user=user) items = rego.ProductItem.objects.filter( - product=self.product, - cart=carts) + cart=carts, + ) - count = 0 - for item in items: - count += item.quantity + prod_items = items.filter(product=self.product) + cat_items = items.filter(product__category=self.product.category) - if quantity + count > self.product.limit_per_user: - return False - else: + prod_count = prod_items.aggregate(Sum("quantity"))["quantity__sum"] + cat_count = cat_items.aggregate(Sum("quantity"))["quantity__sum"] + + prod_limit = self.product.limit_per_user + prod_met = prod_limit is None or quantity + prod_count <= prod_limit + + cat_limit = self.product.category.limit_per_user + cat_met = cat_limit is None or quantity + cat_count <= cat_limit + + if prod_met and cat_met: return True + else: + return False def can_add_with_enabling_conditions(self, user, quantity): ''' Returns true if the user is able to add _quantity_ to their count diff --git a/registrasion/migrations/0007_auto_20160326_2105.py b/registrasion/migrations/0007_auto_20160326_2105.py new file mode 100644 index 00000000..dbcf2ac7 --- /dev/null +++ b/registrasion/migrations/0007_auto_20160326_2105.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0006_category_required'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='limit_per_user', + field=models.PositiveIntegerField(null=True, verbose_name='Limit per user', blank=True), + ), + migrations.AlterField( + model_name='product', + name='limit_per_user', + field=models.PositiveIntegerField(null=True, verbose_name='Limit per user', blank=True), + ), + ] diff --git a/registrasion/models.py b/registrasion/models.py index 75949e24..c95e1740 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -132,6 +132,10 @@ class Category(models.Model): name = models.CharField(max_length=65, verbose_name=_("Name")) description = models.CharField(max_length=255, verbose_name=_("Description")) + limit_per_user = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Limit per user")) required = models.BooleanField(blank=True) order = models.PositiveIntegerField(verbose_name=("Display order")) render_type = models.IntegerField(choices=CATEGORY_RENDER_TYPES, @@ -153,6 +157,7 @@ class Product(models.Model): decimal_places=2, verbose_name=_("Price")) limit_per_user = models.PositiveIntegerField( + null=True, blank=True, verbose_name=_("Limit per user")) reservation_duration = models.DurationField( diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index b4ff3919..5989f6e2 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -35,7 +35,7 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.RESERVATION = datetime.timedelta(hours=1) cls.categories = [] - for i in xrange(3): + for i in xrange(2): cat = rego.Category.objects.create( name="Category " + str(i + 1), description="This is a test category", @@ -48,13 +48,12 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.CAT_1 = cls.categories[0] cls.CAT_2 = cls.categories[1] - cls.CAT_3 = cls.categories[2] cls.products = [] - for i in xrange(6): + for i in xrange(4): prod = rego.Product.objects.create( name="Product 1", - description="This is a test product." + description="This is a test product.", category=cls.categories[i / 2], # 2 products per category price=Decimal("10.00"), reservation_duration=cls.RESERVATION, @@ -68,8 +67,6 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): cls.PROD_2 = cls.products[1] cls.PROD_3 = cls.products[2] cls.PROD_4 = cls.products[3] - cls.PROD_5 = cls.products[4] - cls.PROD_6 = cls.products[5] cls.PROD_4.price = Decimal("5.00") cls.PROD_4.save() @@ -208,3 +205,86 @@ class BasicCartTests(RegistrationCartTestCase): # Second user should not be affected by first user's limits second_user_cart = CartController.for_user(self.USER_2) second_user_cart.add_to_cart(self.PROD_1, 10) + + def set_limits(self): + self.CAT_2.limit_per_user = 10 + self.PROD_2.limit_per_user = None + self.PROD_3.limit_per_user = None + self.PROD_4.limit_per_user = 6 + + self.CAT_2.save() + self.PROD_2.save() + self.PROD_3.save() + self.PROD_4.save() + + def test_per_user_product_limit_ignored_if_blank(self): + self.set_limits() + + current_cart = CartController.for_user(self.USER_1) + # There is no product limit on PROD_2, and there is no cat limit + current_cart.add_to_cart(self.PROD_2, 1) + # There is no product limit on PROD_3, but there is a cat limit + current_cart.add_to_cart(self.PROD_3, 1) + + def test_per_user_category_limit_ignored_if_blank(self): + self.set_limits() + current_cart = CartController.for_user(self.USER_1) + # There is no product limit on PROD_2, and there is no cat limit + current_cart.add_to_cart(self.PROD_2, 1) + # There is no cat limit on PROD_1, but there is a prod limit + current_cart.add_to_cart(self.PROD_1, 1) + + def test_per_user_category_limit_only(self): + self.set_limits() + + current_cart = CartController.for_user(self.USER_1) + + # Cannot add to cart if category limit is filled by one product. + current_cart.set_quantity(self.PROD_3, 10) + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_4, 1) + + # Can add to cart if category limit is not filled by one product + current_cart.set_quantity(self.PROD_3, 5) + current_cart.set_quantity(self.PROD_4, 5) + # Cannot add to cart if category limit is filled by two products + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_3, 1) + + current_cart.cart.active = False + current_cart.cart.save() + + current_cart = CartController.for_user(self.USER_1) + # The category limit should extend across carts + with self.assertRaises(ValidationError): + current_cart.add_to_cart(self.PROD_3, 10) + + def test_per_user_category_and_product_limits(self): + self.set_limits() + + current_cart = CartController.for_user(self.USER_1) + + # Hit both the product and category edges: + current_cart.set_quantity(self.PROD_3, 4) + current_cart.set_quantity(self.PROD_4, 6) + with self.assertRaises(ValidationError): + # There's unlimited PROD_3, but limited in the category + current_cart.add_to_cart(self.PROD_3, 1) + + current_cart.set_quantity(self.PROD_3, 0) + with self.assertRaises(ValidationError): + # There's only 6 allowed of PROD_4 + current_cart.add_to_cart(self.PROD_4, 1) + + # The limits should extend across carts... + current_cart.cart.active = False + current_cart.cart.save() + + current_cart = CartController.for_user(self.USER_1) + current_cart.set_quantity(self.PROD_3, 4) + + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_3, 5) + + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_4, 1)