diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 0e060764cc..b672b3c57d 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -35,6 +35,12 @@ tax_rule integer The internal ID admission boolean ``true`` for items that grant admission to the event (such as primary tickets) and ``false`` for others (such as add-ons or merchandise). +personalized boolean ``true`` for items that require personalization according + to event settings. Only affects system-level fields, not + custom questions. Currently only allowed for products with + ``admission`` set to ``true``. For backwards compatibility, + when creating new items and this field is not given, it defaults + to the same value as ``admission``. position integer An integer, used for sorting picture file A product picture to be displayed in the shop (can be ``null``). @@ -158,7 +164,7 @@ meta_data object Values set for .. versionchanged:: 4.16 - The ``variations[x].meta_data`` attribute has been added. + The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added. Notes ----- @@ -213,6 +219,7 @@ Endpoints "tax_rate": "0.00", "tax_rule": 1, "admission": false, + "personalized": false, "issue_giftcard": false, "meta_data": {}, "position": 0, @@ -329,6 +336,7 @@ Endpoints "tax_rate": "0.00", "tax_rule": 1, "admission": false, + "personalized": false, "issue_giftcard": false, "meta_data": {}, "position": 0, @@ -426,6 +434,7 @@ Endpoints "tax_rate": "0.00", "tax_rule": 1, "admission": false, + "personalized": false, "issue_giftcard": false, "meta_data": {}, "position": 0, @@ -510,6 +519,7 @@ Endpoints "tax_rate": "0.00", "tax_rule": 1, "admission": false, + "personalized": false, "issue_giftcard": false, "meta_data": {}, "position": 0, @@ -626,6 +636,7 @@ Endpoints "tax_rate": "0.00", "tax_rule": 1, "admission": false, + "personalized": false, "issue_giftcard": false, "meta_data": {}, "position": 0, diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 6067eb1625..77971537bf 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -97,6 +97,7 @@ overpayment param passphrase percental +personalization pluggable positionid pre diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 97ad28e34b..e4add131ae 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -234,7 +234,7 @@ class ItemSerializer(I18nAwareModelSerializer): class Meta: model = Item fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', - 'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', + 'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', 'personalized', 'position', 'picture', 'available_from', 'available_until', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', @@ -262,6 +262,15 @@ class ItemSerializer(I18nAwareModelSerializer): Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_available(data.get('available_from'), data.get('available_until')) + if data.get('personalized') and not data.get('admission'): + raise ValidationError(_('Only admission products can currently be personalized.')) + + if data.get('admission') and 'personalized' not in data and not self.instance: + # Backwards compatibility + data['personalized'] = True + elif not data.get('admission'): + data['personalized'] = False + if data.get('issue_giftcard'): if data.get('tax_rule') and data.get('tax_rule').rate > 0: raise ValidationError( diff --git a/src/pretix/base/exporters/items.py b/src/pretix/base/exporters/items.py index 596fe0fdd1..dde6905a92 100644 --- a/src/pretix/base/exporters/items.py +++ b/src/pretix/base/exporters/items.py @@ -73,6 +73,7 @@ class ItemDataExporter(ListExporter): _("Free price input"), _("Sales tax"), _("Is an admission ticket"), + _("Personalized ticket"), _("Generate tickets"), _("Waiting list"), _("Available from"), @@ -144,6 +145,7 @@ class ItemDataExporter(ListExporter): _("Yes") if i.free_price else "", str(i.tax_rule) if i.tax_rule else "", _("Yes") if i.admission else "", + _("Yes") if i.personalized else "", _("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""), _("Yes") if i.allow_waitinglist else "", date_format(_max(i.available_from, v.available_from).astimezone(self.timezone), diff --git a/src/pretix/base/exporters/json.py b/src/pretix/base/exporters/json.py index 6d0cd75243..bf3aff800c 100644 --- a/src/pretix/base/exporters/json.py +++ b/src/pretix/base/exporters/json.py @@ -78,6 +78,7 @@ class JSONExporter(BaseExporter): 'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'), 'tax_name': str(item.tax_rule.name) if item.tax_rule else None, 'admission': item.admission, + 'personalized': item.personalized, 'active': item.active, 'sales_channels': item.sales_channels, 'description': str(item.description), diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 23ac056a35..9fcc7342e7 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -575,7 +575,7 @@ class BaseQuestionsForm(forms.Form): add_fields = {} - if item.admission and event.settings.attendee_names_asked: + if item.ask_attendee_data and event.settings.attendee_names_asked: add_fields['attendee_name_parts'] = NamePartsFormField( max_length=255, required=event.settings.attendee_names_required and not self.all_optional, @@ -584,7 +584,7 @@ class BaseQuestionsForm(forms.Form): label=_('Attendee name'), initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts), ) - if item.admission and event.settings.attendee_emails_asked: + if item.ask_attendee_data and event.settings.attendee_emails_asked: add_fields['attendee_email'] = forms.EmailField( required=event.settings.attendee_emails_required and not self.all_optional, label=_('Attendee email'), @@ -595,7 +595,7 @@ class BaseQuestionsForm(forms.Form): } ) ) - if item.admission and event.settings.attendee_company_asked: + if item.ask_attendee_data and event.settings.attendee_company_asked: add_fields['company'] = forms.CharField( required=event.settings.attendee_company_required and not self.all_optional, label=_('Company'), @@ -603,7 +603,7 @@ class BaseQuestionsForm(forms.Form): initial=(cartpos.company if cartpos else orderpos.company), ) - if item.admission and event.settings.attendee_addresses_asked: + if item.ask_attendee_data and event.settings.attendee_addresses_asked: add_fields['street'] = forms.CharField( required=event.settings.attendee_addresses_required and not self.all_optional, label=_('Address'), diff --git a/src/pretix/base/migrations/0227_item_personalized.py b/src/pretix/base/migrations/0227_item_personalized.py new file mode 100644 index 0000000000..1f320f08cd --- /dev/null +++ b/src/pretix/base/migrations/0227_item_personalized.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.16 on 2022-12-21 08:59 + +from django.db import migrations, models + + +def item_set_personalized(apps, schema_editor): + # We cannot really know if a position was bundled or an add-on, but we can at least guess + Item = apps.get_model("pretixbase", "Item") + Item.objects.filter(admission=True).update(personalized=True) + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0226_itemvariationmetavalue'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='personalized', + field=models.BooleanField(default=False), + ), + migrations.RunPython( + item_set_personalized, + migrations.RunPython.noop, + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 94c60dffcb..fed9e29f7f 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -310,6 +310,8 @@ class Item(LoggedModel): :type tax_rate: decimal.Decimal :param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise) :type admission: bool + :param personalized: ``True``, if attendee information should be collected for this ticket + :type personalized: bool :param picture: A product picture to be shown next to the product description :type picture: File :param available_from: The date this product goes on sale @@ -396,8 +398,14 @@ class Item(LoggedModel): admission = models.BooleanField( verbose_name=_("Is an admission ticket"), help_text=_( - 'Whether or not buying this product allows a person to enter ' - 'your event' + 'Whether or not buying this product allows a person to enter your event' + ), + default=False + ) + personalized = models.BooleanField( + verbose_name=_("Is a personalized ticket"), + help_text=_( + 'Whether or not buying this product allows to enter attendee information' ), default=False ) @@ -578,6 +586,10 @@ class Item(LoggedModel): return self.event.settings.show_quota_left return self.show_quota_left + @property + def ask_attendee_data(self): + return self.admission and self.personalized + def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False): price = price if price is not None else self.default_price diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d7480735a9..dd19a99932 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -808,7 +808,7 @@ class Order(LockModel, LoggedModel): return True ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) for cp in positions: - if (cp.item.admission and ask_names) or cp.item.questions.all(): + if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all(): return True return False # nothing there to modify diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 7518ddf685..96d6d5a816 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -206,7 +206,7 @@ DEFAULTS = { 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( label=_("Ask for attendee names"), - help_text=_("Ask for a name for all tickets which include admission to the event."), + help_text=_("Ask for a name for all personalized tickets."), ) }, 'attendee_names_required': { @@ -229,10 +229,10 @@ DEFAULTS = { label=_("Ask for email addresses per ticket"), help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent " "only to that email address. If you enable this option, the system will additionally ask for " - "individual email addresses for every admission ticket. This might be useful if you want to " + "individual email addresses for every personalized ticket. This might be useful if you want to " "obtain individual addresses for every attendee even in case of group orders. However, " "pretix will send the order confirmation by default only to the one primary email address, not to " - "the per-attendee addresses. You can however enable this in the E-mail settings."), + "the per-attendee addresses. You can however enable this in the email settings."), ) }, 'attendee_emails_required': { @@ -242,7 +242,7 @@ DEFAULTS = { 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( label=_("Require email addresses per ticket"), - help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the " + help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the " "above option for more details. One email address for the order confirmation will always be " "required regardless of this setting."), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}), @@ -2574,7 +2574,7 @@ Your {organizer} team""")) label=_("Attendee data explanation"), widget=I18nTextarea, widget_kwargs={'attrs': {'rows': '2'}}, - help_text=_("This text will be shown above the questions asked for every admission product. You can use it e.g. to explain " + help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain " "why you need information from them.") ) }, diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py index 16440b7b82..efd879a86d 100644 --- a/src/pretix/base/views/mixins.py +++ b/src/pretix/base/views/mixins.py @@ -78,7 +78,7 @@ class BaseQuestionsViewMixin: form.pos = cartpos or orderpos form.show_copy_answers_to_addon_button = form.pos.addon_to and ( set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or - (form.pos.addon_to.item.admission and form.pos.item.admission and ( + (form.pos.addon_to.item.ask_attendee_data and form.pos.item.ask_attendee_data and ( self.request.event.settings.attendee_names_asked or self.request.event.settings.attendee_emails_asked or self.request.event.settings.attendee_company_asked or diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 360812a3e0..ea5e67dd42 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -295,6 +295,7 @@ class ItemCreateForm(I18nModelForm): self.user = kwargs.pop('user') kwargs.setdefault('initial', {}) kwargs['initial'].setdefault('admission', True) + kwargs['initial'].setdefault('personalized', True) super().__init__(*args, **kwargs) self.fields['category'].queryset = self.instance.event.categories.all() @@ -403,6 +404,8 @@ class ItemCreateForm(I18nModelForm): self.instance.sales_channels = list(get_all_sales_channels().keys()) self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1 + if not self.instance.admission: + self.instance.personalized = False instance = super().save(*args, **kwargs) if not self.event.has_subevents and not self.cleaned_data.get('has_variations'): @@ -494,6 +497,7 @@ class ItemCreateForm(I18nModelForm): 'internal_name', 'category', 'admission', + 'personalized', 'default_price', 'tax_rule', ] @@ -588,13 +592,14 @@ class ItemUpdateForm(I18nModelForm): 'tax_rule', _("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.") ) - if d['admission']: + if d.get('admission'): self.add_error( 'admission', _( "Gift card products should not be admission products at the same time." ) ) + if d.get('require_membership') and not d.get('require_membership_types'): self.add_error( 'require_membership_types', @@ -602,6 +607,18 @@ class ItemUpdateForm(I18nModelForm): "If a valid membership is required, at least one valid membership type needs to be selected." ) ) + + if not d.get('admission'): + d['personalized'] = False + + if d.get('grant_membership_type'): + if not d['grant_membership_type'].transferable and not d['personalized']: + self.add_error( + 'personalized' if d['admission'] else 'admission', + _("Your product grants a non-transferable membership and should therefore be a personalized " + "admission ticket. Otherwise customers might not be able to use the membership later. If you " + "want the membership to be non-personalized, set the membership type to be transferable.") + ) return d def clean_picture(self): @@ -622,6 +639,7 @@ class ItemUpdateForm(I18nModelForm): 'active', 'sales_channels', 'admission', + 'personalized', 'description', 'picture', 'default_price', diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 0a01f8be2f..c877e51adb 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -90,7 +90,7 @@ -

{% trans "Attendee data (once per admission ticket)" %}

+

{% trans "Attendee data (once per personalized ticket)" %}

{% bootstrap_field sform.attendee_names_asked_required layout="control" %} {% bootstrap_field sform.attendee_emails_asked_required layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/item/create.html b/src/pretix/control/templates/pretixcontrol/item/create.html index 154a585281..d8c40d9311 100644 --- a/src/pretix/control/templates/pretixcontrol/item/create.html +++ b/src/pretix/control/templates/pretixcontrol/item/create.html @@ -20,13 +20,19 @@