Changeset - 02fe88a4e4db
[Not reviewed]
0 2 0
Christopher Neugebauer - 8 years ago 2016-04-29 01:11:59
chrisjrn@gmail.com
Tests and fixes for a bug where discount quantities did not respect per-line item quantities.
2 files changed with 52 insertions and 15 deletions:
0 comments (0 inline, 0 general)
registrasion/controllers/discount.py
Show inline comments
 
import itertools
 

	
 
from conditions import ConditionController
 
from registrasion.models import commerce
 
from registrasion.models import conditions
 

	
 
from django.db.models import Case
 
from django.db.models import Q
 
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
 

	
 

	
 
class DiscountAndQuantity(object):
 
    ''' Represents a discount that can be applied to a product or category
 
    for a given user.
 

	
 
    Attributes:
 

	
 
        discount (conditions.DiscountBase): The discount object that the
 
            clause arises from. A given DiscountBase can apply to multiple
 
            clauses.
 

	
 
        clause (conditions.DiscountForProduct|conditions.DiscountForCategory):
 
            A clause describing which product or category this discount item
 
            applies to. This casts to ``str()`` to produce a human-readable
 
            version of the clause.
 

	
 
        quantity (int): The number of times this discount item can be applied
 
            for the given user.
 

	
 
    '''
...
 
@@ -43,51 +43,49 @@ class DiscountAndQuantity(object):
 

	
 

	
 
class DiscountController(object):
 

	
 
    @classmethod
 
    def available_discounts(cls, 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. '''
 

	
 

	
 
        filtered_clauses = cls._filtered_discounts(user, categories, products)
 

	
 
        discounts = []
 

	
 
        # Markers so that we don't need to evaluate given conditions
 
        # more than once
 
        accepted_discounts = set()
 
        failed_discounts = set()
 

	
 
        for clause in filtered_clauses:
 
            discount = clause.discount
 
            cond = ConditionController.for_condition(discount)
 

	
 
            past_use_count = discount.past_use_count
 

	
 

	
 
            past_use_count = clause.past_use_count
 
            if past_use_count >= clause.quantity:
 
                # This clause has exceeded its use count
 
                pass
 
            elif discount not in failed_discounts:
 
                # This clause is still available
 
                is_accepted = discount in accepted_discounts
 
                if is_accepted or cond.is_met(user, filtered=True):
 
                    # This clause is valid for this user
 
                    discounts.append(DiscountAndQuantity(
 
                        discount=discount,
 
                        clause=clause,
 
                        quantity=clause.quantity - past_use_count,
 
                    ))
 
                    accepted_discounts.add(discount)
 
                else:
 
                    # This clause is not valid for this user
 
                    failed_discounts.add(discount)
 
        return discounts
 

	
 
    @classmethod
 
    def _filtered_discounts(cls, user, categories, products):
 
        '''
 

	
 
        Returns:
...
 
@@ -118,69 +116,85 @@ class DiscountController(object):
 

	
 
        product_discounts = product_discounts.select_related(
 
            "product",
 
            "product__category",
 
        )
 

	
 
        all_category_discounts = (
 
            category_discounts | product_category_discounts
 
        )
 
        all_category_discounts = all_category_discounts.select_related(
 
            "category",
 
        )
 

	
 
        valid_discounts = conditions.DiscountBase.objects.filter(
 
            Q(discountforproduct__in=product_discounts) |
 
            Q(discountforcategory__in=all_category_discounts)
 
        )
 

	
 
        all_subsets = []
 

	
 
        for discounttype in discounttypes:
 
            discounts = discounttype.objects.filter(id__in=valid_discounts)
 
            ctrl = ConditionController.for_type(discounttype)
 
            discounts = ctrl.pre_filter(discounts, user)
 
            discounts = cls._annotate_with_past_uses(discounts, user)
 
            all_subsets.append(discounts)
 

	
 
        filtered_discounts = list(itertools.chain(*all_subsets))
 

	
 
        # Map from discount key to itself
 
        # (contains annotations needed in the future)
 
        from_filter = dict((i.id, i) for i in filtered_discounts)
 

	
 
        # The set of all potential discounts
 
        discount_clauses = set(itertools.chain(
 
        clause_sets = (
 
            product_discounts.filter(discount__in=filtered_discounts),
 
            all_category_discounts.filter(discount__in=filtered_discounts),
 
        ))
 
        )
 

	
 
        clause_sets = (
 
            cls._annotate_with_past_uses(i, user) for i in clause_sets
 
        )
 

	
 
        # The set of all potential discount clauses
 
        discount_clauses = set(itertools.chain(*clause_sets))
 

	
 
        # Replace discounts with the filtered ones
 
        # These are the correct subclasses (saves query later on), and have
 
        # correct annotations from filters if necessary.
 
        for clause in discount_clauses:
 
            clause.discount = from_filter[clause.discount.id]
 

	
 
        return discount_clauses
 

	
 
    @classmethod
 
    def _annotate_with_past_uses(cls, queryset, user):
 
        ''' Annotates the queryset with a usage count for that discount by the
 
        given user. '''
 
        ''' Annotates the queryset with a usage count for that discount claus
 
        by the given user. '''
 

	
 
        if queryset.model == conditions.DiscountForCategory:
 
            matches = (
 
                Q(category=F('discount__discountitem__product__category'))
 
            )
 
        elif queryset.model == conditions.DiscountForProduct:
 
            matches = (
 
                Q(product=F('discount__discountitem__product'))
 
            )
 

	
 
        in_carts = (
 
            Q(discount__discountitem__cart__user=user) &
 
            Q(discount__discountitem__cart__status=commerce.Cart.STATUS_PAID)
 
        )
 

	
 
        past_use_quantity = When(
 
            (
 
                Q(discountitem__cart__user=user) &
 
                Q(discountitem__cart__status=commerce.Cart.STATUS_PAID)
 
            ),
 
            then="discountitem__quantity",
 
            in_carts & matches,
 
            then="discount__discountitem__quantity",
 
        )
 

	
 
        past_use_quantity_or_zero = Case(
 
            past_use_quantity,
 
            default=Value(0),
 
        )
 

	
 
        queryset = queryset.annotate(
 
            past_use_count=Sum(past_use_quantity_or_zero)
 
        )
 
        return queryset
registrasion/tests/test_discount.py
Show inline comments
...
 
@@ -377,48 +377,71 @@ class DiscountTestCase(RegistrationCartTestCase):
 
        self.assertEqual(1, discounts[0].quantity)
 

	
 
        cart.next_cart()
 

	
 
    def test_discount_is_gone_after_quantity_exhausted(self):
 
        self.test_discount_quantity_is_correct_after_first_purchase()
 
        discounts = DiscountController.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 = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)  # Enable the discount
 
        discounts = DiscountController.available_discounts(
 
            self.USER_1,
 
            [],
 
            [self.PROD_3, self.PROD_4],
 
        )
 
        self.assertEqual(2, len(discounts))
 

	
 
    def test_product_discount_applied_on_different_invoices(self):
 
        # quantity=1 means "quantity per product"
 
        self.add_discount_prod_1_includes_prod_3_and_prod_4(quantity=1)
 
        cart = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)  # Enable the discount
 
        discounts = DiscountController.available_discounts(
 
            self.USER_1,
 
            [],
 
            [self.PROD_3, self.PROD_4],
 
        )
 
        self.assertEqual(2, len(discounts))
 
        # adding one of PROD_3 should make it no longer an available discount.
 
        cart.add_to_cart(self.PROD_3, 1)
 
        cart.next_cart()
 

	
 
        # should still have (and only have) the discount for prod_4
 
        discounts = DiscountController.available_discounts(
 
            self.USER_1,
 
            [],
 
            [self.PROD_3, self.PROD_4],
 
        )
 
        self.assertEqual(1, len(discounts))
 

	
 
    def test_discounts_are_released_by_refunds(self):
 
        self.add_discount_prod_1_includes_prod_2(quantity=2)
 
        cart = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_1, 1)  # Enable the discount
 
        discounts = DiscountController.available_discounts(
 
            self.USER_1,
 
            [],
 
            [self.PROD_2],
 
        )
 
        self.assertEqual(1, len(discounts))
 

	
 
        cart.next_cart()
 

	
 
        cart = TestingCartController.for_user(self.USER_1)
 
        cart.add_to_cart(self.PROD_2, 2)  # The discount will be exhausted
 

	
 
        cart.next_cart()
 

	
 
        discounts = DiscountController.available_discounts(
 
            self.USER_1,
 
            [],
 
            [self.PROD_2],
 
        )
 
        self.assertEqual(0, len(discounts))
0 comments (0 inline, 0 general)