From fb3878ce2e44904be7a26929304c5126d9d4324c Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 25 Mar 2016 18:09:24 +1100 Subject: [PATCH] Adds available_discounts, which allows enumeration of the discounts that are available for a given set of products and categories --- registrasion/controllers/discount.py | 83 +++++++++++++ registrasion/tests/test_discount.py | 169 ++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 registrasion/controllers/discount.py diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py new file mode 100644 index 00000000..dcb83dfd --- /dev/null +++ b/registrasion/controllers/discount.py @@ -0,0 +1,83 @@ +import itertools + +from conditions import ConditionController +from registrasion import models as rego + +from django.db.models import Sum + + +class DiscountAndQuantity(object): + def __init__(self, discount, clause, quantity): + self.discount = discount + self.clause = clause + self.quantity = quantity + + +def available_discounts(user, categories, products): + ''' Returns all discounts available to this user for the given categories + and products. The discounts also list the available quantity for this user, + not including products that are pending purchase. ''' + + # discounts that match provided categories + category_discounts = rego.DiscountForCategory.objects.filter( + category__in=categories + ) + # discounts that match provided products + product_discounts = rego.DiscountForProduct.objects.filter( + product__in=products + ) + # discounts that match categories for provided products + product_category_discounts = rego.DiscountForCategory.objects.filter( + category__in=(product.category for product in products) + ) + # (Not relevant: discounts that match products in provided categories) + + # The set of all potential discounts + potential_discounts = set(itertools.chain( + product_discounts, + category_discounts, + product_category_discounts, + )) + + discounts = [] + + # Markers so that we don't need to evaluate given conditions more than once + accepted_discounts = set() + failed_discounts = set() + + for discount in potential_discounts: + real_discount = rego.DiscountBase.objects.get_subclass( + pk=discount.discount.pk, + ) + cond = ConditionController.for_condition(real_discount) + + # Count the past uses of the given discount item. + # If this user has exceeded the limit for the clause, this clause + # is not available any more. + past_uses = rego.DiscountItem.objects.filter( + cart__user=user, + cart__active=False, # Only past carts count + discount=discount.discount, + ) + agg = past_uses.aggregate(Sum("quantity")) + past_use_count = agg["quantity__sum"] + if past_use_count is None: + past_use_count = 0 + + if past_use_count >= discount.quantity: + # This clause has exceeded its use count + pass + elif real_discount not in failed_discounts: + # This clause is still available + if real_discount in accepted_discounts or cond.is_met(user, 0): + # This clause is valid for this user + discounts.append(DiscountAndQuantity( + discount=real_discount, + clause=discount, + quantity=discount.quantity - past_use_count, + )) + accepted_discounts.add(real_discount) + else: + # This clause is not valid for this user + failed_discounts.add(real_discount) + return discounts diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index bb1c4bfe..222afc09 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -3,7 +3,9 @@ import pytz from decimal import Decimal from registrasion import models as rego +from registrasion.controllers import discount from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController from test_cart import RegistrationCartTestCase @@ -13,7 +15,11 @@ UTC = pytz.timezone('UTC') class DiscountTestCase(RegistrationCartTestCase): @classmethod - def add_discount_prod_1_includes_prod_2(cls, amount=Decimal(100)): + def add_discount_prod_1_includes_prod_2( + cls, + amount=Decimal(100), + quantity=2, + ): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes PROD_2 " + str(amount) + "%", ) @@ -24,7 +30,7 @@ class DiscountTestCase(RegistrationCartTestCase): discount=discount, product=cls.PROD_2, percentage=amount, - quantity=2 + quantity=quantity, ).save() return discount @@ -32,7 +38,8 @@ class DiscountTestCase(RegistrationCartTestCase): def add_discount_prod_1_includes_cat_2( cls, amount=Decimal(100), - quantity=2): + quantity=2, + ): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) @@ -47,6 +54,33 @@ class DiscountTestCase(RegistrationCartTestCase): ).save() return discount + @classmethod + def add_discount_prod_1_includes_prod_3_and_prod_4( + cls, + amount=Decimal(100), + quantity=2, + ): + discount = rego.IncludedProductDiscount.objects.create( + description="PROD_1 includes PROD_3 and PROD_4 " + + str(amount) + "%", + ) + discount.save() + discount.enabling_products.add(cls.PROD_1) + discount.save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=cls.PROD_3, + percentage=amount, + quantity=quantity, + ).save() + rego.DiscountForProduct.objects.create( + discount=discount, + product=cls.PROD_4, + percentage=amount, + quantity=quantity, + ).save() + return discount + def test_discount_is_applied(self): self.add_discount_prod_1_includes_prod_2() @@ -214,3 +248,132 @@ class DiscountTestCase(RegistrationCartTestCase): discount_items = list(cart.cart.discountitem_set.all()) # The discount is applied. self.assertEqual(1, len(discount_items)) + + # Tests for the discount.available_discounts enumerator + def test_enumerate_no_discounts_for_no_input(self): + discounts = discount.available_discounts(self.USER_1, [], []) + self.assertEqual(0, len(discounts)) + + def test_enumerate_no_discounts_if_condition_not_met(self): + self.add_discount_prod_1_includes_cat_2(quantity=1) + + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_3], + ) + self.assertEqual(0, len(discounts)) + + discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(0, len(discounts)) + + def test_category_discount_appears_once_if_met_twice(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) # Enable the discount + + discounts = discount.available_discounts( + self.USER_1, + [self.CAT_2], + [self.PROD_3], + ) + self.assertEqual(1, len(discounts)) + + def test_category_discount_appears_with_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) # Enable the discount + + discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(1, len(discounts)) + + def test_category_discount_appears_with_product(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) # Enable the discount + + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_3], + ) + self.assertEqual(1, len(discounts)) + + def test_category_discount_appears_once_with_two_valid_product(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) # Enable the discount + + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_3, self.PROD_4] + ) + self.assertEqual(1, len(discounts)) + + def test_product_discount_appears_with_product(self): + self.add_discount_prod_1_includes_prod_2(quantity=1) + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_2], + ) + self.assertEqual(1, len(discounts)) + + def test_product_discount_does_not_appear_with_category(self): + self.add_discount_prod_1_includes_prod_2(quantity=1) + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + + discounts = discount.available_discounts(self.USER_1, [self.CAT_1], []) + self.assertEqual(0, len(discounts)) + + def test_discount_quantity_is_correct_before_first_purchase(self): + self.add_discount_prod_1_includes_cat_2(quantity=2) + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity + + discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(2, discounts[0].quantity) + inv = InvoiceController.for_cart(cart.cart) + inv.pay("Dummy reference", inv.invoice.value) + self.assertTrue(inv.invoice.paid) + + def test_discount_quantity_is_correct_after_first_purchase(self): + self.test_discount_quantity_is_correct_before_first_purchase() + + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_3, 1) # Exhaust the quantity + + discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(1, discounts[0].quantity) + inv = InvoiceController.for_cart(cart.cart) + inv.pay("Dummy reference", inv.invoice.value) + self.assertTrue(inv.invoice.paid) + + def test_discount_is_gone_after_quantity_exhausted(self): + self.test_discount_quantity_is_correct_after_first_purchase() + discounts = discount.available_discounts(self.USER_1, [self.CAT_2], []) + self.assertEqual(0, len(discounts)) + + def test_product_discount_enabled_twice_appears_twice(self): + self.add_discount_prod_1_includes_prod_3_and_prod_4(quantity=2) + cart = CartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_1, 1) # Enable the discount + discounts = discount.available_discounts( + self.USER_1, + [], + [self.PROD_3, self.PROD_4], + ) + self.assertEqual(2, len(discounts))