Merge branch 'fix_cart'

Resolves #13.
This commit is contained in:
Christopher Neugebauer 2016-04-06 16:13:06 +10:00
commit eb5dd59036
6 changed files with 233 additions and 38 deletions

View file

@ -120,14 +120,12 @@ class CartController(object):
# Test each product limit here # Test each product limit here
for product, quantity in product_quantities: for product, quantity in product_quantities:
if quantity < 0: if quantity < 0:
# TODO: batch errors
errors.append((product, "Value must be zero or greater.")) errors.append((product, "Value must be zero or greater."))
prod = ProductController(product) prod = ProductController(product)
limit = prod.user_quantity_remaining(self.cart.user) limit = prod.user_quantity_remaining(self.cart.user)
if quantity > limit: if quantity > limit:
# TODO: batch errors
errors.append(( errors.append((
product, product,
"You may only have %d of product: %s" % ( "You may only have %d of product: %s" % (
@ -149,7 +147,6 @@ class CartController(object):
to_add = sum(i[1] for i in by_cat[category]) to_add = sum(i[1] for i in by_cat[category])
if to_add > limit: if to_add > limit:
# TODO: batch errors
errors.append(( errors.append((
category, category,
"You may only have %d items in category: %s" % ( "You may only have %d items in category: %s" % (
@ -164,10 +161,8 @@ class CartController(object):
) )
if errs: if errs:
# TODO: batch errors for error in errs:
errors.append( errors.append(error)
("enabling_conditions", "An enabling condition failed")
)
if errors: if errors:
raise CartValidationError(errors) raise CartValidationError(errors)
@ -175,43 +170,79 @@ class CartController(object):
def apply_voucher(self, voucher_code): def apply_voucher(self, voucher_code):
''' Applies the voucher with the given code to this cart. ''' ''' Applies the voucher with the given code to this cart. '''
# Is voucher exhausted?
active_carts = rego.Cart.reserved_carts()
# Try and find the voucher # Try and find the voucher
voucher = rego.Voucher.objects.get(code=voucher_code.upper()) voucher = rego.Voucher.objects.get(code=voucher_code.upper())
# It's invalid for a user to enter a voucher that's exhausted # Re-applying vouchers should be idempotent
carts_with_voucher = active_carts.filter(vouchers=voucher) if voucher in self.cart.vouchers.all():
if len(carts_with_voucher) >= voucher.limit: return
raise ValidationError("This voucher is no longer available")
# It's not valid for users to re-enter a voucher they already have self._test_voucher(voucher)
user_carts_with_voucher = rego.Cart.objects.filter(
user=self.cart.user,
released=False,
vouchers=voucher,
)
if len(user_carts_with_voucher) > 0:
raise ValidationError("You have already entered this voucher.")
# If successful... # If successful...
self.cart.vouchers.add(voucher) self.cart.vouchers.add(voucher)
self.end_batch() self.end_batch()
def _test_voucher(self, voucher):
''' Tests whether this voucher is allowed to be applied to this cart.
Raises ValidationError if not. '''
# Is voucher exhausted?
active_carts = rego.Cart.reserved_carts()
# It's invalid for a user to enter a voucher that's exhausted
carts_with_voucher = active_carts.filter(vouchers=voucher)
carts_with_voucher = carts_with_voucher.exclude(pk=self.cart.id)
if carts_with_voucher.count() >= voucher.limit:
raise ValidationError("Voucher %s is no longer available" % voucher.code)
# It's not valid for users to re-enter a voucher they already have
user_carts_with_voucher = carts_with_voucher.filter(
user=self.cart.user,
)
if user_carts_with_voucher.count() > 0:
raise ValidationError("You have already entered this voucher.")
def _test_vouchers(self, vouchers):
''' Tests each of the vouchers against self._test_voucher() and raises
the collective ValidationError.
Future work will refactor _test_voucher in terms of this, and save some
queries. '''
errors = []
for voucher in vouchers:
try:
self._test_voucher(voucher)
except ValidationError as ve:
errors.append(ve)
if errors:
raise(ValidationError(ve))
def validate_cart(self): def validate_cart(self):
''' Determines whether the status of the current cart is valid; ''' Determines whether the status of the current cart is valid;
this is normally called before generating or paying an invoice ''' this is normally called before generating or paying an invoice '''
# TODO: validate vouchers cart = self.cart
user = self.cart.user
errors = []
items = rego.ProductItem.objects.filter(cart=self.cart) try:
self._test_vouchers(self.cart.vouchers.all())
except ValidationError as ve:
errors.append(ve)
items = rego.ProductItem.objects.filter(cart=cart)
product_quantities = list((i.product, i.quantity) for i in items) product_quantities = list((i.product, i.quantity) for i in items)
try:
self._test_limits(product_quantities) self._test_limits(product_quantities)
except ValidationError as ve:
for error in ve.error_list:
errors.append(error.message[1])
# Validate the discounts # Validate the discounts
discount_items = rego.DiscountItem.objects.filter(cart=self.cart) discount_items = rego.DiscountItem.objects.filter(cart=cart)
seen_discounts = set() seen_discounts = set()
for discount_item in discount_items: for discount_item in discount_items:
@ -223,12 +254,49 @@ class CartController(object):
pk=discount.pk) pk=discount.pk)
cond = ConditionController.for_condition(real_discount) cond = ConditionController.for_condition(real_discount)
if not cond.is_met(self.cart.user): if not cond.is_met(user):
raise ValidationError("Discounts are no longer available") errors.append(
ValidationError("Discounts are no longer available")
)
if errors:
raise ValidationError(errors)
@transaction.atomic
def fix_simple_errors(self):
''' This attempts to fix the easy errors raised by ValidationError.
This includes removing items from the cart that are no longer
available, recalculating all of the discounts, and removing voucher
codes that are no longer available. '''
# Fix vouchers first (this affects available discounts)
active_carts = rego.Cart.reserved_carts()
to_remove = []
for voucher in self.cart.vouchers.all():
try:
self._test_voucher(voucher)
except ValidationError as ve:
to_remove.append(voucher)
for voucher in to_remove:
self.cart.vouchers.remove(voucher)
# Fix products and discounts
items = rego.ProductItem.objects.filter(cart=self.cart)
products = set(i.product for i in items)
available = set(ProductController.available_products(
self.cart.user,
products=products,
))
not_available = products - available
zeros = [(product, 0) for product in not_available]
self.set_quantities(zeros)
@transaction.atomic
def recalculate_discounts(self): def recalculate_discounts(self):
''' Calculates all of the discounts available for this product. ''' Calculates all of the discounts available for this product.
NB should be transactional, and it's terribly inefficient.
''' '''
# Delete the existing entries. # Delete the existing entries.

View file

@ -44,6 +44,26 @@ class ConditionController(object):
except KeyError: except KeyError:
return ConditionController() 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 @classmethod
def test_enabling_conditions( def test_enabling_conditions(
cls, user, products=None, product_quantities=None): cls, user, products=None, product_quantities=None):
@ -83,6 +103,8 @@ class ConditionController(object):
# if there are no mandatory conditions # if there are no mandatory conditions
non_mandatory = defaultdict(lambda: False) non_mandatory = defaultdict(lambda: False)
messages = {}
for condition in all_conditions: for condition in all_conditions:
cond = cls.for_condition(condition) cond = cls.for_condition(condition)
remainder = cond.user_quantity_remaining(user) remainder = cond.user_quantity_remaining(user)
@ -104,12 +126,21 @@ class ConditionController(object):
consumed = 1 consumed = 1
met = consumed <= remainder 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: for product in all_products:
if condition.mandatory: if condition.mandatory:
mandatory[product] &= met mandatory[product] &= met
else: else:
non_mandatory[product] |= met non_mandatory[product] |= met
if not met and product not in messages:
messages[product] = message
valid = defaultdict(lambda: True) valid = defaultdict(lambda: True)
for product in itertools.chain(mandatory, non_mandatory): for product in itertools.chain(mandatory, non_mandatory):
if product in mandatory: if product in mandatory:
@ -119,7 +150,11 @@ class ConditionController(object):
# Otherwise, we need just one non-mandatory condition met # Otherwise, we need just one non-mandatory condition met
valid[product] = non_mandatory[product] valid[product] = non_mandatory[product]
error_fields = [product for product in valid if not valid[product]] error_fields = [
(product, messages[product])
for product in valid if not valid[product]
]
return error_fields return error_fields
def user_quantity_remaining(self, user): def user_quantity_remaining(self, user):

View file

@ -38,9 +38,10 @@ class ProductController(object):
if cls(product).user_quantity_remaining(user) > 0 if cls(product).user_quantity_remaining(user) > 0
) )
failed_conditions = set(ConditionController.test_enabling_conditions( failed_and_messages = ConditionController.test_enabling_conditions(
user, products=passed_limits user, products=passed_limits
)) )
failed_conditions = set(i[0] for i in failed_and_messages)
out = list(passed_limits - failed_conditions) out = list(passed_limits - failed_conditions)
out.sort(key=lambda product: product.order) out.sort(key=lambda product: product.order)

View file

@ -293,3 +293,48 @@ class EnablingConditionTestCases(RegistrationCartTestCase):
self.assertTrue(self.CAT_1 in cats) self.assertTrue(self.CAT_1 in cats)
self.assertTrue(self.CAT_2 in cats) self.assertTrue(self.CAT_2 in cats)
def test_validate_cart_when_enabling_conditions_become_unmet(self):
self.add_product_enabling_condition(mandatory=False)
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
cart.add_to_cart(self.PROD_1, 1)
# Should pass
cart.validate_cart()
cart.set_quantity(self.PROD_2, 0)
# Should fail
with self.assertRaises(ValidationError):
cart.validate_cart()
def test_fix_simple_errors_resolves_unavailable_products(self):
self.test_validate_cart_when_enabling_conditions_become_unmet()
cart = TestingCartController.for_user(self.USER_1)
# Should just remove all of the unavailable products
cart.fix_simple_errors()
# Should now succeed
cart.validate_cart()
# Should keep PROD_2 in the cart
items = rego.ProductItem.objects.filter(cart=cart.cart)
self.assertFalse([i for i in items if i.product == self.PROD_1])
def test_fix_simple_errors_does_not_remove_limited_items(self):
cart = TestingCartController.for_user(self.USER_1)
cart.add_to_cart(self.PROD_2, 1)
cart.add_to_cart(self.PROD_1, 10)
# Should just remove all of the unavailable products
cart.fix_simple_errors()
# Should now succeed
cart.validate_cart()
# Should keep PROD_2 in the cart
# and also PROD_1, which is now exhausted for user.
items = rego.ProductItem.objects.filter(cart=cart.cart)
self.assertTrue([i for i in items if i.product == self.PROD_1])

View file

@ -37,11 +37,23 @@ class VoucherTestCases(RegistrationCartTestCase):
cart_2.cart.active = False cart_2.cart.active = False
cart_2.cart.save() cart_2.cart.save()
# After the reservation duration, user 1 should not be able to apply # After the reservation duration, even though the voucher has applied,
# voucher, as user 2 has paid for their cart. # it exceeds the number of vouchers available.
self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cart_1.apply_voucher(voucher.code) cart_1.validate_cart()
def test_fix_simple_errors_resolves_unavailable_voucher(self):
self.test_apply_voucher()
# User has an exhausted voucher leftover from test_apply_voucher
cart_1 = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError):
cart_1.validate_cart()
cart_1.fix_simple_errors()
# This should work now.
cart_1.validate_cart()
def test_voucher_enables_item(self): def test_voucher_enables_item(self):
voucher = self.new_voucher() voucher = self.new_voucher()
@ -103,9 +115,11 @@ class VoucherTestCases(RegistrationCartTestCase):
voucher = self.new_voucher(limit=2) voucher = self.new_voucher(limit=2)
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
# You can apply the code twice, but it will only add to the cart once.
self.assertEqual(1, current_cart.cart.vouchers.count())
def test_voucher_can_only_be_applied_once_across_multiple_carts(self): def test_voucher_can_only_be_applied_once_across_multiple_carts(self):
voucher = self.new_voucher(limit=2) voucher = self.new_voucher(limit=2)
current_cart = TestingCartController.for_user(self.USER_1) current_cart = TestingCartController.for_user(self.USER_1)
@ -114,6 +128,8 @@ class VoucherTestCases(RegistrationCartTestCase):
inv = InvoiceController.for_cart(current_cart.cart) inv = InvoiceController.for_cart(current_cart.cart)
inv.pay("Hello!", inv.invoice.value) inv.pay("Hello!", inv.invoice.value)
current_cart = TestingCartController.for_user(self.USER_1)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
@ -133,3 +149,11 @@ class VoucherTestCases(RegistrationCartTestCase):
inv.refund("Hello!", inv.invoice.value) inv.refund("Hello!", inv.invoice.value)
current_cart.apply_voucher(voucher.code) current_cart.apply_voucher(voucher.code)
def test_fix_simple_errors_does_not_remove_limited_voucher(self):
voucher = self.new_voucher(code="VOUCHER")
current_cart = TestingCartController.for_user(self.USER_1)
current_cart.apply_voucher(voucher.code)
current_cart.fix_simple_errors()
self.assertEqual(1, current_cart.cart.vouchers.count())

View file

@ -337,6 +337,8 @@ def set_quantities_from_products_form(products_form, current_cart):
product, message = ve_field.message product, message = ve_field.message
if product in field_names: if product in field_names:
field = field_names[product] field = field_names[product]
elif isinstance(product, rego.Product):
continue
else: else:
field = None field = None
products_form.add_error(field, message) products_form.add_error(field, message)
@ -377,10 +379,30 @@ def checkout(request):
invoice. ''' invoice. '''
current_cart = CartController.for_user(request.user) current_cart = CartController.for_user(request.user)
if "fix_errors" in request.GET and request.GET["fix_errors"] == "true":
current_cart.fix_simple_errors()
try:
current_invoice = InvoiceController.for_cart(current_cart.cart) current_invoice = InvoiceController.for_cart(current_cart.cart)
except ValidationError as ve:
return checkout_errors(request, ve)
return redirect("invoice", current_invoice.invoice.id) return redirect("invoice", current_invoice.invoice.id)
def checkout_errors(request, errors):
error_list = []
for error in errors.error_list:
if isinstance(error, tuple):
error = error[1]
error_list.append(error)
data = {
"error_list": error_list,
}
return render(request, "registrasion/checkout_errors.html", data)
@login_required @login_required
def invoice(request, invoice_id): def invoice(request, invoice_id):