Files
pretix_original/src/pretix/control/forms/item.py

823 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from decimal import Decimal
from urllib.parse import urlencode
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Max
from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse
from django.utils.translation import (
gettext as __, gettext_lazy as _, pgettext_lazy,
)
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2
from pretix.helpers.models import modelcopy
from pretix.helpers.money import change_decimal_field
class CategoryForm(I18nModelForm):
class Meta:
model = ItemCategory
localized_fields = '__all__'
fields = [
'name',
'internal_name',
'description',
'is_addon'
]
class QuestionForm(I18nModelForm):
question = I18nFormField(
label=_("Question"),
widget_kwargs={'attrs': {'rows': 2}},
widget=I18nTextarea
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['items'].queryset = self.instance.event.items.all()
self.fields['items'].required = True
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
ask_during_checkin=False
)
if self.instance.pk:
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
pk=self.instance.pk
)
self.fields['identifier'].required = False
self.fields['dependency_values'].required = False
self.fields['help_text'].widget.attrs['rows'] = 3
def clean_dependency_values(self):
val = self.data.getlist('dependency_values')
return val
def clean_dependency_question(self):
dep = val = self.cleaned_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
return val
def clean_ask_during_checkin(self):
val = self.cleaned_data.get('ask_during_checkin')
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
return val
def clean(self):
d = super().clean()
if d.get('dependency_question') and not d.get('dependency_values'):
raise ValidationError({'dependency_values': [_('This field is required')]})
if d.get('dependency_question') and d.get('ask_during_checkin'):
raise ValidationError(_('Dependencies between questions are not supported during check-in.'))
return d
class Meta:
model = Question
localized_fields = '__all__'
fields = [
'question',
'help_text',
'type',
'required',
'ask_during_checkin',
'hidden',
'identifier',
'items',
'dependency_question',
'dependency_values',
'print_on_invoice',
]
widgets = {
'items': forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
'dependency_values': forms.SelectMultiple,
}
field_classes = {
'items': SafeModelMultipleChoiceField,
'dependency_question': SafeModelChoiceField,
}
class QuestionOptionForm(I18nModelForm):
class Meta:
model = QuestionOption
localized_fields = '__all__'
fields = [
'answer',
]
class QuotaForm(I18nModelForm):
def __init__(self, **kwargs):
self.instance = kwargs.get('instance', None)
self.event = kwargs.get('event')
items = kwargs.pop('items', None) or self.event.items.prefetch_related('variations')
self.original_instance = modelcopy(self.instance) if self.instance else None
initial = kwargs.get('initial', {})
if self.instance and self.instance.pk and 'itemvars' not in initial:
initial['itemvars'] = [str(i.pk) for i in self.instance.items.all()] + [
'{}-{}'.format(v.item_id, v.pk) for v in self.instance.variations.all()
]
kwargs['initial'] = initial
super().__init__(**kwargs)
choices = []
for item in items:
if len(item.variations.all()) > 0:
for v in item.variations.all():
choices.append(('{}-{}'.format(item.pk, v.pk), '{} {}'.format(item, v.value)))
else:
choices.append(('{}'.format(item.pk), str(item)))
self.fields['itemvars'] = forms.MultipleChoiceField(
label=_('Products'),
required=False,
choices=choices,
widget=forms.CheckboxSelectMultiple
)
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'Date')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
self.fields['subevent'].required = True
else:
del self.fields['subevent']
class Meta:
model = Quota
localized_fields = '__all__'
fields = [
'name',
'size',
'subevent',
'close_when_sold_out',
'release_after_exit',
]
field_classes = {
'subevent': SafeModelChoiceField,
}
def save(self, *args, **kwargs):
creating = not self.instance.pk
inst = super().save(*args, **kwargs)
selected_items = set(list(self.event.items.filter(id__in=[
i.split('-')[0] for i in self.cleaned_data['itemvars']
])))
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
]))
current_items = [] if creating else self.instance.items.all()
current_variations = [] if creating else self.instance.variations.all()
self.instance.items.remove(*[i for i in current_items if i not in selected_items])
self.instance.items.add(*[i for i in selected_items if i not in current_items])
self.instance.variations.remove(*[i for i in current_variations if i not in selected_variations])
self.instance.variations.add(*[i for i in selected_variations if i not in current_variations])
return inst
class ItemCreateForm(I18nModelForm):
NONE = 'none'
EXISTING = 'existing'
NEW = 'new'
has_variations = forms.BooleanField(label=_('The product should exist in multiple variations'),
help_text=_('Select this option e.g. for t-shirts that come in multiple sizes. '
'You can select the variations in the next step.'),
required=False)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
self.user = kwargs.pop('user')
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True)
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
self.fields['category'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.items.categories.select2', kwargs={
'event': self.instance.event.slug,
'organizer': self.instance.event.organizer.slug,
}),
'data-placeholder': _('No category'),
}
)
self.fields['category'].widget.choices = self.fields['category'].choices
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
change_decimal_field(self.fields['default_price'], self.instance.event.currency)
self.fields['tax_rule'].empty_label = _('No taxation')
self.fields['copy_from'] = forms.ModelChoiceField(
label=_("Copy product information"),
queryset=self.event.items.all(),
widget=forms.Select,
empty_label=_('Do not copy'),
required=False
)
if self.event.tax_rules.exists():
self.fields['tax_rule'].required = True
if not self.event.has_subevents:
choices = [
(self.NONE, _("Do not add to a quota now")),
(self.EXISTING, _("Add product to an existing quota")),
(self.NEW, _("Create a new quota for this product"))
]
if not self.event.quotas.exists():
choices.remove(choices[1])
self.fields['quota_option'] = forms.ChoiceField(
label=_("Quota options"),
widget=forms.RadioSelect,
choices=choices,
initial=self.NONE,
required=False
)
self.fields['quota_add_existing'] = forms.ModelChoiceField(
label=_("Add to existing quota"),
widget=forms.Select(),
queryset=self.instance.event.quotas.all(),
required=False
)
self.fields['quota_add_new_name'] = forms.CharField(
label=_("Name"),
max_length=200,
widget=forms.TextInput(attrs={'placeholder': _("New quota name")}),
required=False
)
self.fields['quota_add_new_size'] = forms.IntegerField(
min_value=0,
label=_("Size"),
widget=forms.TextInput(attrs={'placeholder': _("Number of tickets")}),
help_text=_("Leave empty for an unlimited number of tickets."),
required=False
)
def save(self, *args, **kwargs):
if self.cleaned_data.get('copy_from'):
fields = (
'description',
'active',
'available_from',
'available_until',
'require_voucher',
'hide_without_voucher',
'allow_cancel',
'min_per_order',
'max_per_order',
'generate_tickets',
'checkin_attention',
'free_price',
'original_price',
'sales_channels',
'issue_giftcard',
'require_approval',
'allow_waitinglist',
'show_quota_left',
'hidden_if_available',
'require_bundling',
'checkin_attention',
)
for f in fields:
setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f))
else:
# Add to all sales channels by default
self.instance.sales_channels = [k for k in get_all_sales_channels().keys()]
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
instance = super().save(*args, **kwargs)
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
quota = self.cleaned_data.get('quota_add_existing')
quota.items.add(self.instance)
quota.log_action('pretix.event.quota.changed', user=self.user, data={
'item_added': self.instance.pk
})
elif self.cleaned_data.get('quota_option') == self.NEW:
quota_name = self.cleaned_data.get('quota_add_new_name')
quota_size = self.cleaned_data.get('quota_add_new_size')
quota = Quota.objects.create(
event=self.event, name=quota_name, size=quota_size
)
quota.items.add(self.instance)
quota.log_action('pretix.event.quota.added', user=self.user, data={
'name': quota_name,
'size': quota_size,
'items': [self.instance.pk]
})
if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
for variation in self.cleaned_data['copy_from'].variations.all():
ItemVariation.objects.create(item=instance, value=variation.value, active=variation.active,
position=variation.position, default_price=variation.default_price,
description=variation.description, original_price=variation.original_price)
else:
ItemVariation.objects.create(
item=instance, value=__('Standard')
)
if self.cleaned_data.get('copy_from'):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
for a in self.cleaned_data['copy_from'].addons.all():
instance.addons.create(addon_category=a.addon_category, min_count=a.min_count, max_count=a.max_count,
price_included=a.price_included, position=a.position)
for b in self.cleaned_data['copy_from'].bundles.all():
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
return instance
def clean(self):
cleaned_data = super().clean()
if not self.event.has_subevents:
if cleaned_data.get('quota_option') == self.NEW:
if not self.cleaned_data.get('quota_add_new_name'):
raise forms.ValidationError(
{'quota_add_new_name': [_("Quota name is required.")]}
)
elif cleaned_data.get('quota_option') == self.EXISTING:
if not self.cleaned_data.get('quota_add_existing'):
raise forms.ValidationError(
{'quota_add_existing': [_("Please select a quota.")]}
)
return cleaned_data
class Meta:
model = Item
localized_fields = '__all__'
fields = [
'name',
'internal_name',
'category',
'admission',
'default_price',
'tax_rule',
]
class ShowQuotaNullBooleanSelect(forms.NullBooleanSelect):
def __init__(self, attrs=None):
choices = (
('unknown', _('(Event default)')),
('true', _('Yes')),
('false', _('No')),
)
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
class TicketNullBooleanSelect(forms.NullBooleanSelect):
def __init__(self, attrs=None):
choices = (
('unknown', _('Choose automatically depending on event settings')),
('true', _('Yes, if ticket generation is enabled in general')),
('false', _('Never')),
)
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
class ItemUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
self.fields['description'].widget.attrs['placeholder'] = _(
'e.g. This reduced price is available for full-time students, jobless and people '
'over 65. This ticket includes access to all parts of the event, except the VIP '
'area.'
)
if self.event.tax_rules.exists():
self.fields['tax_rule'].required = True
self.fields['description'].widget.attrs['rows'] = '4'
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
required=False,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
change_decimal_field(self.fields['default_price'], self.event.currency)
self.fields['hidden_if_available'].queryset = self.event.quotas.all()
self.fields['hidden_if_available'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('Shown independently of other products')
}
)
self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices
self.fields['hidden_if_available'].required = False
self.fields['category'].queryset = self.instance.event.categories.all()
self.fields['category'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.items.categories.select2', kwargs={
'event': self.instance.event.slug,
'organizer': self.instance.event.organizer.slug,
}),
'data-placeholder': _('No category'),
}
)
self.fields['category'].widget.choices = self.fields['category'].choices
def clean(self):
d = super().clean()
if d['issue_giftcard']:
if d['tax_rule'] and d['tax_rule'].rate > 0:
self.add_error(
'tax_rule',
_("Gift card products should not be associated with non-zero tax rates since sales tax will be applied when the gift card is redeemed.")
)
if d['admission']:
self.add_error(
'admission',
_(
"Gift card products should not be admission products at the same time."
)
)
return d
class Meta:
model = Item
localized_fields = '__all__'
fields = [
'category',
'name',
'internal_name',
'active',
'sales_channels',
'admission',
'description',
'picture',
'default_price',
'free_price',
'tax_rule',
'available_from',
'available_until',
'require_voucher',
'require_approval',
'hide_without_voucher',
'allow_cancel',
'allow_waitinglist',
'max_per_order',
'min_per_order',
'checkin_attention',
'generate_tickets',
'original_price',
'require_bundling',
'show_quota_left',
'hidden_if_available',
'issue_giftcard',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'hidden_if_available': SafeModelChoiceField,
}
widgets = {
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
'generate_tickets': TicketNullBooleanSelect(),
'show_quota_left': ShowQuotaNullBooleanSelect()
}
class ItemVariationsFormSet(I18nFormSet):
template = "pretixcontrol/item/include_variations.html"
title = _('Variations')
def clean(self):
super().clean()
for f in self.forms:
if hasattr(f, '_delete_fail'):
f.fields['DELETE'].initial = False
f.fields['DELETE'].disabled = True
raise ValidationError(
message=_('The variation "%s" cannot be deleted because it has already been ordered by a user or '
'currently is in a user\'s cart. Please set the variation as "inactive" instead.'),
params=(str(f.instance),)
)
def _should_delete_form(self, form):
should_delete = super()._should_delete_form(form)
if should_delete and (form.instance.orderposition_set.exists() or form.instance.cartposition_set.exists()):
form._delete_fail = True
return False
return form.cleaned_data.get(DELETION_FIELD_NAME, False)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemVariationForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['default_price'], self.event.currency)
class Meta:
model = ItemVariation
localized_fields = '__all__'
fields = [
'value',
'active',
'default_price',
'original_price',
'description',
]
class ItemAddOnsFormSet(I18nFormSet):
title = _('Add-ons')
template = "pretixcontrol/item/include_addons.html"
def __init__(self, *args, **kwargs):
self.event = kwargs.get('event')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
def clean(self):
super().clean()
categories = set()
for i in range(0, self.total_form_count()):
form = self.forms[i]
if self.can_delete:
if self._should_delete_form(form):
# This form is going to be deleted so any of its errors
# should not cause the entire formset to be invalid.
try:
categories.remove(form.cleaned_data['addon_category'].pk)
except KeyError:
pass
continue
if 'addon_category' in form.cleaned_data:
if form.cleaned_data['addon_category'].pk in categories:
raise ValidationError(_('You added the same add-on category twice'))
categories.add(form.cleaned_data['addon_category'].pk)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemAddOnForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['addon_category'].queryset = self.event.categories.all()
self.fields['addon_category'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.items.categories.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
}
)
self.fields['addon_category'].widget.choices = self.fields['addon_category'].choices
class Meta:
model = ItemAddOn
localized_fields = '__all__'
fields = [
'addon_category',
'min_count',
'max_count',
'price_included',
'multi_allowed',
]
help_texts = {
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
'available add-ons are sold out.')
}
class ItemBundleFormSet(I18nFormSet):
template = "pretixcontrol/item/include_bundles.html"
title = _('Bundled products')
def __init__(self, *args, **kwargs):
self.event = kwargs.get('event')
self.item = kwargs.pop('item')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
kwargs['item'] = self.item
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
item=self.item,
event=self.event
)
self.add_fields(form, None)
return form
def clean(self):
super().clean()
ivs = set()
for i in range(0, self.total_form_count()):
form = self.forms[i]
if self.can_delete:
if self._should_delete_form(form):
# This form is going to be deleted so any of its errors
# should not cause the entire formset to be invalid.
try:
ivs.remove(form.cleaned_data['itemvar'])
except KeyError:
pass
continue
if 'itemvar' in form.cleaned_data:
if form.cleaned_data['itemvar'] in ivs:
raise ValidationError(_('You added the same bundled product twice.'))
ivs.add(form.cleaned_data['itemvar'])
class ItemBundleForm(I18nModelForm):
itemvar = forms.ChoiceField(label=_('Bundled product'))
def __init__(self, *args, **kwargs):
self.item = kwargs.pop('item')
super().__init__(*args, **kwargs)
instance = kwargs.get('instance', None)
initial = kwargs.get('initial', {})
if instance:
try:
if instance.bundled_variation:
initial['itemvar'] = '%d-%d' % (instance.bundled_item.pk, instance.bundled_variation.pk)
elif instance.bundled_item:
initial['itemvar'] = str(instance.bundled_item.pk)
except Item.DoesNotExist:
pass
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
choices = []
for i in self.event.items.prefetch_related('variations').all():
pname = str(i)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())
if variations:
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s' % (pname, v.value)))
else:
choices.append((str(i.pk), '%s' % pname))
self.fields['itemvar'].choices = choices
change_decimal_field(self.fields['designated_price'], self.event.currency)
def clean(self):
d = super().clean()
if not self.cleaned_data.get('designated_price'):
d['designated_price'] = Decimal('0.00')
self.instance.designated_price = Decimal('0.00')
if 'itemvar' in self.cleaned_data:
if '-' in self.cleaned_data['itemvar']:
itemid, varid = self.cleaned_data['itemvar'].split('-')
else:
itemid, varid = self.cleaned_data['itemvar'], None
item = Item.objects.get(pk=itemid, event=self.event)
if varid:
variation = ItemVariation.objects.get(pk=varid, item=item)
else:
variation = None
if item == self.item:
raise ValidationError(_("The bundled item must not be the same item as the bundling one."))
if item.bundles.exists():
raise ValidationError(_("The bundled item must not have bundles on its own."))
self.instance.bundled_item = item
self.instance.bundled_variation = variation
return d
class Meta:
model = ItemBundle
localized_fields = '__all__'
fields = [
'count',
'designated_price',
]
class ItemMetaValueForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.property = kwargs.pop('property')
super().__init__(*args, **kwargs)
self.fields['value'].required = False
self.fields['value'].widget.attrs['placeholder'] = self.property.default
self.fields['value'].widget.attrs['data-typeahead-url'] = (
reverse('control:event.items.meta.typeahead', kwargs={
'organizer': self.property.event.organizer.slug,
'event': self.property.event.slug
}) + '?' + urlencode({
'property': self.property.name,
})
)
class Meta:
model = ItemMetaValue
fields = ['value']
widgets = {
'value': forms.TextInput()
}