diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index a8d747076..cebc10a3a 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -64,6 +64,12 @@ original_price money (string) An original pri require_approval boolean If ``True``, orders with this product will need to be approved by the event organizer before they can be paid. +generate_tickets boolean If ``False``, tickets are never generated for this + product, regardless of other settings. If ``True``, + tickets are generated even if this is a + non-admission or add-on product, regardless of event + settings. If this is ``null``, regular ticketing + rules apply. has_variations boolean Shows whether or not this item has variations. variations list of objects A list with one object for each variation of this item. Can be empty. Only writable during creation, @@ -111,6 +117,10 @@ addons list of objects Definition of a The ``sales_channels`` attribute has been added. +.. versionchanged:: 2.4 + + The ``generate_tickets`` attribute has been added. + Notes ----- Please note that an item either always has variations or never has. Once created with variations the item can never @@ -174,6 +184,7 @@ Endpoints "max_per_order": null, "checkin_attention": false, "has_variations": false, + "generate_tickets": null, "require_approval": false, "variations": [ { @@ -256,6 +267,7 @@ Endpoints "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, + "generate_tickets": null, "min_per_order": null, "max_per_order": null, "checkin_attention": false, @@ -323,6 +335,7 @@ Endpoints "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, + "generate_tickets": null, "min_per_order": null, "max_per_order": null, "checkin_attention": false, @@ -379,6 +392,7 @@ Endpoints "allow_cancel": true, "min_per_order": null, "max_per_order": null, + "generate_tickets": null, "checkin_attention": false, "has_variations": true, "require_approval": false, @@ -462,6 +476,7 @@ Endpoints "available_until": null, "require_voucher": false, "hide_without_voucher": false, + "generate_tickets": null, "allow_cancel": true, "min_per_order": null, "max_per_order": null, diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 79d4b3055..43ee660e5 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer): 'position', 'picture', 'available_from', 'available_until', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', - 'variations', 'addons', 'original_price', 'require_approval') + 'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets') read_only_fields = ('has_variations', 'picture') def get_serializer_context(self): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index e254700dc..56c1e8d0b 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -114,9 +114,7 @@ class PositionDownloadsField(serializers.Field): if instance.order.status != Order.STATUS_PAID: if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending: return [] - if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons: - return [] - if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm: + if not instance.generate_ticket: return [] request = self.context['request'] diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 27b33a10e..875a060cd 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -456,10 +456,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS if pos.order.status != Order.STATUS_PAID: raise PermissionDenied("Downloads are not available for unpaid orders.") - if pos.addon_to_id and not request.event.settings.ticket_download_addons: - raise PermissionDenied("Downloads are not enabled for add-on products.") - if not pos.item.admission and not request.event.settings.ticket_download_nonadm: - raise PermissionDenied("Downloads are not enabled for non-admission products.") + if not pos.generate_ticket: + raise PermissionDenied("Downloads are not enabled for this product.") ct = CachedTicket.objects.filter( order_position=pos, provider=provider.identifier, file__isnull=False diff --git a/src/pretix/base/migrations/0108_auto_20190201_1527.py b/src/pretix/base/migrations/0108_auto_20190201_1527.py new file mode 100644 index 000000000..ba0f257f6 --- /dev/null +++ b/src/pretix/base/migrations/0108_auto_20190201_1527.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.5 on 2019-02-01 15:27 + +from django.db import migrations, models +import django.db.models.deletion +import jsonfallback.fields +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0107_auto_20190129_1337'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='generate_tickets', + field=models.NullBooleanField(verbose_name='Allow ticket download'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 3f177eef9..d40a7d6b8 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -287,6 +287,10 @@ class Item(LoggedModel): ), default=False ) + generate_tickets = models.NullBooleanField( + verbose_name=_("Generate tickets"), + blank=True, null=True, + ) position = models.IntegerField( default=0 ) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 00d4ec6da..a92cacb14 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -689,9 +689,7 @@ class Order(LockModel, LoggedModel): @property def positions_with_tickets(self): for op in self.positions.all(): - if op.addon_to_id and not self.event.settings.ticket_download_addons: - continue - if not op.item.admission and not self.event.settings.ticket_download_nonadm: + if not op.generate_ticket: continue yield op @@ -1579,6 +1577,15 @@ class OrderPosition(AbstractPosition): def sort_key(self): return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0 + @property + def generate_ticket(self): + if self.item.generate_tickets is not None: + return self.item.generate_tickets + return ( + (self.order.event.settings.ticket_download_addons or not self.addon_to_id) and + (self.event.settings.ticket_download_nonadm or self.item.admission) + ) + @classmethod def transform_cart_positions(cls, cp: List, order) -> list: from . import Voucher diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index de865148f..29f704896 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -297,6 +297,16 @@ class ItemCreateForm(I18nModelForm): ] +class TicketNullBooleanSelect(forms.NullBooleanSelect): + def __init__(self, attrs=None): + choices = ( + ('1', _('Choose automatically depending on event settings')), + ('2', _('Yes, if ticket generation is enabled in general')), + ('3', _('Never')), + ) + super(forms.NullBooleanSelect, self).__init__(attrs, choices) + + class ItemUpdateForm(I18nModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -340,6 +350,7 @@ class ItemUpdateForm(I18nModelForm): 'max_per_order', 'min_per_order', 'checkin_attention', + 'generate_tickets', 'original_price' ] field_classes = { @@ -349,6 +360,7 @@ class ItemUpdateForm(I18nModelForm): widgets = { 'available_from': SplitDateTimePickerWidget(), 'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}), + 'generate_tickets': TicketNullBooleanSelect() } diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 0fbd42b08..0af425fad 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -43,6 +43,7 @@ {% trans "Additional settings" %} {% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.require_approval layout="control" %} + {% bootstrap_field form.generate_tickets layout="control" %} {% for f in plugin_forms %} {% bootstrap_form f layout="control" %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index a7d87324b..b09b02836 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -257,20 +257,18 @@ {% endif %} {% if not line.canceled %}
- {% if not line.addon_to or request.event.settings.ticket_download_addons %} - {% if line.item.admission or request.event.settings.ticket_download_nonadm %} - {% for b in download_buttons %} -
- {% csrf_token %} - -
- {% endfor %} - {% endif %} + {% if line.generate_ticket %} + {% for b in download_buttons %} +
+ {% csrf_token %} + +
+ {% endfor %} {% endif %} {% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 26e560739..864bca541 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -301,10 +301,8 @@ class OrderDownload(AsyncAction, OrderView): return self.error(_('You requested an invalid ticket output type.')) if not self.order_position: raise Http404(_('Unknown order code or not authorized to access this order.')) - if 'position' in kwargs and (self.order_position.addon_to and not self.request.event.settings.ticket_download_addons): - return self.error(_('Ticket download is not enabled for add-on products.')) - if 'position' in kwargs and (not self.order_position.item.admission and not self.request.event.settings.ticket_download_nonadm): - return self.error(_('Ticket download is not enabled for non-admission products.')) + if 'position' in kwargs and not self.order_position.generate_ticket: + return self.error(_('Ticket download is not enabled for this product.')) ct = self.get_last_ct() if ct: diff --git a/src/pretix/plugins/ticketoutputpdf/exporters.py b/src/pretix/plugins/ticketoutputpdf/exporters.py index aef6acdab..ed7cb7e36 100644 --- a/src/pretix/plugins/ticketoutputpdf/exporters.py +++ b/src/pretix/plugins/ticketoutputpdf/exporters.py @@ -77,9 +77,7 @@ class AllTicketsPDF(BaseExporter): ) for op in qs: - if op.addon_to_id and not self.event.settings.ticket_download_addons: - continue - if not op.item.admission and not self.event.settings.ticket_download_nonadm: + if not op.generate_ticket: continue with language(op.order.locale): diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 4b8a9c207..d495c1f94 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -56,20 +56,18 @@ {% endif %} - {% if download and line.item.admission|default:event.settings.ticket_download_nonadm %} + {% if download and line.generate_ticket %}
- {% if not line.addon_to or event.settings.ticket_download_addons %} - {% for b in download_buttons %} -
- {% csrf_token %} - -
- {% endfor %} - {% endif %} + {% for b in download_buttons %} +
+ {% csrf_token %} + +
+ {% endfor %}
{% elif line.addon_to %}
 
@@ -149,20 +147,18 @@ {% endif %} {% endif %} - {% if download and line.item.admission|default:event.settings.ticket_download_nonadm %} + {% if download and line.generate_ticket %}
- {% if not line.addon_to or event.settings.ticket_download_addons %} - {% for b in download_buttons %} -
- {% csrf_token %} - -
- {% endfor %} - {% endif %} + {% for b in download_buttons %} +
+ {% csrf_token %} + +
+ {% endfor %}
{% endif %}
diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 8e0bf508a..2a1f52cee 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -102,8 +102,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView): order=self.order ) ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and ( - self.request.event.settings.ticket_download_nonadm or - [p.item.admission for p in ctx['cart']['positions']].count(True) > 1 + [p.generate_ticket for p in ctx['cart']['positions']].count(True) > 1 ) ctx['invoices'] = list(self.order.invoices.all()) can_generate_invoice = ( @@ -685,10 +684,8 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, AsyncAction, View): raise Http404(_('Unknown order code or not authorized to access this order.')) if not self.order.ticket_download_available: return self.error(_('Ticket download is not (yet) enabled for this order.')) - if 'position' in kwargs and (self.order_position.addon_to and not self.request.event.settings.ticket_download_addons): - return self.error(_('Ticket download is not enabled for add-on products.')) - if 'position' in kwargs and (not self.order_position.item.admission and not self.request.event.settings.ticket_download_nonadm): - return self.error(_('Ticket download is not enabled for non-admission products.')) + if 'position' in kwargs and not self.order_position.generate_ticket: + return self.error(_('Ticket download is not enabled for this product.')) ct = self.get_last_ct() if ct: diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index cdc1ba166..4ae26aa50 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -221,6 +221,7 @@ TEST_ITEM_RES = { "tax_rule": None, "admission": False, "position": 0, + "generate_tickets": None, "picture": None, "available_from": None, "available_until": None, diff --git a/src/tests/plugins/badges/test_pdf.py b/src/tests/plugins/badges/test_pdf.py index 4c9ecf84b..72ada8e72 100644 --- a/src/tests/plugins/badges/test_pdf.py +++ b/src/tests/plugins/badges/test_pdf.py @@ -9,6 +9,7 @@ from PyPDF2 import PdfFileReader from pretix.base.models import ( Event, Item, ItemVariation, Order, OrderPosition, Organizer, ) +from pretix.base.services.orders import OrderError from pretix.plugins.badges.exporters import BadgeExporter @@ -43,21 +44,17 @@ def test_generate_pdf(env): event, order, shirt = env event.badge_layouts.create(name="Default", default=True) e = BadgeExporter(event) - fname, ftype, buf = e.render({ - 'items': [shirt.pk], - 'include_pending': False - }) - assert ftype == 'application/pdf' - pdf = PdfFileReader(BytesIO(buf)) - assert pdf.numPages == 0 + with pytest.raises(OrderError): + e.render({ + 'items': [shirt.pk], + 'include_pending': False + }) - fname, ftype, buf = e.render({ - 'items': [], - 'include_pending': True - }) - assert ftype == 'application/pdf' - pdf = PdfFileReader(BytesIO(buf)) - assert pdf.numPages == 0 + with pytest.raises(OrderError): + e.render({ + 'items': [], + 'include_pending': True + }) fname, ftype, buf = e.render({ 'items': [shirt.pk],