From 8c34c7498aeaad524e6602e6a7a1a3ab62406655 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:13:02 +1000 Subject: [PATCH 1/6] Factors _ProductsForm into _HasProductsFields --- registrasion/forms.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index 68302f56..f7157c82 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -47,7 +47,7 @@ class ManualPaymentForm(forms.ModelForm): # Products forms -- none of these have any fields: they are to be subclassed # and the fields added as needs be. -class _ProductsForm(forms.Form): +class _HasProductsFields(object): PRODUCT_PREFIX = "product_" @@ -57,7 +57,7 @@ class _ProductsForm(forms.Form): initial = self.initial_data(k["product_quantities"]) k["initial"] = initial del k["product_quantities"] - super(_ProductsForm, self).__init__(*a, **k) + super(_ProductsFieldsHelpers, self).__init__(*a, **k) @classmethod def field_name(cls, product): @@ -81,6 +81,10 @@ class _ProductsForm(forms.Form): return iter([]) +class _ProductsForm(_HasProductsFields, forms.Form): + pass + + class _QuantityBoxProductsForm(_ProductsForm): ''' Products entry form that allows users to enter quantities of desired products. ''' From c4274817a86b1b0789f3763b06aa09ea863a2d49 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:15:01 +1000 Subject: [PATCH 2/6] Moves ProductsForm to the top of its file --- registrasion/forms.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index f7157c82..f21f800a 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -45,7 +45,26 @@ class ManualPaymentForm(forms.ModelForm): # Products forms -- none of these have any fields: they are to be subclassed -# and the fields added as needs be. +# and the fields added as needs be. ProductsForm (the function) is responsible +# for the subclassing. + +def ProductsForm(category, products): + ''' Produces an appropriate _ProductsForm subclass for the given render + type. ''' + + # Each Category.RENDER_TYPE value has a subclass here. + RENDER_TYPES = { + inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, + inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, + } + + # Produce a subclass of _ProductsForm which we can alter the base_fields on + class ProductsForm(RENDER_TYPES[category.render_type]): + pass + + ProductsForm.set_fields(category, products) + return ProductsForm + class _HasProductsFields(object): @@ -57,7 +76,7 @@ class _HasProductsFields(object): initial = self.initial_data(k["product_quantities"]) k["initial"] = initial del k["product_quantities"] - super(_ProductsFieldsHelpers, self).__init__(*a, **k) + super(_HasProductsFields, self).__init__(*a, **k) @classmethod def field_name(cls, product): @@ -172,24 +191,6 @@ class _RadioButtonProductsForm(_ProductsForm): ) -def ProductsForm(category, products): - ''' Produces an appropriate _ProductsForm subclass for the given render - type. ''' - - # Each Category.RENDER_TYPE value has a subclass here. - RENDER_TYPES = { - inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, - inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, - } - - # Produce a subclass of _ProductsForm which we can alter the base_fields on - class ProductsForm(RENDER_TYPES[category.render_type]): - pass - - ProductsForm.set_fields(category, products) - return ProductsForm - - class VoucherForm(forms.Form): voucher = forms.CharField( label="Voucher code", From d9f9af9827bfc6f8a693178ab61f85e05dff0777 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:35:30 +1000 Subject: [PATCH 3/6] Modifies the Category model to allow for ITEM_QUANTITY forms --- .../migrations/0002_auto_20160822_0034.py | 20 +++++++++++++++++++ registrasion/models/inventory.py | 8 ++++++++ 2 files changed, 28 insertions(+) create mode 100644 registrasion/migrations/0002_auto_20160822_0034.py diff --git a/registrasion/migrations/0002_auto_20160822_0034.py b/registrasion/migrations/0002_auto_20160822_0034.py new file mode 100644 index 00000000..ab346388 --- /dev/null +++ b/registrasion/migrations/0002_auto_20160822_0034.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-08-22 00:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('registrasion', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='render_type', + field=models.IntegerField(choices=[(1, 'Radio button'), (2, 'Quantity boxes'), (3, 'Product selector and quantity box')], help_text='The registration form will render this category in this style.', verbose_name='Render type'), + ), + ] diff --git a/registrasion/models/inventory.py b/registrasion/models/inventory.py index 84f386e1..a84f0fa8 100644 --- a/registrasion/models/inventory.py +++ b/registrasion/models/inventory.py @@ -36,6 +36,12 @@ class Category(models.Model): where the user can specify a quantity of each Product type. This is useful for additional extras, like Dinner Tickets. + ``RENDER_TYPE_ITEM_QUANTITY`` shows a select menu to select a + Product type, and an input field, where the user can specify the + quantity for that Product type. This is useful for categories that + have a lot of options, from which the user is not going to select + all of the options. + limit_per_user (Optional[int]): This restricts the number of items from this Category that each attendee may claim. This extends across multiple Invoices. @@ -56,10 +62,12 @@ class Category(models.Model): RENDER_TYPE_RADIO = 1 RENDER_TYPE_QUANTITY = 2 + RENDER_TYPE_ITEM_QUANTITY = 3 CATEGORY_RENDER_TYPES = [ (RENDER_TYPE_RADIO, _("Radio button")), (RENDER_TYPE_QUANTITY, _("Quantity boxes")), + (RENDER_TYPE_ITEM_QUANTITY, _("Product selector and quantity box")), ] name = models.CharField( From 02e415c104c1d72f903db5c29c1727e09e606fdc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 10:41:39 +1000 Subject: [PATCH 4/6] Adds an implementation for item-quantity forms. --- registrasion/forms.py | 59 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index f21f800a..dfd1a9d8 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -56,6 +56,7 @@ def ProductsForm(category, products): RENDER_TYPES = { inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm, inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, + inventory.Category.RENDER_TYPE_ITEM_QUANTITY: _ItemQuantityProductsForm, } # Produce a subclass of _ProductsForm which we can alter the base_fields on @@ -152,7 +153,6 @@ class _RadioButtonProductsForm(_ProductsForm): def set_fields(cls, category, products): choices = [] for product in products: - choice_text = "%s -- $%d" % (product.name, product.price) choices.append((product.id, choice_text)) @@ -190,6 +190,63 @@ class _RadioButtonProductsForm(_ProductsForm): self.FIELD, ) +class _ItemQuantityProductsForm(_ProductsForm): + ''' Products entry form that allows users to select a product type, and + enter a quantity of that product. This version _only_ allows a specific + product type to be purchased.''' + + CHOICE_FIELD = "choice" + QUANTITY_FIELD = "quantity" + + @classmethod + def set_fields(cls, category, products): + choices = [] + for product in products: + choice_text = "%s -- $%d each" % (product.name, product.price) + choices.append((product.id, choice_text)) + + if not category.required: + choices.append((0, "No selection")) + + cls.base_fields[cls.CHOICE_FIELD] = forms.TypedChoiceField( + label=category.name, + widget=forms.Select, + choices=choices, + empty_value=0, + coerce=int, + ) + + cls.base_fields[cls.QUANTITY_FIELD] = forms.IntegerField( + label="Quantity", # TODO: internationalise + min_value=0, + max_value=500, # Issue #19. We should figure out real limit. + ) + + @classmethod + def initial_data(cls, product_quantities): + initial = {} + + for product, quantity in product_quantities: + if quantity > 0: + initial[cls.CHOICE_FIELD] = product.id + initial[cls.QUANTITY_FIELD] = quantity + break + + return initial + + def product_quantities(self): + our_choice = self.cleaned_data[self.CHOICE_FIELD] + our_quantity = self.cleaned_data[self.QUANTITY_FIELD] + choices = self.fields[self.CHOICE_FIELD].choices + for choice_value, choice_display in choices: + if choice_value == 0: + continue + yield ( + choice_value, + our_quantity if our_choice == choice_value else 0, + self.CHOICE_FIELD, + ) + class VoucherForm(forms.Form): voucher = forms.CharField( From d52fc6eb9d6366b2dcd9bca5669926f90dbc96e6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 13:17:24 +1000 Subject: [PATCH 5/6] Adds a formset for dealing with long-and-thin product categories. --- registrasion/forms.py | 104 +++++++++++++++++++++++++++++++++++++++--- registrasion/views.py | 18 +++----- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/registrasion/forms.py b/registrasion/forms.py index dfd1a9d8..e9ba1282 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -2,6 +2,7 @@ from registrasion.models import commerce from registrasion.models import inventory from django import forms +from django.core.exceptions import ValidationError class ApplyCreditNoteForm(forms.Form): @@ -64,6 +65,13 @@ def ProductsForm(category, products): pass ProductsForm.set_fields(category, products) + + if category.render_type == inventory.Category.RENDER_TYPE_ITEM_QUANTITY: + ProductsForm = forms.formset_factory( + ProductsForm, + formset=_ItemQuantityProductsFormSet, + ) + return ProductsForm @@ -100,6 +108,18 @@ class _HasProductsFields(object): cleaned form data. ''' return iter([]) + def add_product_error(self, product, error): + ''' Adds an error to the given product's field ''' + + ''' if product in field_names: + field = field_names[product] + elif isinstance(product, inventory.Product): + return + else: + field = None ''' + + self.add_error(self.field_name(product), error) + class _ProductsForm(_HasProductsFields, forms.Form): pass @@ -140,7 +160,7 @@ class _QuantityBoxProductsForm(_ProductsForm): for name, value in self.cleaned_data.items(): if name.startswith(self.PRODUCT_PREFIX): product_id = int(name[len(self.PRODUCT_PREFIX):]) - yield (product_id, value, name) + yield (product_id, value) class _RadioButtonProductsForm(_ProductsForm): @@ -190,10 +210,14 @@ class _RadioButtonProductsForm(_ProductsForm): self.FIELD, ) + def add_product_error(self, product, error): + self.add_error(cls.FIELD, error) + class _ItemQuantityProductsForm(_ProductsForm): ''' Products entry form that allows users to select a product type, and - enter a quantity of that product. This version _only_ allows a specific - product type to be purchased.''' + enter a quantity of that product. This version _only_ allows a single + product type to be purchased. This form is usually used in concert with the + _ItemQuantityProductsFormSet to allow selection of multiple products.''' CHOICE_FIELD = "choice" QUANTITY_FIELD = "quantity" @@ -201,17 +225,19 @@ class _ItemQuantityProductsForm(_ProductsForm): @classmethod def set_fields(cls, category, products): choices = [] + + if not category.required: + choices.append((0, "---")) + for product in products: choice_text = "%s -- $%d each" % (product.name, product.price) choices.append((product.id, choice_text)) - if not category.required: - choices.append((0, "No selection")) - cls.base_fields[cls.CHOICE_FIELD] = forms.TypedChoiceField( label=category.name, widget=forms.Select, choices=choices, + initial=0, empty_value=0, coerce=int, ) @@ -244,9 +270,73 @@ class _ItemQuantityProductsForm(_ProductsForm): yield ( choice_value, our_quantity if our_choice == choice_value else 0, - self.CHOICE_FIELD, ) + def add_product_error(self, product, error): + if self.CHOICE_FIELD not in self.cleaned_data: + return + + if product.id == self.cleaned_data[self.CHOICE_FIELD]: + self.add_error(self.QUANTITY_FIELD, error) + + +class _ItemQuantityProductsFormSet(_HasProductsFields, forms.BaseFormSet): + + @classmethod + def set_fields(cls, category, products): + raise ValueError("set_fields must be called on the underlying Form") + + @classmethod + def initial_data(cls, product_quantities): + ''' Prepares initial data for an instance of this form. + product_quantities is a sequence of (product,quantity) tuples ''' + + f = [ + { + _ItemQuantityProductsForm.CHOICE_FIELD: product.id, + _ItemQuantityProductsForm.QUANTITY_FIELD: quantity, + } + for product, quantity in product_quantities + if quantity > 0 + ] + return f + + def product_quantities(self): + ''' Yields a sequence of (product, quantity) tuples from the + cleaned form data. ''' + + products = set() + # Track everything so that we can yield some zeroes + all_products = set() + + for form in self: + if form.empty_permitted and not form.cleaned_data: + # This is the magical empty form at the end of the list. + continue + + for product, quantity in form.product_quantities(): + all_products.add(product) + if quantity == 0: + continue + if product in products: + form.add_error( + _ItemQuantityProductsForm.CHOICE_FIELD, + "You may only choose each product type once.", + ) + form.add_error( + _ItemQuantityProductsForm.QUANTITY_FIELD, + "You may only choose each product type once.", + ) + products.add(product) + yield product, quantity + + for product in (all_products - products): + yield product, 0 + + def add_product_error(self, product, error): + for form in self.forms: + form.add_product_error(product, error) + class VoucherForm(forms.Form): voucher = forms.CharField( diff --git a/registrasion/views.py b/registrasion/views.py index 7c3634d3..13ebd927 100644 --- a/registrasion/views.py +++ b/registrasion/views.py @@ -446,33 +446,29 @@ def _handle_products(request, category, products, prefix): def _set_quantities_from_products_form(products_form, current_cart): + # Makes id_to_quantity, a dictionary from product ID to its quantity quantities = list(products_form.product_quantities()) - id_to_quantity = dict(i[:2] for i in quantities) + id_to_quantity = dict(quantities) + + # Get the actual product objects pks = [i[0] for i in quantities] products = inventory.Product.objects.filter( id__in=pks, ).select_related("category").order_by("id") + quantities.sort(key = lambda i: i[0]) + # Match the product objects to their quantities product_quantities = [ (product, id_to_quantity[product.id]) for product in products ] - field_names = dict( - (i[0][0], i[1][2]) for i in zip(product_quantities, quantities) - ) try: current_cart.set_quantities(product_quantities) except CartValidationError as ve: for ve_field in ve.error_list: product, message = ve_field.message - if product in field_names: - field = field_names[product] - elif isinstance(product, inventory.Product): - continue - else: - field = None - products_form.add_error(field, message) + products_form.add_product_error(product, message) def _handle_voucher(request, prefix): From 482fe22d891142e9b380df0a773de4c864cc7877 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 22 Aug 2016 15:03:08 +1000 Subject: [PATCH 6/6] Better reporting of errors in long-and-thin categories --- registrasion/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registrasion/forms.py b/registrasion/forms.py index e9ba1282..552ea991 100644 --- a/registrasion/forms.py +++ b/registrasion/forms.py @@ -277,6 +277,7 @@ class _ItemQuantityProductsForm(_ProductsForm): return if product.id == self.cleaned_data[self.CHOICE_FIELD]: + self.add_error(self.CHOICE_FIELD, error) self.add_error(self.QUANTITY_FIELD, error)