diff --git a/registrasion/controllers/conditions.py b/registrasion/controllers/conditions.py index db40d0c24e1cecc10787b8b5e85041bb0ba541ab..51078016ef16cac2de8ac751e7d86fda21f22e57 100644 --- a/registrasion/controllers/conditions.py +++ b/registrasion/controllers/conditions.py @@ -1,36 +1,27 @@ -import itertools -import operator - -from collections import defaultdict -from collections import namedtuple - +from django.db.models import Case +from django.db.models import F, Q from django.db.models import Sum +from django.db.models import Value +from django.db.models import When from django.utils import timezone from registrasion.models import commerce from registrasion.models import conditions -from registrasion.models import inventory -ConditionAndRemainder = namedtuple( - "ConditionAndRemainder", - ( - "condition", - "remainder", - ), -) +_BIG_QUANTITY = 99999999 # A big quantity class ConditionController(object): ''' Base class for testing conditions that activate Flag or Discount objects. ''' - def __init__(self): - pass + def __init__(self, condition): + self.condition = condition @staticmethod - def for_condition(condition): - CONTROLLERS = { + def _controllers(): + return { conditions.CategoryFlag: CategoryConditionController, conditions.IncludedProductDiscount: ProductConditionController, conditions.ProductFlag: ProductConditionController, @@ -42,137 +33,49 @@ class ConditionController(object): conditions.VoucherFlag: VoucherConditionController, } + @staticmethod + def for_type(cls): + return ConditionController._controllers()[cls] + + @staticmethod + def for_condition(condition): try: - return CONTROLLERS[type(condition)](condition) + return ConditionController.for_type(type(condition))(condition) except KeyError: return ConditionController() - SINGLE = True - PLURAL = False - NONE = True - SOME = False - MESSAGE = { - NONE: { - SINGLE: - "%(items)s is no longer available to you", - PLURAL: - "%(items)s are no longer available to you", - }, - SOME: { - SINGLE: - "Only %(remainder)d of the following item remains: %(items)s", - PLURAL: - "Only %(remainder)d of the following items remain: %(items)s" - }, - } - @classmethod - def test_flags( - cls, user, products=None, product_quantities=None): - ''' Evaluates all of the flag 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 flag 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 - prods = ( - product.flagbase_set.select_subclasses() - for product in products - ) - # Get the conditions covered by their categories - cats = ( - category.flagbase_set.select_subclasses() - for category in set(product.category for product in products) - ) + def pre_filter(cls, queryset, user): + ''' Returns only the flag conditions that might be available for this + user. It should hopefully reduce the number of queries that need to be + executed to determine if a flag is met. - if products: - # Simplify the query. - all_conditions = reduce(operator.or_, itertools.chain(prods, cats)) - else: - all_conditions = [] - - # All disable-if-false conditions on a product need to be met - do_not_disable = defaultdict(lambda: True) - # At least one enable-if-true condition on a product must be met - do_enable = defaultdict(lambda: False) - # (if either sort of condition is present) - - messages = {} - - 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 = inventory.Product.objects.filter( - category__in=condition.categories.all(), - ).all() - all_products = cond_products | from_category - all_products = all_products.select_related("category") - # Remove the products that we aren't asking about - all_products = [ - product - for product in all_products - if product in products - ] - - if quantities: - consumed = sum(quantities[i] for i in all_products) - else: - consumed = 1 - met = consumed <= remainder - - if not met: - items = ", ".join(str(product) for product in all_products) - base = cls.MESSAGE[remainder == 0][len(all_products) == 1] - message = base % {"items": items, "remainder": remainder} - - for product in all_products: - if condition.is_disable_if_false: - do_not_disable[product] &= met - else: - do_enable[product] |= met - - if not met and product not in messages: - messages[product] = message - - valid = {} - for product in itertools.chain(do_not_disable, do_enable): - if product in do_enable: - # If there's an enable-if-true, we need need of those met too. - # (do_not_disable will default to true otherwise) - valid[product] = do_not_disable[product] and do_enable[product] - elif product in do_not_disable: - # If there's a disable-if-false condition, all must be met - valid[product] = do_not_disable[product] - - error_fields = [ - (product, messages[product]) - for product in valid if not valid[product] - ] - - return error_fields - - def user_quantity_remaining(self, user): + If this filtration implements the same query as is_met, then you should + be able to implement ``is_met()`` in terms of this. + + Arguments: + + queryset (Queryset[c]): The canditate conditions. + + user (User): The user for whom we're testing these conditions. + + Returns: + Queryset[c]: A subset of the conditions that pass the pre-filter + test for this user. + + ''' + + # Default implementation does NOTHING. + return queryset + + def passes_filter(self, user): + ''' Returns true if the condition passes the filter ''' + + cls = type(self.condition) + qs = cls.objects.filter(pk=self.condition.id) + return self.condition in self.pre_filter(qs, user) + + def user_quantity_remaining(self, user, filtered=False): ''' Returns the number of items covered by this flag condition the user can add to the current cart. This default implementation returns a big number if is_met() is true, otherwise 0. @@ -180,144 +83,210 @@ class ConditionController(object): Either this method, or is_met() must be overridden in subclasses. ''' - return 99999999 if self.is_met(user) else 0 + return _BIG_QUANTITY if self.is_met(user, filtered) else 0 - def is_met(self, user): + def is_met(self, user, filtered=False): ''' Returns True if this flag condition is met, otherwise returns False. Either this method, or user_quantity_remaining() must be overridden in subclasses. + + Arguments: + + user (User): The user for whom this test must be met. + + filter (bool): If true, this condition was part of a queryset + returned by pre_filter() for this user. + ''' - return self.user_quantity_remaining(user) > 0 + return self.user_quantity_remaining(user, filtered) > 0 -class CategoryConditionController(ConditionController): +class IsMetByFilter(object): - def __init__(self, condition): - self.condition = condition + def is_met(self, user, filtered=False): + ''' Returns True if this flag condition is met, otherwise returns + False. It determines if the condition is met by calling pre_filter + with a queryset containing only self.condition. ''' + + if filtered: + return True # Why query again? + + return self.passes_filter(user) + + +class RemainderSetByFilter(object): + + def user_quantity_remaining(self, user, filtered=True): + ''' returns 0 if the date range is violated, otherwise, it will return + the quantity remaining under the stock limit. + + The filter for this condition must add an annotation called "remainder" + in order for this to work. + ''' + + if filtered: + if hasattr(self.condition, "remainder"): + return self.condition.remainder + + # Mark self.condition with a remainder + qs = type(self.condition).objects.filter(pk=self.condition.id) + qs = self.pre_filter(qs, user) - 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 ''' + if len(qs) > 0: + return qs[0].remainder + else: + return 0 + + +class CategoryConditionController(IsMetByFilter, ConditionController): + + @classmethod + def pre_filter(self, queryset, user): + ''' Returns all of the items from queryset where the user has a + product from a category invoking that item's condition in one of their + carts. ''' - carts = commerce.Cart.objects.filter(user=user) - carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) - enabling_products = inventory.Product.objects.filter( - category=self.condition.enabling_category, + in_user_carts = Q( + enabling_category__product__productitem__cart__user=user + ) + released = commerce.Cart.STATUS_RELEASED + in_released_carts = Q( + enabling_category__product__productitem__cart__status=released ) - products_count = commerce.ProductItem.objects.filter( - cart__in=carts, - product__in=enabling_products, - ).count() - return products_count > 0 + queryset = queryset.filter(in_user_carts) + queryset = queryset.exclude(in_released_carts) + + return queryset -class ProductConditionController(ConditionController): +class ProductConditionController(IsMetByFilter, ConditionController): ''' Condition tests for ProductFlag and IncludedProductDiscount. ''' - def __init__(self, condition): - self.condition = condition - - def is_met(self, user): - ''' returns True if the user has a product that invokes this - condition in one of their carts ''' + @classmethod + def pre_filter(self, queryset, user): + ''' Returns all of the items from queryset where the user has a + product invoking that item's condition in one of their carts. ''' + + in_user_carts = Q(enabling_products__productitem__cart__user=user) + released = commerce.Cart.STATUS_RELEASED + in_released_carts = Q( + enabling_products__productitem__cart__status=released + ) + queryset = queryset.filter(in_user_carts) + queryset = queryset.exclude(in_released_carts) - carts = commerce.Cart.objects.filter(user=user) - carts = carts.exclude(status=commerce.Cart.STATUS_RELEASED) - products_count = commerce.ProductItem.objects.filter( - cart__in=carts, - product__in=self.condition.enabling_products.all(), - ).count() - return products_count > 0 + return queryset -class TimeOrStockLimitConditionController(ConditionController): +class TimeOrStockLimitConditionController( + RemainderSetByFilter, + ConditionController, + ): ''' Common condition tests for TimeOrStockLimit Flag and Discount.''' - def __init__(self, ceiling): - self.ceiling = 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 0 - - return self._get_remaining_stock(user) + @classmethod + def pre_filter(self, queryset, user): + ''' Returns all of the items from queryset where the date falls into + any specified range, but not yet where the stock limit is not yet + reached.''' - def _test_date_range(self): now = timezone.now() - if self.ceiling.start_time is not None: - if now < self.ceiling.start_time: - return False + # Keep items with no start time, or start time not yet met. + queryset = queryset.filter(Q(start_time=None) | Q(start_time__lte=now)) + queryset = queryset.filter(Q(end_time=None) | Q(end_time__gte=now)) - if self.ceiling.end_time is not None: - if now > self.ceiling.end_time: - return False + # Filter out items that have been reserved beyond the limits + quantity_or_zero = self._calculate_quantities(user) - return True + remainder = Case( + When(limit=None, then=Value(_BIG_QUANTITY)), + default=F("limit") - Sum(quantity_or_zero), + ) - def _get_remaining_stock(self, user): - ''' Returns the stock that remains under this ceiling, excluding the - user's current cart. ''' + queryset = queryset.annotate(remainder=remainder) + queryset = queryset.filter(remainder__gt=0) - if self.ceiling.limit is None: - return 99999999 + return queryset - # We care about all reserved carts, but not the user's current cart + @classmethod + def _relevant_carts(cls, user): reserved_carts = commerce.Cart.reserved_carts() reserved_carts = reserved_carts.exclude( user=user, status=commerce.Cart.STATUS_ACTIVE, ) - - items = self._items() - items = items.filter(cart__in=reserved_carts) - count = items.aggregate(Sum("quantity"))["quantity__sum"] or 0 - - return self.ceiling.limit - count + return reserved_carts class TimeOrStockLimitFlagController( TimeOrStockLimitConditionController): - def _items(self): - category_products = inventory.Product.objects.filter( - category__in=self.ceiling.categories.all(), + @classmethod + def _calculate_quantities(cls, user): + reserved_carts = cls._relevant_carts(user) + + # Calculate category lines + item_cats = F('categories__product__productitem__product__category') + reserved_category_products = ( + Q(categories=item_cats) & + Q(categories__product__productitem__cart__in=reserved_carts) + ) + + # Calculate product lines + reserved_products = ( + Q(products=F('products__productitem__product')) & + Q(products__productitem__cart__in=reserved_carts) + ) + + category_quantity_in_reserved_carts = When( + reserved_category_products, + then="categories__product__productitem__quantity", + ) + + product_quantity_in_reserved_carts = When( + reserved_products, + then="products__productitem__quantity", ) - products = self.ceiling.products.all() | category_products - product_items = commerce.ProductItem.objects.filter( - product__in=products.all(), + quantity_or_zero = Case( + category_quantity_in_reserved_carts, + product_quantity_in_reserved_carts, + default=Value(0), ) - return product_items + + return quantity_or_zero class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController): - def _items(self): - discount_items = commerce.DiscountItem.objects.filter( - discount=self.ceiling, + @classmethod + def _calculate_quantities(cls, user): + reserved_carts = cls._relevant_carts(user) + + quantity_in_reserved_carts = When( + discountitem__cart__in=reserved_carts, + then="discountitem__quantity" + ) + + quantity_or_zero = Case( + quantity_in_reserved_carts, + default=Value(0) ) - return discount_items + return quantity_or_zero -class VoucherConditionController(ConditionController): + +class VoucherConditionController(IsMetByFilter, ConditionController): ''' Condition test for VoucherFlag and VoucherDiscount.''' - def __init__(self, condition): - self.condition = condition + @classmethod + def pre_filter(self, queryset, user): + ''' Returns all of the items from queryset where the user has entered + a voucher that invokes that item's condition in one of their carts. ''' - def is_met(self, user): - ''' returns True if the user has the given voucher attached. ''' - carts_count = commerce.Cart.objects.filter( - user=user, - vouchers=self.condition.voucher, - ).count() - return carts_count > 0 + return queryset.filter(voucher__cart__user=user)