Removed multi-dimensional item variations [backwards-incompatible]

This commit is contained in:
Raphael Michel
2015-12-13 15:03:56 +01:00
parent bc13ba9517
commit f748752391
36 changed files with 616 additions and 2019 deletions

View File

@@ -110,277 +110,6 @@ def selector(values, prop):
]
def sort(v, prop):
# Given a list of variations, this will sort them by their position
# on the x-axis
return v[prop.id].sortkey
class VariationsFieldRenderer(forms.widgets.CheckboxFieldRenderer):
"""
This is the default renderer for a VariationsField. Based on the choice input class
this renders a list or a matrix of checkboxes/radio buttons/...
"""
def __init__(self, name, value, attrs, choices):
self.name = name
self.value = value
self.attrs = attrs
self.choices = choices
def render(self):
"""
Outputs a grid for this set of choice fields.
"""
if len(self.choices) == 0:
raise ValueError("Can't handle empty lists")
variations = []
for key, value in self.choices:
value['key'] = key
variations.append(value)
properties = [v.prop for v in variations[0].relevant_values()]
dimension = len(properties)
id_ = self.attrs.get('id', None)
start_tag = format_html('<div class="variations" id="{0}">', id_) if id_ else '<div class="variations">'
output = [start_tag]
# TODO: This is very duplicate to pretixcontrol.views.item.ItemVariations.get_forms()
# Find a common abstraction to avoid the repetition.
if dimension == 0:
output.append(format_html('<em>{0}</em>', _("not applicable")))
elif dimension == 1:
output = self.render_1d(output, variations, properties)
else:
output = self.render_nd(output, variations, properties)
output.append(
('<div class="help-block"><a href="#" class="variations-select-all">{0}</a> · '
'<a href="#" class="variations-select-none">{1}</a></div></div>').format(
_("Select all"),
_("Deselect all")
)
)
return mark_safe('\n'.join(output))
def render_1d(self, output, variations, properties):
output.append('<ul>')
for i, variation in enumerate(variations):
final_attrs = dict(
self.attrs.copy(), type=self.choice_input_class.input_type,
name=self.name, value=variation['key']
)
if variation['key'] in self.value:
final_attrs['checked'] = 'checked'
w = self.choice_input_class(
self.name, self.value, self.attrs.copy(),
(variation['key'], variation[properties[0].id].value),
i
)
output.append(format_html('<li>{0}</li>', force_text(w)))
output.append('</ul>')
return output
def render_nd(self, output, variations, properties):
# prop1 is the property on all the grid's y-axes
prop1 = properties[0]
prop1v = list(prop1.values.all())
# prop2 is the property on all the grid's x-axes
prop2 = properties[1]
prop2v = list(prop2.values.all())
# We now iterate over the cartesian product of all the other
# properties which are NOT on the axes of the grid because we
# create one grid for any combination of them.
for gridrow in product(*[prop.values.all() for prop in properties[2:]]):
if len(gridrow) > 0:
output.append('<strong>')
output.append(", ".join([str(value.value) for value in gridrow]))
output.append('</strong>')
output.append('<table class="table"><thead><tr><th></th>')
for val2 in prop2v:
output.append(format_html('<th>{0}</th>', val2.value))
output.append('</thead><tbody>')
for val1 in prop1v:
output.append(format_html('<tr><th>{0}</th>', val1.value))
# We are now inside one of the rows of the grid and have to
# select the variations to display in this row. In order to
# achieve this, we use the 'selector' lambda defined above.
# It gives us a normalized, comparable version of a set of
# PropertyValue objects. In this case, we compute the
# selector of our row as the selector of the sum of the
# values defining our grind and the value defining our row.
selection = selector(gridrow + (val1,), prop2)
# We now iterate over all variations who generate the same
# selector as 'selection'.
filtered = [v for v in variations if selector(v.relevant_values(), prop2) == selection]
for variation in sorted(filtered, key=partial(sort, prop=prop2)):
final_attrs = dict(
self.attrs.copy(), type=self.choice_input_class.input_type,
name=self.name, value=variation['key']
)
if variation['key'] in self.value:
final_attrs['checked'] = 'checked'
output.append(format_html('<td><label><input{0} /></label></td>', flatatt(final_attrs)))
output.append('</td>')
output.append('</tbody></table>')
return output
class VariationsCheckboxRenderer(VariationsFieldRenderer):
"""
This is the same as VariationsFieldRenderer but with the choice input class
forced to checkboxes
"""
choice_input_class = forms.widgets.CheckboxChoiceInput
class VariationsSelectMultiple(forms.CheckboxSelectMultiple):
"""
This is the default widget for a VariationsField
"""
renderer = VariationsCheckboxRenderer
_empty_value = []
class VariationsField(forms.ModelMultipleChoiceField):
"""
This form field is intended to be used to let the user select a
variation of a certain item, for example in a restriction plugin.
As this field expects the non-standard keyword parameter ``item``
at initialization time, this is field is normally named ``variations``
and lives inside a ``pretixcontrol.views.forms.RestrictionForm``, which
does some magic to provide this parameter.
"""
def __init__(self, *args, item=None, **kwargs):
self.item = item
if 'widget' not in args or kwargs['widget'] is None:
kwargs['widget'] = VariationsSelectMultiple
super().__init__(*args, **kwargs)
def set_item(self, item: Item):
assert isinstance(item, Item)
self.item = item
self._set_choices(self._get_choices())
def _get_choices(self) -> "list[(str, VariationDict)]":
"""
We can't use a normal QuerySet as there theoretically might be
two types of variations: Some who already have a ItemVariation
object associated with them and some who don't. We therefore use
the item's ``get_all_variations`` method. In the first case, we
use the ItemVariation objects primary key as our choice, key,
in the latter case we use a string constructed from the values
(see VariationDict.key() for implementation details).
"""
if self.item is None:
return ()
variations = self.item.get_all_variations(use_cache=True)
return (
(
v['variation'].id if 'variation' in v else v.key(),
v
) for v in variations
)
def clean(self, value: "list[int]"):
"""
At cleaning time, we have to clean up the mess we produced with our
_get_choices implementation. In the case of ItemVariation object ids
we don't to anything to them, but if one of the selected items is a
list of PropertyValue objects (see _get_choices), we need to create
a new ItemVariation object for this combination and then add this to
our list of selected items.
"""
if self.item is None:
raise ValueError(
"VariationsField object was not properly initialized. Please"
"use a pretixcontrol.views.forms.RestrictionForm form instead of"
"a plain Django ModelForm"
)
# Standard validation foo
if self.required and not value:
raise ValidationError(self.error_messages['required'], code='required')
elif not self.required and not value:
return []
if not isinstance(value, (list, tuple)):
raise ValidationError(self.error_messages['list'], code='list')
cleaned_value = self._clean_value(value)
qs = self.item.variations.filter(id__in=cleaned_value)
# Re-check for consistency
pks = set(force_text(getattr(o, "id")) for o in qs)
for val in cleaned_value:
if force_text(val) not in pks:
raise ValidationError(
self.error_messages['invalid_choice'],
code='invalid_choice',
params={'value': val},
)
# Since this overrides the inherited ModelChoiceField.clean
# we run custom validators here
self.run_validators(cleaned_value)
return qs
def _clean_value(self, value):
# Build up a cache of variations having an ItemVariation object
# For implementation details, see ItemVariation.get_all_variations()
# which uses a very similar method
all_variations = self.item.variations.all().prefetch_related("values")
variations_cache = {
var.to_variation_dict().identify(): var.id for var in all_variations
}
cleaned_value = []
# Wrap this in a transaction to prevent strange database state if we
# get a ValidationError half-way through
with transaction.atomic():
for pk in value:
if ":" in pk:
# A combination of PropertyValues was given
# Hash the combination in the same way as in our cache above
key = ",".join([pair.split(":")[1] for pair in sorted(pk.split(","))])
if key in variations_cache:
# An ItemVariation object already exists for this variation,
# so use this. (This might occur if the variation object was
# created _after_ the user loaded the form but _before_ he
# submitted it.)
cleaned_value.append(str(variations_cache[key]))
continue
# No ItemVariation present, create one!
var = ItemVariation()
var.item_id = self.item.id
var.save()
# Add the values to the ItemVariation object
try:
var.add_values_from_string(pk)
except:
raise ValidationError(
self.error_messages['invalid_pk_value'],
code='invalid_pk_value',
params={'pk': value},
)
variations_cache[key] = var.id
cleaned_value.append(str(var.id))
else:
# An ItemVariation id was given
cleaned_value.append(pk)
return cleaned_value
choices = property(_get_choices, forms.ChoiceField._set_choices)
class ExtFileField(forms.FileField):
def __init__(self, *args, **kwargs):
ext_whitelist = kwargs.pop("ext_whitelist")
@@ -396,108 +125,3 @@ class ExtFileField(forms.FileField):
if ext not in self.ext_whitelist:
raise forms.ValidationError(_("Filetype not allowed!"))
return data
class BaseNestedFormset(I18nFormSet):
def add_fields(self, form, index):
# allow the super class to create the fields as usual
super().add_fields(form, index)
form.nested = []
for f in self.nested_formset_class:
inner_formset = f(
instance=form.instance,
data=form.data if form.is_bound else None,
prefix='%s-%s' % (form.prefix, f.get_default_prefix()),
queryset=form.instance.values.all(),
event=self.event
)
form.nested.append(inner_formset)
def is_valid(self):
result = super(BaseNestedFormset, self).is_valid()
if self.is_bound:
# look at any nested formsets, as well
for form in self.forms:
if not self._should_delete_form(form):
for n in form.nested:
result = result and n.is_valid()
return result
def save(self, commit=True):
result = super(BaseNestedFormset, self).save(commit=commit)
for form in self.forms:
if not self._should_delete_form(form):
for n in form.nested:
n.save(commit=commit)
return result
def nestedformset_factory(model, nested_formset, form=ModelForm,
formset=BaseNestedFormset, fk_name=None, fields=None,
exclude=None, extra=3, can_order=False,
can_delete=True, max_num=None,
formfield_callback=None, widgets=None,
validate_max=False, localized_fields=None,
labels=None, help_texts=None, error_messages=None):
kwargs = {
'form': form,
'formfield_callback': formfield_callback,
'formset': formset,
'extra': extra,
'can_delete': can_delete,
'can_order': can_order,
'fields': fields,
'exclude': exclude,
'max_num': max_num,
'widgets': widgets,
'validate_max': validate_max,
'localized_fields': localized_fields,
'labels': labels,
'help_texts': help_texts,
'error_messages': error_messages,
}
nfs_class = modelformset_factory(model, **kwargs)
nfs_class.nested_formset_class = []
for f in nested_formset:
nfs_class.nested_formset_class.append(f)
return nfs_class
class NestedInnerI18nInlineFormSet(I18nFormSet):
"""A formset for child objects related to a parent."""
def __init__(self, data=None, files=None, instance=None,
save_as_new=False, prefix=None, queryset=None, **kwargs):
if instance is None:
self.instance = self.fk.rel.to()
else:
self.instance = instance
self.save_as_new = save_as_new
if queryset is None:
if self.instance is not None:
queryset = getattr(self.instance, self.fk.related_query_name()).all()
else:
queryset = self.model._default_manager
if self.instance.pk is not None:
qs = queryset
else:
qs = self.model._default_manager.none()
super().__init__(data, files, prefix=prefix, queryset=qs, **kwargs)
@property
def empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__inner_prefix__'),
empty_permitted=True,
event=self.event
)
self.add_fields(form, None)
return form

View File

@@ -1,15 +1,13 @@
import copy
from django import forms
from django.forms import BooleanField
from django.forms import BooleanField, ModelMultipleChoiceField
from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Property, PropertyValue, Question,
Quota,
Item, ItemCategory, ItemVariation, Question, Quota,
)
from pretix.control.forms import TolerantFormsetModelForm, VariationsField
class CategoryForm(I18nModelForm):
@@ -21,24 +19,6 @@ class CategoryForm(I18nModelForm):
]
class PropertyForm(I18nModelForm):
class Meta:
model = Property
localized_fields = '__all__'
fields = [
'name',
]
class PropertyValueForm(TolerantFormsetModelForm):
class Meta:
model = PropertyValue
localized_fields = '__all__'
fields = [
'value',
]
class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -75,13 +55,14 @@ class QuotaForm(I18nModelForm):
active_variations = set()
for item in items:
if len(item.properties.all()) > 0:
self.fields['item_%s' % item.id] = VariationsField(
item, label=_("Activate for"),
if len(item.variations.all()) > 0:
self.fields['item_%s' % item.id] = ModelMultipleChoiceField(
label=_("Activate for"),
required=False,
initial=active_variations
initial=active_variations,
queryset=item.variations.all(),
widget=forms.CheckboxSelectMultiple
)
self.fields['item_%s' % item.id].set_item(item)
else:
self.fields['item_%s' % item.id] = BooleanField(
label=_("Activate"),
@@ -125,6 +106,7 @@ class ItemVariationForm(I18nModelForm):
model = ItemVariation
localized_fields = '__all__'
fields = [
'value',
'active',
'default_price',
]