diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 336214f1a..207cdf8f7 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -47,6 +47,8 @@ item_meta_properties object Item-specific m valid_keys object Cryptographic keys for non-default signature schemes. For performance reason, value is omitted in lists and only contained in detail views. Value can be cached. +sales_channels list A list of sales channels this event is available for + sale on. ===================================== ========================== ======================================================= @@ -91,6 +93,11 @@ valid_keys object Cryptographic k The attribute ``valid_keys`` has been added. +.. versionchanged:: 3.14 + + The attribute ``sales_channels`` has been added. + + Endpoints --------- @@ -147,11 +154,16 @@ Endpoints "timezone": "Europe/Berlin", "item_meta_properties": {}, "plugins": [ - "pretix.plugins.banktransfer" - "pretix.plugins.stripe" - "pretix.plugins.paypal" + "pretix.plugins.banktransfer", + "pretix.plugins.stripe", + "pretix.plugins.paypal", "pretix.plugins.ticketoutputpdf" ], + "sales_channels": [ + "web", + "pretixpos", + "resellers" + ] } ] } @@ -170,6 +182,7 @@ Endpoints only contain the events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return only those events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value set. Please note that this filter will respect default values set on organizer level. + :query sales_channel: If set to a sales channel identifier, only events allowed to be sold on the specified sales channel are returned. :param organizer: The ``slug`` field of a valid organizer :statuscode 200: no error :statuscode 401: Authentication failure @@ -219,16 +232,21 @@ Endpoints "timezone": "Europe/Berlin", "item_meta_properties": {}, "plugins": [ - "pretix.plugins.banktransfer" - "pretix.plugins.stripe" - "pretix.plugins.paypal" + "pretix.plugins.banktransfer", + "pretix.plugins.stripe", + "pretix.plugins.paypal", "pretix.plugins.ticketoutputpdf" ], "valid_keys": { "pretix_sig1": [ "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=" ] - } + }, + "sales_channels": [ + "web", + "pretixpos", + "resellers" + ] } :param organizer: The ``slug`` field of the organizer to fetch @@ -279,6 +297,11 @@ Endpoints "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" + ], + "sales_channels": [ + "web", + "pretixpos", + "resellers" ] } @@ -314,6 +337,11 @@ Endpoints "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" + ], + "sales_channels": [ + "web", + "pretixpos", + "resellers" ] } @@ -369,6 +397,11 @@ Endpoints "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" + ], + "sales_channels": [ + "web", + "pretixpos", + "resellers" ] } @@ -404,6 +437,11 @@ Endpoints "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" + ], + "sales_channels": [ + "web", + "pretixpos", + "resellers" ] } @@ -473,6 +511,11 @@ Endpoints "pretix.plugins.stripe", "pretix.plugins.paypal", "pretix.plugins.pretixdroid" + ], + "sales_channels": [ + "web", + "pretixpos", + "resellers" ] } diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 682fa59ed..f8336a529 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -124,7 +124,8 @@ class EventSerializer(I18nAwareModelSerializer): fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', - 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys') + 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys', + 'sales_channels') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index c04dba262..f6037ca5b 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -28,6 +28,7 @@ with scopes_disabled(): is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs') is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs') ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs') + sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs') class Meta: model = Event @@ -69,6 +70,9 @@ with scopes_disabled(): else: return queryset.exclude(expr) + def sales_channel_qs(self, queryset, name, value): + return queryset.filter(sales_channels__contains=value) + class EventViewSet(viewsets.ModelViewSet): serializer_class = EventSerializer diff --git a/src/pretix/base/migrations/0172_event_sales_channels.py b/src/pretix/base/migrations/0172_event_sales_channels.py new file mode 100644 index 000000000..f04b3abf3 --- /dev/null +++ b/src/pretix/base/migrations/0172_event_sales_channels.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.9 on 2020-12-02 12:37 + +from django.db import migrations + +import pretix.base.models.fields +from pretix.base.channels import get_all_sales_channels + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0171_auto_20201126_1635'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='sales_channels', + field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channels().keys())), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index e42bcc242..15b258dd9 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -23,6 +23,7 @@ from django_scopes import ScopedManager, scopes_disabled from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.models.base import LoggedModel +from pretix.base.models.fields import MultiStringField from pretix.base.reldate import RelativeDateWrapper from pretix.base.validators import EventSlugBanlistValidator from pretix.helpers.database import GroupConcat @@ -331,6 +332,8 @@ class Event(EventMixin, LoggedModel): :type plugins: str :param has_subevents: Enable event series functionality :type has_subevents: bool + :param sales_channels: A list of sales channel identifiers, that this event is available for sale on + :type sales_channels: list """ settings_namespace = 'event' @@ -409,7 +412,11 @@ class Event(EventMixin, LoggedModel): ) seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True, related_name='events') - + sales_channels = MultiStringField( + verbose_name=_('Restrict to specific sales channels'), + help_text=_('Only sell tickets for this event on the following sales channels.'), + default=['web'], + ) objects = ScopedManager(organizer='organizer') class Meta: diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index b6085b838..fa85e51ea 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.db.models import Q -from django.forms import formset_factory +from django.forms import CheckboxSelectMultiple, formset_factory from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe @@ -311,6 +311,16 @@ class EventUpdateForm(I18nModelForm): required=False, help_text=_('You need to configure the custom domain in the webserver beforehand.') ) + self.fields['sales_channels'] = forms.MultipleChoiceField( + label=self.fields['sales_channels'].label, + help_text=self.fields['sales_channels'].help_text, + required=self.fields['sales_channels'].required, + initial=self.fields['sales_channels'].initial, + choices=( + (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() + ), + widget=forms.CheckboxSelectMultiple + ) def clean_domain(self): d = self.cleaned_data['domain'] @@ -367,6 +377,7 @@ class EventUpdateForm(I18nModelForm): 'location', 'geo_lat', 'geo_lon', + 'sales_channels' ] field_classes = { 'date_from': SplitDateTimeField, @@ -381,6 +392,7 @@ class EventUpdateForm(I18nModelForm): 'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}), 'presale_start': SplitDateTimePickerWidget(), 'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}), + 'sales_channels': CheckboxSelectMultiple() } diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 9b7c678ba..13117c713 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -219,6 +219,7 @@ {% if sform.event_list_type %} {% bootstrap_field sform.event_list_type layout="control" %} {% endif %} + {% bootstrap_field form.sales_channels layout="control" %}
{% trans "Cart" %} diff --git a/src/pretix/presale/middleware.py b/src/pretix/presale/middleware.py index 7ac999c95..811dad05e 100644 --- a/src/pretix/presale/middleware.py +++ b/src/pretix/presale/middleware.py @@ -2,6 +2,7 @@ from django.template.response import TemplateResponse from django.urls import resolve from django_scopes import scope +from pretix.base.channels import WebshopSalesChannel from pretix.presale.signals import process_response from .utils import _detect_event @@ -20,6 +21,11 @@ class EventMiddleware: def __call__(self, request): url = resolve(request.path_info) request._namespace = url.namespace + + if not hasattr(request, 'sales_channel'): + # The environ lookup is only relevant during unit testing + request.sales_channel = request.environ.get('PRETIX_SALES_CHANNEL', WebshopSalesChannel()) + if url.namespace != 'presale': return self.get_response(request) diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index 80c47eb25..f19cbab26 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -11,7 +11,6 @@ from django.urls import resolve from django.utils.translation import gettext_lazy as _ from django_scopes import scope -from pretix.base.channels import WebshopSalesChannel from pretix.base.middleware import LocaleMiddleware from pretix.base.models import Event, Organizer from pretix.multidomain.urlreverse import ( @@ -32,6 +31,7 @@ def _detect_event(request, require_live=True, require_plugin=None): db = settings.DATABASE_REPLICA url = resolve(request.path_info) + try: if hasattr(request, 'event_domain'): # We are on an event's custom domain @@ -127,9 +127,6 @@ def _detect_event(request, require_live=True, require_plugin=None): if require_plugin not in request.event.get_plugins() and not is_core: raise Http404(_('This feature is not enabled.')) - if not hasattr(request, 'sales_channel'): - # The environ lookup is only relevant during unit testing - request.sales_channel = request.environ.get('PRETIX_SALES_CHANNEL', WebshopSalesChannel()) for receiver, response in process_request.send(request.event, request=request): if response: return response diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index e9f1563b3..517ca3690 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -419,6 +419,9 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): return u def post(self, request, *args, **kwargs): + if request.sales_channel.identifier not in request.event.sales_channels: + raise Http404(_('Tickets for this event cannot be purchased on this sales channel.')) + cart_id = get_or_create_cart_id(self.request) if "widget_data" in request.POST: try: diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 95be0a439..9172b8115 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -348,6 +348,9 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): r._csp_ignore = True return r + if request.sales_channel.identifier not in request.event.sales_channels: + raise Http404(_('Tickets for this event cannot be purchased on this sales channel.')) + if request.event.has_subevents: if 'subevent' in kwargs: self.subevent = request.event.subevents.using(settings.DATABASE_REPLICA).filter(pk=kwargs['subevent'], active=True).first() diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index f108419d6..c1f4aba4b 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -101,6 +101,7 @@ class EventListMixin: def _get_event_queryset(self): query = Q(is_public=True) & Q(live=True) qs = self.request.organizer.events.using(settings.DATABASE_REPLICA).filter(query) + qs = qs.filter(sales_channels__contains=self.request.sales_channel.identifier) qs = qs.annotate( min_from=Min('subevents__date_from'), min_to=Min('subevents__date_to'), @@ -487,11 +488,16 @@ class CalendarView(OrganizerViewMixin, EventListMixin, TemplateView): def _events_by_day(self, before, after): ebd = defaultdict(list) timezones = set() - add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using( + settings.DATABASE_REPLICA + ).filter( + sales_channels__contains=self.request.sales_channel.identifier + ), before, after, ebd, timezones) add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( event__organizer=self.request.organizer, event__is_public=True, event__live=True, + event__sales_channels__contains=self.request.sales_channel.identifier ).prefetch_related( 'event___settings_objects', 'event__organizer___settings_objects' )), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) @@ -539,11 +545,16 @@ class WeekCalendarView(OrganizerViewMixin, EventListMixin, TemplateView): def _events_by_day(self, before, after): ebd = defaultdict(list) timezones = set() - add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using( + settings.DATABASE_REPLICA + ).filter( + sales_channels__contains=self.request.sales_channel.identifier + ), before, after, ebd, timezones) add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( event__organizer=self.request.organizer, event__is_public=True, event__live=True, + event__sales_channels__contains=self.request.sales_channel.identifier ).prefetch_related( 'event___settings_objects', 'event__organizer___settings_objects' )), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) @@ -556,7 +567,12 @@ class OrganizerIcalDownload(OrganizerViewMixin, View): def get(self, request, *args, **kwargs): events = list( filter_qs_by_attr( - self.request.organizer.events.filter(is_public=True, live=True, has_subevents=False), + self.request.organizer.events.filter( + is_public=True, + live=True, + has_subevents=False, + sales_channels__contains=self.request.sales_channel.identifier + ), request ).order_by( 'date_from' @@ -571,7 +587,8 @@ class OrganizerIcalDownload(OrganizerViewMixin, View): event__is_public=True, event__live=True, is_public=True, - active=True + active=True, + event__sales_channels__contains=self.request.sales_channel.identifier ), request ).prefetch_related( diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index f1ad8d57c..c173e2c35 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -279,6 +279,11 @@ class WidgetAPIProductList(EventListMixin, View): 'error': gettext('This ticket shop is currently disabled.') }) + if request.sales_channel.identifier not in request.event.sales_channels: + return self.response({ + 'error': gettext('Tickets for this event cannot be purchased on this sales channel.') + }) + self.subevent = None if request.event.has_subevents: if 'subevent' in kwargs: @@ -417,7 +422,11 @@ class WidgetAPIProductList(EventListMixin, View): if hasattr(self.request, 'event'): add_subevents_for_days( - filter_qs_by_attr(self.request.event.subevents_annotated('web'), self.request), + filter_qs_by_attr( + self.request.event.subevents_annotated('web').filter( + event__sales_channels__contains=self.request.sales_channel.identifier + ), self.request + ), before, after, ebd, set(), self.request.event, kwargs.get('cart_namespace') ) @@ -425,13 +434,18 @@ class WidgetAPIProductList(EventListMixin, View): timezones = set() add_events_for_days( self.request, - filter_qs_by_attr(Event.annotated(self.request.organizer.events, 'web'), self.request), + filter_qs_by_attr( + Event.annotated(self.request.organizer.events, 'web').filter( + sales_channels__contains=self.request.sales_channel.identifier + ), self.request + ), before, after, ebd, timezones ) add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( event__organizer=self.request.organizer, event__is_public=True, event__live=True, + event__sales_channels__contains=self.request.sales_channel.identifier ).prefetch_related( 'event___settings_objects', 'event__organizer___settings_objects' )), self.request), before, after, ebd, timezones) diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index bbf099417..5e61cd280 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -85,7 +85,8 @@ TEST_EVENT_RES = { ], 'item_meta_properties': { 'day': 'Monday', - } + }, + 'sales_channels': ['web'] } diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index ceb752d8c..4484e4d0b 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -34,7 +34,8 @@ class CartTestMixin: organizer=self.orga, name='30C3', slug='30c3', date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc), live=True, - plugins="pretix.plugins.banktransfer" + plugins="pretix.plugins.banktransfer", + sales_channels=['web', 'bar'] ) self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index bf5cd9e9d..d4ac13858 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -28,7 +28,7 @@ class EventTestMixin: self.event = Event.objects.create( organizer=self.orga, name='30C3', slug='30c3', date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc), - live=True + live=True, sales_channels=['web', 'bar'] ) self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') t = Team.objects.create(organizer=self.orga, can_change_event_settings=True) @@ -1018,6 +1018,23 @@ class DeadlineTest(EventTestMixin, TestCase): ) self.assertNotEqual(response.status_code, 403) + def test_saleschannel_disabled(self): + self.event.presale_start = None + self.event.presale_end = None + self.event.sales_channels = [] + self.event.save() + response = self.client.get( + '/%s/%s/' % (self.orga.slug, self.event.slug) + ) + self.assertEqual(response.status_code, 404) + response = self.client.post( + '/%s/%s/cart/add' % (self.orga.slug, self.event.slug), + { + 'item_%d' % self.item.id: '1' + } + ) + self.assertEqual(response.status_code, 404) + def test_in_time(self): self.event.presale_start = now() - datetime.timedelta(days=1) self.event.presale_end = now() + datetime.timedelta(days=1) diff --git a/src/tests/presale/test_widget.py b/src/tests/presale/test_widget.py index 28272625c..07b812600 100644 --- a/src/tests/presale/test_widget.py +++ b/src/tests/presale/test_widget.py @@ -121,6 +121,21 @@ class WidgetCartTest(CartTestMixin, TestCase): self.assertIn('23', doc.select('.cart .cart-row')[0].select('.price')[0].text) self.assertIn('23', doc.select('.cart .cart-row')[0].select('.price')[1].text) + def test_saleschannel_disabled(self): + self.event.sales_channels = [] + self.event.save() + response = self.client.get('/%s/%s/widget/product_list' % (self.orga.slug, self.event.slug)) + data = json.loads(response.content.decode()) + assert data == { + "error": "Tickets for this event cannot be purchased on this sales channel.", + 'poweredby': 'event ticketing powered by pretix', + } + self.assertEqual(response.status_code, 200) + response = self.client.post('/%s/%s/w/aaaaaaaaaaaaaaab/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + self.assertEqual(response.status_code, 404) + def test_product_list_view(self): response = self.client.get('/%s/%s/widget/product_list' % (self.orga.slug, self.event.slug)) assert response['Access-Control-Allow-Origin'] == '*'