Merge branch 'chrisjrn/long_and_thin'

Fixes #26
This commit is contained in:
Christopher Neugebauer 2016-08-22 15:03:32 +10:00
commit 1b40472835
4 changed files with 206 additions and 29 deletions

View file

@ -2,6 +2,7 @@ from registrasion.models import commerce
from registrasion.models import inventory from registrasion.models import inventory
from django import forms from django import forms
from django.core.exceptions import ValidationError
class ApplyCreditNoteForm(forms.Form): class ApplyCreditNoteForm(forms.Form):
@ -45,9 +46,36 @@ class ManualPaymentForm(forms.ModelForm):
# Products forms -- none of these have any fields: they are to be subclassed # 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.
class _ProductsForm(forms.Form): 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,
inventory.Category.RENDER_TYPE_ITEM_QUANTITY: _ItemQuantityProductsForm,
}
# 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)
if category.render_type == inventory.Category.RENDER_TYPE_ITEM_QUANTITY:
ProductsForm = forms.formset_factory(
ProductsForm,
formset=_ItemQuantityProductsFormSet,
)
return ProductsForm
class _HasProductsFields(object):
PRODUCT_PREFIX = "product_" PRODUCT_PREFIX = "product_"
@ -57,7 +85,7 @@ class _ProductsForm(forms.Form):
initial = self.initial_data(k["product_quantities"]) initial = self.initial_data(k["product_quantities"])
k["initial"] = initial k["initial"] = initial
del k["product_quantities"] del k["product_quantities"]
super(_ProductsForm, self).__init__(*a, **k) super(_HasProductsFields, self).__init__(*a, **k)
@classmethod @classmethod
def field_name(cls, product): def field_name(cls, product):
@ -80,6 +108,22 @@ class _ProductsForm(forms.Form):
cleaned form data. ''' cleaned form data. '''
return iter([]) 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
class _QuantityBoxProductsForm(_ProductsForm): class _QuantityBoxProductsForm(_ProductsForm):
''' Products entry form that allows users to enter quantities ''' Products entry form that allows users to enter quantities
@ -116,7 +160,7 @@ class _QuantityBoxProductsForm(_ProductsForm):
for name, value in self.cleaned_data.items(): for name, value in self.cleaned_data.items():
if name.startswith(self.PRODUCT_PREFIX): if name.startswith(self.PRODUCT_PREFIX):
product_id = int(name[len(self.PRODUCT_PREFIX):]) product_id = int(name[len(self.PRODUCT_PREFIX):])
yield (product_id, value, name) yield (product_id, value)
class _RadioButtonProductsForm(_ProductsForm): class _RadioButtonProductsForm(_ProductsForm):
@ -129,7 +173,6 @@ class _RadioButtonProductsForm(_ProductsForm):
def set_fields(cls, category, products): def set_fields(cls, category, products):
choices = [] choices = []
for product in products: for product in products:
choice_text = "%s -- $%d" % (product.name, product.price) choice_text = "%s -- $%d" % (product.name, product.price)
choices.append((product.id, choice_text)) choices.append((product.id, choice_text))
@ -167,23 +210,133 @@ class _RadioButtonProductsForm(_ProductsForm):
self.FIELD, self.FIELD,
) )
def add_product_error(self, product, error):
self.add_error(cls.FIELD, error)
def ProductsForm(category, products): class _ItemQuantityProductsForm(_ProductsForm):
''' Produces an appropriate _ProductsForm subclass for the given render ''' Products entry form that allows users to select a product type, and
type. ''' 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.'''
# Each Category.RENDER_TYPE value has a subclass here. CHOICE_FIELD = "choice"
RENDER_TYPES = { QUANTITY_FIELD = "quantity"
inventory.Category.RENDER_TYPE_QUANTITY: _QuantityBoxProductsForm,
inventory.Category.RENDER_TYPE_RADIO: _RadioButtonProductsForm, @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))
cls.base_fields[cls.CHOICE_FIELD] = forms.TypedChoiceField(
label=category.name,
widget=forms.Select,
choices=choices,
initial=0,
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,
)
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.CHOICE_FIELD, error)
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
# Produce a subclass of _ProductsForm which we can alter the base_fields on def product_quantities(self):
class ProductsForm(RENDER_TYPES[category.render_type]): ''' Yields a sequence of (product, quantity) tuples from the
pass cleaned form data. '''
ProductsForm.set_fields(category, products) products = set()
return ProductsForm # 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): class VoucherForm(forms.Form):

View file

@ -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'),
),
]

View file

@ -36,6 +36,12 @@ class Category(models.Model):
where the user can specify a quantity of each Product type. This is where the user can specify a quantity of each Product type. This is
useful for additional extras, like Dinner Tickets. 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 limit_per_user (Optional[int]): This restricts the number of items
from this Category that each attendee may claim. This extends from this Category that each attendee may claim. This extends
across multiple Invoices. across multiple Invoices.
@ -56,10 +62,12 @@ class Category(models.Model):
RENDER_TYPE_RADIO = 1 RENDER_TYPE_RADIO = 1
RENDER_TYPE_QUANTITY = 2 RENDER_TYPE_QUANTITY = 2
RENDER_TYPE_ITEM_QUANTITY = 3
CATEGORY_RENDER_TYPES = [ CATEGORY_RENDER_TYPES = [
(RENDER_TYPE_RADIO, _("Radio button")), (RENDER_TYPE_RADIO, _("Radio button")),
(RENDER_TYPE_QUANTITY, _("Quantity boxes")), (RENDER_TYPE_QUANTITY, _("Quantity boxes")),
(RENDER_TYPE_ITEM_QUANTITY, _("Product selector and quantity box")),
] ]
name = models.CharField( name = models.CharField(

View file

@ -446,33 +446,29 @@ def _handle_products(request, category, products, prefix):
def _set_quantities_from_products_form(products_form, current_cart): 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()) 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] pks = [i[0] for i in quantities]
products = inventory.Product.objects.filter( products = inventory.Product.objects.filter(
id__in=pks, id__in=pks,
).select_related("category").order_by("id") ).select_related("category").order_by("id")
quantities.sort(key = lambda i: i[0]) quantities.sort(key = lambda i: i[0])
# Match the product objects to their quantities
product_quantities = [ product_quantities = [
(product, id_to_quantity[product.id]) for product in products (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: try:
current_cart.set_quantities(product_quantities) current_cart.set_quantities(product_quantities)
except CartValidationError as ve: except CartValidationError as ve:
for ve_field in ve.error_list: for ve_field in ve.error_list:
product, message = ve_field.message product, message = ve_field.message
if product in field_names: products_form.add_product_error(product, message)
field = field_names[product]
elif isinstance(product, inventory.Product):
continue
else:
field = None
products_form.add_error(field, message)
def _handle_voucher(request, prefix): def _handle_voucher(request, prefix):