diff --git a/registrasion/admin.py b/registrasion/admin.py index e00b58ea..17969cd1 100644 --- a/registrasion/admin.py +++ b/registrasion/admin.py @@ -80,3 +80,15 @@ class VoucherAdmin(nested_admin.NestedAdmin): VoucherDiscountInline, VoucherEnablingConditionInline, ] + + +# Enabling conditions +@admin.register(rego.ProductEnablingCondition) +class ProductEnablingConditionAdmin(nested_admin.NestedAdmin): + model = rego.ProductEnablingCondition + + +# Enabling conditions +@admin.register(rego.CategoryEnablingCondition) +class CategoryEnablingConditionAdmin(nested_admin.NestedAdmin): + model = rego.CategoryEnablingCondition diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index e9532bf5..59b308ed 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -54,57 +54,91 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) - def add_to_cart(self, product, quantity): - ''' Adds _quantity_ of the given _product_ to the cart. Raises - ValidationError if constraints are violated.''' - - prod = ProductController(product) - - # TODO: Check enabling conditions for product for user - - if not prod.can_add_with_enabling_conditions(self.cart.user, quantity): - raise ValidationError("Not enough of that product left (ec)") - - if not prod.user_can_add_within_limit(self.cart.user, quantity): - raise ValidationError("Not enough of that product left (user)") - - try: - # Try to update an existing item within this cart if possible. - product_item = rego.ProductItem.objects.get( - cart=self.cart, - product=product) - product_item.quantity += quantity - except ObjectDoesNotExist: - product_item = rego.ProductItem.objects.create( - cart=self.cart, - product=product, - quantity=quantity, - ) - product_item.save() - + def end_batch(self): + ''' Performs operations that occur occur at the end of a batch of + product changes/voucher applications etc. ''' self.recalculate_discounts() self.extend_reservation() self.cart.revision += 1 self.cart.save() - def apply_voucher(self, voucher): - ''' Applies the given voucher to this cart. ''' + def set_quantity(self, product, quantity, batched=False): + ''' Sets the _quantity_ of the given _product_ in the cart to the given + _quantity_. ''' + + if quantity < 0: + raise ValidationError("Cannot have fewer than 0 items in cart.") + + try: + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product) + old_quantity = product_item.quantity + + if quantity == 0: + product_item.delete() + return + except ObjectDoesNotExist: + if quantity == 0: + return + + product_item = rego.ProductItem.objects.create( + cart=self.cart, + product=product, + quantity=0, + ) + + old_quantity = 0 + + # Validate the addition to the cart + 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)") + + if not prod.user_can_add_within_limit(self.cart.user, adjustment): + raise ValidationError("Not enough of that product left (user)") + + product_item.quantity = quantity + product_item.save() + + if not batched: + self.end_batch() + + def add_to_cart(self, product, quantity): + ''' Adds _quantity_ of the given _product_ to the cart. Raises + ValidationError if constraints are violated.''' + + try: + product_item = rego.ProductItem.objects.get( + cart=self.cart, + product=product) + old_quantity = product_item.quantity + except ObjectDoesNotExist: + old_quantity = 0 + self.set_quantity(product, old_quantity + quantity) + + def apply_voucher(self, voucher_code): + ''' Applies the voucher with the given code to this cart. ''' # TODO: is it valid for a cart to re-add a voucher that they have? # Is voucher exhausted? active_carts = rego.Cart.reserved_carts() + + # Try and find the voucher + voucher = rego.Voucher.objects.get(code=voucher_code.upper()) + carts_with_voucher = active_carts.filter(vouchers=voucher) if len(carts_with_voucher) >= voucher.limit: raise ValidationError("This voucher is no longer available") # If successful... self.cart.vouchers.add(voucher) - - self.extend_reservation() - self.cart.revision += 1 - self.cart.save() + self.end_batch() def validate_cart(self): ''' Determines whether the status of the current cart is valid; @@ -153,7 +187,12 @@ class CartController(object): # Delete the existing entries. rego.DiscountItem.objects.filter(cart=self.cart).delete() - for item in self.cart.productitem_set.all(): + # The highest-value discounts will apply to the highest-value + # products first. + product_items = self.cart.productitem_set.all() + product_items = product_items.order_by('product__price') + product_items = reversed(product_items) + for item in product_items: self._add_discount(item.product, item.quantity) def _add_discount(self, product, quantity): @@ -172,9 +211,7 @@ class CartController(object): # Get the count of past uses of this discount condition # as this affects the total amount we're allowed to use now. past_uses = rego.DiscountItem.objects.filter( - cart__active=False, discount=discount.discount, - product=product, ) agg = past_uses.aggregate(Sum("quantity")) past_uses = agg["quantity__sum"] diff --git a/registrasion/controllers/invoice.py b/registrasion/controllers/invoice.py index 5ce562f3..e9a654e4 100644 --- a/registrasion/controllers/invoice.py +++ b/registrasion/controllers/invoice.py @@ -59,20 +59,23 @@ class InvoiceController(object): # TODO: calculate line items. product_items = rego.ProductItem.objects.filter(cart=cart) + product_items = product_items.order_by( + "product__category__order", "product__order" + ) discount_items = rego.DiscountItem.objects.filter(cart=cart) invoice_value = Decimal() for item in product_items: + product = item.product line_item = rego.LineItem.objects.create( invoice=invoice, - description=item.product.name, + description="%s - %s" % (product.category.name, product.name), quantity=item.quantity, - price=item.product.price, + price=product.price, ) line_item.save() invoice_value += line_item.quantity * line_item.price for item in discount_items: - line_item = rego.LineItem.objects.create( invoice=invoice, description=item.discount.description, diff --git a/registrasion/forms.py b/registrasion/forms.py new file mode 100644 index 00000000..54b3115a --- /dev/null +++ b/registrasion/forms.py @@ -0,0 +1,55 @@ +import models as rego + +from django import forms + + +def CategoryForm(category): + + PREFIX = "product_" + + def field_name(product): + return PREFIX + ("%d" % product.id) + + class _CategoryForm(forms.Form): + + @staticmethod + def initial_data(product_quantities): + ''' Prepares initial data for an instance of this form. + product_quantities is a sequence of (product,quantity) tuples ''' + initial = {} + for product, quantity in product_quantities: + initial[field_name(product)] = quantity + + return initial + + def product_quantities(self): + ''' Yields a sequence of (product, quantity) tuples from the + cleaned form data. ''' + for name, value in self.cleaned_data.items(): + if name.startswith(PREFIX): + product_id = int(name[len(PREFIX):]) + yield (product_id, value, name) + + def disable_product(self, product): + ''' Removes a given product from this form. ''' + del self.fields[field_name(product)] + + products = rego.Product.objects.filter(category=category).order_by("order") + for product in products: + + help_text = "$%d -- %s" % (product.price, product.description) + + field = forms.IntegerField( + label=product.name, + help_text=help_text, + ) + _CategoryForm.base_fields[field_name(product)] = field + + return _CategoryForm + +class VoucherForm(forms.Form): + voucher = forms.CharField( + label="Voucher code", + help_text="If you have a voucher code, enter it here", + required=True, + ) diff --git a/registrasion/migrations/0001_initial.py b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py similarity index 99% rename from registrasion/migrations/0001_initial.py rename to registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py index ab78d1f0..cc1e3f0d 100644 --- a/registrasion/migrations/0001_initial.py +++ b/registrasion/migrations/0001_squashed_0002_auto_20160304_1723.py @@ -9,6 +9,8 @@ from django.conf import settings class Migration(migrations.Migration): + replaces = [('registrasion', '0001_initial'), ('registrasion', '0002_auto_20160304_1723')] + dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -225,12 +227,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='enablingconditionbase', name='categories', - field=models.ManyToManyField(to=b'registrasion.Category'), + field=models.ManyToManyField(to=b'registrasion.Category', blank=True), ), migrations.AddField( model_name='enablingconditionbase', name='products', - field=models.ManyToManyField(to=b'registrasion.Product'), + field=models.ManyToManyField(to=b'registrasion.Product', blank=True), ), migrations.AddField( model_name='discountitem', diff --git a/registrasion/models.py b/registrasion/models.py index 4866c00b..96ecf219 100644 --- a/registrasion/models.py +++ b/registrasion/models.py @@ -99,6 +99,11 @@ class Voucher(models.Model): def __str__(self): return "Voucher for %s" % self.recipient + def save(self, *a, **k): + ''' Normalise the voucher code to be uppercase ''' + self.code = self.code.upper() + super(Voucher, self).save(*a, **k) + recipient = models.CharField(max_length=64, verbose_name=_("Recipient")) code = models.CharField(max_length=16, unique=True, @@ -149,13 +154,13 @@ class DiscountForProduct(models.Model): cats = DiscountForCategory.objects.filter( discount=self.discount, category=self.product.category) - if len(prods) > 1 or self not in prods: + if len(prods) > 1: raise ValidationError( _("You may only have one discount line per product")) if len(cats) != 0: raise ValidationError( _("You may only have one discount for " - "a product or its category")) + "a product or its category")) discount = models.ForeignKey(DiscountBase, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) @@ -184,8 +189,8 @@ class DiscountForCategory(models.Model): if len(prods) != 0: raise ValidationError( _("You may only have one discount for " - "a product or its category")) - if len(cats) > 1 or self not in cats: + "a product or its category")) + if len(cats) > 1: raise ValidationError( _("You may only have one discount line per category")) @@ -257,8 +262,8 @@ class EnablingConditionBase(models.Model): description = models.CharField(max_length=255) mandatory = models.BooleanField(default=False) - products = models.ManyToManyField(Product) - categories = models.ManyToManyField(Category) + products = models.ManyToManyField(Product, blank=True) + categories = models.ManyToManyField(Category, blank=True) class TimeOrStockLimitEnablingCondition(EnablingConditionBase): diff --git a/registrasion/templates/invoice.html b/registrasion/templates/invoice.html new file mode 100644 index 00000000..a66132cf --- /dev/null +++ b/registrasion/templates/invoice.html @@ -0,0 +1,37 @@ + + +{% extends "site_base.html" %} +{% block body %} + +
Description | +Quantity | +Price/Unit | +Total | +
---|---|---|---|
{{line_item.description}} | +{{line_item.quantity}} | +{{line_item.price}} | +FIXME | +
TOTAL | ++ | + | {{ invoice.value }} | +
{{ category.description }}
+ + + + +{% endblock %} diff --git a/registrasion/tests/test_cart.py b/registrasion/tests/test_cart.py index c0c0bc49..c65011b9 100644 --- a/registrasion/tests/test_cart.py +++ b/registrasion/tests/test_cart.py @@ -3,6 +3,7 @@ import pytz from decimal import Decimal from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.test import TestCase @@ -81,7 +82,18 @@ class RegistrationCartTestCase(SetTimeMixin, TestCase): limit_per_user=10, order=10, ) - cls.PROD_2.save() + cls.PROD_3.save() + + cls.PROD_4 = rego.Product.objects.create( + name="Product 4", + description="This is a test product. It costs $5. " + "A user may have 10 of them.", + category=cls.CAT_2, + price=Decimal("5.00"), + limit_per_user=10, + order=10, + ) + cls.PROD_4.save() @classmethod def make_ceiling(cls, name, limit=None, start_time=None, end_time=None): @@ -159,6 +171,38 @@ class BasicCartTests(RegistrationCartTestCase): item = items[0] self.assertEquals(2, item.quantity) + def test_set_quantity(self): + current_cart = CartController.for_user(self.USER_1) + + def get_item(): + return rego.ProductItem.objects.get( + cart=current_cart.cart, + product=self.PROD_1) + + current_cart.set_quantity(self.PROD_1, 1) + self.assertEqual(1, get_item().quantity) + + # Setting the quantity to zero should remove the entry from the cart. + current_cart.set_quantity(self.PROD_1, 0) + with self.assertRaises(ObjectDoesNotExist): + get_item() + + current_cart.set_quantity(self.PROD_1, 9) + self.assertEqual(9, get_item().quantity) + + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_1, 11) + + self.assertEqual(9, get_item().quantity) + + with self.assertRaises(ValidationError): + current_cart.set_quantity(self.PROD_1, -1) + + self.assertEqual(9, get_item().quantity) + + current_cart.set_quantity(self.PROD_1, 2) + self.assertEqual(2, get_item().quantity) + def test_add_to_cart_per_user_limit(self): current_cart = CartController.for_user(self.USER_1) diff --git a/registrasion/tests/test_discount.py b/registrasion/tests/test_discount.py index c0709e8e..5d325eff 100644 --- a/registrasion/tests/test_discount.py +++ b/registrasion/tests/test_discount.py @@ -29,7 +29,10 @@ class DiscountTestCase(RegistrationCartTestCase): return discount @classmethod - def add_discount_prod_1_includes_cat_2(cls, amount=Decimal(100)): + def add_discount_prod_1_includes_cat_2( + cls, + amount=Decimal(100), + quantity=2): discount = rego.IncludedProductDiscount.objects.create( description="PROD_1 includes CAT_2 " + str(amount) + "%", ) @@ -40,7 +43,7 @@ class DiscountTestCase(RegistrationCartTestCase): discount=discount, category=cls.CAT_2, percentage=amount, - quantity=2 + quantity=quantity, ).save() return discount @@ -169,3 +172,31 @@ class DiscountTestCase(RegistrationCartTestCase): discount_items = list(cart.cart.discountitem_set.all()) self.assertEqual(2, discount_items[0].quantity) + + def test_category_discount_applies_once_per_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) + + # Add two items from category 2 + cart.add_to_cart(self.PROD_3, 1) + cart.add_to_cart(self.PROD_4, 1) + + discount_items = list(cart.cart.discountitem_set.all()) + # There is one discount, and it should apply to one item. + self.assertEqual(1, len(discount_items)) + self.assertEqual(1, discount_items[0].quantity) + + def test_category_discount_applies_to_highest_value(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) + + # Add two items from category 2, add the less expensive one first + cart.add_to_cart(self.PROD_4, 1) + cart.add_to_cart(self.PROD_3, 1) + + discount_items = list(cart.cart.discountitem_set.all()) + # There is one discount, and it should apply to the more expensive. + self.assertEqual(1, len(discount_items)) + self.assertEqual(self.PROD_3, discount_items[0].product) diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 4d0360ce..637e82db 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -91,7 +91,7 @@ class InvoiceTestCase(RegistrationCartTestCase): ).save() current_cart = CartController.for_user(self.USER_1) - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) # Should be able to create an invoice after the product is added current_cart.add_to_cart(self.PROD_1, 1) diff --git a/registrasion/tests/test_voucher.py b/registrasion/tests/test_voucher.py index 169a1b79..50b83417 100644 --- a/registrasion/tests/test_voucher.py +++ b/registrasion/tests/test_voucher.py @@ -3,6 +3,7 @@ import pytz from decimal import Decimal from django.core.exceptions import ValidationError +from django.db import IntegrityError from registrasion import models as rego from registrasion.controllers.cart import CartController @@ -15,10 +16,10 @@ UTC = pytz.timezone('UTC') class VoucherTestCases(RegistrationCartTestCase): @classmethod - def new_voucher(self): + def new_voucher(self, code="VOUCHER"): voucher = rego.Voucher.objects.create( recipient="Voucher recipient", - code="VOUCHER", + code=code, limit=1 ) voucher.save() @@ -30,18 +31,18 @@ class VoucherTestCases(RegistrationCartTestCase): self.set_time(datetime.datetime(2015, 01, 01, tzinfo=UTC)) cart_1 = CartController.for_user(self.USER_1) - cart_1.apply_voucher(voucher) + cart_1.apply_voucher(voucher.code) self.assertIn(voucher, cart_1.cart.vouchers.all()) # Second user should not be able to apply this voucher (it's exhausted) cart_2 = CartController.for_user(self.USER_2) with self.assertRaises(ValidationError): - cart_2.apply_voucher(voucher) + cart_2.apply_voucher(voucher.code) # After the reservation duration # user 2 should be able to apply voucher self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) - cart_2.apply_voucher(voucher) + cart_2.apply_voucher(voucher.code) cart_2.cart.active = False cart_2.cart.save() @@ -49,7 +50,7 @@ class VoucherTestCases(RegistrationCartTestCase): # voucher, as user 2 has paid for their cart. self.add_timedelta(rego.Voucher.RESERVATION_DURATION * 2) with self.assertRaises(ValidationError): - cart_1.apply_voucher(voucher) + cart_1.apply_voucher(voucher.code) def test_voucher_enables_item(self): voucher = self.new_voucher() @@ -69,7 +70,7 @@ class VoucherTestCases(RegistrationCartTestCase): current_cart.add_to_cart(self.PROD_1, 1) # Apply the voucher - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) def test_voucher_enables_discount(self): @@ -89,6 +90,20 @@ class VoucherTestCases(RegistrationCartTestCase): # Having PROD_1 in place should add a discount current_cart = CartController.for_user(self.USER_1) - current_cart.apply_voucher(voucher) + current_cart.apply_voucher(voucher.code) current_cart.add_to_cart(self.PROD_1, 1) self.assertEqual(1, len(current_cart.cart.discountitem_set.all())) + + def test_voucher_codes_unique(self): + voucher1 = self.new_voucher(code="VOUCHER") + with self.assertRaises(IntegrityError): + voucher2 = self.new_voucher(code="VOUCHER") + + def test_multiple_vouchers_work(self): + voucher1 = self.new_voucher(code="VOUCHER1") + voucher2 = self.new_voucher(code="VOUCHER2") + + def test_vouchers_case_insensitive(self): + voucher = self.new_voucher(code="VOUCHeR") + current_cart = CartController.for_user(self.USER_1) + current_cart.apply_voucher(voucher.code.lower()) diff --git a/registrasion/urls.py b/registrasion/urls.py new file mode 100644 index 00000000..746d163d --- /dev/null +++ b/registrasion/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url, patterns + +urlpatterns = patterns( + "registrasion.views", + url(r"^category/([0-9]+)$", "product_category", name="product_category"), + url(r"^checkout$", "checkout", name="checkout"), + url(r"^invoice/([0-9]+)$", "invoice", name="invoice"), + url(r"^invoice/([0-9]+)/pay$", "pay_invoice", name="pay_invoice"), +) diff --git a/registrasion/views.py b/registrasion/views.py new file mode 100644 index 00000000..3957da7d --- /dev/null +++ b/registrasion/views.py @@ -0,0 +1,135 @@ +from registrasion import forms +from registrasion import models as rego +from registrasion.controllers.cart import CartController +from registrasion.controllers.invoice import InvoiceController +from registrasion.controllers.product import ProductController + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError +from django.db import transaction +from django.shortcuts import redirect +from django.shortcuts import render + + +@login_required +def product_category(request, category_id): + ''' Registration selections form for a specific category of items ''' + + PRODUCTS_FORM_PREFIX = "products" + VOUCHERS_FORM_PREFIX = "vouchers" + + category_id = int(category_id) # Routing is [0-9]+ + category = rego.Category.objects.get(pk=category_id) + current_cart = CartController.for_user(request.user) + + CategoryForm = forms.CategoryForm(category) + + products = rego.Product.objects.filter(category=category) + products = products.order_by("order") + + if request.method == "POST": + cat_form = CategoryForm(request.POST, request.FILES, prefix=PRODUCTS_FORM_PREFIX) + voucher_form = forms.VoucherForm(request.POST, prefix=VOUCHERS_FORM_PREFIX) + + if voucher_form.is_valid(): + # Apply voucher + # leave + voucher = voucher_form.cleaned_data["voucher"] + try: + current_cart.apply_voucher(voucher) + except Exception as e: + voucher_form.add_error("voucher", e) + elif cat_form.is_valid(): + try: + with transaction.atomic(): + for product_id, quantity, field_name \ + in cat_form.product_quantities(): + product = rego.Product.objects.get(pk=product_id) + try: + current_cart.set_quantity( + product, quantity, batched=True) + except ValidationError as ve: + cat_form.add_error(field_name, ve) + if cat_form.errors: + raise ValidationError("Cannot add that stuff") + current_cart.end_batch() + except ValidationError as ve: + pass + + else: + # Create initial data for each of products in category + items = rego.ProductItem.objects.filter( + product__category=category, + cart=current_cart.cart, + ) + quantities = [] + for product in products: + # Only add items that are enabled. + prod = ProductController(product) + try: + quantity = items.get(product=product).quantity + except ObjectDoesNotExist: + quantity = 0 + quantities.append((product, quantity)) + + initial = CategoryForm.initial_data(quantities) + cat_form = CategoryForm(prefix=PRODUCTS_FORM_PREFIX, initial=initial) + + voucher_form = forms.VoucherForm(prefix=VOUCHERS_FORM_PREFIX) + + for product in products: + # Remove fields that do not have an enabling condition. + prod = ProductController(product) + if not prod.can_add_with_enabling_conditions(request.user, 0): + cat_form.disable_product(product) + + + data = { + "category": category, + "form": cat_form, + "voucher_form": voucher_form, + } + + return render(request, "product_category.html", data) + + +@login_required +def checkout(request): + ''' Runs checkout for the current cart of items, ideally generating an + invoice. ''' + + current_cart = CartController.for_user(request.user) + current_invoice = InvoiceController.for_cart(current_cart.cart) + + return redirect("invoice", current_invoice.invoice.id) + + +@login_required +def invoice(request, invoice_id): + ''' Displays an invoice for a given invoice id. ''' + + invoice_id = int(invoice_id) + inv = rego.Invoice.objects.get(pk=invoice_id) + current_invoice = InvoiceController(inv) + + data = { + "invoice": current_invoice.invoice, + } + + return render(request, "invoice.html", data) + +@login_required +def pay_invoice(request, invoice_id): + ''' Marks the invoice with the given invoice id as paid. + WORK IN PROGRESS FUNCTION. Must be replaced with real payment workflow. + + ''' + + invoice_id = int(invoice_id) + inv = rego.Invoice.objects.get(pk=invoice_id) + current_invoice = InvoiceController(inv) + if not inv.paid and not current_invoice.is_valid(): + current_invoice.pay("Demo invoice payment", inv.value) + + return redirect("invoice", current_invoice.invoice.id)