From 1e7a2abc7f9292328593a5325313ed12bad32ad4 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sat, 2 Apr 2016 16:26:27 +1100 Subject: [PATCH] Refactors testing of enabling conditions so that they are done in bulk in ConditionsController, rather than one product at a time. --- registrasion/controllers/cart.py | 17 ++- registrasion/controllers/conditions.py | 167 ++++++++++++++++++++----- registrasion/controllers/discount.py | 2 +- registrasion/controllers/product.py | 2 +- 4 files changed, 151 insertions(+), 37 deletions(-) diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 98fb9ba8..f62e134b 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -117,7 +117,18 @@ class CartController(object): ) ) + product_quantities_all = list(product_quantities) + [ + (i.product, i.quantity) for i in items_in_cart.all() + ] + # Test each enabling condition here + errs = ConditionController.test_enabling_conditions( + self.cart.user, + product_quantities=product_quantities_all, + ) + + if errs: + raise ValidationError("Whoops") for product, quantity in product_quantities: self._set_quantity_old(product, quantity) @@ -162,10 +173,6 @@ class CartController(object): 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)") - product_item.quantity = quantity product_item.save() @@ -245,7 +252,7 @@ class CartController(object): quantity = 0 if is_reserved else discount_item.quantity - if not cond.is_met(self.cart.user, quantity): + if not cond.is_met(self.cart.user): # TODO: REPLACE WITH QUANTITY CHECKER WHEN FIXING CEILINGS raise ValidationError("Discounts are no longer available") def recalculate_discounts(self): diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index ebd43df9..63362a28 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,3 +1,8 @@ +import itertools + +from collections import defaultdict +from collections import namedtuple + from django.db.models import Q from django.db.models import Sum from django.utils import timezone @@ -5,6 +10,15 @@ from django.utils import timezone from registrasion import models as rego +ConditionAndRemainder = namedtuple( + "ConditionAndRemainder", + ( + "condition", + "remainder", + ), +) + + class ConditionController(object): ''' Base class for testing conditions that activate EnablingCondition or Discount objects. ''' @@ -31,8 +45,103 @@ class ConditionController(object): except KeyError: return ConditionController() - def is_met(self, user, quantity): - return True + @classmethod + def test_enabling_conditions( + cls, user, products=None, product_quantities=None): + ''' Evaluates all of the enabling conditions on the given products. + + If `product_quantities` is supplied, the condition is only met if it + will permit the sum of the product quantities for all of the products + it covers. Otherwise, it will be met if at least one item can be + accepted. + + If all enabling conditions pass, an empty list is returned, otherwise + a list is returned containing all of the products that are *not + enabled*. ''' + + if products is not None and product_quantities is not None: + raise ValueError("Please specify only products or " + "product_quantities") + elif products is None: + products = set(i[0] for i in product_quantities) + quantities = dict( (product, quantity) + for product, quantity in product_quantities ) + elif product_quantities is None: + products = set(products) + quantities = {} + + # Get the conditions covered by the products themselves + all_conditions = [ + product.enablingconditionbase_set.select_subclasses() | + product.category.enablingconditionbase_set.select_subclasses() + for product in products + ] + all_conditions = set(itertools.chain(*all_conditions)) + + # All mandatory conditions on a product need to be met + mandatory = defaultdict(lambda: True) + # At least one non-mandatory condition on a product must be met + # if there are no mandatory conditions + non_mandatory = defaultdict(lambda: False) + + remainders = [] + for condition in all_conditions: + cond = cls.for_condition(condition) + remainder = cond.user_quantity_remaining(user) + + # Get all products covered by this condition, and the products + # from the categories covered by this condition + cond_products = condition.products.all() + from_category = rego.Product.objects.filter( + category__in=condition.categories.all(), + ).all() + all_products = set(itertools.chain(cond_products, from_category)) + + # Remove the products that we aren't asking about + all_products = all_products & products + + if quantities: + consumed = sum(quantities[i] for i in all_products) + else: + consumed = 1 + met = consumed <= remainder + + for product in all_products: + if condition.mandatory: + mandatory[product] &= met + else: + non_mandatory[product] |= met + + valid = defaultdict(lambda: True) + for product in itertools.chain(mandatory, non_mandatory): + if product in mandatory: + # If there's a mandatory condition, all must be met + valid[product] = mandatory[product] + else: + # Otherwise, we need just one non-mandatory condition met + valid[product] = non_mandatory[product] + + error_fields = [product for product in valid if not valid[product]] + return error_fields + + def user_quantity_remaining(self, user): + ''' Returns the number of items covered by this enabling condition the + user can add to the current cart. This default implementation returns + a big number if is_met() is true, otherwise 0. + + Either this method, or is_met() must be overridden in subclasses. + ''' + + return 99999999 if self.is_met(user) else 0 + + def is_met(self, user): + ''' Returns True if this enabling condition is met, otherwise returns + False. + + Either this method, or user_quantity_remaining() must be overridden + in subclasses. + ''' + return self.user_quantity_remaining(user) > 0 class CategoryConditionController(ConditionController): @@ -40,7 +149,7 @@ class CategoryConditionController(ConditionController): def __init__(self, condition): self.condition = condition - def is_met(self, user, quantity): + def is_met(self, user): ''' returns True if the user has a product from a category that invokes this condition in one of their carts ''' @@ -62,7 +171,7 @@ class ProductConditionController(ConditionController): def __init__(self, condition): self.condition = condition - def is_met(self, user, quantity): + def is_met(self, user): ''' returns True if the user has a product that invokes this condition in one of their carts ''' @@ -81,22 +190,17 @@ class TimeOrStockLimitConditionController(ConditionController): def __init__(self, ceiling): self.ceiling = ceiling - def is_met(self, user, quantity): - ''' returns True if adding _quantity_ of _product_ will not vioilate - this ceiling. ''' + def user_quantity_remaining(self, user): + ''' returns 0 if the date range is violated, otherwise, it will return + the quantity remaining under the stock limit. ''' # Test date range - if not self.test_date_range(): - return False + if not self._test_date_range(): + return 0 - # Test limits - if not self.test_limits(quantity): - return False + return self._get_remaining_stock(user) - # All limits have been met - return True - - def test_date_range(self): + def _test_date_range(self): now = timezone.now() if self.ceiling.start_time is not None: @@ -114,7 +218,7 @@ class TimeOrStockLimitConditionController(ConditionController): list products differently to discounts. ''' if isinstance(self.ceiling, rego.TimeOrStockLimitEnablingCondition): category_products = rego.Product.objects.filter( - category=self.ceiling.categories.all(), + category__in=self.ceiling.categories.all(), ) return self.ceiling.products.all() | category_products else: @@ -123,28 +227,31 @@ class TimeOrStockLimitConditionController(ConditionController): ) return rego.Product.objects.filter( Q(discountforproduct__discount=self.ceiling) | - Q(category=categories.all()) + Q(category__in=categories.all()) ) - def test_limits(self, quantity): - if self.ceiling.limit is None: - return True + def _get_remaining_stock(self, user): + ''' Returns the stock that remains under this ceiling, excluding the + user's current cart. ''' + if self.ceiling.limit is None: + return 99999999 + + # We care about all reserved carts, but not the user's current cart reserved_carts = rego.Cart.reserved_carts() + reserved_carts = reserved_carts.exclude( + user=user, + active=True, + ) + product_items = rego.ProductItem.objects.filter( product__in=self._products().all(), ) product_items = product_items.filter(cart=reserved_carts) - agg = product_items.aggregate(Sum("quantity")) - count = agg["quantity__sum"] - if count is None: - count = 0 + count = product_items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - if count + quantity > self.ceiling.limit: - return False - - return True + return self.ceiling.limit - count class VoucherConditionController(ConditionController): @@ -153,7 +260,7 @@ class VoucherConditionController(ConditionController): def __init__(self, condition): self.condition = condition - def is_met(self, user, quantity): + def is_met(self, user): ''' returns True if the user has the given voucher attached. ''' carts_count = rego.Cart.objects.filter( user=user, diff --git a/registrasion/controllers/discount.py b/registrasion/controllers/discount.py index 7d6a959a..084584ee 100644 --- a/registrasion/controllers/discount.py +++ b/registrasion/controllers/discount.py @@ -75,7 +75,7 @@ def available_discounts(user, categories, products): 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): + if real_discount in accepted_discounts or cond.is_met(user): # This clause is valid for this user discounts.append(DiscountAndQuantity( discount=real_discount, diff --git a/registrasion/controllers/product.py b/registrasion/controllers/product.py index 929f7290..ec9a73e5 100644 --- a/registrasion/controllers/product.py +++ b/registrasion/controllers/product.py @@ -79,7 +79,7 @@ class ProductController(object): for condition in conditions: cond = ConditionController.for_condition(condition) - met = cond.is_met(user, quantity) + met = cond.is_met(user) if condition.mandatory and not met: mandatory_violated = True