diff --git a/registrasion/controllers/cart.py b/registrasion/controllers/cart.py index 89e660d6..f8cbd0f5 100644 --- a/registrasion/controllers/cart.py +++ b/registrasion/controllers/cart.py @@ -1,6 +1,7 @@ import collections import datetime import discount +import functools import itertools from django.core.exceptions import ObjectDoesNotExist @@ -19,6 +20,18 @@ from conditions import ConditionController from product import ProductController +def _modifies_cart(func): + ''' Decorator that makes the wrapped function raise ValidationError + if we're doing something that could modify the cart. ''' + + @functools.wraps(func) + def inner(self, *a, **k): + self._fail_if_cart_is_not_active() + return func(self, *a, **k) + + return inner + + class CartController(object): def __init__(self, cart): @@ -42,6 +55,12 @@ class CartController(object): ) return cls(existing) + def _fail_if_cart_is_not_active(self): + self.cart.refresh_from_db() + if self.cart.status != commerce.Cart.STATUS_ACTIVE: + raise ValidationError("You can only amend active carts.") + + @_modifies_cart def extend_reservation(self): ''' Updates the cart's time last updated value, which is used to determine whether the cart has reserved the items and discounts it @@ -64,6 +83,7 @@ class CartController(object): self.cart.time_last_updated = timezone.now() self.cart.reservation_duration = max(reservations) + @_modifies_cart def end_batch(self): ''' Performs operations that occur occur at the end of a batch of product changes/voucher applications etc. @@ -76,6 +96,7 @@ class CartController(object): self.cart.revision += 1 self.cart.save() + @_modifies_cart @transaction.atomic def set_quantities(self, product_quantities): ''' Sets the quantities on each of the products on each of the @@ -176,6 +197,7 @@ class CartController(object): if errors: raise CartValidationError(errors) + @_modifies_cart def apply_voucher(self, voucher_code): ''' Applies the voucher with the given code to this cart. ''' @@ -229,6 +251,37 @@ class CartController(object): if errors: raise(ValidationError(ve)) + def _test_required_categories(self): + ''' Makes sure that the owner of this cart has satisfied all of the + required category constraints in the inventory (be it in this cart + or others). ''' + + required = set(inventory.Category.objects.filter(required=True)) + + items = commerce.ProductItem.objects.filter( + product__category__required=True, + cart__user=self.cart.user, + ).exclude( + cart__status=commerce.Cart.STATUS_RELEASED, + ) + + for item in items: + print item + required.remove(item.product.category) + + errors = [] + for category in required: + msg = "You must have at least one item from: %s" % category + errors.append((None, msg)) + + if errors: + raise ValidationError(errors) + + def _append_errors(self, errors, ve): + for error in ve.error_list: + print error.message + errors.append(error.message[1]) + def validate_cart(self): ''' Determines whether the status of the current cart is valid; this is normally called before generating or paying an invoice ''' @@ -248,8 +301,12 @@ class CartController(object): try: self._test_limits(product_quantities) except ValidationError as ve: - for error in ve.error_list: - errors.append(error.message[1]) + self._append_errors(errors, ve) + + try: + self._test_required_categories() + except ValidationError as ve: + self._append_errors(errors, ve) # Validate the discounts discount_items = commerce.DiscountItem.objects.filter(cart=cart) @@ -272,6 +329,7 @@ class CartController(object): if errors: raise ValidationError(errors) + @_modifies_cart @transaction.atomic def fix_simple_errors(self): ''' This attempts to fix the easy errors raised by ValidationError. @@ -304,6 +362,7 @@ class CartController(object): self.set_quantities(zeros) + @_modifies_cart @transaction.atomic def recalculate_discounts(self): ''' Calculates all of the discounts available for this product. diff --git a/registrasion/forms.py b/registrasion/forms.py index 1037d229..68302f56 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -100,6 +100,7 @@ class _QuantityBoxProductsForm(_ProductsForm): label=product.name, help_text=help_text, min_value=0, + max_value=500, # Issue #19. We should figure out real limit. ) cls.base_fields[cls.field_name(product)] = field @@ -132,6 +133,9 @@ class _RadioButtonProductsForm(_ProductsForm): choice_text = "%s -- $%d" % (product.name, product.price) choices.append((product.id, choice_text)) + if not category.required: + choices.append((0, "No selection")) + cls.base_fields[cls.FIELD] = forms.TypedChoiceField( label=category.name, widget=forms.RadioSelect, @@ -155,6 +159,8 @@ class _RadioButtonProductsForm(_ProductsForm): ours = self.cleaned_data[self.FIELD] choices = self.fields[self.FIELD].choices for choice_value, choice_display in choices: + if choice_value == 0: + continue yield ( choice_value, 1 if ours == choice_value else 0, diff --git a/registrasion/migrations/0026_manualpayment_entered_by.py b/registrasion/migrations/0026_manualpayment_entered_by.py new file mode 100644 index 00000000..0e068b17 --- /dev/null +++ b/registrasion/migrations/0026_manualpayment_entered_by.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-25 06:05 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('registrasion', '0025_auto_20160425_0411'), + ] + + operations = [ + migrations.AddField( + model_name='manualpayment', + name='entered_by', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/registrasion/models/commerce.py b/registrasion/models/commerce.py index cea8cc11..7e86acf2 100644 --- a/registrasion/models/commerce.py +++ b/registrasion/models/commerce.py @@ -220,6 +220,8 @@ class ManualPayment(PaymentBase): class Meta: app_label = "registrasion" + entered_by = models.ForeignKey(User) + class CreditNote(PaymentBase): ''' Credit notes represent money accounted for in the system that do not diff --git a/registrasion/tests/controller_helpers.py b/registrasion/tests/controller_helpers.py index 1022ffe8..1684ea75 100644 --- a/registrasion/tests/controller_helpers.py +++ b/registrasion/tests/controller_helpers.py @@ -45,7 +45,7 @@ class TestingInvoiceController(InvoiceController): self.validate_allowed_to_pay() ''' Adds a payment ''' - commerce.ManualPayment.objects.create( + commerce.PaymentBase.objects.create( invoice=self.invoice, reference=reference, amount=amount, diff --git a/registrasion/tests/test_invoice.py b/registrasion/tests/test_invoice.py index 1b687c0c..9e5b55cf 100644 --- a/registrasion/tests/test_invoice.py +++ b/registrasion/tests/test_invoice.py @@ -499,3 +499,37 @@ class InvoiceTestCase(RegistrationCartTestCase): invoice.pay("Paying into the void.", val, pre_validate=False) cn = self._credit_note_for_invoice(invoice.invoice) self.assertEqual(val, cn.credit_note.value) + + def test_required_category_constraints_prevent_invoicing(self): + self.CAT_1.required = True + self.CAT_1.save() + + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_3, 1) + + # CAT_1 is required, we don't have CAT_1 yet + with self.assertRaises(ValidationError): + invoice = TestingInvoiceController.for_cart(cart.cart) + + # Now that we have CAT_1, we can check out the cart + cart.add_to_cart(self.PROD_1, 1) + invoice = TestingInvoiceController.for_cart(cart.cart) + + # Paying for the invoice should work fine + invoice.pay("Boop", invoice.invoice.value) + + # We have an item in the first cart, so should be able to invoice + # for the second cart, even without CAT_1 in it. + cart = TestingCartController.for_user(self.USER_1) + cart.add_to_cart(self.PROD_3, 1) + + invoice2 = TestingInvoiceController.for_cart(cart.cart) + + # Void invoice2, and release the first cart + # now we don't have any CAT_1 + invoice2.void() + invoice.refund() + + # Now that we don't have CAT_1, we can't checkout this cart + with self.assertRaises(ValidationError): + invoice = TestingInvoiceController.for_cart(cart.cart) diff --git a/registrasion/views.py b/registrasion/views.py index c71cdc9d..1d357d17 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -542,8 +542,14 @@ def _checkout_errors(request, errors): def invoice_access(request, access_code): - ''' Redirects to the first unpaid invoice for the attendee that matches - the given access code, if any. + ''' Redirects to an invoice for the attendee that matches the given access + code, if any. + + If the attendee has multiple invoices, we use the following tie-break: + + - If there's an unpaid invoice, show that, otherwise + - If there's a paid invoice, show the most recent one, otherwise + - Show the most recent invoid of all Arguments: @@ -552,21 +558,29 @@ def invoice_access(request, access_code): Returns: redirect: - Redirect to the first unpaid invoice for that user. + Redirect to the selected invoice for that user. Raises: - Http404: If there is no such invoice. + Http404: If the user has no invoices. ''' invoices = commerce.Invoice.objects.filter( user__attendee__access_code=access_code, - status=commerce.Invoice.STATUS_UNPAID, - ).order_by("issue_time") + ).order_by("-issue_time") + if not invoices: raise Http404() - invoice = invoices[0] + unpaid = invoices.filter(status=commerce.Invoice.STATUS_UNPAID) + paid = invoices.filter(status=commerce.Invoice.STATUS_PAID) + + if unpaid: + invoice = unpaid[0] # (should only be 1 unpaid invoice?) + elif paid: + invoice = paid[0] # Most recent paid invoice + else: + invoice = invoices[0] # Most recent of any invoices return redirect("invoice", invoice.id, access_code) @@ -655,6 +669,7 @@ def manual_payment(request, invoice_id): if request.POST and form.is_valid(): form.instance.invoice = inv + form.instance.entered_by = request.user form.save() current_invoice.update_status() form = forms.ManualPaymentForm(prefix=FORM_PREFIX)